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:
@@ -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>"
|
||||||
|
|||||||
@@ -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
16
src/env/server.ts
vendored
@@ -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"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 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 }) => {
|
||||||
|
|||||||
@@ -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 {
|
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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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