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
6 changes: 6 additions & 0 deletions apps/web/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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!;
Expand Down Expand Up @@ -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, {
Expand Down
69 changes: 69 additions & 0 deletions apps/web/app/api/v1/bridge/[action]/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
57 changes: 57 additions & 0 deletions apps/web/lib/members.ts
Original file line number Diff line number Diff line change
@@ -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<void> | null = null;

function ensureTable(): Promise<void> {
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<void> {
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<Member | null> {
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;
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
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.

Loading