mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
fix(couples): protect joined invites from cancellation
This commit is contained in:
1 parent
a02e9d531b
commit
1fed348072
10 files changed
+139
-2
No files matched your search
@@ -33,6 +33,7 @@ export interface CoupleRepository {
|
|||||||
getState(userId: string): Promise<CoupleStateResponse>;
|
getState(userId: string): Promise<CoupleStateResponse>;
|
||||||
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
||||||
join(userId: string, inviteCode: string): Promise<Couple>;
|
join(userId: string, inviteCode: string): Promise<Couple>;
|
||||||
|
cancelInvite(userId: string): Promise<void>;
|
||||||
cancel(userId: string): Promise<void>;
|
cancel(userId: string): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -268,6 +269,42 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelInvite(userId: string): Promise<void> {
|
||||||
|
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<void> {
|
async close(): Promise<void> {
|
||||||
await closeResolvedPool(this.pool, this.ownsPool);
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,9 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi
|
|||||||
const result = await route(() => service.cancel(c.get('identity').id));
|
const result = await route(() => service.cancel(c.get('identity').id));
|
||||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -41,4 +41,9 @@ export class CoupleService {
|
|||||||
await this.repository.cancel(userId);
|
await this.repository.cancel(userId);
|
||||||
return { success: true, couple_id: null };
|
return { success: true, couple_id: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelInvite(userId: string): Promise<CoupleMutationResponse> {
|
||||||
|
await this.repository.cancelInvite(userId);
|
||||||
|
return { success: true, couple_id: null };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ export type CoupleErrorCode =
|
|||||||
| 'invalid_invite_code'
|
| 'invalid_invite_code'
|
||||||
| 'couple_full'
|
| 'couple_full'
|
||||||
| 'not_paired'
|
| 'not_paired'
|
||||||
|
| 'invite_already_joined'
|
||||||
| 'invite_code_collision';
|
| 'invite_code_collision';
|
||||||
|
|
||||||
export class CoupleError extends Error {
|
export class CoupleError extends Error {
|
||||||
|
|||||||
@@ -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);
|
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 () => {
|
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
|
||||||
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js';
|
|||||||
import type { CoupleRepository } from '../src/domains/couples/repository.js';
|
import type { CoupleRepository } from '../src/domains/couples/repository.js';
|
||||||
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
||||||
import { CoupleService } from '../src/domains/couples/service.js';
|
import { CoupleService } from '../src/domains/couples/service.js';
|
||||||
|
import { CoupleError } from '../src/domains/couples/types.js';
|
||||||
|
|
||||||
const identity: AuthIdentity = {
|
const identity: AuthIdentity = {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -25,6 +26,7 @@ function setup() {
|
|||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
created_at: '2026-08-27T00:00:00.000Z',
|
created_at: '2026-08-27T00:00:00.000Z',
|
||||||
})),
|
})),
|
||||||
|
cancelInvite: vi.fn(async () => undefined),
|
||||||
cancel: vi.fn(async () => undefined),
|
cancel: vi.fn(async () => undefined),
|
||||||
close: 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(await response.json()).toEqual({ success: true, couple_id: null });
|
||||||
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -15,6 +15,7 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
|
|||||||
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
||||||
create: vi.fn(async () => couple),
|
create: vi.fn(async () => couple),
|
||||||
join: vi.fn(async () => couple),
|
join: vi.fn(async () => couple),
|
||||||
|
cancelInvite: vi.fn(async () => undefined),
|
||||||
cancel: vi.fn(async () => undefined),
|
cancel: vi.fn(async () => undefined),
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
|
|||||||
expect(create).toHaveBeenCalledTimes(2);
|
expect(create).toHaveBeenCalledTimes(2);
|
||||||
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -12,12 +12,14 @@ describe("coupleApi", () => {
|
|||||||
await coupleApi.getState();
|
await coupleApi.getState();
|
||||||
await coupleApi.create();
|
await coupleApi.create();
|
||||||
await coupleApi.join("ABCD2345");
|
await coupleApi.join("ABCD2345");
|
||||||
|
await coupleApi.cancelInvite();
|
||||||
await coupleApi.cancel();
|
await coupleApi.cancel();
|
||||||
|
|
||||||
expect(request.mock.calls).toEqual([
|
expect(request.mock.calls).toEqual([
|
||||||
["/v1/couple"],
|
["/v1/couple"],
|
||||||
["/v1/couple", { method: "POST", body: {} }],
|
["/v1/couple", { method: "POST", body: {} }],
|
||||||
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
||||||
|
["/v1/couple/invite", { method: "DELETE" }],
|
||||||
["/v1/couple", { method: "DELETE" }],
|
["/v1/couple", { method: "DELETE" }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const coupleApi = {
|
|||||||
body: { invite_code: inviteCode },
|
body: { invite_code: inviteCode },
|
||||||
}),
|
}),
|
||||||
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
|
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
|
||||||
|
cancelInvite: () => apiRequest<CoupleMutationResponse>("/v1/couple/invite", { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
+20
-2
@@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null).
|
|||||||
- All messages
|
- All messages
|
||||||
- All couple_packs settings
|
- 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
|
## Database Schema
|
||||||
|
|
||||||
### Couple Record
|
### 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
|
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
|
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
|
`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
|
removes only an unjoined invite and retains sealed answers. The five-second
|
||||||
an inline offline state but continues retrying.
|
poll surfaces an inline offline state but continues retrying.
|
||||||
|
|
||||||
When pairing succeeds, both members are routed once per account and couple to
|
When pairing succeeds, both members are routed once per account and couple to
|
||||||
the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never
|
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" |
|
| 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" |
|
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
|
||||||
| Couple full | Couple already has 2 members | "This couple already has two partners" |
|
| 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" |
|
| DB trigger | Race condition caught | "A couple can only have 2 members" |
|
||||||
Reference in new issue
Block a user