From 84f5114859ffb98df423ebaaff3768f5a3056339 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 06:20:56 +0000 Subject: [PATCH] feat(sh1pt.com): Sign in with CoinPay CoinPay is the fleet's master account. Adds "Sign in with CoinPay" first on /login and "Sign up with CoinPay" on /waitlist, and a CoinPay card on the dashboard (Connect / Disconnect), via @profullstack/coinpay-onboard 0.3.1: auth code + PKCE S256 + state + nonce, client secret on the exchange, id_token iss/aud/exp/nonce and userinfo sub checks. - Bound by the CoinPay sub (public.coinpay_identities), never by email. A CoinPay email matching an existing account gets a new account on a placeholder auth email; the CoinPay email is contact info only. - State pinned to the browser by an httpOnly cookie, spent from a server-side coinpay_pending row (single use, 10 minutes). - Connect needs the signed-in user who started it to finish it. - Disconnect is refused when CoinPay is the only way in. - Session minted server side (generateLink hash + verifyOtp), as crawlproof. - Invite bonuses are never paid to a CoinPay-made account (no proven mailbox), and its first login does not accept an invite. Co-Authored-By: Claude Opus 5.5 --- pnpm-lock.yaml | 24 +- .../app/api/v1/coinpay/disconnect/route.ts | 35 ++ .../api/v1/coinpay/oauth/callback/route.ts | 91 ++++++ .../app/api/v1/coinpay/oauth/start/route.ts | 63 ++++ .../app/components/CoinpaySignIn.tsx | 35 ++ sites/sh1pt.com/app/dashboard/page.tsx | 55 +++- sites/sh1pt.com/app/login/page.tsx | 7 + sites/sh1pt.com/app/waitlist/page.tsx | 2 + sites/sh1pt.com/lib/coinpay-signin.test.ts | 194 ++++++++++++ sites/sh1pt.com/lib/coinpay-signin.ts | 298 ++++++++++++++++++ sites/sh1pt.com/lib/invite-rules.ts | 6 + sites/sh1pt.com/package.json | 1 + .../20261029063000_coinpay_identities.sql | 40 +++ 13 files changed, 845 insertions(+), 6 deletions(-) create mode 100644 sites/sh1pt.com/app/api/v1/coinpay/disconnect/route.ts create mode 100644 sites/sh1pt.com/app/api/v1/coinpay/oauth/callback/route.ts create mode 100644 sites/sh1pt.com/app/api/v1/coinpay/oauth/start/route.ts create mode 100644 sites/sh1pt.com/app/components/CoinpaySignIn.tsx create mode 100644 sites/sh1pt.com/lib/coinpay-signin.test.ts create mode 100644 sites/sh1pt.com/lib/coinpay-signin.ts create mode 100644 sites/sh1pt.com/supabase/migrations/20261029063000_coinpay_identities.sql diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0d85d26..2b083f98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4241,9 +4241,12 @@ importers: '@profullstack/autoblog': 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 '@profullstack/footer': specifier: latest - version: 0.4.0(react@19.3.1) + version: 0.5.0(react@19.3.1) '@profullstack/profile': specifier: 0.1.6 version: 0.1.6(react@19.3.1) @@ -5035,11 +5038,16 @@ packages: version: 0.4.0 engines: {node: '>=18'} + '@profullstack/coinpay-onboard@0.3.1': + resolution: {integrity: sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ==} + engines: {node: '>=20'} + hasBin: true + '@profullstack/emailer@1.0.3': resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} - '@profullstack/footer@0.4.0': - resolution: {integrity: sha512-EJZnMUQmWDi9Gzx6BAjogloeaPFFSSCHtHJuC9dsXiOz8M87hfn6Z+Zx9AMYWk8s6xemPrPlkXKGB3PZugkcEA==} + '@profullstack/footer@0.5.0': + resolution: {integrity: sha512-TrllS87nK7J0c/UgYckaFIWVhLOtr6m73XXFSHIuSfOg7NfuWUdwjtsfMXGaZiiUnmMYj3zhGcfzW0DicDwl8w==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -8850,9 +8858,17 @@ snapshots: '@profullstack/autoblog@https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af': {} + '@profullstack/coinpay-onboard@0.3.1': + dependencies: + '@modelcontextprotocol/sdk': 1.32.2(zod@4.6.7) + zod: 4.6.7 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + '@profullstack/emailer@1.0.3': {} - '@profullstack/footer@0.4.0(react@19.3.1)': + '@profullstack/footer@0.5.0(react@19.3.1)': optionalDependencies: react: 19.3.1 diff --git a/sites/sh1pt.com/app/api/v1/coinpay/disconnect/route.ts b/sites/sh1pt.com/app/api/v1/coinpay/disconnect/route.ts new file mode 100644 index 00000000..258118b8 --- /dev/null +++ b/sites/sh1pt.com/app/api/v1/coinpay/disconnect/route.ts @@ -0,0 +1,35 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { getSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseServiceClient } from '@/lib/supabase/service'; +import { coinpayConfig, disconnectCoinpay, supabaseCoinpayStore } from '@/lib/coinpay-signin'; + +export const dynamic = 'force-dynamic'; + +// POST /api/v1/coinpay/disconnect: the dashboard's form. Refused when CoinPay +// is the only way into the account (its auth email is a CoinPay placeholder). +export async function POST(request: NextRequest) { + const site = coinpayConfig()?.siteUrl ?? 'https://sh1pt.com'; + if (!sameSite(request.headers.get('origin'), site)) return new NextResponse('Bad origin', { status: 403 }); + const supabase = await getSupabaseServerClient(); + const { data } = await supabase.auth.getUser(); + if (!data?.user) return NextResponse.redirect(new URL('/login?next=/dashboard', site), 303); + + const result = await disconnectCoinpay( + data.user.id, + data.user.email ?? null, + supabaseCoinpayStore(getSupabaseServiceClient()), + ); + return NextResponse.redirect( + new URL(`/dashboard?coinpay=${result.ok ? 'disconnected' : 'only_sign_in'}#coinpay`, site), + 303, + ); +} + +function sameSite(origin: string | null, site: string): boolean { + if (!origin) return true; + try { + return new URL(origin).host === new URL(site).host; + } catch { + return false; + } +} diff --git a/sites/sh1pt.com/app/api/v1/coinpay/oauth/callback/route.ts b/sites/sh1pt.com/app/api/v1/coinpay/oauth/callback/route.ts new file mode 100644 index 00000000..a50aea32 --- /dev/null +++ b/sites/sh1pt.com/app/api/v1/coinpay/oauth/callback/route.ts @@ -0,0 +1,91 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { finishSignIn } from '@profullstack/coinpay-onboard/oauth'; +import { getSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseServiceClient } from '@/lib/supabase/service'; +import { onSignedIn } from '@/lib/credits'; +import { + STATE_COOKIE, + STATE_COOKIE_PATH, + coinpayConfig, + mintMagicLinkHash, + resolveCoinpayUser, + supabaseCoinpayStore, + takeCallback, +} from '@/lib/coinpay-signin'; + +export const dynamic = 'force-dynamic'; + +// GET /api/v1/coinpay/oauth/callback?code=…&state=… +// +// Refused before the code is exchanged when the state is not this browser's, +// was already used, or (Connect) the signed-in user is not the one who asked. +// The exchange sends the PKCE verifier and the client secret; the id_token's +// iss/aud/exp/nonce are checked and userinfo's sub must match it. The account +// is chosen by sub only (lib/coinpay-signin.ts), then a normal Supabase +// session is minted server side. +export async function GET(request: NextRequest) { + const cfg = coinpayConfig(); + const site = cfg?.siteUrl ?? 'https://sh1pt.com'; + const to = (path: string) => { + const res = NextResponse.redirect(new URL(path, site)); + res.cookies.set(STATE_COOKIE, '', { path: STATE_COOKIE_PATH, maxAge: 0 }); + res.headers.set('cache-control', 'no-store'); + return res; + }; + if (!cfg) return to('/login?error=coinpay_off'); + + const url = new URL(request.url); + const supabase = await getSupabaseServerClient(); + const { data: who } = await supabase.auth.getUser(); + const currentUserId = who?.user?.id ?? null; + + const admin = getSupabaseServiceClient(); + const store = supabaseCoinpayStore(admin); + const checked = await takeCallback(store, { + state: url.searchParams.get('state'), + cookieState: request.cookies.get(STATE_COOKIE)?.value, + currentUserId, + }); + if (!checked.ok) return to(`/login?error=coinpay_${checked.code}`); + const { pending } = checked; + const fail = (code: string) => + to(pending.mode === 'connect' ? `/dashboard?coinpay=${code}#coinpay` : `/login?error=coinpay_${code}`); + + const code = url.searchParams.get('code'); + if (url.searchParams.get('error') || !code) return fail('cancelled'); + + let claims; + try { + const result = await finishSignIn({ + code, + verifier: pending.verifier, + nonce: pending.nonce, + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + redirectUri: cfg.redirectUri, + }); + claims = { sub: result.sub, email: result.email, name: result.name }; + } catch (err) { + console.error('[coinpay] sign-in failed', err instanceof Error ? err.message : err); + return fail('failed'); + } + + const resolved = await resolveCoinpayUser({ mode: pending.mode, currentUserId, claims }, store); + if (!resolved.ok) return fail(resolved.code); + if (pending.mode === 'connect') return to('/dashboard?coinpay=connected#coinpay'); + + const minted = await mintMagicLinkHash(admin, resolved.userId); + if (!minted.ok) return fail('session'); + const { error } = await supabase.auth.verifyOtp({ token_hash: minted.tokenHash, type: 'magiclink' }); + if (error) { + console.error('[coinpay] verifyOtp', error.message); + return fail('session'); + } + + // Invite bonuses settle for accounts with a real address only: an account a + // CoinPay login just made has no proven email, so it neither accepts an + // invite nor earns one (see lib/credits.ts). + if (resolved.action === 'login') await onSignedIn(resolved.userId, null); + + return to(resolved.action === 'created' ? '/dashboard?coinpay=welcome#coinpay' : pending.next); +} diff --git a/sites/sh1pt.com/app/api/v1/coinpay/oauth/start/route.ts b/sites/sh1pt.com/app/api/v1/coinpay/oauth/start/route.ts new file mode 100644 index 00000000..67ad6491 --- /dev/null +++ b/sites/sh1pt.com/app/api/v1/coinpay/oauth/start/route.ts @@ -0,0 +1,63 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth'; +import { getSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseServiceClient } from '@/lib/supabase/service'; +import { safeNextPath } from '@/lib/safe-next'; +import { + PENDING_SECONDS, + STATE_COOKIE, + STATE_COOKIE_PATH, + coinpayConfig, + savePending, + supabaseCoinpayStore, + type OAuthMode, +} from '@/lib/coinpay-signin'; + +export const dynamic = 'force-dynamic'; + +// GET /api/v1/coinpay/oauth/start?next=/dashboard Sign in with CoinPay +// GET /api/v1/coinpay/oauth/start?mode=connect Connect CoinPay (signed in) +// +// PKCE S256 + state + nonce. The verifier and nonce stay server side in +// coinpay_pending (keyed by sha256(state)); state is pinned to this browser by +// an httpOnly cookie scoped to the OAuth paths. +export async function GET(request: NextRequest) { + const cfg = coinpayConfig(); + if (!cfg) return NextResponse.redirect(new URL('/login?error=coinpay_off', cfgOrigin(request))); + + const url = new URL(request.url); + const mode: OAuthMode = url.searchParams.get('mode') === 'connect' ? 'connect' : 'login'; + + let linkUserId: string | null = null; + if (mode === 'connect') { + // Connecting happens only from inside the account it joins. + const supabase = await getSupabaseServerClient(); + const { data } = await supabase.auth.getUser(); + if (!data?.user) return NextResponse.redirect(new URL('/login?next=/dashboard', cfg.siteUrl)); + linkUserId = data.user.id; + } + + const state = randomToken(); + const nonce = randomToken(); + const { verifier, challenge } = pkce(); + const next = safeNextPath(url.searchParams.get('next'), '/dashboard'); + await savePending(supabaseCoinpayStore(getSupabaseServiceClient()), state, { verifier, nonce, mode, next, linkUserId }); + + const res = NextResponse.redirect( + authorizeUrl({ clientId: cfg.clientId, redirectUri: cfg.redirectUri, state, challenge, nonce, scopes: SIGN_IN_SCOPES }), + ); + res.headers.set('cache-control', 'no-store'); + res.cookies.set(STATE_COOKIE, state, { + httpOnly: true, + secure: cfg.siteUrl.startsWith('https:'), + sameSite: 'lax', + path: STATE_COOKIE_PATH, + maxAge: PENDING_SECONDS, + }); + return res; +} + +function cfgOrigin(request: NextRequest): string { + const site = (process.env as Record)['NEXT_PUBLIC_SITE_URL']; + return site || new URL(request.url).origin; +} diff --git a/sites/sh1pt.com/app/components/CoinpaySignIn.tsx b/sites/sh1pt.com/app/components/CoinpaySignIn.tsx new file mode 100644 index 00000000..4b238696 --- /dev/null +++ b/sites/sh1pt.com/app/components/CoinpaySignIn.tsx @@ -0,0 +1,35 @@ +import { coinpayConfig } from '@/lib/coinpay-signin'; + +// "Sign in with CoinPay": CoinPay is the fleet's master account, so it is +// offered first. Rendered only when the deployment has a CoinPay client. +export function CoinpaySignIn({ label = 'Sign in with CoinPay', next = '/dashboard' }: { label?: string; next?: string }) { + if (!coinpayConfig()) return null; + return ( +
+ + {label} + +

+ or use your email below +

+
+ ); +} + +const COINPAY_ERRORS: Record = { + coinpay_bad_state: "That CoinPay sign-in didn't start in this browser. Try again.", + coinpay_expired: 'That CoinPay sign-in expired or was already used. Try again.', + coinpay_wrong_session: 'Sign in to the account you want to connect, then try again.', + coinpay_cancelled: 'CoinPay sign-in was cancelled.', + coinpay_failed: "CoinPay sign-in couldn't be completed. Try again.", + coinpay_session: "Couldn't start your session after CoinPay. Try again.", + coinpay_off: 'Sign in with CoinPay is not available right now.', +}; + +export function coinpayLoginError(code: string | undefined): string | null { + return code ? (COINPAY_ERRORS[code] ?? null) : null; +} diff --git a/sites/sh1pt.com/app/dashboard/page.tsx b/sites/sh1pt.com/app/dashboard/page.tsx index 19754975..9755da89 100644 --- a/sites/sh1pt.com/app/dashboard/page.tsx +++ b/sites/sh1pt.com/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { getSupabaseServerClient } from '@/lib/supabase/server'; import { getSupabaseServiceClient } from '@/lib/supabase/service'; import { planFor } from '@/lib/credits'; import { RedeemCodeForm } from './RedeemCodeForm'; +import { coinpayConfig, isPlaceholderEmail, supabaseCoinpayStore } from '@/lib/coinpay-signin'; export const metadata = { title: 'sh1pt — dashboard', @@ -26,8 +27,22 @@ const INVITE_NOTICES: Record = { self: 'You cannot use your own invite.', }; +const COINPAY_NOTICES: Record = { + welcome: 'Welcome. You signed in with CoinPay, which is how you get back in.', + connected: 'CoinPay connected. You can sign in with it from now on.', + disconnected: 'CoinPay disconnected.', + only_sign_in: 'CoinPay is the only way into this account, so it stays connected.', + linked_elsewhere: 'That CoinPay account is already connected to another sh1pt account.', + already_connected: 'This account is already connected to a different CoinPay account. Disconnect it first.', + not_signed_in: 'Sign in first, then connect CoinPay.', + cancelled: 'CoinPay was cancelled.', + failed: "CoinPay couldn't be reached. Try again.", +}; + export default async function Dashboard({ searchParams }: { searchParams: Promise> }) { - const inviteNotice = INVITE_NOTICES[(await searchParams).invite ?? ''] ?? null; + const params = await searchParams; + const inviteNotice = INVITE_NOTICES[params.invite ?? ''] ?? null; + const coinpayNotice = COINPAY_NOTICES[params.coinpay ?? ''] ?? null; const supabase = await getSupabaseServerClient(); let user = null; try { @@ -66,6 +81,12 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis .in('installation_pk', installationIds) : { count: 0 }; const installationCount = installationIds.length; + // Sign in with CoinPay: connected or not, and whether it may be dropped. + const coinpayIdentity = await supabaseCoinpayStore(adminClient) + .identityByUser(user.id) + .catch(() => null); + const coinpayOnly = isPlaceholderEmail(user.email); + const coinpayAvailable = coinpayConfig() !== null; const plan = await planFor(user.id).catch((err) => { console.error('[dashboard] plan', err); return null; @@ -87,7 +108,7 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis

Your dashboard

- Signed in as {profile.email} + Signed in as {coinpayOnly ? 'your CoinPay account' : profile.email} {profile.handle ? ` · ${profile.handle}` : null}
@@ -96,6 +117,36 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis +
+
+ CoinPay +
+ {coinpayNotice ?

{coinpayNotice}

: null} + {coinpayIdentity ? ( + <> +

+ Connected{coinpayIdentity.coinpay_email ? ` (${coinpayIdentity.coinpay_email})` : ''}. You can sign in with CoinPay. +

+ {coinpayOnly ? ( +

+ CoinPay is the only way into this account, so it can't be disconnected. +

+ ) : ( +
+ +
+ )} + + ) : coinpayAvailable ? ( + <> +

Connect your CoinPay account to sign in with it.

+ Connect CoinPay + + ) : ( +

Sign in with CoinPay is not available right now.

+ )} +
+ {inviteNotice ? (
{inviteNotice} diff --git a/sites/sh1pt.com/app/login/page.tsx b/sites/sh1pt.com/app/login/page.tsx index 1450022c..60bdcede 100644 --- a/sites/sh1pt.com/app/login/page.tsx +++ b/sites/sh1pt.com/app/login/page.tsx @@ -1,4 +1,5 @@ import { sendLoginLink } from './actions'; +import { CoinpaySignIn, coinpayLoginError } from '../components/CoinpaySignIn'; export const metadata = { title: 'Sign in to sh1pt', @@ -8,6 +9,7 @@ export const metadata = { export default async function Login({ searchParams }: { searchParams: Promise> }) { const params = await searchParams; const error = params.error; + const coinpayError = coinpayLoginError(error); return (
@@ -18,6 +20,8 @@ export default async function Login({ searchParams }: { searchParams: Promise + +