diff --git a/bun.lock b/bun.lock index af47ad3a..01f91a0a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@profullstack/affiliates": "^0.3.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/coinpay": "^0.25.0", + "@profullstack/coinpay-onboard": "0.4.0", "@profullstack/fleetbar": "0.2.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", @@ -319,6 +320,8 @@ "@profullstack/coinpay": ["@profullstack/coinpay@0.25.0", "", { "dependencies": { "@inquirer/prompts": "^8.3.0", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@profullstack/hqtui": "^0.6.3", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0" }, "bin": { "coinpay": "bin/coinpay.js" } }, "sha512-ZoK8COqBCdJXvr0fO5UWw9HPh88CdMBLycc/dU6kXwAyeIrwZ4WkBApisuDgs4leXNN+SMjwex2etgrEvlICTQ=="], + "@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=="], "@profullstack/fleetbar": ["@profullstack/fleetbar@0.2.0", "", {}, "sha512-ImALhrAVZUnc0aqFW518WVZX895ffnLmChC/KXR6mPqONFinoLI2wwWASSN9n5wJrjsJShU6nTebrUXEX4GdCQ=="], diff --git a/lib/coinpay-signin.ts b/lib/coinpay-signin.ts index da23f47c..812539f2 100644 --- a/lib/coinpay-signin.ts +++ b/lib/coinpay-signin.ts @@ -16,6 +16,7 @@ // The decision logic is written against small interfaces so tests run in // memory; supabaseCoinpayStore() is the real one. import { fetchGlobalCoinpayWallets, type GlobalCoinpayWallet } from "./coinpay-wallet-data"; +import { resolveCoinPayAccountLink } from "@profullstack/coinpay-onboard/identity"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; @@ -197,7 +198,7 @@ export type IdentityRow = { user_id: string; coinpay_sub: string; coinpay_email: export interface CoinpayStore { identityBySub(sub: string): Promise; identityByUser(userId: string): Promise; - /** Insert; "conflict" when the sub or the user is already bound. */ + /** Insert; "conflict" when the user is already bound (or signup raced). */ link(row: IdentityRow & { coinpay_name: string | null }): Promise<"ok" | "conflict">; unlink(userId: string): Promise; touch(sub: string): Promise; @@ -232,12 +233,17 @@ export async function resolveCoinpayUser( if (input.mode === "connect") { if (!input.currentUserId) return { ok: false, code: "not_signed_in", error: "Sign in to crawlproof 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 crawlproof account." }; - } const mine = await store.identityByUser(input.currentUserId); - if (mine) return { ok: false, code: "already_connected", error: "This crawlproof account is already connected to a different CoinPay account. Disconnect it first." }; + const decision = resolveCoinPayAccountLink({ + bindings: [ + ...(bound ? [{ sub: bound.coinpay_sub, userId: bound.user_id }] : []), + ...(mine ? [{ sub: mine.coinpay_sub, userId: input.currentUserId }] : []), + ], + 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", error: "This crawlproof account is already connected to a different CoinPay account. Disconnect it first." }; const r = await store.link({ user_id: input.currentUserId, coinpay_sub: claims.sub, @@ -245,7 +251,11 @@ 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") { + const current = await store.identityByUser(input.currentUserId); + if (current?.coinpay_sub === claims.sub) return { ok: true, userId: input.currentUserId, action: "linked" }; + return { ok: false, code: "already_connected", error: "This crawlproof account was just connected to a different CoinPay account." }; + } return { ok: true, userId: input.currentUserId, action: "linked" }; } @@ -284,7 +294,7 @@ export function supabaseCoinpayStore(sb: SupabaseClient): CoinpayStore { const cols = "user_id, coinpay_sub, coinpay_email, created_via"; return { 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 }).order("user_id", { ascending: true }).limit(1).maybeSingle(); if (r.error) throw new Error(r.error.message); return (r.data as IdentityRow | null) ?? null; }, diff --git a/package.json b/package.json index 28237223..2f8d0898 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@profullstack/affiliates": "^0.3.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/coinpay": "^0.25.0", + "@profullstack/coinpay-onboard": "0.4.0", "@profullstack/fleetbar": "0.2.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", diff --git a/supabase/migrations/20261029200000_coinpay_identities.sql b/supabase/migrations/20261029200000_coinpay_identities.sql index ebef659f..7887f615 100644 --- a/supabase/migrations/20261029200000_coinpay_identities.sql +++ b/supabase/migrations/20261029200000_coinpay_identities.sql @@ -30,7 +30,7 @@ stable security definer set search_path = '' as $$ - select i.user_id from public.coinpay_identities i where i.coinpay_sub = p_sub limit 1 + select i.user_id from public.coinpay_identities i where i.coinpay_sub = p_sub order by i.created_at, i.user_id limit 1 $$; revoke all on function public.uptime_user_id_by_coinpay_sub(text) from public, anon, authenticated; grant execute on function public.uptime_user_id_by_coinpay_sub(text) to service_role; diff --git a/supabase/migrations/20261031140000_coinpay_multi_account.sql b/supabase/migrations/20261031140000_coinpay_multi_account.sql new file mode 100644 index 00000000..b34019bc --- /dev/null +++ b/supabase/migrations/20261031140000_coinpay_multi_account.sql @@ -0,0 +1,28 @@ +-- One CoinPay identity can be linked to several local accounts. Keep signup +-- creation unique per identity so simultaneous first logins converge; signed-in +-- connections are intentionally many-to-one. +alter table public.coinpay_identities + drop constraint if exists coinpay_identities_coinpay_sub_key; + +create index if not exists coinpay_identities_coinpay_sub_idx + on public.coinpay_identities (coinpay_sub); + +create unique index if not exists coinpay_identities_one_signup_per_sub_idx + on public.coinpay_identities (coinpay_sub) + where created_via = 'signup'; + +create or replace function public.uptime_user_id_by_coinpay_sub(p_sub text) +returns uuid +language sql +stable +security definer +set search_path = '' +as $$ + select i.user_id + from public.coinpay_identities i + where i.coinpay_sub = p_sub + order by i.created_at, i.user_id + limit 1 +$$; + +notify pgrst, 'reload schema'; diff --git a/tests/coinpay-signin.test.ts b/tests/coinpay-signin.test.ts index e1f9fdeb..70362cca 100644 --- a/tests/coinpay-signin.test.ts +++ b/tests/coinpay-signin.test.ts @@ -33,7 +33,7 @@ function memory() { return ids.find((i) => i.user_id === u) ?? null; }, async link(row) { - if (ids.some((i) => i.coinpay_sub === row.coinpay_sub || i.user_id === row.user_id)) return "conflict"; + if (ids.some((i) => i.user_id === row.user_id || (row.created_via === "signup" && i.coinpay_sub === row.coinpay_sub && i.created_via === "signup"))) return "conflict"; ids.push(row); return "ok"; }, @@ -99,10 +99,17 @@ describe("STANDING rule: bind by CoinPay sub, never by email", () => { expect(db.ids).toHaveLength(0); }); - it("a sub already linked elsewhere cannot be connected to another account", async () => { + it("one CoinPay identity can be connected to multiple crawlproof accounts", async () => { await resolveCoinpayUser({ mode: "login", currentUserId: null, claims: { sub: "s", email: null, name: null } }, db.store); const r = await resolveCoinpayUser({ mode: "connect", currentUserId: "victim", claims: { sub: "s", email: null, name: null } }, db.store); - expect(r).toMatchObject({ ok: false, code: "linked_elsewhere" }); + db.users.set("second", { email: "second@example.com" }); + const second = await resolveCoinpayUser({ mode: "connect", currentUserId: "second", claims: { sub: "s", email: null, name: null } }, db.store); + expect(r).toEqual({ ok: true, userId: "victim", action: "linked" }); + expect(second).toEqual({ ok: true, userId: "second", action: "linked" }); + expect(db.ids.filter((identity) => identity.coinpay_sub === "s")).toHaveLength(3); + // Sign-in maps to the oldest local binding deterministically. + const login = await resolveCoinpayUser({ mode: "login", currentUserId: null, claims: { sub: "s", email: null, name: null } }, db.store); + expect(login).toEqual({ ok: true, userId: "new-1", action: "login" }); }); it("an account already connected to one CoinPay account refuses a second", async () => { diff --git a/tests/coinpay-wallet-api.test.ts b/tests/coinpay-wallet-api.test.ts index 9b25e4e2..4f765a10 100644 --- a/tests/coinpay-wallet-api.test.ts +++ b/tests/coinpay-wallet-api.test.ts @@ -3,7 +3,7 @@ const h=vi.hoisted(()=>({auth:{ok:true,userId:'user',tokenId:'key',scope:null} a vi.mock('@/lib/sp/apiAuth',()=>({authenticateBearer:async()=>h.auth})); vi.mock('@/lib/supabase/service',()=>({serviceClient:()=>({from:(name:string)=>{ const filters:[string,unknown][]=[];let row:Record|null=null;let op='read';const rows=()=> (h.tables[name]??[]).filter(r=>filters.every(([k,v])=>r[k]===v)); - const q:any={select:()=>q,eq:(k:string,v:unknown)=>{filters.push([k,v]);return q;},maybeSingle:async()=>({data:rows()[0]??null,error:null}),insert:(r:Record)=>{row=r;op='insert';return q;},upsert:(r:Record)=>{row=r;op='upsert';return q;},then:(resolve:(v:unknown)=>void)=>{if(row){if(op==='upsert')h.tables[name]=[row];else(h.tables[name]??=[]).push(row);}resolve({error:null});}};return q; + const q:any={select:()=>q,eq:(k:string,v:unknown)=>{filters.push([k,v]);return q;},order:()=>q,limit:()=>q,maybeSingle:async()=>({data:rows()[0]??null,error:null}),insert:(r:Record)=>{row=r;op='insert';return q;},upsert:(r:Record)=>{row=r;op='upsert';return q;},then:(resolve:(v:unknown)=>void)=>{if(row){if(op==='upsert')h.tables[name]=[row];else(h.tables[name]??=[]).push(row);}resolve({error:null});}};return q; }})})); import {POST,GET} from '@/app/api/ads/v1/wallets/route'; const request=(body:unknown)=>new Request('https://crawlproof.com/api/ads/v1/wallets',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}) as never; @@ -11,7 +11,7 @@ beforeEach(()=>{h.auth={ok:true,userId:'user',tokenId:'key',scope:null};h.tables describe('global wallet API authorization',()=>{ it('requires CrawlProof authentication before contacting CoinPay',async()=>{h.auth={ok:false,error:'Missing bearer token',status:401};expect((await POST(request({coinpay_token:'fixture'}))).status).toBe(401);expect(fetch).not.toHaveBeenCalled();}); it('uses provider verified identity and addresses, ignoring supplied subjects and addresses',async()=>{const r=await POST(request({coinpay_token:'fixture-secret',sub:'attacker',wallet_address:'attacker-address'}));expect(r.status).toBe(200);expect(h.tables.coinpay_identities[0]).toMatchObject({user_id:'user',coinpay_sub:'verified-sub'});expect(h.tables.coinpay_wallet_profiles[0].wallets[0].address).toBe('provider-address');expect(JSON.stringify(h.tables)).not.toContain('fixture-secret');expect(JSON.stringify(await r.json())).not.toContain('fixture-secret');}); - it('refuses a provider subject already connected to another local user',async()=>{h.tables.coinpay_identities=[{user_id:'other',coinpay_sub:'verified-sub'}];expect((await POST(request({coinpay_token:'fixture'}))).status).toBe(409);expect(h.tables.coinpay_wallet_profiles).toEqual([]);}); + it('allows one provider identity to sync wallets for multiple local users',async()=>{h.tables.coinpay_identities=[{user_id:'other',coinpay_sub:'verified-sub'}];const r=await POST(request({coinpay_token:'fixture'}));expect(r.status).toBe(200);expect(h.tables.coinpay_identities).toHaveLength(2);expect(h.tables.coinpay_identities).toContainEqual(expect.objectContaining({user_id:'other',coinpay_sub:'verified-sub'}));expect(h.tables.coinpay_identities).toContainEqual(expect.objectContaining({user_id:'user',coinpay_sub:'verified-sub'}));expect(h.tables.coinpay_wallet_profiles[0]).toMatchObject({user_id:'user',coinpay_sub:'verified-sub'});}); it('does not erase old wallets or link identities when provider access fails',async()=>{h.tables.coinpay_wallet_profiles=[{user_id:'user',coinpay_sub:'verified-sub',wallets:[{id:'old'}]}];vi.stubGlobal('fetch',vi.fn(async()=>new Response(null,{status:403})));expect((await POST(request({coinpay_token:'fixture'}))).status).toBe(400);expect(h.tables.coinpay_identities).toEqual([]);expect(h.tables.coinpay_wallet_profiles[0].wallets).toEqual([{id:'old'}]);}); it('does not return another user’s wallet profile',async()=>{h.tables.coinpay_wallet_profiles=[{user_id:'other',coinpay_sub:'other-sub',wallets:[{address:'private-address'}]}];const r=await GET(new Request('https://crawlproof.com/api/ads/v1/wallets') as never);expect(await r.text()).not.toContain('private-address');}); });