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
4 changes: 2 additions & 2 deletions bun.lock

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions src/lib/coinpay/__tests__/signin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function memoryStore(users: Record<string, string> = {}) {
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';
},
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
22 changes: 12 additions & 10 deletions src/lib/coinpay/signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -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' };
}

Expand Down Expand Up @@ -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);
},
Expand Down
5 changes: 5 additions & 0 deletions supabase/migrations/027_coinpay_multi_account.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading