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

@@ -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<typeof NessaConnectionFactory>,
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"
});
}
}
// 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