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
end'
- )
- ).toBe("alert(1)boldend");
- });
-
- it("handles unclosed tags", () => {
- expect(sanitizeCommunityContent("text")).toBe("text");
- expect(sanitizeCommunityContent("text
")).toBe("text");
- });
-
- it("strips