From c586ea1c69be62f4c89f1c44b7c623364bca6ce8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 08:23:44 +0000 Subject: [PATCH] c0upons accounts on the forum: bridge host endpoints + member names /api/v1/bridge/authorize and /token (@profullstack/bridges, OAuth 2.1 code + PKCE): the forum at /bbs signs c0upons members in, silently when they are already signed in here. sub is the member's CoinPay DID, so CoinPay sign-in on the forum lands on the same account. Off unless BRIDGE_TSBB_SECRET is set. lib/members.ts: the CoinPay name is remembered at sign-in (sessions carry only the DID); the table creates itself, as migrations are not run on deploy. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/auth/callback/route.ts | 6 ++ apps/web/app/api/v1/bridge/[action]/route.ts | 69 ++++++++++++++++++++ apps/web/lib/members.ts | 57 ++++++++++++++++ apps/web/package.json | 1 + bun.lock | 3 + 5 files changed, 136 insertions(+) create mode 100644 apps/web/app/api/v1/bridge/[action]/route.ts create mode 100644 apps/web/lib/members.ts diff --git a/apps/web/app/api/auth/callback/route.ts b/apps/web/app/api/auth/callback/route.ts index 7b122e3..1a39eb0 100644 --- a/apps/web/app/api/auth/callback/route.ts +++ b/apps/web/app/api/auth/callback/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createSession, COOKIE } from '@/lib/auth'; +import { rememberMember } from '@/lib/members'; const CLIENT_ID = process.env.COINPAY_API_KEY!; const CLIENT_SECRET = process.env.COINPAY_CLIENT_SECRET!; @@ -78,6 +79,11 @@ export async function GET(req: NextRequest) { if (!did) return fail('Could not resolve DID'); + // Remember the CoinPay name for the forum bridge. Never let it block sign-in. + await rememberMember(did, { name: user.name, email: user.email }).catch((error) => + console.error('rememberMember failed:', error), + ); + const session = await createSession(did); const res = NextResponse.redirect(new URL(returnTo, APP_URL)); res.cookies.set(COOKIE, session, { diff --git a/apps/web/app/api/v1/bridge/[action]/route.ts b/apps/web/app/api/v1/bridge/[action]/route.ts new file mode 100644 index 0000000..ec08f2b --- /dev/null +++ b/apps/web/app/api/v1/bridge/[action]/route.ts @@ -0,0 +1,69 @@ +import { createBridgeHost, type BridgeHost } from '@profullstack/bridges'; +import { COOKIE, parseSession } from '@/lib/auth'; +import { memberByDid } from '@/lib/members'; + +/** + * c0upons accounts on other apps (@profullstack/bridges): the forum at /bbs + * signs c0upons members in with these two endpoints, silently when they are + * already signed in here. + * + * GET /api/v1/bridge/authorize OAuth 2.1 authorization (code + PKCE S256) + * POST /api/v1/bridge/token code -> the member's DID and name + * + * The client secret lives in the vault (BRIDGE_TSBB_SECRET); without it the + * bridge answers 404 and the forum keeps its own sign-in only. + */ +export const dynamic = 'force-dynamic'; + +const APP_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://c0upons.com'; + +function readCookie(request: Request, name: string): string | null { + for (const part of (request.headers.get('cookie') ?? '').split(';')) { + const eq = part.indexOf('='); + if (eq > 0 && part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim()); + } + return null; +} + +let host: BridgeHost | null | undefined; + +function bridge(): BridgeHost | null { + if (host !== undefined) return host; + const secret = process.env.BRIDGE_TSBB_SECRET; + host = secret + ? createBridgeHost({ + clients: { + tsbb: { + secret, + redirectUris: [process.env.BRIDGE_TSBB_REDIRECT_URI ?? `${APP_URL}/bbs/auth/bridge/callback`], + }, + }, + async getUser(request) { + const cookie = readCookie(request, COOKIE); + const did = cookie ? await parseSession(cookie) : null; + if (!did) return null; + const member = await memberByDid(did).catch(() => null); + // CoinPay does not verify emails, so none is passed as verified. + return { sub: did, name: member?.name ?? undefined }; + }, + // Sign in with CoinPay, then come straight back to this authorize request. + loginUrl(returnTo) { + const back = new URL(returnTo); + return `${APP_URL}/api/auth/coinpay?returnTo=${encodeURIComponent(back.pathname + back.search)}`; + }, + }) + : null; + return host; +} + +export async function GET(request: Request, { params }: { params: Promise<{ action: string }> }) { + const b = bridge(); + if (!b || (await params).action !== 'authorize') return new Response('Not found', { status: 404 }); + return b.authorize(request); +} + +export async function POST(request: Request, { params }: { params: Promise<{ action: string }> }) { + const b = bridge(); + if (!b || (await params).action !== 'token') return new Response('Not found', { status: 404 }); + return b.token(request); +} diff --git a/apps/web/lib/members.ts b/apps/web/lib/members.ts new file mode 100644 index 0000000..b175e50 --- /dev/null +++ b/apps/web/lib/members.ts @@ -0,0 +1,57 @@ +import 'server-only'; +import { getDb } from './db'; + +/** + * What c0upons knows about a signed-in member beyond their DID: the name + * CoinPay gave at sign-in. Sessions carry only the DID, so this is where the + * forum bridge (app/api/v1/bridge) finds a name to hand over. + * + * The table creates itself, because migrations are not run on deploy. + */ +let ready: Promise | null = null; + +function ensureTable(): Promise { + ready ??= getDb() + .sql` + CREATE TABLE IF NOT EXISTS members ( + did TEXT PRIMARY KEY, + name TEXT, + email TEXT, + updated_at TEXT NOT NULL + ) + `.then( + () => undefined, + (error) => { + ready = null; + throw error; + }, + ); + return ready; +} + +export interface Member { + did: string; + name: string | null; + email: string | null; +} + +export async function rememberMember(did: string, profile: { name?: unknown; email?: unknown }): Promise { + await ensureTable(); + const name = typeof profile.name === 'string' && profile.name.trim() ? profile.name.trim().slice(0, 120) : null; + const email = typeof profile.email === 'string' && profile.email.includes('@') ? profile.email.trim().slice(0, 320) : null; + await getDb().sql` + INSERT INTO members (did, name, email, updated_at) + VALUES (${did}, ${name}, ${email}, ${new Date().toISOString()}) + ON CONFLICT (did) DO UPDATE SET + name = COALESCE(excluded.name, members.name), + email = COALESCE(excluded.email, members.email), + updated_at = excluded.updated_at + `; +} + +export async function memberByDid(did: string): Promise { + await ensureTable(); + const rows = await getDb().sql`SELECT did, name, email FROM members WHERE did = ${did}`; + const row = rows[0]; + return row ? { did: String(row.did), name: row.name ?? null, email: row.email ?? null } : null; +} diff --git a/apps/web/package.json b/apps/web/package.json index 4c10ec3..241c335 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.104.1", + "@profullstack/bridges": "^0.1.0", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/stack": "^0.1.3", "@profullstack/x402-gateway": "^0.7.0", diff --git a/bun.lock b/bun.lock index 6f63a3a..e800d92 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "version": "1.9.0", "dependencies": { "@anthropic-ai/sdk": "^0.104.1", + "@profullstack/bridges": "^0.1.0", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/stack": "^0.1.3", "@profullstack/x402-gateway": "^0.7.0", @@ -315,6 +316,8 @@ "@posthog/types": ["@posthog/types@1.381.0", "", {}, "sha512-AW68BovKFCNbPdq3VjOzfQeSQRYMvQVv+46LDywWFXO/oOTXFKwjY92FaJQSTXWgTNgDpqigCw3yUFDinK3hZA=="], + "@profullstack/bridges": ["@profullstack/bridges@0.1.0", "", { "bin": { "bridges": "bin/bridges.js" } }, "sha512-UhEPgj8JvN/OSyWIyOB6jdmMDHezupXLp4XpGwtL5JZUdW5XtkYT/GVGRiUQBu5Jpdn5PgF2PBKDY3et4zqQIQ=="], + "@profullstack/emailer": ["@profullstack/emailer@1.0.1", "", {}, "sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg=="], "@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="],