diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b083f98..83b05bd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4242,11 +4242,11 @@ importers: specifier: github:profullstack/autoblog#75e54af version: https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af '@profullstack/coinpay-onboard': - specifier: 0.3.1 - version: 0.3.1 + specifier: 0.4.0 + version: 0.4.0 '@profullstack/footer': specifier: latest - version: 0.5.0(react@19.3.1) + version: 0.6.5(react@19.3.1) '@profullstack/profile': specifier: 0.1.6 version: 0.1.6(react@19.3.1) @@ -5038,16 +5038,16 @@ packages: version: 0.4.0 engines: {node: '>=18'} - '@profullstack/coinpay-onboard@0.3.1': - resolution: {integrity: sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ==} + '@profullstack/coinpay-onboard@0.4.0': + resolution: {integrity: sha512-L2P0fxNswZnXt1tvo6t19L/RrJN204vrWnIEY65QI+zriZN7JJPY4Ap6Nv8MLkyA5wDvY88s0+Dgjf9Myw/tUA==} engines: {node: '>=20'} hasBin: true '@profullstack/emailer@1.0.3': resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} - '@profullstack/footer@0.5.0': - resolution: {integrity: sha512-TrllS87nK7J0c/UgYckaFIWVhLOtr6m73XXFSHIuSfOg7NfuWUdwjtsfMXGaZiiUnmMYj3zhGcfzW0DicDwl8w==} + '@profullstack/footer@0.6.5': + resolution: {integrity: sha512-j1iyMVeCxseq4sRKn2b7YP7tpG0jqyKDKh/bsrOmdtprMLHIOoHA1xxhXnNOSSVBYi6WFrQy+6R86eJVhtY5ug==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -8858,7 +8858,7 @@ snapshots: '@profullstack/autoblog@https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af': {} - '@profullstack/coinpay-onboard@0.3.1': + '@profullstack/coinpay-onboard@0.4.0': dependencies: '@modelcontextprotocol/sdk': 1.32.2(zod@4.6.7) zod: 4.6.7 @@ -8868,7 +8868,7 @@ snapshots: '@profullstack/emailer@1.0.3': {} - '@profullstack/footer@0.5.0(react@19.3.1)': + '@profullstack/footer@0.6.5(react@19.3.1)': optionalDependencies: react: 19.3.1 diff --git a/sites/sh1pt.com/lib/coinpay-signin.test.ts b/sites/sh1pt.com/lib/coinpay-signin.test.ts index db1e3460..16aa9175 100644 --- a/sites/sh1pt.com/lib/coinpay-signin.test.ts +++ b/sites/sh1pt.com/lib/coinpay-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/sites/sh1pt.com/lib/coinpay-signin.ts b/sites/sh1pt.com/lib/coinpay-signin.ts index 210a8d57..6b17a8c5 100644 --- a/sites/sh1pt.com/lib/coinpay-signin.ts +++ b/sites/sh1pt.com/lib/coinpay-signin.ts @@ -19,6 +19,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/v1/coinpay/oauth/callback'; export const STATE_COOKIE = 'sh1pt_cp_state'; @@ -119,7 +120,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 sh1pt user a verified CoinPay sub belongs to. Never looks at the @@ -137,11 +138,14 @@ export async function resolveCoinpayUser( if (!input.currentUserId) { return { ok: false, code: 'not_signed_in', error: 'Sign in to sh1pt first, then connect CoinPay from your dashboard.' }; } - 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 sh1pt 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', @@ -155,9 +159,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 sh1pt account already has a CoinPay identity connected.' }; return { ok: true, userId: input.currentUserId, action: 'linked' }; } @@ -229,7 +231,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_identities').select(cols).eq('coinpay_sub', sub).maybeSingle(); + const r = await sb.from('coinpay_identities').select(cols).eq('coinpay_sub', sub).order('created_at', { ascending: true }).limit(1).maybeSingle(); if (r.error) throw new Error(r.error.message); return (r.data as IdentityRow | null) ?? null; }, diff --git a/sites/sh1pt.com/package.json b/sites/sh1pt.com/package.json index db5eb2b0..7d3c80d7 100644 --- a/sites/sh1pt.com/package.json +++ b/sites/sh1pt.com/package.json @@ -17,7 +17,7 @@ "@opentelemetry/sdk-logs": "^3.0.0", "@profullstack/affiliates": "0.3.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", - "@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/sites/sh1pt.com/supabase/migrations/20261031070000_coinpay_multi_account.sql b/sites/sh1pt.com/supabase/migrations/20261031070000_coinpay_multi_account.sql new file mode 100644 index 00000000..69be8142 --- /dev/null +++ b/sites/sh1pt.com/supabase/migrations/20261031070000_coinpay_multi_account.sql @@ -0,0 +1,4 @@ +-- Permit one CoinPay identity to connect to multiple sh1pt accounts. +-- Keep each local account limited to one CoinPay identity. +alter table public.coinpay_identities drop constraint if exists coinpay_identities_pkey; +alter table public.coinpay_identities add constraint coinpay_identities_pkey primary key (coinpay_sub, user_id);