feat: migrate Nessa auth to Clerk session tokens (task 03)

- src/server/nessa-auth.ts: replace jose HS256 sign/verify with Clerk
  session JWT verification via @clerk/backend verifyToken (RS256/JWKS).
  signNessaToken removed — frontend now supplies Clerk session tokens.
- src/server/api/utils.ts: createTRPCContext verifies Clerk JWT, resolves
  ctx.nessaUserId via SELECT id FROM users WHERE clerkUserId=? on the
  shared NessaConnectionFactory. Lookup miss throws typed UNAUTHORIZED
  (webhook has not run yet). Invalid/expired tokens are swallowed; the
  enforceNessaUser middleware rejects null nessaUserId.
- src/server/api/routers/nessa-community-authz.test.ts: add clerkUserId
  lookup tests (seeded match, missing row, mismatched id, local≠clerk).
- src/server/nessa-auth.test.ts: verifyNessaToken unit tests with mocked
  @clerk/backend (valid sub, missing sub, malformed/expired/wrong-signature
  rejection) plus static audit that signNessaToken is gone.
- src/server/clerk-user-webhook.ts + src/routes/api/clerk-webhook.ts:
  Clerk user.created/user.updated webhook handler (Svix signature
  verification, idempotent upsert by clerkUserId, lazy ALTER TABLE
  migration) with full test suite.
- src/server/api/routers/nessa.ts: remove legacy register/login/google/
  apple sign-in mutations (Clerk is now the sole identity provider).
- src/env/server.ts: add NESSA_CLERK_SECRET, NESSA_CLERK_JWT_ISSUER,
  NESSA_CLERK_WEBHOOK_SECRET; NESSA_JWT_SECRET moved to optional.
- package.json: add @clerk/backend, svix; lineage/auth.test.ts and
  nessa-ownership.test.ts: add Clerk env vars to env mocks.
- .env.example: document Clerk config vars and rotation.
- delete nessa-google-oauth.test.ts (Google auth removed).

ctx.nessaUserId remains the local users.id — router bodies are untouched.
This commit is contained in:
2026-07-23 01:40:53 -04:00
parent 7ddbe752a5
commit 7287f10c9a
15 changed files with 886 additions and 985 deletions

View File

@@ -0,0 +1,219 @@
// ───────────────────────────────────────────────────────────────────────
// Clerk webhook → local `users` table sync
//
// Clerk emits `user.created` / `user.updated` events (signed via Svix) whenever
// a user signs up or their profile/email changes. This module verifies the
// Svix signature with `NESSA_CLERK_WEBHOOK_SECRET` and upserts the
// corresponding row in the Nessa `users` table, keyed by `clerkUserId`.
//
// The local UUID is generated here ("shadow" account) — Clerk is the identity
// provider; the Nessa DB only stores a denormalized copy for-fast-lookup and
// for rows that reference `users.id` (workouts, plans, community posts, etc.).
//
// All signature verification + DB mutation lives in `handleClerkUserWebhook`
// so it can be exercised directly by tests with an in-memory SQLite DB and a
// real Svix-signed payload.
// ───────────────────────────────────────────────────────────────────────
import { Webhook } from "svix";
/** Minimal libsql-shaped connection (same contract as NessaConnectionFactory). */
export interface NessaConn {
execute: (stmt: { sql: string; args?: (string | number | null)[] }) => Promise<{
rows: unknown[];
rowsAffected?: number;
}>;
}
/** Svix signature headers Clerk (and Svix) attach to every webhook. */
export interface ClerkWebhookHeaders {
"svix-id": string;
"svix-timestamp": string;
"svix-signature": string;
}
interface ClerkEmailAddress {
id: string;
email_address: string;
verification?: { status?: string } | null;
}
interface ClerkUserData {
id: string;
email_addresses?: ClerkEmailAddress[];
primary_email_address_id?: string | null;
first_name?: string | null;
last_name?: string | null;
username?: string | null;
image_url?: string | null;
}
interface ClerkWebhookEvent {
object: "event";
type: string;
data: ClerkUserData;
}
export type WebhookResult = { status: number; body: unknown };
/** Resolve the user's primary email address from the Clerk payload. */
function resolveEmail(data: ClerkUserData): string | null {
const addresses = data.email_addresses ?? [];
const primaryId = data.primary_email_address_id ?? null;
const primary =
addresses.find((e) => e.id === primaryId) ?? addresses[0] ?? null;
return primary?.email_address ?? null;
}
/** Resolve a display name (first + last, falling back to username). */
function resolveDisplayName(data: ClerkUserData): string | null {
const first = (data.first_name ?? "").trim();
const last = (data.last_name ?? "").trim();
const full = `${first} ${last}`.trim();
if (full) return full;
return data.username?.trim() || null;
}
// The `users` table predates Clerk — the `clerkUserId` column is added lazily
// (idempotently) so the migration runs against existing dev/prod databases
// without a separate deploy step. SQLite's `ALTER TABLE ... ADD COLUMN` does
// not support `IF NOT EXISTS`, so a duplicate-column error is the expected
// "already migrated" signal.
let columnMigrationDone = false;
/** Test-only: reset the module-level migration flag so each test gets a fresh DB. */
export function __resetClerkWebhookMigrationForTests(): void {
columnMigrationDone = false;
}
export async function ensureClerkUsersColumn(conn: NessaConn): Promise<void> {
if (columnMigrationDone) return;
try {
await conn.execute({
sql: "ALTER TABLE users ADD COLUMN clerkUserId TEXT"
});
} catch (err) {
const msg =
err instanceof Error ? err.message : err == null ? String(err) : String(err);
// "duplicate column name" (SQLite) is the expected success-already case.
if (!/duplicate column/i.test(msg)) {
throw err;
}
}
try {
await conn.execute({
sql: "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)"
});
} catch (err) {
// Best-effort: if the index already exists or creation is unsupported in
// the test harness, fall through. The ON CONFLICT(clerkUserId) upsert
// requires a unique constraint; in tests we create the index in the
// schema instead.
}
columnMigrationDone = true;
}
/**
* Verify a Clerk webhook and upsert the user row.
*
* @returns a `{ status, body }` describing the HTTP response to send.
*/
export async function handleClerkUserWebhook(opts: {
rawBody: string;
headers: ClerkWebhookHeaders;
webhookSecret: string;
conn: NessaConn;
}): Promise<WebhookResult> {
const { rawBody, headers, webhookSecret, conn } = opts;
// Reject unsigned / partially-signed requests before touching the DB.
if (
!headers["svix-id"] ||
!headers["svix-timestamp"] ||
!headers["svix-signature"]
) {
return {
status: 400,
body: { error: "Missing Svix signature headers" }
};
}
let evt: ClerkWebhookEvent;
try {
const wh = new Webhook(webhookSecret);
evt = wh.verify(rawBody, {
"svix-id": headers["svix-id"],
"svix-timestamp": headers["svix-timestamp"],
"svix-signature": headers["svix-signature"]
}) as ClerkWebhookEvent;
} catch (err) {
console.error("Clerk webhook signature verification failed:", err);
return { status: 401, body: { error: "Invalid signature" } };
}
if (evt.type !== "user.created" && evt.type !== "user.updated") {
return {
status: 200,
body: { received: true, ignored: evt.type }
};
}
try {
await ensureClerkUsersColumn(conn);
const data = evt.data;
const clerkUserId = data.id;
const email = resolveEmail(data);
const firstName = data.first_name ?? null;
const lastName = data.last_name ?? null;
const displayName = resolveDisplayName(data);
const avatarUrl = data.image_url ?? null;
if (evt.type === "user.created") {
// Upsert by clerkUserId — Clerk may replay the same event, so insert
// ON CONFLICT instead of erroring on a duplicate.
await conn.execute({
sql: `INSERT INTO users
(id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
ON CONFLICT(clerkUserId) DO UPDATE SET
email = excluded.email,
firstName = excluded.firstName,
lastName = excluded.lastName,
displayName = excluded.displayName,
avatarUrl = excluded.avatarUrl,
updatedAt = datetime('now')`,
args: [
crypto.randomUUID(),
clerkUserId,
email,
firstName,
lastName,
displayName,
avatarUrl
]
});
} else {
// user.updated — mutate identity fields only; never change clerkUserId.
await conn.execute({
sql: `UPDATE users SET
email = ?,
firstName = ?,
lastName = ?,
displayName = ?,
avatarUrl = ?,
updatedAt = datetime('now')
WHERE clerkUserId = ?`,
args: [email, firstName, lastName, displayName, avatarUrl, clerkUserId]
});
}
return { status: 200, body: { success: true, type: evt.type } };
} catch (error) {
console.error("Clerk webhook DB sync failed:", error);
return {
status: 500,
body: { error: "Failed to sync user" }
};
}
}