From 333ea9a28a8c3a97bfbfb104b02772f6b9e61ce5 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 22 Jul 2026 16:58:08 -0400 Subject: [PATCH] fix(p8-003): enforce club membership checks on 7 community endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce requireClubMembership on social.getPost, addComment, comments, like, unlike, challenges.leave, and challenges.submitProgress so private club content is not readable/actionable by non-members (was IDOR). Extract the membership helpers (requireClubMembership, resolveClubIdFromPost, resolveClubIdFromChallenge) into a shared dependency-free module (nessa-community-authz.ts) so all membership-gated endpoints use one implementation and the libsql connection surface is typed uniformly. Each post/challenge endpoint now resolves the owning clubId first (NOT_FOUND if the resource is missing) then gates on it. Add regression tests (nessa-community-authz.test.ts) covering: non-member FORBIDDEN vs member allowed for all 7 endpoints' resolve→require sequences, NOT_FOUND for missing post/challenge, and a join→allowed→leave→blocked integration. --- .../api/routers/nessa-community-authz.test.ts | 203 ++++++++++++++++++ .../api/routers/nessa-community-authz.ts | 90 ++++++++ src/server/api/routers/nessa-community.ts | 44 ++-- 3 files changed, 319 insertions(+), 18 deletions(-) create mode 100644 src/server/api/routers/nessa-community-authz.test.ts create mode 100644 src/server/api/routers/nessa-community-authz.ts diff --git a/src/server/api/routers/nessa-community-authz.test.ts b/src/server/api/routers/nessa-community-authz.test.ts new file mode 100644 index 0000000..bd81684 --- /dev/null +++ b/src/server/api/routers/nessa-community-authz.test.ts @@ -0,0 +1,203 @@ +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"); + }); +}); diff --git a/src/server/api/routers/nessa-community-authz.ts b/src/server/api/routers/nessa-community-authz.ts new file mode 100644 index 0000000..5d3e755 --- /dev/null +++ b/src/server/api/routers/nessa-community-authz.ts @@ -0,0 +1,90 @@ +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.ts b/src/server/api/routers/nessa-community.ts index 854e0dd..9f4d3f6 100644 --- a/src/server/api/routers/nessa-community.ts +++ b/src/server/api/routers/nessa-community.ts @@ -2,6 +2,11 @@ import { createTRPCRouter, nessaProcedure } from "../utils"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { NessaConnectionFactory } from "~/server/database"; +import { + requireClubMembership, + resolveClubIdFromPost, + resolveClubIdFromChallenge +} from "./nessa-community-authz"; /** * nessa.community.* — Community features (clubs, challenges, social feed). @@ -230,23 +235,9 @@ interface CommentRow { authorAvatarUrl: string | null; } -/** Require that the calling user is a member of the club (or is the owner). */ -async function requireClubMembership( - conn: ReturnType, - 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" - }); - } -} +// Membership gating helpers (`requireClubMembership`, `resolveClubIdFromPost`, +// `resolveClubIdFromChallenge`) live in `./nessa-community-authz` and are +// shared by every membership-gated endpoint below — see p8-003. // --------------------------------------------------------------------------- // Router @@ -947,6 +938,8 @@ export const nessaCommunityRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { try { const conn = NessaConnectionFactory(); + const clubId = await resolveClubIdFromChallenge(conn, input.id); + await requireClubMembership(conn, clubId, ctx.nessaUserId); await conn.execute({ sql: "DELETE FROM clubChallengeParticipations WHERE challengeId = ? AND userId = ?", args: [input.id, ctx.nessaUserId] @@ -967,6 +960,11 @@ export const nessaCommunityRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { try { const conn = NessaConnectionFactory(); + const clubId = await resolveClubIdFromChallenge( + conn, + input.challengeId + ); + await requireClubMembership(conn, clubId, ctx.nessaUserId); // Upsert participation: create if absent, update progress. const existing = await conn.execute({ @@ -1102,6 +1100,8 @@ export const nessaCommunityRouter = createTRPCRouter({ .query(async ({ input, ctx }) => { try { const conn = NessaConnectionFactory(); + const clubId = await resolveClubIdFromPost(conn, input.id); + await requireClubMembership(conn, clubId, ctx.nessaUserId); const result = await conn.execute({ sql: `SELECT p.id, p.clubId, p.userId, p.content, p.postType, p.challengeId, p.createdAt, p.updatedAt, @@ -1166,6 +1166,8 @@ export const nessaCommunityRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { try { const conn = NessaConnectionFactory(); + const clubId = await resolveClubIdFromPost(conn, input.postId); + await requireClubMembership(conn, clubId, ctx.nessaUserId); const existing = await conn.execute({ sql: "SELECT id FROM clubPostLikes WHERE postId = ? AND userId = ?", args: [input.postId, ctx.nessaUserId] @@ -1193,6 +1195,8 @@ export const nessaCommunityRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { try { const conn = NessaConnectionFactory(); + const clubId = await resolveClubIdFromPost(conn, input.postId); + await requireClubMembership(conn, clubId, ctx.nessaUserId); await conn.execute({ sql: "DELETE FROM clubPostLikes WHERE postId = ? AND userId = ?", args: [input.postId, ctx.nessaUserId] @@ -1213,6 +1217,8 @@ export const nessaCommunityRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { try { const conn = NessaConnectionFactory(); + const clubId = await resolveClubIdFromPost(conn, input.postId); + await requireClubMembership(conn, clubId, ctx.nessaUserId); const commentId = crypto.randomUUID(); await conn.execute({ sql: `INSERT INTO clubPostComments (id, postId, userId, content) @@ -1232,9 +1238,11 @@ export const nessaCommunityRouter = createTRPCRouter({ comments: nessaProcedure .input(postLikeSchema) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { try { const conn = NessaConnectionFactory(); + const clubId = await resolveClubIdFromPost(conn, input.postId); + await requireClubMembership(conn, clubId, ctx.nessaUserId); const result = await conn.execute({ sql: `SELECT c.id, c.postId, c.userId, c.content, c.createdAt, c.updatedAt, u.displayName AS authorDisplayName, u.avatarUrl AS authorAvatarUrl