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
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

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

10 changes: 6 additions & 4 deletions sites/sh1pt.com/lib/coinpay-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 sites/sh1pt.com/lib/coinpay-signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -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' };
}

Expand Down Expand Up @@ -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;
},
Expand Down
2 changes: 1 addition & 1 deletion sites/sh1pt.com/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Loading