1/** 2 * apps/worker — search grant snapshot (TASK-0096 §4, search-architecture §5.1). 3 * 4 * The snapshot is a DENORMALIZED, query-time-permission closure of the API's 5 * single permission walk (`apps/api/src/lib/authorize.ts::effectiveLevel`). 6 * It answers one question at index time: "is this resource visible to (a) any 7 * workspace member, (b) an explicit team, (c) an explicit user, (d) the public?" 8 * so the query-time `visibilityFilter` can compare the viewer's identity 9 * against it without a live guard call. 10 * 11 * The decision mirrors `effectiveLevel`'s ≥VIEW outcome: 12 * effectiveLevel ≥ VIEW ⟺ (workspace role floor ≥ VIEW) OR (some 13 * applicable explicit grant ≥ VIEW). 14 * The role floor is ≥VIEW for EVERY member (GUEST→VIEW, MEMBER→EDIT, 15 * OWNER/ADMIN→FULL_ACCESS). A non-member has floor NONE and is visible only via 16 * an explicit user/team grant. 17 * 18 * ⚠️ Recorded conflict (search §0): the LIVE page-read guard does not enforce 19 * `Page.isPrivate`. The snapshot implements the INTENDED model — a private 20 * resource is workspace-invisible unless it has an explicit user/team grant or 21 * an active public share — so search isolates private pages even though the 22 * live page-read endpoint does not (search is stricter than the guard for 23 * private pages; the page-read gate is a filed Phase-3 follow-up). 24 * 25 * `effectiveLevel` specifics mirrored here: 26 * - applicable grant = the grant's (resourceType, resourceId) matches one of 27 * the resource's scope levels (workspace < teamspace < database < page < 28 * record < file); any applicable grant ≥VIEW counts (most-specific-wins only 29 * matters for the exact level, not for the ≥VIEW decision). 30 * - granteeType "role" and "share" rows are NOT counted (the guard filters 31 * user|team; "share" rows are app-level, never grant a user/team). 32 */ 33import type { PrismaClient } from "@prisma/client"; 34import type { GrantSnapshot } from "@factory/search"; 35 36/** The levels the guard treats as "visible" (≥VIEW). */ 37const VIEW_LEVELS = ["VIEW", "COMMENT", "EDIT", "FULL_ACCESS"] as const; 38 39/** A (resourceType, resourceId) scope level the resource is checkable under. */ 40export interface GrantScope { 41 resourceType: string; 42 resourceId: string; 43} 44 45/** A Share row relevant to the snapshot (active public share). */ 46export interface ShareLike { 47 scope: string; 48 allowSearchIndexing: boolean; 49 expiresAt: Date | null; 50} 51 52export interface GrantContext { 53 workspaceId: string; 54 /** The resource's teamspace team (its page's teamId) — the teamspace scope key. */ 55 teamId?: string | null; 56 /** Every (resourceType, resourceId) scope level this resource is checkable under. */ 57 scopes: GrantScope[]; 58 /** Intended model: private resources are workspace-invisible absent an explicit grant. */ 59 isPrivate: boolean; 60 /** Active public shares of the resource (scope PUBLIC, allowSearchIndexing, unexpired). */ 61 shares: ShareLike[]; 62 /** 63 * The resource's CREATOR (Page/Database/Record/Comment `createdBy`, File 64 * `uploadedBy`). TASK-0100 S5: the creator is an implicit viewer of their 65 * own resource. A page's ownership lives in `Page.createdBy`, NOT in a 66 * `Permission` row (the owner has no explicit row), so a private page's 67 * snapshot was `{ public:false, workspace:false, users:[], teams:[] }` and 68 * the §5.2 `visibilityFilter` matched NOBODY — including the creator. The 69 * e2e "creator still finds it" (search-index-lag test 3) is the pinned 70 * positive side; folding the creator into `users[]` restores it. (Recorded: 71 * the architecture's "explicit grant / public share" wording did not account 72 * for creator-ownership-as-implicit-view; AGENTS rule 29 → arch doc intent 73 * (creator can see their own private content) wins over the literal reading.) 74 */ 75 creatorId?: string | null; 76} 77 78/** Minimal structural type of the delegate the snapshot needs (testable). */ 79export interface GrantPrisma { 80 permission: { 81 findMany(args: { where: Record<string, unknown> }): Promise< 82 { 83 granteeType: string; 84 granteeId: string; 85 resourceType: string; 86 resourceId: string; 87 level: string; 88 }[] 89 >; 90 }; 91} 92 93function isPublicShare(s: ShareLike, now: Date): boolean { 94 return s.scope === "PUBLIC" && s.allowSearchIndexing && (s.expiresAt === null || s.expiresAt > now); 95} 96 97/** 98 * Build the §5.1 grant snapshot for a resource. Never throws; returns an 99 * empty-ish snapshot when nothing is visible (non-member, no grants, private). 100 */ 101export async function buildGrantSnapshot( 102 prisma: GrantPrisma, 103 ctx: GrantContext, 104 now: Date = new Date() 105): Promise<GrantSnapshot> { 106 const teams = new Set<string>(); 107 const users = new Set<string>(); 108 109 const scopeKeys = new Set(ctx.scopes.map((s) => s.resourceType + ":" + s.resourceId)); 110 const perms = await prisma.permission.findMany({ 111 where: { 112 workspaceId: ctx.workspaceId, 113 granteeType: { in: ["user", "team"] }, 114 level: { in: [...VIEW_LEVELS] }, 115 }, 116 }); 117 for (const p of perms) { 118 if (!scopeKeys.has(p.resourceType + ":" + p.resourceId)) continue; 119 if (p.granteeType === "team") teams.add(p.granteeId); 120 else if (p.granteeType === "user") users.add(p.granteeId); 121 } 122 // TASK-0100 S5: the creator is an implicit viewer (see GrantContext.creatorId). 123 // Without this, a private page's snapshot had an empty `users[]` and its 124 // creator — the one user who owns the resource — was excluded from search. 125 if (ctx.creatorId) users.add(ctx.creatorId); 126 127 const isPublic = ctx.shares.some((s) => isPublicShare(s, now)); 128 // Any workspace member (floor ≥VIEW) OR an explicit user/team grant makes the 129 // resource workspace-visible — unless it is private (intended model). 130 const hasMemberFloor = true; // every member role is ≥VIEW (GUEST floor) 131 const workspace = !ctx.isPrivate && (hasMemberFloor || teams.size > 0 || users.size > 0); 132 133 return { 134 public: isPublic, 135 workspace, 136 user: users.size > 0, 137 teams: [...teams].sort(), 138 users: [...users].sort(), 139 }; 140} 141 142/** 143 * Convenience: the snapshot for a resource that is workspace-visible to every 144 * member (no page-level privacy), e.g. a `user` doc or a `file` doc (the file 145 * read guard scopes to the workspace only, so file docs inherit the workspace 146 * floor with no resource-specific grants). 147 */ 148export function workspaceFloorSnapshot(ctx: Pick<GrantContext, "isPrivate" | "shares">, now: Date = new Date()): GrantSnapshot { 149 const isPublic = ctx.shares.some((s) => isPublicShare(s, now)); 150 return { public: isPublic, workspace: !ctx.isPrivate, user: false, teams: [], users: [] }; 151} 152 153/** The (resourceType, resourceId) scope set for a PAGE (and its blocks). */ 154export function pageScopes(workspaceId: string, pageId: string, teamId: string | null): GrantScope[] { 155 const scopes: GrantScope[] = [{ resourceType: "workspace", resourceId: workspaceId }]; 156 if (teamId) scopes.push({ resourceType: "teamspace", resourceId: teamId }); 157 scopes.push({ resourceType: "page", resourceId: pageId }); 158 return scopes; 159} 160