/** * apps/worker — search grant snapshot (TASK-0096 §4, search-architecture §5.1). * * The snapshot is a DENORMALIZED, query-time-permission closure of the API's * single permission walk (`apps/api/src/lib/authorize.ts::effectiveLevel`). * It answers one question at index time: "is this resource visible to (a) any * workspace member, (b) an explicit team, (c) an explicit user, (d) the public?" * so the query-time `visibilityFilter` can compare the viewer's identity * against it without a live guard call. * * The decision mirrors `effectiveLevel`'s ≥VIEW outcome: * effectiveLevel ≥ VIEW ⟺ (workspace role floor ≥ VIEW) OR (some * applicable explicit grant ≥ VIEW). * The role floor is ≥VIEW for EVERY member (GUEST→VIEW, MEMBER→EDIT, * OWNER/ADMIN→FULL_ACCESS). A non-member has floor NONE and is visible only via * an explicit user/team grant. * * ⚠️ Recorded conflict (search §0): the LIVE page-read guard does not enforce * `Page.isPrivate`. The snapshot implements the INTENDED model — a private * resource is workspace-invisible unless it has an explicit user/team grant or * an active public share — so search isolates private pages even though the * live page-read endpoint does not (search is stricter than the guard for * private pages; the page-read gate is a filed Phase-3 follow-up). * * `effectiveLevel` specifics mirrored here: * - applicable grant = the grant's (resourceType, resourceId) matches one of * the resource's scope levels (workspace < teamspace < database < page < * record < file); any applicable grant ≥VIEW counts (most-specific-wins only * matters for the exact level, not for the ≥VIEW decision). * - granteeType "role" and "share" rows are NOT counted (the guard filters * user|team; "share" rows are app-level, never grant a user/team). */ import type { PrismaClient } from "@prisma/client"; import type { GrantSnapshot } from "@factory/search"; /** The levels the guard treats as "visible" (≥VIEW). */ const VIEW_LEVELS = ["VIEW", "COMMENT", "EDIT", "FULL_ACCESS"] as const; /** A (resourceType, resourceId) scope level the resource is checkable under. */ export interface GrantScope { resourceType: string; resourceId: string; } /** A Share row relevant to the snapshot (active public share). */ export interface ShareLike { scope: string; allowSearchIndexing: boolean; expiresAt: Date | null; } export interface GrantContext { workspaceId: string; /** The resource's teamspace team (its page's teamId) — the teamspace scope key. */ teamId?: string | null; /** Every (resourceType, resourceId) scope level this resource is checkable under. */ scopes: GrantScope[]; /** Intended model: private resources are workspace-invisible absent an explicit grant. */ isPrivate: boolean; /** Active public shares of the resource (scope PUBLIC, allowSearchIndexing, unexpired). */ shares: ShareLike[]; /** * The resource's CREATOR (Page/Database/Record/Comment `createdBy`, File * `uploadedBy`). TASK-0100 S5: the creator is an implicit viewer of their * own resource. A page's ownership lives in `Page.createdBy`, NOT in a * `Permission` row (the owner has no explicit row), so a private page's * snapshot was `{ public:false, workspace:false, users:[], teams:[] }` and * the §5.2 `visibilityFilter` matched NOBODY — including the creator. The * e2e "creator still finds it" (search-index-lag test 3) is the pinned * positive side; folding the creator into `users[]` restores it. (Recorded: * the architecture's "explicit grant / public share" wording did not account * for creator-ownership-as-implicit-view; AGENTS rule 29 → arch doc intent * (creator can see their own private content) wins over the literal reading.) */ creatorId?: string | null; } /** Minimal structural type of the delegate the snapshot needs (testable). */ export interface GrantPrisma { permission: { findMany(args: { where: Record }): Promise< { granteeType: string; granteeId: string; resourceType: string; resourceId: string; level: string; }[] >; }; } function isPublicShare(s: ShareLike, now: Date): boolean { return s.scope === "PUBLIC" && s.allowSearchIndexing && (s.expiresAt === null || s.expiresAt > now); } /** * Build the §5.1 grant snapshot for a resource. Never throws; returns an * empty-ish snapshot when nothing is visible (non-member, no grants, private). */ export async function buildGrantSnapshot( prisma: GrantPrisma, ctx: GrantContext, now: Date = new Date() ): Promise { const teams = new Set(); const users = new Set(); const scopeKeys = new Set(ctx.scopes.map((s) => s.resourceType + ":" + s.resourceId)); const perms = await prisma.permission.findMany({ where: { workspaceId: ctx.workspaceId, granteeType: { in: ["user", "team"] }, level: { in: [...VIEW_LEVELS] }, }, }); for (const p of perms) { if (!scopeKeys.has(p.resourceType + ":" + p.resourceId)) continue; if (p.granteeType === "team") teams.add(p.granteeId); else if (p.granteeType === "user") users.add(p.granteeId); } // TASK-0100 S5: the creator is an implicit viewer (see GrantContext.creatorId). // Without this, a private page's snapshot had an empty `users[]` and its // creator — the one user who owns the resource — was excluded from search. if (ctx.creatorId) users.add(ctx.creatorId); const isPublic = ctx.shares.some((s) => isPublicShare(s, now)); // Any workspace member (floor ≥VIEW) OR an explicit user/team grant makes the // resource workspace-visible — unless it is private (intended model). const hasMemberFloor = true; // every member role is ≥VIEW (GUEST floor) const workspace = !ctx.isPrivate && (hasMemberFloor || teams.size > 0 || users.size > 0); return { public: isPublic, workspace, user: users.size > 0, teams: [...teams].sort(), users: [...users].sort(), }; } /** * Convenience: the snapshot for a resource that is workspace-visible to every * member (no page-level privacy), e.g. a `user` doc or a `file` doc (the file * read guard scopes to the workspace only, so file docs inherit the workspace * floor with no resource-specific grants). */ export function workspaceFloorSnapshot(ctx: Pick, now: Date = new Date()): GrantSnapshot { const isPublic = ctx.shares.some((s) => isPublicShare(s, now)); return { public: isPublic, workspace: !ctx.isPrivate, user: false, teams: [], users: [] }; } /** The (resourceType, resourceId) scope set for a PAGE (and its blocks). */ export function pageScopes(workspaceId: string, pageId: string, teamId: string | null): GrantScope[] { const scopes: GrantScope[] = [{ resourceType: "workspace", resourceId: workspaceId }]; if (teamId) scopes.push({ resourceType: "teamspace", resourceId: teamId }); scopes.push({ resourceType: "page", resourceId: pageId }); return scopes; }