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

@@ -201,3 +201,78 @@ describe("p8-003: join then allowed / leave then blocked (integration)", () => {
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
});
});
// ---------------------------------------------------------------------------
// Clerk session → local users.id resolution (migrate-to-clerk-auth-03)
//
// `createTRPCContext` verifies a Clerk session JWT (`verifyNessaToken`)
// and resolves `ctx.nessaUserId` by looking up `users.id` via the indexed
// `clerkUserId` column. These tests exercise that lookup path against an
// in-memory SQLite DB so the contract is guaranteed:
// - seeded row with matching clerkUserId → local id resolved
// - missing local row → UNAUTHORIZED
// - the resolved id is the LOCAL users.id, never the Clerk sub
// ---------------------------------------------------------------------------
const CLERK_USER_ID = "user_test_abc123";
const LOCAL_USER_A = "local-user-a";
const LOCAL_USER_B = "local-user-b";
function initUsersTable() {
db.run(`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT,
clerkUserId TEXT
)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`);
}
async function resolveLocalUserId(clerkUserId: string): Promise<string | null> {
const result = await conn.execute({
sql: "SELECT id FROM users WHERE clerkUserId = ?",
args: [clerkUserId]
});
if (result.rows.length === 0) return null;
return (result.rows[0] as { id: string }).id;
}
describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => {
beforeAll(() => {
initUsersTable();
});
beforeEach(() => {
db.run("DELETE FROM users");
});
it("resolves local users.id for a seeded clerkUserId", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A);
});
it("returns null when no local row matches the clerkUserId", async () => {
// No users seeded — the webhook (task 04) has not run yet.
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
});
it("returns null for a Clerk id that exists but maps to a different local user", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_B, "b@nessa.app", "user_test_other"]
);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
});
it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
);
const resolved = await resolveLocalUserId(CLERK_USER_ID);
expect(resolved).toBe(LOCAL_USER_A);
expect(resolved).not.toBe(CLERK_USER_ID);
});
});