fix(p8-003): enforce club membership checks on 7 community endpoints

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.
This commit is contained in:
2026-07-22 16:58:08 -04:00
parent 0c29135bad
commit 333ea9a28a
3 changed files with 319 additions and 18 deletions

View File

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

View File

@@ -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<void> {
const result = await conn.execute({
sql: "SELECT id FROM clubMemberships WHERE clubId = ? AND userId = ?",
args: [clubId, userId]
});
if (!result.rows.length) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not a member of this club"
});
}
}
/**
* Resolve the clubId that owns a post. Returns the owning club's id, or
* throws `NOT_FOUND` if the post does not exist. Used by read/interaction
* endpoints (`getPost`, `addComment`, `comments`, `like`, `unlike`) to derive
* the club a target post belongs to before gating on membership.
*/
export async function resolveClubIdFromPost(
conn: NessaConn,
postId: string
): Promise<string> {
const result = await conn.execute({
sql: "SELECT clubId FROM clubPosts WHERE id = ?",
args: [postId]
});
if (!result.rows.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
}
return (result.rows[0] as unknown as { clubId: string }).clubId;
}
/**
* Resolve the clubId that owns a challenge. Returns the owning club's id, or
* throws `NOT_FOUND` if the challenge does not exist. Used by challenge
* interaction endpoints (`challenges.leave`, `challenges.submitProgress`) to
* derive the club a challenge belongs to before gating on membership.
*/
export async function resolveClubIdFromChallenge(
conn: NessaConn,
challengeId: string
): Promise<string> {
const result = await conn.execute({
sql: "SELECT clubId FROM clubChallenges WHERE id = ?",
args: [challengeId]
});
if (!result.rows.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Challenge not found"
});
}
return (result.rows[0] as unknown as { clubId: string }).clubId;
}

View File

@@ -2,6 +2,11 @@ import { createTRPCRouter, nessaProcedure } from "../utils";
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { NessaConnectionFactory } from "~/server/database"; import { NessaConnectionFactory } from "~/server/database";
import {
requireClubMembership,
resolveClubIdFromPost,
resolveClubIdFromChallenge
} from "./nessa-community-authz";
/** /**
* nessa.community.* — Community features (clubs, challenges, social feed). * nessa.community.* — Community features (clubs, challenges, social feed).
@@ -230,23 +235,9 @@ interface CommentRow {
authorAvatarUrl: string | null; authorAvatarUrl: string | null;
} }
/** Require that the calling user is a member of the club (or is the owner). */ // Membership gating helpers (`requireClubMembership`, `resolveClubIdFromPost`,
async function requireClubMembership( // `resolveClubIdFromChallenge`) live in `./nessa-community-authz` and are
conn: ReturnType<typeof NessaConnectionFactory>, // shared by every membership-gated endpoint below — see p8-003.
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"
});
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router // Router
@@ -947,6 +938,8 @@ export const nessaCommunityRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
const clubId = await resolveClubIdFromChallenge(conn, input.id);
await requireClubMembership(conn, clubId, ctx.nessaUserId);
await conn.execute({ await conn.execute({
sql: "DELETE FROM clubChallengeParticipations WHERE challengeId = ? AND userId = ?", sql: "DELETE FROM clubChallengeParticipations WHERE challengeId = ? AND userId = ?",
args: [input.id, ctx.nessaUserId] args: [input.id, ctx.nessaUserId]
@@ -967,6 +960,11 @@ export const nessaCommunityRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
const clubId = await resolveClubIdFromChallenge(
conn,
input.challengeId
);
await requireClubMembership(conn, clubId, ctx.nessaUserId);
// Upsert participation: create if absent, update progress. // Upsert participation: create if absent, update progress.
const existing = await conn.execute({ const existing = await conn.execute({
@@ -1102,6 +1100,8 @@ export const nessaCommunityRouter = createTRPCRouter({
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
const clubId = await resolveClubIdFromPost(conn, input.id);
await requireClubMembership(conn, clubId, ctx.nessaUserId);
const result = await conn.execute({ const result = await conn.execute({
sql: `SELECT p.id, p.clubId, p.userId, p.content, p.postType, p.challengeId, sql: `SELECT p.id, p.clubId, p.userId, p.content, p.postType, p.challengeId,
p.createdAt, p.updatedAt, p.createdAt, p.updatedAt,
@@ -1166,6 +1166,8 @@ export const nessaCommunityRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
const clubId = await resolveClubIdFromPost(conn, input.postId);
await requireClubMembership(conn, clubId, ctx.nessaUserId);
const existing = await conn.execute({ const existing = await conn.execute({
sql: "SELECT id FROM clubPostLikes WHERE postId = ? AND userId = ?", sql: "SELECT id FROM clubPostLikes WHERE postId = ? AND userId = ?",
args: [input.postId, ctx.nessaUserId] args: [input.postId, ctx.nessaUserId]
@@ -1193,6 +1195,8 @@ export const nessaCommunityRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
const clubId = await resolveClubIdFromPost(conn, input.postId);
await requireClubMembership(conn, clubId, ctx.nessaUserId);
await conn.execute({ await conn.execute({
sql: "DELETE FROM clubPostLikes WHERE postId = ? AND userId = ?", sql: "DELETE FROM clubPostLikes WHERE postId = ? AND userId = ?",
args: [input.postId, ctx.nessaUserId] args: [input.postId, ctx.nessaUserId]
@@ -1213,6 +1217,8 @@ export const nessaCommunityRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
const clubId = await resolveClubIdFromPost(conn, input.postId);
await requireClubMembership(conn, clubId, ctx.nessaUserId);
const commentId = crypto.randomUUID(); const commentId = crypto.randomUUID();
await conn.execute({ await conn.execute({
sql: `INSERT INTO clubPostComments (id, postId, userId, content) sql: `INSERT INTO clubPostComments (id, postId, userId, content)
@@ -1232,9 +1238,11 @@ export const nessaCommunityRouter = createTRPCRouter({
comments: nessaProcedure comments: nessaProcedure
.input(postLikeSchema) .input(postLikeSchema)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
const clubId = await resolveClubIdFromPost(conn, input.postId);
await requireClubMembership(conn, clubId, ctx.nessaUserId);
const result = await conn.execute({ const result = await conn.execute({
sql: `SELECT c.id, c.postId, c.userId, c.content, c.createdAt, c.updatedAt, sql: `SELECT c.id, c.postId, c.userId, c.content, c.createdAt, c.updatedAt,
u.displayName AS authorDisplayName, u.avatarUrl AS authorAvatarUrl u.displayName AS authorDisplayName, u.avatarUrl AS authorAvatarUrl