forked from Mike/freno-dev
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.
91 lines
3.0 KiB
TypeScript
91 lines
3.0 KiB
TypeScript
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;
|
|
}
|