From 1fed348072bdcae8360a3020528f6ae2e2dd20a4 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 6 Sep 2026 12:54:40 +0100 Subject: [PATCH] fix(couples): protect joined invites from cancellation --- apps/api/src/domains/couples/repository.ts | 37 +++++++++++++++++++ apps/api/src/domains/couples/routes.ts | 5 +++ apps/api/src/domains/couples/service.ts | 5 +++ apps/api/src/domains/couples/types.ts | 1 + .../couples.repository.integration.test.ts | 32 ++++++++++++++++ apps/api/test/couples.routes.test.ts | 26 +++++++++++++ apps/api/test/couples.service.test.ts | 10 +++++ .../src/__tests__/lib/coupleApi.test.ts | 2 + apps/mobile/src/lib/coupleApi.ts | 1 + docs/couple-pairing.md | 22 ++++++++++- 10 files changed, 139 insertions(+), 2 deletions(-) diff --git a/apps/api/src/domains/couples/repository.ts b/apps/api/src/domains/couples/repository.ts index ebd6cac..4f6e46b 100644 --- a/apps/api/src/domains/couples/repository.ts +++ b/apps/api/src/domains/couples/repository.ts @@ -33,6 +33,7 @@ export interface CoupleRepository { getState(userId: string): Promise; create(userId: string, coupleId: string, inviteCode: string): Promise; join(userId: string, inviteCode: string): Promise; + cancelInvite(userId: string): Promise; cancel(userId: string): Promise; close(): Promise; } @@ -268,6 +269,42 @@ export class PostgresCoupleRepository implements CoupleRepository { }); } + async cancelInvite(userId: string): Promise { + await transaction(this.pool, async (client) => { + // Lock in the same order as join(): the caller's profile first, then the + // couple. A concurrent join therefore either completes first and makes + // this a safe no-op error, or observes that the invite no longer exists. + const profile = await profileForUpdate(client, userId); + if (!profile.couple_id) { + throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400); + } + + const coupleResult = await client.query<{ id: string }>( + 'select id from couples where id = $1 for update', + [profile.couple_id], + ); + if (!coupleResult.rows[0]) { + throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400); + } + + const members = await client.query<{ id: string }>( + 'select id from profiles where couple_id = $1 for update', + [profile.couple_id], + ); + if (members.rows.length > 1) { + throw new CoupleError('invite_already_joined', 'This invite has already been joined', 409); + } + if (members.rows.length !== 1 || members.rows[0]?.id !== userId) { + throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400); + } + + // At this point the couple has precisely the authenticated creator as a + // member. Removing it cannot delete paired data, and the foreign key + // clears the creator's couple_id while leaving sealed answers intact. + await client.query('delete from couples where id = $1', [profile.couple_id]); + }); + } + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } diff --git a/apps/api/src/domains/couples/routes.ts b/apps/api/src/domains/couples/routes.ts index 8106abe..fcc8aa8 100644 --- a/apps/api/src/domains/couples/routes.ts +++ b/apps/api/src/domains/couples/routes.ts @@ -50,4 +50,9 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi const result = await route(() => service.cancel(c.get('identity').id)); return result.ok ? c.json(result.value) : c.json(result.body, result.status); }); + + app.delete('/v1/couple/invite', async (c) => { + const result = await route(() => service.cancelInvite(c.get('identity').id)); + return result.ok ? c.json(result.value) : c.json(result.body, result.status); + }); } diff --git a/apps/api/src/domains/couples/service.ts b/apps/api/src/domains/couples/service.ts index 6e31bad..df0fa86 100644 --- a/apps/api/src/domains/couples/service.ts +++ b/apps/api/src/domains/couples/service.ts @@ -41,4 +41,9 @@ export class CoupleService { await this.repository.cancel(userId); return { success: true, couple_id: null }; } + + async cancelInvite(userId: string): Promise { + await this.repository.cancelInvite(userId); + return { success: true, couple_id: null }; + } } diff --git a/apps/api/src/domains/couples/types.ts b/apps/api/src/domains/couples/types.ts index a7aec0b..08e9a12 100644 --- a/apps/api/src/domains/couples/types.ts +++ b/apps/api/src/domains/couples/types.ts @@ -19,6 +19,7 @@ export type CoupleErrorCode = | 'invalid_invite_code' | 'couple_full' | 'not_paired' + | 'invite_already_joined' | 'invite_code_collision'; export class CoupleError extends Error { diff --git a/apps/api/test/couples.repository.integration.test.ts b/apps/api/test/couples.repository.integration.test.ts index 65ec939..e7fc324 100644 --- a/apps/api/test/couples.repository.integration.test.ts +++ b/apps/api/test/couples.repository.integration.test.ts @@ -68,6 +68,38 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () = expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0); }); + it('cancels a waiting invite only for its creator', async () => { + const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + await repository.create(charlie, coupleId, 'WAIT2345'); + + await repository.cancelInvite(charlie); + + await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 }); + expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0); + }); + + it('never deletes a joined couple through the invite cancellation path', async () => { + const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + await repository.create(alice, coupleId, 'SAFE2345'); + await repository.join(bob, 'SAFE2345'); + + const before = await pool.query<{ id: string; couple_id: string | null }>( + 'select id, couple_id from profiles where id in ($1, $2) order by id', + [alice, bob], + ); + await expect(repository.cancelInvite(alice)).rejects.toMatchObject({ + code: 'invite_already_joined', + status: 409, + }); + + const after = await pool.query<{ id: string; couple_id: string | null }>( + 'select id, couple_id from profiles where id in ($1, $2) order by id', + [alice, bob], + ); + expect(after.rows).toEqual(before.rows); + expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1); + }); + it('reports a solo answerer\'s sealed count before any couple exists', async () => { const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; diff --git a/apps/api/test/couples.routes.test.ts b/apps/api/test/couples.routes.test.ts index ef11ac3..d4861d4 100644 --- a/apps/api/test/couples.routes.test.ts +++ b/apps/api/test/couples.routes.test.ts @@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js'; import type { CoupleRepository } from '../src/domains/couples/repository.js'; import { registerCoupleRoutes } from '../src/domains/couples/routes.js'; import { CoupleService } from '../src/domains/couples/service.js'; +import { CoupleError } from '../src/domains/couples/types.js'; const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', @@ -25,6 +26,7 @@ function setup() { invite_code: inviteCode, created_at: '2026-08-27T00:00:00.000Z', })), + cancelInvite: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined), close: vi.fn(async () => undefined), }; @@ -80,4 +82,28 @@ describe('couple routes', () => { expect(await response.json()).toEqual({ success: true, couple_id: null }); expect(repository.cancel).toHaveBeenCalledWith(identity.id); }); + + it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => { + const { app, repository } = setup(); + const response = await app.request('/v1/couple/invite', { method: 'DELETE' }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true, couple_id: null }); + expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id); + expect(repository.cancel).not.toHaveBeenCalled(); + }); + + it('does not turn a joined invite into relationship deletion', async () => { + const { app, repository } = setup(); + vi.mocked(repository.cancelInvite).mockRejectedValueOnce( + new CoupleError('invite_already_joined', 'This invite has already been joined', 409), + ); + + const response = await app.request('/v1/couple/invite', { method: 'DELETE' }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: { code: 'invite_already_joined', message: 'This invite has already been joined' }, + }); + expect(repository.cancel).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/test/couples.service.test.ts b/apps/api/test/couples.service.test.ts index 76b4007..745ac20 100644 --- a/apps/api/test/couples.service.test.ts +++ b/apps/api/test/couples.service.test.ts @@ -15,6 +15,7 @@ function repository(overrides: Partial = {}): CoupleRepository getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })), create: vi.fn(async () => couple), join: vi.fn(async () => couple), + cancelInvite: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined), close: vi.fn(async () => undefined), ...overrides, @@ -61,4 +62,13 @@ describe('CoupleService', () => { expect(create).toHaveBeenCalledTimes(2); expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true); }); + + it('uses the invite-only cancellation operation for a waiting creator', async () => { + const repo = repository(); + const service = new CoupleService(repo); + + await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null }); + expect(repo.cancelInvite).toHaveBeenCalledWith('user-one'); + expect(repo.cancel).not.toHaveBeenCalled(); + }); }); diff --git a/apps/mobile/src/__tests__/lib/coupleApi.test.ts b/apps/mobile/src/__tests__/lib/coupleApi.test.ts index 11e067a..05b3328 100644 --- a/apps/mobile/src/__tests__/lib/coupleApi.test.ts +++ b/apps/mobile/src/__tests__/lib/coupleApi.test.ts @@ -12,12 +12,14 @@ describe("coupleApi", () => { await coupleApi.getState(); await coupleApi.create(); await coupleApi.join("ABCD2345"); + await coupleApi.cancelInvite(); await coupleApi.cancel(); expect(request.mock.calls).toEqual([ ["/v1/couple"], ["/v1/couple", { method: "POST", body: {} }], ["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }], + ["/v1/couple/invite", { method: "DELETE" }], ["/v1/couple", { method: "DELETE" }], ]); }); diff --git a/apps/mobile/src/lib/coupleApi.ts b/apps/mobile/src/lib/coupleApi.ts index 05044ac..1ed6cc3 100644 --- a/apps/mobile/src/lib/coupleApi.ts +++ b/apps/mobile/src/lib/coupleApi.ts @@ -24,4 +24,5 @@ export const coupleApi = { body: { invite_code: inviteCode }, }), cancel: () => apiRequest("/v1/couple", { method: "DELETE" }), + cancelInvite: () => apiRequest("/v1/couple/invite", { method: "DELETE" }), }; diff --git a/docs/couple-pairing.md b/docs/couple-pairing.md index 0bef798..14a1343 100644 --- a/docs/couple-pairing.md +++ b/docs/couple-pairing.md @@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null). - All messages - All couple_packs settings +### Standalone API invite cancellation (DELETE) + +`DELETE /v1/couple/invite` cancels an unjoined invite for the authenticated +creator only. It is intentionally separate from `DELETE /v1/couple`, which +continues to end an existing relationship for legacy clients. + +The server locks the authenticated profile and then the couple row in one +transaction. It deletes the invite only when that caller is the sole member. +If another member has joined, it returns `409 invite_already_joined` and makes +no changes. This serializes safely with a concurrent `POST /v1/couple` join: +one operation completes first, while the other observes the resulting state. + +```javascript +// Response on a cancelled waiting invite +{ success: true, couple_id: null } +``` + ## Database Schema ### Couple Record @@ -182,8 +199,8 @@ This prevents race conditions where multiple users try to join simultaneously. The invite journey leads with the shared outcome, then shows the recoverable eight-character code. It offers both a bare-code copy and a full `https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it -deletes answers made after the code was created. The five-second poll surfaces -an inline offline state but continues retrying. +removes only an unjoined invite and retains sealed answers. The five-second +poll surfaces an inline offline state but continues retrying. When pairing succeeds, both members are routed once per account and couple to the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never @@ -397,4 +414,5 @@ Storage (chat-media) is manually cleaned before the cascade. | Already paired | User tries to create/join while in couple | "You are already in a couple" | | Invalid code | Invite code doesn't exist | "Invalid invite code" | | Couple full | Couple already has 2 members | "This couple already has two partners" | +| Invite already joined | Creator tries to cancel after a partner joins | "This invite has already been joined" | | DB trigger | Race condition caught | "A couple can only have 2 members" |