diff --git a/.env.example b/.env.example index 4f7c9c4..039685a 100644 --- a/.env.example +++ b/.env.example @@ -55,15 +55,8 @@ TURSO_DB_TOKEN="" # eyJ... TURSO_DB_API_TOKEN="" # org-level API token TURSO_LINEAGE_URL="libsql://.turso.io" TURSO_LINEAGE_TOKEN="" -NESSA_DB_URL="libsql://.turso.io" -NESSA_DB_TOKEN="" -# Clerk authentication — rotate via Clerk Dashboard or `clerk api .../rotate_secret_keys` -NESSA_CLERK_SECRET="sk_test/live_" # secret key (sk_test_... for dev, sk_live_... for prod) -NESSA_CLERK_JWT_ISSUER="https://.clerk.accounts.dev" # JWT issuer from Clerk Dashboard -NESSA_CLERK_WEBHOOK_SECRET="whsec_" # Svix signing secret from Clerk Dashboard → Webhooks → Signing Secret APPLE_CLIENT_ID_LINEAGE=com... -APPLE_CLIENT_ID_NESSA=com... # ── Infra / integration tokens ── INFILL_BEARER_TOKEN="" diff --git a/bun.lockb b/bun.lockb index 1aafab5..551f395 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b955d35..8ed8203 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "dependencies": { "@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/s3-request-presigner": "^3.953.0", - "@clerk/backend": "^3.12.0", "@libsql/client": "^0.15.15", "@sentry/solidstart": "^10.67.0", "@solidjs/meta": "^0.29.4", @@ -55,7 +54,6 @@ "motion": "^12.23.26", "solid-js": "^1.9.5", "solid-tiptap": "^0.8.0", - "svix": "^1.98.0", "ua-parser-js": "^2.0.7", "uuid": "^13.0.0", "vinxi": "^0.5.7", diff --git a/src/env/server.ts b/src/env/server.ts index 123a40e..f0c9a85 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -53,18 +53,7 @@ const serverEnvSchema = z.object({ VITE_WEBSOCKET: z.string().min(1), VITE_INFILL_ENDPOINT: z.string().min(1), INFILL_BEARER_TOKEN: z.string().min(1), - NESSA_DB_URL: z.string().min(1), - NESSA_DB_TOKEN: z.string().min(1), - // Clerk authentication — Nessa auth is now Clerk-backed. The - // legacy self-issued JWT signing env var was removed. - NESSA_CLERK_SECRET: z.string().min(1), - NESSA_CLERK_JWT_ISSUER: z.string().min(1), - // Clerk webhook signing secret (Svix). Used to verify `user.created` / - // `user.updated` webhook payloads. Find it under Clerk Dashboard → - // Webhooks → your endpoint → Signing Secret (starts with `whsec_`). - NESSA_CLERK_WEBHOOK_SECRET: z.string().min(1), LINEAGE_JWT_SECRET: z.string().min(32), - APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(), APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(), VITE_TURNSTILE_SITE_KEY: z.string().min(1), TURNSTILE_SECRET_KEY: z.string().min(1), @@ -183,11 +172,6 @@ export const getMissingEnvVars = (): string[] => { "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GITHUB_CLIENT_ID", "VITE_WEBSOCKET", - "NESSA_DB_URL", - "NESSA_DB_TOKEN", - "NESSA_CLERK_SECRET", - "NESSA_CLERK_JWT_ISSUER", - "NESSA_CLERK_WEBHOOK_SECRET", "LINEAGE_JWT_SECRET" ]; diff --git a/src/routes/api/clerk-webhook.ts b/src/routes/api/clerk-webhook.ts deleted file mode 100644 index 8dae5a8..0000000 --- a/src/routes/api/clerk-webhook.ts +++ /dev/null @@ -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); -} diff --git a/src/routes/nessa/privacy.tsx b/src/routes/nessa/privacy.tsx index d491a34..392e2db 100644 --- a/src/routes/nessa/privacy.tsx +++ b/src/routes/nessa/privacy.tsx @@ -4,17 +4,16 @@ * Net-new privacy policy for the Nessa subdomain. Modeled on the Life and * Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices: * - * - Authentication: user accounts are managed by Clerk - * (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk - * Backend JWKS endpoint). Nessa itself does not store passwords — Clerk is - * the identity provider. + * - Authentication: user accounts are managed by Clerk (the Nessa API server + * verifies Clerk session JWTs). Nessa itself does not store passwords — + * Clerk is the identity provider. * - Community content: clubs, club challenges, club posts, post likes, and - * post comments (see `src/server/api/routers/nessa-community.ts`, - * `clubMemberships`, `clubChallenges`, `clubChallengeParticipations`, - * `clubPosts`, `clubPostLikes`, `clubPostComments`). - * - Storage: the Nessa data lives in a dedicated Turso (libSQL) database - * (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate - * from the freno.me main DB and the Lineage DB. + * post comments (`clubMemberships`, `clubChallenges`, + * `clubChallengeParticipations`, `clubPosts`, `clubPostLikes`, + * `clubPostComments`). + * - Storage: the Nessa data lives in a dedicated Turso (libSQL) database, + * served by the Nessa API (`nessa-api.freno.me`, the Nessa repo's + * `server/`), separate from the freno.me main DB and the Lineage DB. * * PageHead is site-aware: only the base title is supplied; the * ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 601e852..1c5cf8d 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -5,7 +5,7 @@ import { databaseRouter } from "./routers/database"; import { lineageRouter } from "./routers/lineage"; // Re-exported so the REST shim (src/routes/api/lineage/[...path].ts) can mount // ONLY the lineage sub-router as the trpc-openapi REST surface — not the -// whole appRouter. Keeps nessa/community/auth/audit routers unreachable via +// whole appRouter. Keeps auth/audit routers unreachable via // `/api/lineage/*`. export { lineageRouter }; import { miscRouter } from "./routers/misc"; @@ -16,8 +16,6 @@ import { postHistoryRouter } from "./routers/post-history"; import { infillRouter } from "./routers/infill"; import { accountRouter } from "./routers/account"; import { downloadsRouter } from "./routers/downloads"; -import { nessaDbRouter } from "./routers/nessa"; -import { nessaCommunityRouter } from "./routers/nessa-community"; import { appleNotificationsRouter } from "./routers/apple-notifications"; import { createTRPCRouter, createTRPCContext, t } from "./utils"; import type { H3Event } from "h3"; @@ -37,10 +35,6 @@ export const appRouter = createTRPCRouter({ infill: infillRouter, account: accountRouter, downloads: downloadsRouter, - nessaDb: nessaDbRouter, - // Community features (clubs, challenges, social feed) — ported from the - // standalone nessa-api Express prototype. Exposes nessa.community.*. - nessa: createTRPCRouter({ community: nessaCommunityRouter }), appleNotifications: appleNotificationsRouter }); diff --git a/src/server/api/routers/misc.test.ts b/src/server/api/routers/misc.test.ts index 3434744..03d696b 100644 --- a/src/server/api/routers/misc.test.ts +++ b/src/server/api/routers/misc.test.ts @@ -137,7 +137,7 @@ describe("p8-001 / p8-008 static source audit", () => { it(`${proc} is not declared as publicProcedure`, () => { // Match the procedure declaration line and ensure it is not publicProcedure. const re = new RegExp( - `\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)` + `\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure)` ); const m = SOURCE.match(re); expect(m, `${proc} declaration not found`).not.toBeNull(); diff --git a/src/server/api/routers/nessa-community-authz.test.ts b/src/server/api/routers/nessa-community-authz.test.ts deleted file mode 100644 index 2682777..0000000 --- a/src/server/api/routers/nessa-community-authz.test.ts +++ /dev/null @@ -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): Promise { - 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 { - 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); - }); -}); diff --git a/src/server/api/routers/nessa-community-authz.ts b/src/server/api/routers/nessa-community-authz.ts deleted file mode 100644 index 5d3e755..0000000 --- a/src/server/api/routers/nessa-community-authz.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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; -} diff --git a/src/server/api/routers/nessa-community-sanitize.test.ts b/src/server/api/routers/nessa-community-sanitize.test.ts deleted file mode 100644 index a30b4b1..0000000 --- a/src/server/api/routers/nessa-community-sanitize.test.ts +++ /dev/null @@ -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 hello") - ).toBe("alert(1)hello"); - }); - - it("strips safe" - ) - ).toBe("alert('xss') safe"); - }); - - it("strips event handlers", () => { - expect( - sanitizeCommunityContent( - 'check this out' - ) - ).toBe("check this out"); - }); - - it("strips ", () => { - expect( - sanitizeCommunityContent('text') - ).toBe("text"); - }); - - it("strips ", () => { - expect( - sanitizeCommunityContent('body') - ).toBe("body"); - }); - - it("strips ", () => { - expect( - sanitizeCommunityContent( - 'text' - ) - ).toBe("text"); - }); - - it("strips ", () => { - expect( - sanitizeCommunityContent( - 'click here now' - ) - ).toBe("click here now"); - }); - - it("strips text' - ) - ).toBe("body{background:red}text"); - }); - - it("strips tags", () => { - expect( - sanitizeCommunityContent( - 'text' - ) - ).toBe("text"); - }); - - it("strips self-closing tags", () => { - expect( - sanitizeCommunityContent('text
more
end') - ).toBe("textmoreend"); - }); - - it("handles HTML entities — no round-trip through future HTML renderer", () => { - // `<script>` decoded to `bold

end' - ) - ).toBe("alert(1)boldend"); - }); - - it("handles unclosed tags", () => { - expect(sanitizeCommunityContent("
text")).toBe("text"); - expect(sanitizeCommunityContent("text
")).toBe("text"); - }); - - it("strips , , ", () => { - expect( - sanitizeCommunityContent( - 'text' - ) - ).toBe("text"); - expect( - sanitizeCommunityContent('text') - ).toBe("text"); - expect( - sanitizeCommunityContent('text') - ).toBe("text"); - }); - - it("strips data: URI tags — remaining text is harmless as plain text", () => { - // The tag and its attribute are stripped; the inner text remains. - // As plain text, the leftover characters are not executable. - const result = sanitizeCommunityContent( - 'link' - ); - expect(result).not.toContain("' - ) - ).toBe("alert(1)"); - }); - - it("strips
/ /