forked from Mike/freno-dev
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:
90
src/server/api/routers/nessa-community-authz.ts
Normal file
90
src/server/api/routers/nessa-community-authz.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user