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:
2026-10-13 13:54:14 -04:00
parent 9ff095a82c
commit 6898186df5
21 changed files with 12 additions and 5978 deletions

View File

@@ -55,15 +55,8 @@ TURSO_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
TURSO_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token TURSO_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io" TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>" TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
NESSA_DB_URL="libsql://<nessa-db>.turso.io"
NESSA_DB_TOKEN="<rotate-in-turso-dashboard>"
# Clerk authentication — rotate via Clerk Dashboard or `clerk api .../rotate_secret_keys`
NESSA_CLERK_SECRET="sk_test/live_<rotate-in-clerk-dashboard>" # secret key (sk_test_... for dev, sk_live_... for prod)
NESSA_CLERK_JWT_ISSUER="https://<your-app>.clerk.accounts.dev" # JWT issuer from Clerk Dashboard
NESSA_CLERK_WEBHOOK_SECRET="whsec_<rotate-in-clerk-dashboard>" # Svix signing secret from Clerk Dashboard → Webhooks → Signing Secret
APPLE_CLIENT_ID_LINEAGE=com... APPLE_CLIENT_ID_LINEAGE=com...
APPLE_CLIENT_ID_NESSA=com...
# ── Infra / integration tokens ── # ── Infra / integration tokens ──
INFILL_BEARER_TOKEN="<rotate-at-infill-service>" INFILL_BEARER_TOKEN="<rotate-at-infill-service>"

BIN
bun.lockb

Binary file not shown.

View File

@@ -17,7 +17,6 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/client-s3": "^3.953.0",
"@aws-sdk/s3-request-presigner": "^3.953.0", "@aws-sdk/s3-request-presigner": "^3.953.0",
"@clerk/backend": "^3.12.0",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@sentry/solidstart": "^10.67.0", "@sentry/solidstart": "^10.67.0",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",
@@ -55,7 +54,6 @@
"motion": "^12.23.26", "motion": "^12.23.26",
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"solid-tiptap": "^0.8.0", "solid-tiptap": "^0.8.0",
"svix": "^1.98.0",
"ua-parser-js": "^2.0.7", "ua-parser-js": "^2.0.7",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vinxi": "^0.5.7", "vinxi": "^0.5.7",

16
src/env/server.ts vendored
View File

@@ -53,18 +53,7 @@ const serverEnvSchema = z.object({
VITE_WEBSOCKET: z.string().min(1), VITE_WEBSOCKET: z.string().min(1),
VITE_INFILL_ENDPOINT: z.string().min(1), VITE_INFILL_ENDPOINT: z.string().min(1),
INFILL_BEARER_TOKEN: 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), 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(), APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
VITE_TURNSTILE_SITE_KEY: z.string().min(1), VITE_TURNSTILE_SITE_KEY: z.string().min(1),
TURNSTILE_SECRET_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_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID", "VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET", "VITE_WEBSOCKET",
"NESSA_DB_URL",
"NESSA_DB_TOKEN",
"NESSA_CLERK_SECRET",
"NESSA_CLERK_JWT_ISSUER",
"NESSA_CLERK_WEBHOOK_SECRET",
"LINEAGE_JWT_SECRET" "LINEAGE_JWT_SECRET"
]; ];

View File

@@ -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);
}

View File

@@ -4,17 +4,16 @@
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and * 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: * Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
* *
* - Authentication: user accounts are managed by Clerk * - Authentication: user accounts are managed by Clerk (the Nessa API server
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk * verifies Clerk session JWTs). Nessa itself does not store passwords —
* Backend JWKS endpoint). Nessa itself does not store passwords — Clerk is * Clerk is the identity provider.
* the identity provider.
* - Community content: clubs, club challenges, club posts, post likes, and * - Community content: clubs, club challenges, club posts, post likes, and
* post comments (see `src/server/api/routers/nessa-community.ts`, * post comments (`clubMemberships`, `clubChallenges`,
* `clubMemberships`, `clubChallenges`, `clubChallengeParticipations`, * `clubChallengeParticipations`, `clubPosts`, `clubPostLikes`,
* `clubPosts`, `clubPostLikes`, `clubPostComments`). * `clubPostComments`).
* - Storage: the Nessa data lives in a dedicated Turso (libSQL) database * - Storage: the Nessa data lives in a dedicated Turso (libSQL) database,
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate * served by the Nessa API (`nessa-api.freno.me`, the Nessa repo's
* from the freno.me main DB and the Lineage DB. * `server/`), separate from the freno.me main DB and the Lineage DB.
* *
* PageHead is site-aware: only the base title is supplied; the * PageHead is site-aware: only the base title is supplied; the
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image * ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image

View File

@@ -5,7 +5,7 @@ import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage"; import { lineageRouter } from "./routers/lineage";
// Re-exported so the REST shim (src/routes/api/lineage/[...path].ts) can mount // 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 // 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/*`. // `/api/lineage/*`.
export { lineageRouter }; export { lineageRouter };
import { miscRouter } from "./routers/misc"; import { miscRouter } from "./routers/misc";
@@ -16,8 +16,6 @@ import { postHistoryRouter } from "./routers/post-history";
import { infillRouter } from "./routers/infill"; import { infillRouter } from "./routers/infill";
import { accountRouter } from "./routers/account"; import { accountRouter } from "./routers/account";
import { downloadsRouter } from "./routers/downloads"; import { downloadsRouter } from "./routers/downloads";
import { nessaDbRouter } from "./routers/nessa";
import { nessaCommunityRouter } from "./routers/nessa-community";
import { appleNotificationsRouter } from "./routers/apple-notifications"; import { appleNotificationsRouter } from "./routers/apple-notifications";
import { createTRPCRouter, createTRPCContext, t } from "./utils"; import { createTRPCRouter, createTRPCContext, t } from "./utils";
import type { H3Event } from "h3"; import type { H3Event } from "h3";
@@ -37,10 +35,6 @@ export const appRouter = createTRPCRouter({
infill: infillRouter, infill: infillRouter,
account: accountRouter, account: accountRouter,
downloads: downloadsRouter, 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 appleNotifications: appleNotificationsRouter
}); });

View File

@@ -137,7 +137,7 @@ describe("p8-001 / p8-008 static source audit", () => {
it(`${proc} is not declared as publicProcedure`, () => { it(`${proc} is not declared as publicProcedure`, () => {
// Match the procedure declaration line and ensure it is not publicProcedure. // Match the procedure declaration line and ensure it is not publicProcedure.
const re = new RegExp( 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); const m = SOURCE.match(re);
expect(m, `${proc} declaration not found`).not.toBeNull(); expect(m, `${proc} declaration not found`).not.toBeNull();

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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", () => {
// `&lt;script&gt;` decoded to `<script>` then stripped, not stored as
// literal `&lt;script&gt;` that a future HTML renderer would decode.
expect(
sanitizeCommunityContent("&lt;script&gt;alert(1)&lt;/script&gt;hello")
).toBe("alert(1)hello");
});
it("decodes numeric entities", () => {
expect(sanitizeCommunityContent("&#60;script&#62;alert&#60;/script&#62;")).toBe(
"alert"
);
});
it("decodes hex entities", () => {
expect(sanitizeCommunityContent("&#x3c;script&#x3e;alert&#x3c;/script&#x3e;")).toBe(
"alert"
);
});
it("decodes common named entities", () => {
expect(sanitizeCommunityContent("it&apos;s &quot;great&quot; &amp; 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 &lt;script&gt; decoded and stripped (no entity round-trip)", async () => {
const postId = "post-3";
const raw = "&lt;script&gt;alert(1)&lt;/script&gt;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("&lt;script&gt;");
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

View File

@@ -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

View File

@@ -2,15 +2,12 @@ import { initTRPC, TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics"; import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http"; import { getRequestIP } from "vinxi/http";
import { verifyNessaToken } from "~/server/nessa-auth";
import { getAuthPayloadFromEvent } from "~/server/auth"; import { getAuthPayloadFromEvent } from "~/server/auth";
import { NessaConnectionFactory } from "~/server/database";
export type Context = { export type Context = {
event: APIEvent; event: APIEvent;
userId: string | null; userId: string | null;
isAdmin: boolean; isAdmin: boolean;
nessaUserId: string | null;
}; };
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */ /** 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(req.headers, "referrer") ||
getHeader(event.request?.headers, "referer"); getHeader(event.request?.headers, "referer");
const ipAddress = getRequestIP(event.nativeEvent) || undefined; 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 // Don't log the performance logging endpoint itself to avoid circular tracking
if (!path.includes("analytics.logPerformance")) { if (!path.includes("analytics.logPerformance")) {
@@ -111,8 +70,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
return { return {
event, event,
userId, userId,
isAdmin, isAdmin
nessaUserId
}; };
} }
@@ -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 protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const adminProcedure = t.procedure.use(enforceUserIsAdmin); export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
export const nessaProcedure = t.procedure.use(enforceNessaUser);
// CSRF protection middleware - defined here to avoid circular dependency // CSRF protection middleware - defined here to avoid circular dependency
const csrfProtection = t.middleware(async ({ ctx, next }) => { const csrfProtection = t.middleware(async ({ ctx, next }) => {

View File

@@ -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");
});
});

View File

@@ -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" }
};
}
}

View File

@@ -14,14 +14,12 @@ import {
import { import {
ConnectionFactory, ConnectionFactory,
LineageConnectionFactory, LineageConnectionFactory,
NessaConnectionFactory,
NookConnectionFactory NookConnectionFactory
} from "~/server/db-connections"; } from "~/server/db-connections";
// Re-export connection factories to avoid circular import with auth.ts // Re-export connection factories to avoid circular import with auth.ts
export { export {
ConnectionFactory, ConnectionFactory,
LineageConnectionFactory, LineageConnectionFactory,
NessaConnectionFactory,
NookConnectionFactory NookConnectionFactory
}; };

View File

@@ -3,7 +3,6 @@ import { env } from "~/env/server";
let mainDBConnection: Client | null = null; let mainDBConnection: Client | null = null;
let lineageDBConnection: Client | null = null; let lineageDBConnection: Client | null = null;
let nessaDBConnection: Client | null = null;
let nookDBConnection: Client | null = null; let nookDBConnection: Client | null = null;
export function ConnectionFactory() { export function ConnectionFactory() {
@@ -28,17 +27,6 @@ export function LineageConnectionFactory() {
return lineageDBConnection; 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() { export function NookConnectionFactory() {
if (!nookDBConnection) { if (!nookDBConnection) {
const config = { const config = {

View File

@@ -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");
});
});

View File

@@ -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,
};
}