Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | 491x 491x 469x 469x 469x 358x 358x 19x 339x 61x 61x 39x 55x 55x 37x 19x 19x 15x 15x 7x 241x 17x 56x 56x 51x 51x 36x 3x 33x | // src/server/dal/collection-access.ts
// DAL capability chokepoint + resource wrappers (purses-89g).
// The three pre-existing guards (requireApiCollectionAccess, requireApiCollectionOwner,
// requireApiResultAccess) are re-expressed on the core with NO behavior change.
import { NextResponse } from "next/server";
import type { Database } from "@/lib/db";
import type { Collection, Item, Invitation, SortResult } from "@/types";
import { CollectionRepository } from "@/server/repositories/implementations/CollectionRepository";
import { MembershipRepository } from "@/server/repositories/implementations/MembershipRepository";
import { ItemRepository } from "@/server/repositories/implementations/ItemRepository";
import { InvitationRepository } from "@/server/repositories/implementations/InvitationRepository";
import { SortResultRepository } from "@/server/repositories/implementations/SortResultRepository";
import {
CAPABILITIES,
resolveEffectiveRole,
roleSatisfies,
} from "./capabilities";
import type { Operation } from "./capabilities";
// ─── Core capability gate ────────────────────────────────────────────────────
// Single authz chokepoint for all collection-scoped API routes.
// Resolves the collection (404 if absent), looks up the membership row, derives
// the effective role (null → 403), checks the capability (insufficient → 403),
// and returns the collection on success.
// Arg order (db, collectionId, userId, op) matches the resource wrappers below.
export async function requireApiCapability(
db: Database,
collectionId: string,
userId: string,
op: Operation,
): Promise<Collection | NextResponse> {
const collection = await new CollectionRepository(db).findById(collectionId);
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
const membership = await new MembershipRepository(db).findByUser(userId, collectionId);
const role = resolveEffectiveRole(collection, membership, userId);
if (!role) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const required = CAPABILITIES[op];
if (!roleSatisfies(role, required)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return collection;
}
// ─── Resource wrappers ───────────────────────────────────────────────────────
// Resolve an item, then gate by capability. 404 if the item is absent.
//
// Resolution strategy is explicit, NOT derived from `op`: a soft-deleted item is
// invisible (404) to read/edit/soft-delete ops — matching the pre-pv9 GET/PATCH/DELETE
// `findById` behavior — but the lifecycle ops that ACT on a soft-deleted row
// (restore, purge, remove-from-sorts) must pass `{ includeDeleted: true }` to resolve
// it. Default is the safe-for-reads `findById` so a route can never accidentally leak a
// soft-deleted item as 200. (`op` alone can't decide: restore is items:write yet needs
// the deleted row, while edit is items:write and must 404 on it.)
export async function requireApiItemCapability(
db: Database,
itemId: string,
userId: string,
op: Operation,
opts: { includeDeleted?: boolean } = {},
): Promise<Item | NextResponse> {
const repo = new ItemRepository(db);
const item = opts.includeDeleted
? await repo.findByIdIncludingDeleted(itemId)
: await repo.findById(itemId);
if (!item) return NextResponse.json({ error: "Not found" }, { status: 404 });
const access = await requireApiCapability(db, item.collectionId, userId, op);
if (access instanceof NextResponse) return access;
return item;
}
// Resolve an invitation, then gate by capability. 404 if the invitation is absent.
export async function requireApiInvitationCapability(
db: Database,
invitationId: string,
userId: string,
op: Operation,
): Promise<Invitation | NextResponse> {
const invitation = await new InvitationRepository(db).findById(invitationId);
if (!invitation) return NextResponse.json({ error: "Not found" }, { status: 404 });
const access = await requireApiCapability(db, invitation.collectionId, userId, op);
if (access instanceof NextResponse) return access;
return invitation;
}
// ─── Re-expressed existing guards (no behavior change) ───────────────────────
// Member-or-owner gate for collection-scoped API routes. ULID-only (params.id at
// the API layer is always a ULID — slugs are a nav concern). Returns the collection
// when the user may access it, else a NextResponse the caller returns immediately:
// 404 if the collection is absent, 403 if it exists but the user is neither member
// nor owner. Re-expressed as requireApiCapability("collection:read") — identical
// behavior: curator-level satisfies both members and owner.
export async function requireApiCollectionAccess(
db: Database,
collectionId: string,
userId: string,
): Promise<Collection | NextResponse> {
return requireApiCapability(db, collectionId, userId, "collection:read");
}
// Owner-only gate for collection-scoped API routes (a stricter sibling of
// requireApiCollectionAccess). Resolves the collection from its id, returns it
// iff the caller is the collection owner, else a NextResponse the caller returns
// immediately: 404 if absent, 403 if it exists but the caller is not the owner
// (a member-not-owner gets 403, same as a non-member). Used by destructive
// collection-owner ops (item purge) where membership is insufficient.
//
// Re-expression strategy: requireApiCapability("collection:manage") requires the
// "owner" role, which resolveEffectiveRole derives solely from collection.ownerId —
// so a curator (membership exists, ownerId != userId) gets 403 exactly as before.
// Behavior is identical: 404 absent, 403 exists-but-not-owner.
export async function requireApiCollectionOwner(
db: Database,
collectionId: string,
userId: string,
): Promise<Collection | NextResponse> {
return requireApiCapability(db, collectionId, userId, "collection:manage");
}
// Resolve a sort-result, then gate by collection access AND curator ownership
// (results are curator-scoped — each curator sorts independently, per t9l).
// Returns the result when the caller may act on it, else a NextResponse to return.
// UNCHANGED in behavior: collection access → curatorId===userId → 403.
export async function requireApiResultAccess(
db: Database,
resultId: string,
userId: string,
): Promise<SortResult | NextResponse> {
const result = await new SortResultRepository(db).findById(resultId);
if (!result) return NextResponse.json({ error: "Not found" }, { status: 404 });
const access = await requireApiCollectionAccess(db, result.collectionId, userId);
if (access instanceof NextResponse) return access;
if (result.curatorId !== userId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return result;
}
|