nessa: retire the API copy now served by nessa-api.freno.me
The Nessa API (nessaDb.*, nessa.community.*, Clerk auth and the Clerk user
webhook) lives in the Nessa repo's server/ on pan. This copy never served
the app: RemoteAPIClient wraps every input in {"input": …}, which the stock
tRPC adapter here reads as the input itself (queries 404, writes no-op).
Clerk's webhook already targets nessa-api.freno.me.
Drops the routers, nessa-auth, the webhook, NessaConnectionFactory, the
NESSA_* env and the now-unused @clerk/backend and svix. Nessa's marketing,
privacy and deletion pages stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
16
src/env/server.ts
vendored
16
src/env/server.ts
vendored
@@ -53,18 +53,7 @@ const serverEnvSchema = z.object({
|
||||
VITE_WEBSOCKET: z.string().min(1),
|
||||
VITE_INFILL_ENDPOINT: z.string().min(1),
|
||||
INFILL_BEARER_TOKEN: z.string().min(1),
|
||||
NESSA_DB_URL: z.string().min(1),
|
||||
NESSA_DB_TOKEN: z.string().min(1),
|
||||
// Clerk authentication — Nessa auth is now Clerk-backed. The
|
||||
// legacy self-issued JWT signing env var was removed.
|
||||
NESSA_CLERK_SECRET: z.string().min(1),
|
||||
NESSA_CLERK_JWT_ISSUER: z.string().min(1),
|
||||
// Clerk webhook signing secret (Svix). Used to verify `user.created` /
|
||||
// `user.updated` webhook payloads. Find it under Clerk Dashboard →
|
||||
// Webhooks → your endpoint → Signing Secret (starts with `whsec_`).
|
||||
NESSA_CLERK_WEBHOOK_SECRET: z.string().min(1),
|
||||
LINEAGE_JWT_SECRET: z.string().min(32),
|
||||
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
||||
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
||||
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1),
|
||||
@@ -183,11 +172,6 @@ export const getMissingEnvVars = (): string[] => {
|
||||
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
|
||||
"VITE_GITHUB_CLIENT_ID",
|
||||
"VITE_WEBSOCKET",
|
||||
"NESSA_DB_URL",
|
||||
"NESSA_DB_TOKEN",
|
||||
"NESSA_CLERK_SECRET",
|
||||
"NESSA_CLERK_JWT_ISSUER",
|
||||
"NESSA_CLERK_WEBHOOK_SECRET",
|
||||
"LINEAGE_JWT_SECRET"
|
||||
];
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { env } from "~/env/server";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
import { handleClerkUserWebhook } from "~/server/clerk-user-webhook";
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clerk webhook endpoint — receives `user.created` / `user.updated` events.
|
||||
*
|
||||
* Configure the endpoint URL in the Clerk Dashboard → Webhooks
|
||||
* (e.g. https://freno.me/api/clerk-webhook in prod, or an ngrok/dev url for
|
||||
* local dev). The signing secret (`whsec_...`) is stored in
|
||||
* `NESSA_CLERK_WEBHOOK_SECRET` and used to verify each request via Svix.
|
||||
*
|
||||
* The raw body is read verbatim from the inflight request so the Svix
|
||||
* signature is computed over the exact bytes Clerk sent.
|
||||
*/
|
||||
export async function POST(event: APIEvent) {
|
||||
const svixId = event.request.headers.get("svix-id");
|
||||
const svixTimestamp = event.request.headers.get("svix-timestamp");
|
||||
const svixSignature = event.request.headers.get("svix-signature");
|
||||
|
||||
if (!svixId || !svixTimestamp || !svixSignature) {
|
||||
return json(400, { error: "Missing Svix signature headers" });
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await event.request.text();
|
||||
} catch {
|
||||
return json(400, { error: "Missing request body" });
|
||||
}
|
||||
|
||||
const result = await handleClerkUserWebhook({
|
||||
rawBody,
|
||||
headers: {
|
||||
"svix-id": svixId,
|
||||
"svix-timestamp": svixTimestamp,
|
||||
"svix-signature": svixSignature
|
||||
},
|
||||
webhookSecret: env.NESSA_CLERK_WEBHOOK_SECRET,
|
||||
conn: NessaConnectionFactory()
|
||||
});
|
||||
|
||||
return json(result.status, result.body);
|
||||
}
|
||||
@@ -4,17 +4,16 @@
|
||||
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
|
||||
* Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
|
||||
*
|
||||
* - Authentication: user accounts are managed by Clerk
|
||||
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk
|
||||
* Backend JWKS endpoint). Nessa itself does not store passwords — Clerk is
|
||||
* the identity provider.
|
||||
* - Authentication: user accounts are managed by Clerk (the Nessa API server
|
||||
* verifies Clerk session JWTs). Nessa itself does not store passwords —
|
||||
* Clerk is the identity provider.
|
||||
* - Community content: clubs, club challenges, club posts, post likes, and
|
||||
* post comments (see `src/server/api/routers/nessa-community.ts`,
|
||||
* `clubMemberships`, `clubChallenges`, `clubChallengeParticipations`,
|
||||
* `clubPosts`, `clubPostLikes`, `clubPostComments`).
|
||||
* - Storage: the Nessa data lives in a dedicated Turso (libSQL) database
|
||||
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate
|
||||
* from the freno.me main DB and the Lineage DB.
|
||||
* post comments (`clubMemberships`, `clubChallenges`,
|
||||
* `clubChallengeParticipations`, `clubPosts`, `clubPostLikes`,
|
||||
* `clubPostComments`).
|
||||
* - Storage: the Nessa data lives in a dedicated Turso (libSQL) database,
|
||||
* served by the Nessa API (`nessa-api.freno.me`, the Nessa repo's
|
||||
* `server/`), separate from the freno.me main DB and the Lineage DB.
|
||||
*
|
||||
* PageHead is site-aware: only the base title is supplied; the
|
||||
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
|
||||
|
||||
@@ -5,7 +5,7 @@ import { databaseRouter } from "./routers/database";
|
||||
import { lineageRouter } from "./routers/lineage";
|
||||
// Re-exported so the REST shim (src/routes/api/lineage/[...path].ts) can mount
|
||||
// ONLY the lineage sub-router as the trpc-openapi REST surface — not the
|
||||
// whole appRouter. Keeps nessa/community/auth/audit routers unreachable via
|
||||
// whole appRouter. Keeps auth/audit routers unreachable via
|
||||
// `/api/lineage/*`.
|
||||
export { lineageRouter };
|
||||
import { miscRouter } from "./routers/misc";
|
||||
@@ -16,8 +16,6 @@ import { postHistoryRouter } from "./routers/post-history";
|
||||
import { infillRouter } from "./routers/infill";
|
||||
import { accountRouter } from "./routers/account";
|
||||
import { downloadsRouter } from "./routers/downloads";
|
||||
import { nessaDbRouter } from "./routers/nessa";
|
||||
import { nessaCommunityRouter } from "./routers/nessa-community";
|
||||
import { appleNotificationsRouter } from "./routers/apple-notifications";
|
||||
import { createTRPCRouter, createTRPCContext, t } from "./utils";
|
||||
import type { H3Event } from "h3";
|
||||
@@ -37,10 +35,6 @@ export const appRouter = createTRPCRouter({
|
||||
infill: infillRouter,
|
||||
account: accountRouter,
|
||||
downloads: downloadsRouter,
|
||||
nessaDb: nessaDbRouter,
|
||||
// Community features (clubs, challenges, social feed) — ported from the
|
||||
// standalone nessa-api Express prototype. Exposes nessa.community.*.
|
||||
nessa: createTRPCRouter({ community: nessaCommunityRouter }),
|
||||
appleNotifications: appleNotificationsRouter
|
||||
});
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("p8-001 / p8-008 static source audit", () => {
|
||||
it(`${proc} is not declared as publicProcedure`, () => {
|
||||
// Match the procedure declaration line and ensure it is not publicProcedure.
|
||||
const re = new RegExp(
|
||||
`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`
|
||||
`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure)`
|
||||
);
|
||||
const m = SOURCE.match(re);
|
||||
expect(m, `${proc} declaration not found`).not.toBeNull();
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
requireClubMembership,
|
||||
resolveClubIdFromPost,
|
||||
resolveClubIdFromChallenge,
|
||||
type NessaConn
|
||||
} from "./nessa-community-authz";
|
||||
|
||||
/**
|
||||
* Regression tests for p8-003: private club content (posts, comments, likes,
|
||||
* challenge participation) must NOT be readable/actionable by non-members.
|
||||
*
|
||||
* These tests exercise the shared membership-gating helpers directly against
|
||||
* an in-memory SQLite DB (`bun:sqlite`) wrapped to match the libsql
|
||||
* `execute({ sql, args }) -> { rows }` contract the router uses. The
|
||||
* `nessa-community.ts` router calls these same helpers in the same order, so a
|
||||
* pass here guarantees the authorization decision each endpoint makes before
|
||||
* touching data.
|
||||
*
|
||||
* Two users are seeded: A is a member (owner) of club C (and owns the post +
|
||||
* challenge under test); B is NOT a member of C.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory SQLite connection (libsql-shaped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let db: Database;
|
||||
let conn: NessaConn;
|
||||
|
||||
function makeConn(): NessaConn {
|
||||
return {
|
||||
execute: async ({
|
||||
sql,
|
||||
args
|
||||
}: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => {
|
||||
const stmt = db.prepare(sql);
|
||||
const upper = sql.trim().toUpperCase();
|
||||
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
|
||||
if (isRead) {
|
||||
const rows = stmt.all(...(args ?? []));
|
||||
return { rows: rows as unknown[] };
|
||||
}
|
||||
stmt.run(...(args ?? []));
|
||||
return { rows: [] as unknown[] };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema + seed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USER_A = "user-a";
|
||||
const USER_B = "user-b";
|
||||
const CLUB_C = "club-c";
|
||||
const POST_P = "post-p"; // created by A in club C
|
||||
const CHALLENGE_CH = "challenge-ch"; // in club C, created by A
|
||||
|
||||
function initSchema() {
|
||||
db = new Database(":memory:");
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
|
||||
db.run(
|
||||
"CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)"
|
||||
);
|
||||
db.run(
|
||||
"CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)"
|
||||
);
|
||||
db.run(
|
||||
"CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)"
|
||||
);
|
||||
}
|
||||
|
||||
function seed() {
|
||||
// Club C: A is a member (owner). B is NOT.
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
|
||||
// Post P by A in club C.
|
||||
db.run(
|
||||
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
[POST_P, CLUB_C, USER_A, "Hello from A", "text"]
|
||||
);
|
||||
|
||||
// Challenge CH in club C, created by A.
|
||||
db.run(
|
||||
"INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||
[
|
||||
CHALLENGE_CH,
|
||||
CLUB_C,
|
||||
"Run 5k",
|
||||
"distance",
|
||||
5000,
|
||||
"2025-01-01",
|
||||
"2025-12-31",
|
||||
USER_A,
|
||||
"active"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
initSchema();
|
||||
seed();
|
||||
conn = makeConn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Keep membership state stable across tests (join/leave integration mutates it).
|
||||
db.run("DELETE FROM clubMemberships");
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
});
|
||||
|
||||
async function errCode(p: Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await p;
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
return (e as { code?: string }).code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("p8-003: resolveClubIdFromPost", () => {
|
||||
it("resolves the owning club for an existing post", async () => {
|
||||
expect(await resolveClubIdFromPost(conn, POST_P)).toBe(CLUB_C);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for a missing post", async () => {
|
||||
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe(
|
||||
"NOT_FOUND"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-003: resolveClubIdFromChallenge", () => {
|
||||
it("resolves the owning club for an existing challenge", async () => {
|
||||
expect(await resolveClubIdFromChallenge(conn, CHALLENGE_CH)).toBe(CLUB_C);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for a missing challenge", async () => {
|
||||
expect(
|
||||
await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))
|
||||
).toBe("NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-003: requireClubMembership", () => {
|
||||
it("passes silently for a member", async () => {
|
||||
await expect(
|
||||
requireClubMembership(conn, CLUB_C, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for a non-member", async () => {
|
||||
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* End-to-end authorization sequence for each of the 7 fixed endpoints. The
|
||||
* router does exactly: resolve the resource's clubId, then
|
||||
* requireClubMembership on it. Replaying that here proves the decision a
|
||||
* non-member is rejected / a member is allowed.
|
||||
*/
|
||||
describe("p8-003: endpoint authorization sequences (resolve → require)", () => {
|
||||
// social.getPost / addComment / comments / like / unlike
|
||||
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
|
||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
});
|
||||
|
||||
it("getPost/addComment/comments/like/unlike: member A allowed", async () => {
|
||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||
await expect(
|
||||
requireClubMembership(conn, clubId, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// challenges.leave / challenges.submitProgress
|
||||
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
|
||||
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
});
|
||||
|
||||
it("challenges.leave / submitProgress: member A allowed", async () => {
|
||||
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||
await expect(
|
||||
requireClubMembership(conn, clubId, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-003: join then allowed / leave then blocked (integration)", () => {
|
||||
it("B is blocked, allowed after joining C, blocked again after leaving", async () => {
|
||||
// Initially blocked.
|
||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||
"FORBIDDEN"
|
||||
);
|
||||
|
||||
// B joins.
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-b", CLUB_C, USER_B, "member"]
|
||||
);
|
||||
await expect(
|
||||
requireClubMembership(conn, clubId, USER_B)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// B leaves.
|
||||
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
|
||||
CLUB_C,
|
||||
USER_B
|
||||
]);
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
/**
|
||||
* Community authorization helpers (p8-003).
|
||||
*
|
||||
* Membership gating for `nessaCommunityRouter`. Extracted into a dependency-
|
||||
* free module (no `~/env/server` import) so it can be unit-tested directly
|
||||
* against an in-memory SQLite connection without booting the SSR-guarded env
|
||||
* chain, and so every membership-gated endpoint shares ONE implementation of
|
||||
* each check (no ad-hoc duplicated SQL).
|
||||
*
|
||||
* Contract mirrors the libsql client the router uses: a connection exposes
|
||||
* `execute({ sql, args }) -> { rows }`.
|
||||
*/
|
||||
|
||||
/** Minimal libsql-shaped connection surface used by community authz. */
|
||||
export interface NessaConn {
|
||||
execute: (q: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => Promise<{ rows: unknown[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the calling user is a member of the club (or is the owner).
|
||||
*
|
||||
* Throws `TRPCError({ code: "FORBIDDEN", message: "Not a member of this club" })`
|
||||
* on miss and returns void on success. This is the single source of truth for
|
||||
* "is this user allowed to touch this club's content" — every membership-gated
|
||||
* endpoint in `nessa-community.ts` MUST route through this helper.
|
||||
*/
|
||||
export async function requireClubMembership(
|
||||
conn: NessaConn,
|
||||
clubId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id FROM clubMemberships WHERE clubId = ? AND userId = ?",
|
||||
args: [clubId, userId]
|
||||
});
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Not a member of this club"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the clubId that owns a post. Returns the owning club's id, or
|
||||
* throws `NOT_FOUND` if the post does not exist. Used by read/interaction
|
||||
* endpoints (`getPost`, `addComment`, `comments`, `like`, `unlike`) to derive
|
||||
* the club a target post belongs to before gating on membership.
|
||||
*/
|
||||
export async function resolveClubIdFromPost(
|
||||
conn: NessaConn,
|
||||
postId: string
|
||||
): Promise<string> {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT clubId FROM clubPosts WHERE id = ?",
|
||||
args: [postId]
|
||||
});
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
|
||||
}
|
||||
return (result.rows[0] as unknown as { clubId: string }).clubId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the clubId that owns a challenge. Returns the owning club's id, or
|
||||
* throws `NOT_FOUND` if the challenge does not exist. Used by challenge
|
||||
* interaction endpoints (`challenges.leave`, `challenges.submitProgress`) to
|
||||
* derive the club a challenge belongs to before gating on membership.
|
||||
*/
|
||||
export async function resolveClubIdFromChallenge(
|
||||
conn: NessaConn,
|
||||
challengeId: string
|
||||
): Promise<string> {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT clubId FROM clubChallenges WHERE id = ?",
|
||||
args: [challengeId]
|
||||
});
|
||||
if (!result.rows.length) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Challenge not found"
|
||||
});
|
||||
}
|
||||
return (result.rows[0] as unknown as { clubId: string }).clubId;
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { sanitizeCommunityContent } from "~/server/lib/sanitize";
|
||||
import {
|
||||
requireClubMembership,
|
||||
resolveClubIdFromPost,
|
||||
type NessaConn
|
||||
} from "./nessa-community-authz";
|
||||
|
||||
/**
|
||||
* Tests for p8-012: sanitize community post/comment content on write.
|
||||
*
|
||||
* Content model: plain text — no HTML is stored. The iOS client renders
|
||||
* with SwiftUI `Text()` (not a WebView), so there is no render-time XSS
|
||||
* surface. Sanitization on write is defense-in-depth against a future HTML
|
||||
* render path.
|
||||
*
|
||||
* The router's `social.createPost` and `social.addComment` run
|
||||
* `sanitizeCommunityContent(input.content)` before the INSERT. These tests
|
||||
* verify the sanitizer directly and then exercise the full write path
|
||||
* against an in-memory SQLite DB wrapped to match the libsql contract.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanitizer unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sanitizeCommunityContent", () => {
|
||||
it("strips <script> tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent("<script>alert(1)</script>hello")
|
||||
).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("strips <script> with attributes and newlines", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
"<script type='text/javascript'>\nalert('xss')\n</script>safe"
|
||||
)
|
||||
).toBe("alert('xss') safe");
|
||||
});
|
||||
|
||||
it("strips <img onerror=...> event handlers", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'check this <img src=x onerror="alert(1)"> out'
|
||||
)
|
||||
).toBe("check this out");
|
||||
});
|
||||
|
||||
it("strips <svg onload=...>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent('<svg onload="alert(1)">text</svg>')
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips <iframe>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<iframe src="javascript:alert(1)"></iframe>content'
|
||||
)
|
||||
).toBe("content");
|
||||
});
|
||||
|
||||
it("strips <body onload=...>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent('<body onload="alert(1)">body</body>')
|
||||
).toBe("body");
|
||||
});
|
||||
|
||||
it("strips <input onfocus=... autofocus>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<input onfocus="alert(1)" autofocus>text'
|
||||
)
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips <a href=javascript:...>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'click <a href="javascript:alert(1)">here</a> now'
|
||||
)
|
||||
).toBe("click here now");
|
||||
});
|
||||
|
||||
it("strips <style> tags (inner text is harmless as plain text)", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<style>body{background:red}</style>text'
|
||||
)
|
||||
).toBe("body{background:red}text");
|
||||
});
|
||||
|
||||
it("strips <link> tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<link rel="stylesheet" href="evil.css">text'
|
||||
)
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips self-closing tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent('text<br/>more<br />end')
|
||||
).toBe("textmoreend");
|
||||
});
|
||||
|
||||
it("handles HTML entities — no round-trip through future HTML renderer", () => {
|
||||
// `<script>` decoded to `<script>` then stripped, not stored as
|
||||
// literal `<script>` that a future HTML renderer would decode.
|
||||
expect(
|
||||
sanitizeCommunityContent("<script>alert(1)</script>hello")
|
||||
).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("decodes numeric entities", () => {
|
||||
expect(sanitizeCommunityContent("<script>alert</script>")).toBe(
|
||||
"alert"
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes hex entities", () => {
|
||||
expect(sanitizeCommunityContent("<script>alert</script>")).toBe(
|
||||
"alert"
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes common named entities", () => {
|
||||
expect(sanitizeCommunityContent("it's "great" & fun")).toBe(
|
||||
"it's \"great\" & fun"
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses whitespace and trims", () => {
|
||||
expect(sanitizeCommunityContent(" hello world ")).toBe("hello world");
|
||||
expect(sanitizeCommunityContent("\n\t spaced \n")).toBe("spaced");
|
||||
});
|
||||
|
||||
it("preserves plain text unchanged", () => {
|
||||
expect(sanitizeCommunityContent("Hello world! How are you?")).toBe(
|
||||
"Hello world! How are you?"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves text with mixed safe punctuation", () => {
|
||||
expect(sanitizeCommunityContent("It's 100% amazing — really!")).toBe(
|
||||
"It's 100% amazing — really!"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty / whitespace-only input", () => {
|
||||
expect(sanitizeCommunityContent("")).toBe("");
|
||||
expect(sanitizeCommunityContent(" ")).toBe("");
|
||||
expect(sanitizeCommunityContent("\n\n\t")).toBe("");
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
expect(sanitizeCommunityContent("你好世界 🌍")).toBe("你好世界 🌍");
|
||||
});
|
||||
|
||||
it("strips nested tags", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<div><p><script>alert(1)</script><b>bold</b></p></div>end'
|
||||
)
|
||||
).toBe("alert(1)boldend");
|
||||
});
|
||||
|
||||
it("handles unclosed tags", () => {
|
||||
expect(sanitizeCommunityContent("<div>text")).toBe("text");
|
||||
expect(sanitizeCommunityContent("text</div>")).toBe("text");
|
||||
});
|
||||
|
||||
it("strips <object>, <embed>, <applet>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<object data="evil.swf"></object>text'
|
||||
)
|
||||
).toBe("text");
|
||||
expect(
|
||||
sanitizeCommunityContent('<embed src="evil.swf">text')
|
||||
).toBe("text");
|
||||
expect(
|
||||
sanitizeCommunityContent('<applet code="Evil.class">text</applet>')
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
it("strips data: URI tags — remaining text is harmless as plain text", () => {
|
||||
// The <a> tag and its attribute are stripped; the inner text remains.
|
||||
// As plain text, the leftover characters are not executable.
|
||||
const result = sanitizeCommunityContent(
|
||||
'<a href="data:text/html,<script>alert(1)</script>">link</a>'
|
||||
);
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("data:text/html");
|
||||
expect(result).toContain("link");
|
||||
});
|
||||
|
||||
it("strips <math> and <foreignObject>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<math><maction actiontype="statusline#http://evil.com">click</maction></math>'
|
||||
)
|
||||
).toBe("click");
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<svg><foreignObject><div xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></div></foreignObject></svg>'
|
||||
)
|
||||
).toBe("alert(1)");
|
||||
});
|
||||
|
||||
it("strips <details> / <summary> / <template>", () => {
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<details><summary>click</summary><script>alert(1)</script></details>'
|
||||
)
|
||||
).toBe("clickalert(1)");
|
||||
expect(
|
||||
sanitizeCommunityContent(
|
||||
'<template><script>alert(1)</script></template>text'
|
||||
)
|
||||
).toBe("alert(1)text");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory SQLite write-path tests (libsql-shaped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let db: Database;
|
||||
let conn: NessaConn;
|
||||
|
||||
function makeConn(): NessaConn {
|
||||
return {
|
||||
execute: async ({
|
||||
sql,
|
||||
args
|
||||
}: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => {
|
||||
const stmt = db.prepare(sql);
|
||||
const upper = sql.trim().toUpperCase();
|
||||
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
|
||||
if (isRead) {
|
||||
const rows = stmt.all(...(args ?? []));
|
||||
return { rows: rows as unknown[] };
|
||||
}
|
||||
stmt.run(...(args ?? []));
|
||||
return { rows: [] as unknown[] };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const USER_A = "user-a";
|
||||
const CLUB_C = "club-c";
|
||||
|
||||
function initSchema() {
|
||||
db = new Database(":memory:");
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
|
||||
db.run("CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)");
|
||||
db.run("CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)");
|
||||
db.run("CREATE TABLE clubPostComments (id TEXT PRIMARY KEY, postId TEXT, userId TEXT, content TEXT, createdAt TEXT, updatedAt TEXT)");
|
||||
}
|
||||
|
||||
function seed() {
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
initSchema();
|
||||
seed();
|
||||
conn = makeConn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.run("DELETE FROM clubPostComments");
|
||||
db.run("DELETE FROM clubPosts");
|
||||
db.run("DELETE FROM clubMemberships");
|
||||
db.run(
|
||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
["mem-a", CLUB_C, USER_A, "owner"]
|
||||
);
|
||||
});
|
||||
|
||||
describe("p8-012: createPost sanitizes content on write", () => {
|
||||
it("stores <script>alert(1)</script> without the script tag", async () => {
|
||||
const postId = "post-1";
|
||||
const raw = "<script>alert(1)</script>hello";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).not.toContain("<script>");
|
||||
expect(row.content).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("stores <img onerror=...> without the event handler", async () => {
|
||||
const postId = "post-2";
|
||||
const raw = 'check this <img src=x onerror="alert(1)"> out';
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).not.toContain("onerror");
|
||||
expect(row.content).not.toContain("<img");
|
||||
expect(row.content).toBe("check this out");
|
||||
});
|
||||
|
||||
it("stores <script> decoded and stripped (no entity round-trip)", async () => {
|
||||
const postId = "post-3";
|
||||
const raw = "<script>alert(1)</script>hello";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).not.toContain("<script>");
|
||||
expect(row.content).not.toContain("<script");
|
||||
expect(row.content).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("preserves plain text content unchanged", async () => {
|
||||
const postId = "post-4";
|
||||
const raw = "Hello world! This is a normal post.";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))",
|
||||
args: [postId, CLUB_C, USER_A, sanitized, "text"]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPosts WHERE id = ?").get(postId);
|
||||
expect(row.content).toBe("Hello world! This is a normal post.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("p8-012: addComment sanitizes content on write", () => {
|
||||
it("stores <script>alert(1)</script> without the script tag", async () => {
|
||||
const postId = "post-1";
|
||||
const commentId = "comment-1";
|
||||
const raw = "<script>alert(1)</script>hello";
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
// Seed the post first
|
||||
db.run(
|
||||
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, 'normal post', 'text', NULL, datetime('now'), datetime('now'))",
|
||||
[postId, CLUB_C, USER_A]
|
||||
);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPostComments (id, postId, userId, content, createdAt, updatedAt) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||
args: [commentId, postId, USER_A, sanitized]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPostComments WHERE id = ?").get(commentId);
|
||||
expect(row.content).not.toContain("<script>");
|
||||
expect(row.content).toBe("alert(1)hello");
|
||||
});
|
||||
|
||||
it("stores <img onerror=...> without the event handler", async () => {
|
||||
const postId = "post-2";
|
||||
const commentId = "comment-2";
|
||||
const raw = 'look <img src=x onerror="alert(1)"> here';
|
||||
const sanitized = sanitizeCommunityContent(raw);
|
||||
|
||||
db.run(
|
||||
"INSERT INTO clubPosts (id, clubId, userId, content, postType, challengeId, createdAt, updatedAt) VALUES (?, ?, ?, 'normal post', 'text', NULL, datetime('now'), datetime('now'))",
|
||||
[postId, CLUB_C, USER_A]
|
||||
);
|
||||
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO clubPostComments (id, postId, userId, content, createdAt, updatedAt) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||
args: [commentId, postId, USER_A, sanitized]
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT content FROM clubPostComments WHERE id = ?").get(commentId);
|
||||
expect(row.content).not.toContain("onerror");
|
||||
expect(row.content).not.toContain("<img");
|
||||
expect(row.content).toBe("look here");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,371 +0,0 @@
|
||||
/**
|
||||
* Nessa CRUD Ownership Check Tests
|
||||
* Regression tests for p8-002: per-resource ownership verification on mutation endpoints
|
||||
*
|
||||
* The ownership enforcement lives in three exported helpers — assertWorkoutOwned,
|
||||
* assertAuthProviderOwned, and assertExerciseLibraryOwned — which every targeted
|
||||
* mutation calls before modifying data. These tests verify the helpers and the
|
||||
* direct userId comparisons used in create/createAuthProvider/createExerciseLibrary
|
||||
* and bulkUpsert.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { Client } from "@libsql/client/web";
|
||||
|
||||
// Prevent the env/server.ts client-side guard from throwing during tests
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
TURSO_DB_URL: "libsql://test.turso.io",
|
||||
TURSO_DB_TOKEN: "test-token",
|
||||
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
||||
NESSA_DB_TOKEN: "test-token",
|
||||
TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io",
|
||||
TURSO_LINEAGE_TOKEN: "test-token",
|
||||
TURSO_DB_API_TOKEN: "test-token",
|
||||
NODE_ENV: "test",
|
||||
// Clerk env vars (required after migration)
|
||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||
},
|
||||
validateServerEnv: () => ({}),
|
||||
isMissingEnvVar: () => false,
|
||||
getMissingEnvVars: () => []
|
||||
}));
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMockConn(rows: Record<string, unknown>[]): Client {
|
||||
const executeMock = mock(async () => {
|
||||
return { rows, rowsAffected: 0, lastInsertRowid: 0n } as any;
|
||||
});
|
||||
return { execute: executeMock } as Client;
|
||||
}
|
||||
|
||||
const USER_A = "user-a";
|
||||
const USER_B = "user-b";
|
||||
const WORKOUT_ID = "workout-1";
|
||||
const HR_SAMPLE_ID = "hr-1";
|
||||
const LOC_SAMPLE_ID = "loc-1";
|
||||
const SPLIT_ID = "split-1";
|
||||
const EXERCISE_ID = "ex-1";
|
||||
const PROVIDER_ID = "prov-1";
|
||||
|
||||
// ─── assertWorkoutOwned helper ────────────────────────────────────────────────
|
||||
// Used by: create/update/deleteHeartRateSample, create/update/deleteLocationSample,
|
||||
// create/update/deleteWorkoutSplit
|
||||
|
||||
describe("assertWorkoutOwned helper", () => {
|
||||
let assertWorkoutOwned: (
|
||||
conn: Client,
|
||||
workoutId: string,
|
||||
userId: string
|
||||
) => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./nessa");
|
||||
assertWorkoutOwned = mod.assertWorkoutOwned;
|
||||
});
|
||||
|
||||
it("rejects when workout belongs to another user", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_B }]);
|
||||
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
|
||||
/owner/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when workout does not exist", async () => {
|
||||
const conn = makeMockConn([]);
|
||||
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
|
||||
/not found/i
|
||||
);
|
||||
});
|
||||
|
||||
it("succeeds when workout belongs to the caller", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
await expect(
|
||||
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── assertAuthProviderOwned helper ───────────────────────────────────────────
|
||||
// Used by: updateAuthProvider, deleteAuthProvider
|
||||
|
||||
describe("assertAuthProviderOwned helper", () => {
|
||||
let assertAuthProviderOwned: (
|
||||
conn: Client,
|
||||
providerId: string,
|
||||
userId: string
|
||||
) => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./nessa");
|
||||
assertAuthProviderOwned = mod.assertAuthProviderOwned;
|
||||
});
|
||||
|
||||
it("rejects when auth provider belongs to another user", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_B }]);
|
||||
await expect(
|
||||
assertAuthProviderOwned(conn, PROVIDER_ID, USER_A)
|
||||
).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it("rejects when auth provider does not exist", async () => {
|
||||
const conn = makeMockConn([]);
|
||||
await expect(
|
||||
assertAuthProviderOwned(conn, PROVIDER_ID, USER_A)
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it("succeeds when auth provider belongs to the caller", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
await expect(
|
||||
assertAuthProviderOwned(conn, PROVIDER_ID, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── assertExerciseLibraryOwned helper ────────────────────────────────────────
|
||||
// Used by: updateExerciseLibrary, deleteExerciseLibrary
|
||||
|
||||
describe("assertExerciseLibraryOwned helper", () => {
|
||||
let assertExerciseLibraryOwned: (
|
||||
conn: Client,
|
||||
exerciseId: string,
|
||||
userId: string
|
||||
) => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./nessa");
|
||||
assertExerciseLibraryOwned = mod.assertExerciseLibraryOwned;
|
||||
});
|
||||
|
||||
it("rejects when exercise belongs to another user", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_B }]);
|
||||
await expect(
|
||||
assertExerciseLibraryOwned(conn, EXERCISE_ID, USER_A)
|
||||
).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it("rejects when exercise does not exist", async () => {
|
||||
const conn = makeMockConn([]);
|
||||
await expect(
|
||||
assertExerciseLibraryOwned(conn, EXERCISE_ID, USER_A)
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it("succeeds when exercise belongs to the caller", async () => {
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
await expect(
|
||||
assertExerciseLibraryOwned(conn, EXERCISE_ID, USER_A)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mutation handler direct ownership checks ─────────────────────────────────
|
||||
// createHeartRateSample, createLocationSample, createWorkoutSplit call
|
||||
// assertWorkoutOwned — covered above.
|
||||
//
|
||||
// The remaining create mutations (createExerciseLibrary, createAuthProvider)
|
||||
// use a direct userId comparison: input.userId !== ctx.nessaUserId.
|
||||
// bulkUpsert also uses direct comparisons for users/workoutPlans/workouts/
|
||||
// exerciseLibrary/authProviders.
|
||||
//
|
||||
// We verify the comparison logic with pure unit tests.
|
||||
|
||||
describe("createExerciseLibrary direct userId check", () => {
|
||||
it("user B creating an exercise with userId=userA → FORBIDDEN", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_B };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("user A creating an exercise with userId=userA → allowed", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuthProvider direct userId check (account takeover prevention)", () => {
|
||||
it("user B creating an auth provider with userId=userA → FORBIDDEN", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_B };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("user A creating an auth provider with userId=userA → allowed", () => {
|
||||
const input = { userId: USER_A };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (input.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkUpsert ownership checks", () => {
|
||||
it("rejects a user record whose id ≠ caller", () => {
|
||||
const record = { id: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.id !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a workoutPlan whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a workout whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an exerciseLibrary record whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an authProvider whose userId ≠ caller", () => {
|
||||
const record = { userId: USER_B };
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
if (record.userId !== ctx.nessaUserId) threw = true;
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts records that belong to the caller", () => {
|
||||
const ctx = { nessaUserId: USER_A };
|
||||
let threw = false;
|
||||
for (const key of ["id", "userId"] as const) {
|
||||
const record = { [key]: USER_A };
|
||||
const val = record[key] as string;
|
||||
if (val !== ctx.nessaUserId) threw = true;
|
||||
}
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── static audit: no mutation handler ignores ctx.nessaUserId ────────────────
|
||||
// Verify by source-code inspection that every targeted mutation references ctx.
|
||||
|
||||
describe("static audit: every targeted mutation handler uses ctx", () => {
|
||||
const MUTATIONS = [
|
||||
"createHeartRateSample",
|
||||
"updateHeartRateSample",
|
||||
"deleteHeartRateSample",
|
||||
"createLocationSample",
|
||||
"updateLocationSample",
|
||||
"deleteLocationSample",
|
||||
"createWorkoutSplit",
|
||||
"updateWorkoutSplit",
|
||||
"deleteWorkoutSplit",
|
||||
"createExerciseLibrary",
|
||||
"updateExerciseLibrary",
|
||||
"deleteExerciseLibrary",
|
||||
"createAuthProvider",
|
||||
"updateAuthProvider",
|
||||
"deleteAuthProvider"
|
||||
];
|
||||
|
||||
it("no mutation handler in the list uses async ({ input }) without ctx", async () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||
|
||||
for (const name of MUTATIONS) {
|
||||
const re = new RegExp(
|
||||
`${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`,
|
||||
"s"
|
||||
);
|
||||
const match = source.match(re);
|
||||
expect(
|
||||
match,
|
||||
`${name} should not use async ({ input }) — must use ctx`
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("every mutation handler in the list references ctx", async () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||
|
||||
for (const name of MUTATIONS) {
|
||||
const re = new RegExp(
|
||||
`${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`,
|
||||
"s"
|
||||
);
|
||||
const match = source.match(re);
|
||||
expect(match, `${name} mutation block not found`).toBeTruthy();
|
||||
expect(match![0].includes("ctx"), `${name} must reference ctx`).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("upsertExerciseLibrary rejects an exercise owned by another user", async () => {
|
||||
const mod = await import("./nessa");
|
||||
const conn = makeMockConn([]);
|
||||
const exercise = {
|
||||
id: EXERCISE_ID,
|
||||
userId: USER_B,
|
||||
name: "Squat",
|
||||
category: "Strength"
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
await mod.upsertExerciseLibrary(conn, USER_A, [exercise]);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect((caught as TRPCError).code).toBe("FORBIDDEN");
|
||||
expect((caught as TRPCError).message).toBe("User mismatch");
|
||||
});
|
||||
|
||||
it("upsertExerciseLibrary upserts an exercise owned by the caller", async () => {
|
||||
const mod = await import("./nessa");
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
const exercise = {
|
||||
id: EXERCISE_ID,
|
||||
userId: USER_A,
|
||||
name: "Squat",
|
||||
category: "Strength"
|
||||
};
|
||||
await expect(
|
||||
mod.upsertExerciseLibrary(conn, USER_A, [exercise])
|
||||
).resolves.toBeUndefined();
|
||||
expect(conn.execute).toHaveBeenCalledWith({
|
||||
sql: expect.stringContaining(
|
||||
"INSERT INTO exerciseLibrary (id, userId, name, category"
|
||||
),
|
||||
args: [
|
||||
EXERCISE_ID,
|
||||
USER_A,
|
||||
"Squat",
|
||||
"Strength",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,12 @@ import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { verifyNessaToken } from "~/server/nessa-auth";
|
||||
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||
import { NessaConnectionFactory } from "~/server/database";
|
||||
|
||||
export type Context = {
|
||||
event: APIEvent;
|
||||
userId: string | null;
|
||||
isAdmin: boolean;
|
||||
nessaUserId: string | null;
|
||||
};
|
||||
|
||||
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
|
||||
@@ -55,44 +52,6 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||
const authHeader =
|
||||
getHeader(req.headers, "authorization") ||
|
||||
getHeader(event.request?.headers, "authorization") ||
|
||||
null;
|
||||
|
||||
let nessaUserId: string | null = null;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.replace("Bearer ", "").trim();
|
||||
try {
|
||||
// Verify the Clerk session JWT — `sub` is the Clerk user id.
|
||||
const clerkPayload = await verifyNessaToken(token);
|
||||
|
||||
// Resolve the Clerk user id to the local users.id via the indexed
|
||||
// clerkUserId column. One indexed query per request is acceptable;
|
||||
// no premature caching (the row is created by the Clerk webhook).
|
||||
const conn = NessaConnectionFactory();
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id FROM users WHERE clerkUserId = ?",
|
||||
args: [clerkPayload.sub]
|
||||
});
|
||||
if (result.rows.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Nessa user not found — Clerk account not linked"
|
||||
});
|
||||
}
|
||||
// `nessaUserId` is the LOCAL users.id — router bodies reference it
|
||||
// exactly as before (club ownership, membership, row scoping).
|
||||
nessaUserId = (result.rows[0] as { id: string }).id;
|
||||
} catch (error) {
|
||||
// Re-throw typed TRPCError (lookup miss) so the caller gets UNAUTHORIZED;
|
||||
// swallow Clerk verification failures (expired/invalid token) the same
|
||||
// way the legacy path did — the enforceNessaUser middleware rejects
|
||||
// null nessaUserId with UNAUTHORIZED.
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error("Nessa JWT verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't log the performance logging endpoint itself to avoid circular tracking
|
||||
if (!path.includes("analytics.logPerformance")) {
|
||||
@@ -111,8 +70,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
return {
|
||||
event,
|
||||
userId,
|
||||
isAdmin,
|
||||
nessaUserId
|
||||
isAdmin
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,24 +110,8 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
const enforceNessaUser = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Nessa authentication required"
|
||||
});
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
nessaUserId: ctx.nessaUserId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
||||
export const nessaProcedure = t.procedure.use(enforceNessaUser);
|
||||
|
||||
// CSRF protection middleware - defined here to avoid circular dependency
|
||||
const csrfProtection = t.middleware(async ({ ctx, next }) => {
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
/**
|
||||
* Clerk user webhook sync tests
|
||||
*
|
||||
* Exercises `handleClerkUserWebhook` against an in-memory SQLite DB
|
||||
* (`bun:sqlite`) wrapped to match the libsql `{ execute({ sql, args }) }`
|
||||
* contract. Each event payload is Svix-signed with the same secret the
|
||||
* handler verifies against, so the signature path is exercised for real —
|
||||
* including the unsigned / wrong-signature rejection cases.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { Webhook } from "svix";
|
||||
import {
|
||||
handleClerkUserWebhook,
|
||||
__resetClerkWebhookMigrationForTests,
|
||||
type NessaConn
|
||||
} from "~/server/clerk-user-webhook";
|
||||
|
||||
// ─── harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WEBHOOK_SECRET =
|
||||
"whsec_dGhpcyBpcyBhIHRlc3Qgc2VjcmV0IGtleSBmb3IgY2xlcmsgd2ViaG9va3M=";
|
||||
|
||||
let db: Database;
|
||||
let conn: NessaConn;
|
||||
|
||||
function makeConn(): NessaConn {
|
||||
return {
|
||||
execute: async ({
|
||||
sql,
|
||||
args
|
||||
}: {
|
||||
sql: string;
|
||||
args?: (string | number | null)[];
|
||||
}) => {
|
||||
const stmt = db.prepare(sql);
|
||||
const upper = sql.trim().toUpperCase();
|
||||
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
|
||||
if (isRead) {
|
||||
const rows = stmt.all(...(args ?? []));
|
||||
return { rows: rows as unknown[] };
|
||||
}
|
||||
const info = stmt.run(...(args ?? []));
|
||||
return { rows: [] as unknown[], rowsAffected: info.changes };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function initSchema() {
|
||||
db = new Database(":memory:");
|
||||
// `users` table mirrors the Nessa production schema — note clerkUserId is
|
||||
// NOT present here so we exercise the idempotent ALTER-TABLE migration path.
|
||||
db.run(`CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
emailVerified INTEGER DEFAULT 0,
|
||||
firstName TEXT,
|
||||
lastName TEXT,
|
||||
displayName TEXT,
|
||||
avatarUrl TEXT,
|
||||
provider TEXT,
|
||||
appleUserId TEXT,
|
||||
status TEXT,
|
||||
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
lastLoginAt TEXT
|
||||
)`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initSchema();
|
||||
conn = makeConn();
|
||||
__resetClerkWebhookMigrationForTests();
|
||||
});
|
||||
|
||||
// ─── signings helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function sign(
|
||||
payload: object,
|
||||
secret: string = WEBHOOK_SECRET
|
||||
): { rawBody: string; headers: ReturnType<typeof headersFor> } {
|
||||
const rawBody = JSON.stringify(payload);
|
||||
const msgId = `msg_${Math.random().toString(36).slice(2)}`;
|
||||
const ts = new Date();
|
||||
const wh = new Webhook(secret);
|
||||
const signature = wh.sign(msgId, ts, rawBody);
|
||||
return {
|
||||
rawBody,
|
||||
headers: {
|
||||
"svix-id": msgId,
|
||||
"svix-timestamp": String(Math.floor(ts.getTime() / 1000)),
|
||||
"svix-signature": signature
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// satisfy the inferred header type
|
||||
function headersFor() {
|
||||
return {
|
||||
"svix-id": "",
|
||||
"svix-timestamp": "",
|
||||
"svix-signature": ""
|
||||
};
|
||||
}
|
||||
|
||||
function userCreatedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
object: "event",
|
||||
type: "user.created",
|
||||
data: {
|
||||
id: "user_abc123",
|
||||
email_addresses: [
|
||||
{
|
||||
id: "idn_1",
|
||||
email_address: "jane@example.com",
|
||||
verification: { status: "verified" }
|
||||
}
|
||||
],
|
||||
primary_email_address_id: "idn_1",
|
||||
first_name: "Jane",
|
||||
last_name: "Doe",
|
||||
username: "janedoe",
|
||||
image_url: "https://cdn.clerk.com/avatar.png",
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function userUpdatedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
object: "event",
|
||||
type: "user.updated",
|
||||
data: {
|
||||
id: "user_abc123",
|
||||
email_addresses: [
|
||||
{
|
||||
id: "idn_1",
|
||||
email_address: "jane.new@example.com",
|
||||
verification: { status: "verified" }
|
||||
}
|
||||
],
|
||||
primary_email_address_id: "idn_1",
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
username: "janesmith",
|
||||
image_url: "https://cdn.clerk.com/avatar2.png",
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function call(
|
||||
signed: { rawBody: string; headers: ReturnType<typeof headersFor> },
|
||||
secret: string = WEBHOOK_SECRET
|
||||
) {
|
||||
return handleClerkUserWebhook({
|
||||
rawBody: signed.rawBody,
|
||||
headers: signed.headers,
|
||||
webhookSecret: secret,
|
||||
conn
|
||||
});
|
||||
}
|
||||
|
||||
function getUserByClerkId(clerkUserId: string) {
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?"
|
||||
)
|
||||
.get(clerkUserId) as
|
||||
| {
|
||||
id: string;
|
||||
clerkUserId: string;
|
||||
email: string | null;
|
||||
emailVerified: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
provider: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
| undefined;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Clerk user.created webhook", () => {
|
||||
it("creates a local users row keyed by clerkUserId", async () => {
|
||||
const res = await call(sign(userCreatedPayload()));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.clerkUserId).toBe("user_abc123");
|
||||
expect(user!.email).toBe("jane@example.com");
|
||||
expect(user!.emailVerified).toBe(1); // Clerk marked the email verified
|
||||
expect(user!.firstName).toBe("Jane");
|
||||
expect(user!.lastName).toBe("Doe");
|
||||
expect(user!.displayName).toBe("Jane Doe");
|
||||
expect(user!.avatarUrl).toBe("https://cdn.clerk.com/avatar.png");
|
||||
expect(user!.provider).toBe("clerk");
|
||||
expect(user!.status).toBe("active");
|
||||
expect(user!.id).not.toBe("user_abc123"); // a fresh local UUID, not the Clerk id
|
||||
});
|
||||
|
||||
it("upserts (does not duplicate) on a replayed created event", async () => {
|
||||
const signed = sign(userCreatedPayload());
|
||||
await call(signed);
|
||||
await call(signed); // replay
|
||||
const count = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM users WHERE clerkUserId = ?").get(
|
||||
"user_abc123"
|
||||
) as { n: number }
|
||||
).n;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to username when first/last name are absent", async () => {
|
||||
const res = await call(
|
||||
sign(
|
||||
userCreatedPayload({
|
||||
first_name: null,
|
||||
last_name: null
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user!.displayName).toBe("janedoe");
|
||||
});
|
||||
|
||||
it("records emailVerified=0 when Clerk marks the email unverified", async () => {
|
||||
const res = await call(
|
||||
sign(
|
||||
userCreatedPayload({
|
||||
email_addresses: [
|
||||
{
|
||||
id: "idn_1",
|
||||
email_address: "unverified@example.com",
|
||||
verification: { status: "unverified" }
|
||||
}
|
||||
],
|
||||
primary_email_address_id: "idn_1"
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user!.emailVerified).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clerk user.updated webhook", () => {
|
||||
it("updates mutable fields and leaves clerkUserId unchanged", async () => {
|
||||
await call(sign(userCreatedPayload()));
|
||||
|
||||
const before = getUserByClerkId("user_abc123");
|
||||
const localId = before!.id;
|
||||
|
||||
const res = await call(sign(userUpdatedPayload()));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const after = getUserByClerkId("user_abc123");
|
||||
expect(after!.id).toBe(localId); // local UUID stable
|
||||
expect(after!.clerkUserId).toBe("user_abc123");
|
||||
expect(after!.email).toBe("jane.new@example.com");
|
||||
expect(after!.emailVerified).toBe(1); // verification status carried through update
|
||||
expect(after!.lastName).toBe("Smith");
|
||||
expect(after!.displayName).toBe("Jane Smith");
|
||||
expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png");
|
||||
});
|
||||
|
||||
it("is a no-op when the user does not exist (no row inserted)", async () => {
|
||||
const res = await call(sign(userUpdatedPayload()));
|
||||
expect(res.status).toBe(200);
|
||||
const count = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM users").get() as { n: number }
|
||||
).n;
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clerk webhook signature enforcement", () => {
|
||||
it("rejects a missing-svix-header request with 400", async () => {
|
||||
const res = await handleClerkUserWebhook({
|
||||
rawBody: JSON.stringify(userCreatedPayload()),
|
||||
headers: {
|
||||
"svix-id": "",
|
||||
"svix-timestamp": "",
|
||||
"svix-signature": ""
|
||||
},
|
||||
webhookSecret: WEBHOOK_SECRET,
|
||||
conn
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects an unsigned payload with 401", async () => {
|
||||
const res = await handleClerkUserWebhook({
|
||||
rawBody: JSON.stringify(userCreatedPayload()),
|
||||
headers: {
|
||||
"svix-id": "msg_x",
|
||||
"svix-timestamp": String(Math.floor(Date.now() / 1000)),
|
||||
"svix-signature": "v1,tampered"
|
||||
},
|
||||
webhookSecret: WEBHOOK_SECRET,
|
||||
conn
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a payload signed with the wrong secret with 401", async () => {
|
||||
const res = await call(
|
||||
sign(
|
||||
userCreatedPayload(),
|
||||
"whsec_YW5vdGhlcl9kaWZmZXJlbnRfc2VjcmV0X2tleV9mb3JfdGVzdHM="
|
||||
),
|
||||
WEBHOOK_SECRET // handler uses this
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("ignores non user.* event types with 200", async () => {
|
||||
const res = await call(
|
||||
sign({
|
||||
object: "event",
|
||||
type: "session.created",
|
||||
data: { id: "sess_1" }
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { ignored: string }).ignored).toBe("session.created");
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// 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 the primary email's verification status (1 = verified, 0 = unverified). */
|
||||
function resolveEmailVerified(data: ClerkUserData): number {
|
||||
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?.verification?.status === "verified" ? 1 : 0;
|
||||
}
|
||||
|
||||
/** 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 emailVerified = resolveEmailVerified(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, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
|
||||
ON CONFLICT(clerkUserId) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
emailVerified = excluded.emailVerified,
|
||||
firstName = excluded.firstName,
|
||||
lastName = excluded.lastName,
|
||||
displayName = excluded.displayName,
|
||||
avatarUrl = excluded.avatarUrl,
|
||||
updatedAt = datetime('now')`,
|
||||
args: [
|
||||
crypto.randomUUID(),
|
||||
clerkUserId,
|
||||
email,
|
||||
emailVerified,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
avatarUrl
|
||||
]
|
||||
});
|
||||
} else {
|
||||
// user.updated — mutate identity fields only; never change clerkUserId.
|
||||
await conn.execute({
|
||||
sql: `UPDATE users SET
|
||||
email = ?,
|
||||
emailVerified = ?,
|
||||
firstName = ?,
|
||||
lastName = ?,
|
||||
displayName = ?,
|
||||
avatarUrl = ?,
|
||||
updatedAt = datetime('now')
|
||||
WHERE clerkUserId = ?`,
|
||||
args: [email, emailVerified, 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" }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
import {
|
||||
ConnectionFactory,
|
||||
LineageConnectionFactory,
|
||||
NessaConnectionFactory,
|
||||
NookConnectionFactory
|
||||
} from "~/server/db-connections";
|
||||
// Re-export connection factories to avoid circular import with auth.ts
|
||||
export {
|
||||
ConnectionFactory,
|
||||
LineageConnectionFactory,
|
||||
NessaConnectionFactory,
|
||||
NookConnectionFactory
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { env } from "~/env/server";
|
||||
|
||||
let mainDBConnection: Client | null = null;
|
||||
let lineageDBConnection: Client | null = null;
|
||||
let nessaDBConnection: Client | null = null;
|
||||
let nookDBConnection: Client | null = null;
|
||||
|
||||
export function ConnectionFactory() {
|
||||
@@ -28,17 +27,6 @@ export function LineageConnectionFactory() {
|
||||
return lineageDBConnection;
|
||||
}
|
||||
|
||||
export function NessaConnectionFactory() {
|
||||
if (!nessaDBConnection) {
|
||||
const config = {
|
||||
url: env.NESSA_DB_URL,
|
||||
authToken: env.NESSA_DB_TOKEN
|
||||
};
|
||||
nessaDBConnection = createClient(config);
|
||||
}
|
||||
return nessaDBConnection;
|
||||
}
|
||||
|
||||
export function NookConnectionFactory() {
|
||||
if (!nookDBConnection) {
|
||||
const config = {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Clerk JWT verification tests for nessa-auth
|
||||
*
|
||||
* Tests the verifyNessaToken function after migration from HS256 self-signed
|
||||
* tokens to Clerk session token verification via @clerk/backend.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock env BEFORE importing the module
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
TURSO_DB_URL: "libsql://test.turso.io",
|
||||
TURSO_DB_TOKEN: "test-token",
|
||||
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
||||
NESSA_DB_TOKEN: "test-token",
|
||||
TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io",
|
||||
TURSO_LINEAGE_TOKEN: "test-token",
|
||||
TURSO_DB_API_TOKEN: "test-token",
|
||||
NODE_ENV: "test",
|
||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||
},
|
||||
validateServerEnv: () => ({}),
|
||||
isMissingEnvVar: () => false,
|
||||
getMissingEnvVars: () => []
|
||||
}));
|
||||
|
||||
// Mock @clerk/backend verifyToken
|
||||
const mockVerifyToken = mock();
|
||||
mock.module("@clerk/backend", () => ({
|
||||
verifyToken: mockVerifyToken,
|
||||
createClerkClient: mock()
|
||||
}));
|
||||
|
||||
describe("verifyNessaToken with Clerk JWT", () => {
|
||||
beforeEach(() => {
|
||||
mockVerifyToken.mockReset();
|
||||
});
|
||||
|
||||
it("returns the subject (Clerk user id) for a valid session token", async () => {
|
||||
const mockPayload = {
|
||||
sub: "user_clerk123456",
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: "https://nessa-test.clerk.accounts.dev",
|
||||
sid: "sess_123"
|
||||
};
|
||||
|
||||
mockVerifyToken.mockResolvedValue(mockPayload);
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
const result = await verifyNessaToken("valid-clerk-session-token");
|
||||
|
||||
expect(result.sub).toBe("user_clerk123456");
|
||||
expect(result.exp).toBe(mockPayload.exp);
|
||||
expect(result.iat).toBe(mockPayload.iat);
|
||||
|
||||
expect(mockVerifyToken).toHaveBeenCalledWith(
|
||||
"valid-clerk-session-token",
|
||||
expect.objectContaining({
|
||||
secretKey: "sk_test_test-secret"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when token has no subject", async () => {
|
||||
const mockPayload = {
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: "https://nessa-test.clerk.accounts.dev"
|
||||
// Missing 'sub'
|
||||
};
|
||||
|
||||
mockVerifyToken.mockResolvedValue(mockPayload);
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("token-without-subject")).rejects.toThrow(
|
||||
/Missing subject/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed token", async () => {
|
||||
mockVerifyToken.mockRejectedValue(new Error("Invalid token format"));
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("malformed-token")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an expired token", async () => {
|
||||
mockVerifyToken.mockRejectedValue(new Error("Token has expired"));
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("expired-token")).rejects.toThrow(/expired/i);
|
||||
});
|
||||
|
||||
it("rejects a token with wrong signature", async () => {
|
||||
mockVerifyToken.mockRejectedValue(
|
||||
new Error("Token signature verification failed")
|
||||
);
|
||||
|
||||
const { verifyNessaToken } = await import("./nessa-auth");
|
||||
|
||||
await expect(verifyNessaToken("wrong-key-token")).rejects.toThrow(
|
||||
/signature/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static audit: signNessaToken removed", () => {
|
||||
it("signNessaToken is not exported fromessa-auth", async () => {
|
||||
const moduleExports = await import("./nessa-auth");
|
||||
expect(moduleExports).not.toHaveProperty("signNessaToken");
|
||||
});
|
||||
|
||||
it("nessa-auth.ts source does not reference the legacy JWT secret", async () => {
|
||||
// Reassemble the legacy env-var name so this test itself does not contain
|
||||
// the literal token (keeps the source tree grep-clean).
|
||||
const legacyVar = ["NESSA", "JWT", "SECRET"].join("_");
|
||||
const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
|
||||
expect(source).not.toContain(legacyVar);
|
||||
});
|
||||
|
||||
it("nessa-auth.ts uses verifyToken from @clerk/backend", async () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
|
||||
expect(source).toContain("verifyToken");
|
||||
expect(source).toContain("@clerk/backend");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Nessa auth — Clerk session JWT verification (RS256 / JWKS)
|
||||
//
|
||||
// Migrated from self-signed HS256 tokens to Clerk session token
|
||||
// verification. Incoming `Authorization: Bearer <token>` headers are
|
||||
// verified against Clerk's JWKS endpoint via `@clerk/backend`.
|
||||
//
|
||||
// Public API is unchanged so callers need not be modified:
|
||||
// * verifyNessaToken(token) → { sub, exp?, iat? }
|
||||
// * NessaAuthPayload type
|
||||
//
|
||||
// signNessaToken was removed — the frontend now supplies Clerk session
|
||||
// tokens directly; the backend only verifies.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { verifyToken } from "@clerk/backend";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
export type NessaAuthPayload = {
|
||||
sub: string; // Clerk user id
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a Clerk session JWT and return the subject (user id).
|
||||
*
|
||||
* Uses the Clerk Backend API secret key to fetch the JWKS and verify the
|
||||
* RS256 signature. Rejects expired, malformed, or improperly signed tokens.
|
||||
*/
|
||||
export async function verifyNessaToken(
|
||||
token: string
|
||||
): Promise<NessaAuthPayload> {
|
||||
const payload = await verifyToken(token, {
|
||||
secretKey: env.NESSA_CLERK_SECRET,
|
||||
});
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new Error("Missing subject in Clerk session token");
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
exp: payload.exp,
|
||||
iat: payload.iat,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user