import { sql, type SQL } from "drizzle-orm"; import { checkMembership } from "@/worker/lib/membership"; import { MAX_TREE_DEPTH } from "@/shared/constants"; import type { Db } from "@/worker/db/d1/client"; import type { WorkspaceRole } from "@/shared/types"; export function canEdit(role: string): boolean { return role === "owner" || role === "admin" || role === "member"; } export function isAdminOrOwner(role: string): boolean { return role === "owner" || role === "admin"; } /** * Writer-role predicate for the role axis. Owner/admin/member are writers; * guest and non-members (null) are not. Used by `toResolvedViewerContext`, * the WS `member_edit` tag, and anywhere the writer/full-member fast path * needs to be separated from the membership-presence question. */ export function isWriterRole(role: WorkspaceRole | null): boolean { return role === "owner" || role === "admin" || role === "member"; } export type ShareAction = "view" | "edit"; export type AccessLevel = "none" | "view" | "edit"; const FULL_WORKSPACE_ACCESS_LEVEL: AccessLevel = "edit"; const ACCESS_RANK: Record = { none: 0, view: 1, edit: 2, }; /** * Principal represents who is requesting access. * Either an authenticated user or a link share token. */ export type Principal = { type: "user"; userId: string } | { type: "link"; token: string }; /** * Resolved principal carries the caller's effective workspace role alongside the * principal. Two axes live here: * - membership: `workspaceRole !== null` iff the caller holds a memberships * row on the canonical surface. Drives `access_mode`. * - writer: `isWriterRole(workspaceRole)` iff the caller can edit via their * role. Drives the WS `member_edit` tag and the writer fast-path shortcut. * On the shared surface `workspaceRole` is always null — `/s/:token` and * `?share=` requests are link-scoped end to end, even for workspace members. */ export type ResolvedPrincipal = { principal: Principal; workspaceRole: WorkspaceRole | null }; export type ViewerSurface = "canonical" | "shared"; export interface ResolvePrincipalOptions { surface: ViewerSurface; shareToken?: string; } /** * Resolve the access principal from an optionalAuth context. * * Route surface is authoritative: when `surface === "shared"` and a share token is * present, the principal resolves as a link even for a workspace member. That keeps * `/s/:token` link-scoped end to end. */ export async function resolvePrincipal( db: Db, user: { id: string } | null, workspaceId: string, opts: ResolvePrincipalOptions, ): Promise { const { surface, shareToken } = opts; if (surface === "shared" && shareToken) { return { principal: { type: "link", token: shareToken }, workspaceRole: null }; } if (user) { const membership = await checkMembership(db, user.id, workspaceId); if (membership && membership.role !== "guest") { // Writer role (owner/admin/member) on canonical surface — user principal // flows through the writer fast-path in `resolvePageAccessLevels`. return { principal: { type: "user", userId: user.id }, workspaceRole: membership.role }; } // Guest or non-member on canonical surface. Prefer link share token for // page access (spec §10.8) so a guest with a link grant can still reach a // shared page via the link principal. `workspaceRole` is orthogonal — // "guest" for a membership row, null for a share-only canonical visitor. const principal: Principal = shareToken ? { type: "link", token: shareToken } : { type: "user", userId: user.id }; return { principal, workspaceRole: membership ? membership.role : null }; } if (shareToken) { return { principal: { type: "link", token: shareToken }, workspaceRole: null }; } return null; } export function toResolvedViewerContext( resolved: ResolvedPrincipal, workspaceSlug: string, surface: ViewerSurface, ): { access_mode: "member" | "shared"; principal_type: "user" | "link"; route_kind: "canonical" | "shared"; workspace_slug: string | null; workspace_role: WorkspaceRole | null; } { // Shared surface is always `access_mode: "shared"` with `workspace_role: null` // so the wire contract matches the link-scoped invariant. On canonical surface, // membership presence drives access_mode; role is surfaced for entitlement // gating (AI, member-management, affordances). if (surface === "shared") { return { access_mode: "shared", principal_type: resolved.principal.type, route_kind: surface, workspace_slug: null, workspace_role: null, }; } const hasMembership = resolved.workspaceRole !== null; return { access_mode: hasMembership ? "member" : "shared", principal_type: resolved.principal.type, route_kind: surface, workspace_slug: workspaceSlug, workspace_role: resolved.workspaceRole, }; } /** * Resolve the effective access level for each requested page. * * Assumption: v1 has monotonic positive permissions only (`none` < `view` < `edit`). * There are no page-level deny rules, so resolving the strongest applicable grant once * is enough to answer both "can view?" and "can edit?" checks. */ export async function resolvePageAccessLevels( db: Db, principal: Principal, pageIds: string[], workspaceId: string, ): Promise> { const uniquePageIds = [...new Set(pageIds)]; const levels = new Map(uniquePageIds.map((pageId) => [pageId, "none" as AccessLevel])); if (uniquePageIds.length === 0) { return levels; } if (principal.type === "user") { // Assumption: in v1, workspace owner/admin/member always have full page access. // If page-level denies or weaker workspace roles are added later, update this // short-circuit together with the access-rank comparison helpers below. const membership = await checkMembership(db, principal.userId, workspaceId); if (membership && canEdit(membership.role)) { return new Map(uniquePageIds.map((pageId) => [pageId, FULL_WORKSPACE_ACCESS_LEVEL])); } } const resolved = await db.all<{ page_id: string; access_rank: number }>( buildBatchPageAccessQuery(uniquePageIds, principal, workspaceId), ); for (const row of resolved) { levels.set(row.page_id, rankToAccessLevel(row.access_rank)); } return levels; } /** * Resolve access for many pages at once. * * This intentionally wraps the richer access-level resolver instead of returning booleans * directly from SQL. Several callers need both view and edit answers for the same page, * and reusing the resolved level avoids paying for the tree walk twice. */ export async function canAccessPages( db: Db, principal: Principal, pageIds: string[], workspaceId: string, action: ShareAction, ): Promise> { const uniquePageIds = [...new Set(pageIds)]; const allowed = new Map(uniquePageIds.map((pageId) => [pageId, false])); if (uniquePageIds.length === 0) { return allowed; } const levels = await resolvePageAccessLevels(db, principal, uniquePageIds, workspaceId); for (const pageId of uniquePageIds) { allowed.set(pageId, accessLevelSatisfies(levels.get(pageId) ?? "none", action)); } return allowed; } /** * Resolve access for a single page. * * Implements spec §9 / §20.2: * 1. Workspace owner/admin/member → use role * 2. Walk page_shares up the tree (replace-not-merge) * 3. Deny if no shares found */ export async function canAccessPage( db: Db, principal: Principal, pageId: string, workspaceId: string, action: ShareAction, ): Promise { const results = await canAccessPages(db, principal, [pageId], workspaceId, action); return results.get(pageId) ?? false; } function buildBatchPageAccessQuery(pageIds: string[], principal: Principal, workspaceId: string): SQL { const requestedValues = sql.join( pageIds.map((pageId) => sql`(${pageId})`), sql`, `, ); const granteeId = principal.type === "user" ? principal.userId : null; const linkToken = principal.type === "link" ? principal.token : null; return sql` WITH RECURSIVE requested(root_id) AS ( VALUES ${requestedValues} ), ancestors(root_id, id, parent_id, depth) AS ( SELECT r.root_id, p.id, p.parent_id, 0 FROM requested r JOIN pages p ON p.id = r.root_id WHERE p.workspace_id = ${workspaceId} AND p.archived_at IS NULL UNION ALL SELECT a.root_id, p.id, p.parent_id, a.depth + 1 FROM pages p JOIN ancestors a ON p.id = a.parent_id WHERE p.workspace_id = ${workspaceId} AND p.archived_at IS NULL AND a.depth < ${MAX_TREE_DEPTH - 1} ), nearest_shared_depth AS ( SELECT a.root_id, MIN(a.depth) AS depth FROM ancestors a WHERE EXISTS ( SELECT 1 FROM page_shares s WHERE s.page_id = a.id ) GROUP BY a.root_id ), nearest_shared AS ( SELECT a.root_id, a.id AS shared_page_id FROM ancestors a JOIN nearest_shared_depth d ON d.root_id = a.root_id AND d.depth = a.depth ) SELECT r.root_id AS page_id, -- Assumption: v1 share inheritance is replace-not-merge, so only the nearest -- shared ancestor can contribute grants for a requested page. -- -- MAX() is defensive. The route layer prevents duplicate user shares, but the -- schema does not enforce one share row per principal/page pair yet. COALESCE( MAX( CASE s.permission WHEN 'edit' THEN ${ACCESS_RANK.edit} WHEN 'view' THEN ${ACCESS_RANK.view} ELSE ${ACCESS_RANK.none} END ), ${ACCESS_RANK.none} ) AS access_rank FROM requested r LEFT JOIN nearest_shared n ON n.root_id = r.root_id LEFT JOIN page_shares s ON s.page_id = n.shared_page_id AND ( (s.grantee_type = 'user' AND s.grantee_id = ${granteeId}) OR (s.grantee_type = 'link' AND s.link_token = ${linkToken}) ) GROUP BY r.root_id `; } function accessLevelSatisfies(granted: AccessLevel, required: ShareAction): boolean { return ACCESS_RANK[granted] >= ACCESS_RANK[required]; } function rankToAccessLevel(rank: number): AccessLevel { if (rank >= ACCESS_RANK.edit) return "edit"; if (rank >= ACCESS_RANK.view) return "view"; return "none"; }