diff --git a/bun.lock b/bun.lock index 00b46fd6..26632f44 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "smshub", "dependencies": { "@profullstack/affiliates": "^0.3.0", - "@profullstack/coinpay-onboard": "0.3.1", + "@profullstack/coinpay-onboard": "0.4.0", "@profullstack/footer": "latest", "@profullstack/profile": "0.1.6", "@profullstack/redeem-credits": "^0.2.0", @@ -295,7 +295,7 @@ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], - "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.4.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-L2P0fxNswZnXt1tvo6t19L/RrJN204vrWnIEY65QI+zriZN7JJPY4Ap6Nv8MLkyA5wDvY88s0+Dgjf9Myw/tUA=="], "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], diff --git a/package.json b/package.json index 690e1e0b..4c7735c7 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@profullstack/affiliates": "^0.3.0", - "@profullstack/coinpay-onboard": "0.3.1", + "@profullstack/coinpay-onboard": "0.4.0", "@profullstack/footer": "latest", "@profullstack/profile": "0.1.6", "@profullstack/redeem-credits": "^0.2.0", diff --git a/src/lib/coinpay/__tests__/signin.test.ts b/src/lib/coinpay/__tests__/signin.test.ts index 0ddcb485..18a89108 100644 --- a/src/lib/coinpay/__tests__/signin.test.ts +++ b/src/lib/coinpay/__tests__/signin.test.ts @@ -39,7 +39,7 @@ function memoryStore(users: Record = {}) { return identities.find((i) => i.user_id === userId) ?? null; }, async link(row) { - if (identities.some((i) => i.coinpay_sub === row.coinpay_sub || i.user_id === row.user_id)) return 'conflict'; + if (identities.some((i) => i.user_id === row.user_id)) return 'conflict'; identities.push(row); return 'ok'; }, @@ -87,7 +87,7 @@ describe('resolveCoinpayUser', () => { expect(first.ok && again.ok && again.userId === first.userId && again.action).toBe('login'); }); - it('connects only for a signed-in user, and a sub belongs to one account', async () => { + it('connects only for a signed-in user, and links one CoinPay identity to several accounts', async () => { const m = memoryStore({ alice: 'alice@example.com', bob: 'bob@example.com' }); expect(await resolveCoinpayUser({ mode: 'connect', currentUserId: null, claims: claims('s1') }, m.store)).toMatchObject({ ok: false, @@ -99,9 +99,11 @@ describe('resolveCoinpayUser', () => { action: 'linked', }); expect(await resolveCoinpayUser({ mode: 'connect', currentUserId: 'bob', claims: claims('s1') }, m.store)).toMatchObject({ - ok: false, - code: 'linked_elsewhere', + ok: true, + userId: 'bob', + action: 'linked', }); + expect(m.identities.filter((identity) => identity.coinpay_sub === 's1')).toHaveLength(2); expect(await resolveCoinpayUser({ mode: 'connect', currentUserId: 'alice', claims: claims('s2') }, m.store)).toMatchObject({ ok: false, code: 'already_connected', diff --git a/src/lib/coinpay/signin.ts b/src/lib/coinpay/signin.ts index e79af99a..c71c5046 100644 --- a/src/lib/coinpay/signin.ts +++ b/src/lib/coinpay/signin.ts @@ -20,6 +20,7 @@ // memory; supabaseCoinpayStore() is the real one (service role). import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import type { SupabaseClient } from '@supabase/supabase-js'; +import { resolveCoinPayAccountLink } from '@profullstack/coinpay-onboard/identity'; export const CALLBACK_PATH = '/api/coinpay/callback'; export const STATE_COOKIE = 'smshub_cp_state'; @@ -121,7 +122,7 @@ export async function takeCallback( export type Resolution = | { ok: true; userId: string; action: 'login' | 'created' | 'linked' } - | { ok: false; code: 'not_signed_in' | 'linked_elsewhere' | 'already_connected'; error: string }; + | { ok: false; code: 'not_signed_in' | 'already_connected'; error: string }; /** * Decide which smshub user a verified CoinPay sub belongs to. Never looks at the @@ -139,11 +140,14 @@ export async function resolveCoinpayUser( if (!input.currentUserId) { return { ok: false, code: 'not_signed_in', error: 'Sign in to smshub first, then connect CoinPay from Settings.' }; } - if (bound) { - if (bound.user_id === input.currentUserId) return { ok: true, userId: bound.user_id, action: 'linked' }; - return { ok: false, code: 'linked_elsewhere', error: 'That CoinPay account is already connected to another smshub account.' }; - } - if (await store.identityByUser(input.currentUserId)) { + const mine = await store.identityByUser(input.currentUserId); + const decision = resolveCoinPayAccountLink({ + bindings: [bound, mine].filter((row): row is IdentityRow => row !== null).map((row) => ({ sub: row.coinpay_sub, userId: row.user_id })), + sub: claims.sub, + userId: input.currentUserId, + }); + if (decision.kind === 'already_linked') return { ok: true, userId: input.currentUserId, action: 'linked' }; + if (decision.kind === 'account_has_other_identity') { return { ok: false, code: 'already_connected', @@ -157,9 +161,7 @@ export async function resolveCoinpayUser( coinpay_name: claims.name, created_via: 'connect', }); - if (r === 'conflict') { - return { ok: false, code: 'linked_elsewhere', error: 'That CoinPay account was just connected elsewhere.' }; - } + if (r === 'conflict') return { ok: false, code: 'already_connected', error: 'This smshub account already has a CoinPay identity connected.' }; return { ok: true, userId: input.currentUserId, action: 'linked' }; } @@ -234,7 +236,7 @@ export function supabaseCoinpayStore(sb: SupabaseClient): CoinpayStore { return { verifier: row.verifier, nonce: row.nonce, mode: row.mode, next: row.next_path, linkUserId: row.link_user_id }; }, async identityBySub(sub) { - const r = await sb.from('coinpay_connections').select(cols).eq('coinpay_user_id', sub).maybeSingle(); + const r = await sb.from('coinpay_connections').select(cols).eq('coinpay_user_id', sub).order('created_at', { ascending: true }).limit(1).maybeSingle(); if (r.error) throw new Error(r.error.message); return toIdentity(r.data as Row | null); }, diff --git a/supabase/migrations/027_coinpay_multi_account.sql b/supabase/migrations/027_coinpay_multi_account.sql new file mode 100644 index 00000000..b8805cd2 --- /dev/null +++ b/supabase/migrations/027_coinpay_multi_account.sql @@ -0,0 +1,5 @@ +-- One CoinPay identity may connect to multiple smshub accounts. Each local +-- user remains limited to one connection by coinpay_connections.user_id UNIQUE. +drop index if exists public.coinpay_connections_coinpay_user_id_key; +create index if not exists coinpay_connections_coinpay_user_id_idx + on public.coinpay_connections (coinpay_user_id);