Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 18 additions & 8 deletions lib/coinpay-signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -197,7 +198,7 @@ export type IdentityRow = { user_id: string; coinpay_sub: string; coinpay_email:
export interface CoinpayStore {
identityBySub(sub: string): Promise<IdentityRow | null>;
identityByUser(userId: string): Promise<IdentityRow | null>;
/** 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<void>;
touch(sub: string): Promise<void>;
Expand Down Expand Up @@ -232,20 +233,29 @@ 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,
coinpay_email: claims.email,
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" };
}

Expand Down Expand Up @@ -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;
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion supabase/migrations/20261029200000_coinpay_identities.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
28 changes: 28 additions & 0 deletions supabase/migrations/20261031140000_coinpay_multi_account.sql
Original file line number Diff line number Diff line change
@@ -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';
13 changes: 10 additions & 3 deletions tests/coinpay-signin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
},
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions tests/coinpay-wallet-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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<string,unknown>|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<string,unknown>)=>{row=r;op='insert';return q;},upsert:(r:Record<string,unknown>)=>{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<string,unknown>)=>{row=r;op='insert';return q;},upsert:(r:Record<string,unknown>)=>{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;
beforeEach(()=>{h.auth={ok:true,userId:'user',tokenId:'key',scope:null};h.tables={coinpay_identities:[],coinpay_wallet_profiles:[]};vi.stubGlobal('fetch',vi.fn(async(url:string)=>url.endsWith('/userinfo')?Response.json({sub:'verified-sub',email:'irrelevant@example.com'}):Response.json({wallets:[{id:'global',source:'account',is_active:true,cryptocurrency:'USDC_POL',wallet_address:'provider-address'}]})));});
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');});
});