| 1 | /** |
| 2 | * Folio access in D1: reading a folio's chain (itself and the ancestors |
| 3 | * its access comes from) and grants, keeping each subtree's denormalized |
| 4 | * `acl_root` and `path` right, and rebuilding the materialized |
| 5 | * `folio_access` rows the lists and search filter by. The rules |
| 6 | * themselves are pure, in src/access.ts. |
| 7 | */ |
| 8 | import type { DocRole, FolioGeneralAccess } from "@g1t/contracts"; |
| 9 | |
| 10 | import { aclChain, aclRootOf, effectiveRole, folioPathOf, folioReadableByWorkspace, folioScope, materialize, type FolioAclNode, type FolioGrant, type Person } from "../access.ts"; |
| 11 | |
| 12 | /** A folio row as stored. `text` is left out of lists (see FOLIO_COLUMNS). */ |
| 13 | export type FolioRow = { |
| 14 | id: string; |
| 15 | workspace_id: string; |
| 16 | kind: "doc" | "slides" | "design" | "dashboard"; |
| 17 | title: string; |
| 18 | icon: string | null; |
| 19 | cover: string | null; |
| 20 | owner: string; |
| 21 | space_id: string | null; |
| 22 | parent_id: string | null; |
| 23 | position: number; |
| 24 | inherit: number; |
| 25 | acl_root: string; |
| 26 | path: string; |
| 27 | general_access: FolioGeneralAccess; |
| 28 | general_role: DocRole | null; |
| 29 | agent_mode: "suggest" | "edit" | null; |
| 30 | text: string; |
| 31 | excerpt: string; |
| 32 | preview: string | null; |
| 33 | source: string | null; |
| 34 | mentioned: string; |
| 35 | created_by: string; |
| 36 | created_at: string; |
| 37 | updated_by: string | null; |
| 38 | updated_at: string; |
| 39 | edited_by: string | null; |
| 40 | edited_at: string; |
| 41 | trashed_at: string | null; |
| 42 | trashed_by: string | null; |
| 43 | }; |
| 44 | |
| 45 | /** Every column but the text, as lists read them. */ |
| 46 | export const FOLIO_COLUMNS = |
| 47 | "id, workspace_id, kind, title, icon, cover, owner, space_id, parent_id, position, inherit, acl_root, path, general_access, general_role, agent_mode, '' AS text, excerpt, preview, source, mentioned, created_by, created_at, updated_by, updated_at, edited_by, edited_at, trashed_at, trashed_by"; |
| 48 | |
| 49 | /** The same, prefixed by a table alias. */ |
| 50 | export function folioColumns(alias: string): string { |
| 51 | return FOLIO_COLUMNS.split(", ") |
| 52 | .map((c) => (c.startsWith("'") ? c : `${alias}.${c}`)) |
| 53 | .join(", "); |
| 54 | } |
| 55 | |
| 56 | /** D1 binds at most 100 parameters; lists go in as one JSON parameter instead. */ |
| 57 | export const json = (values: readonly string[]) => JSON.stringify([...new Set(values)]); |
| 58 | |
| 59 | /** How many statements one batch carries. */ |
| 60 | const BATCH = 80; |
| 61 | |
| 62 | export async function runBatches(db: D1Database, statements: D1PreparedStatement[]): Promise<void> { |
| 63 | for (let i = 0; i < statements.length; i += BATCH) await db.batch(statements.slice(i, i + BATCH)); |
| 64 | } |
| 65 | |
| 66 | export function aclNode(row: Pick<FolioRow, "id" | "owner" | "parent_id" | "space_id" | "inherit" | "general_access" | "general_role" | "created_at">): FolioAclNode { |
| 67 | return { |
| 68 | id: row.id, |
| 69 | owner: row.owner, |
| 70 | parent_id: row.parent_id, |
| 71 | space_id: row.space_id, |
| 72 | inherit: !!row.inherit, |
| 73 | general_access: row.general_access, |
| 74 | general_role: row.general_role, |
| 75 | created_at: row.created_at, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | /** The ids in a path, top first. */ |
| 80 | export function pathIds(path: string): string[] { |
| 81 | return String(path ?? "") |
| 82 | .split("/") |
| 83 | .filter(Boolean); |
| 84 | } |
| 85 | |
| 86 | /** Folio rows by id (any workspace's; callers check), without their text. */ |
| 87 | export async function foliosById(db: D1Database, ids: readonly string[]): Promise<Map<string, FolioRow>> { |
| 88 | const out = new Map<string, FolioRow>(); |
| 89 | const unique = [...new Set(ids)]; |
| 90 | for (let i = 0; i < unique.length; i += 500) { |
| 91 | const rows = await db |
| 92 | .prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE id IN (SELECT value FROM json_each(?))`) |
| 93 | .bind(json(unique.slice(i, i + 500))) |
| 94 | .all<FolioRow>(); |
| 95 | for (const r of rows.results) out.set(r.id, r); |
| 96 | } |
| 97 | return out; |
| 98 | } |
| 99 | |
| 100 | /** Grants on these folios, by folio. */ |
| 101 | export async function grantsOf(db: D1Database, ids: readonly string[]): Promise<Map<string, FolioGrant[]>> { |
| 102 | const out = new Map<string, FolioGrant[]>(); |
| 103 | const unique = [...new Set(ids)]; |
| 104 | for (let i = 0; i < unique.length; i += 500) { |
| 105 | const rows = await db |
| 106 | .prepare("SELECT folio_id, principal, role, granted_at FROM folio_grants WHERE folio_id IN (SELECT value FROM json_each(?))") |
| 107 | .bind(json(unique.slice(i, i + 500))) |
| 108 | .all<{ folio_id: string; principal: string; role: DocRole; granted_at: string }>(); |
| 109 | for (const r of rows.results) out.set(r.folio_id, [...(out.get(r.folio_id) ?? []), { principal: r.principal, role: r.role, granted_at: r.granted_at }]); |
| 110 | } |
| 111 | return out; |
| 112 | } |
| 113 | |
| 114 | /** Everything access needs for these folios: them and their ancestors as nodes, and every grant on them. */ |
| 115 | export type Ancestry = { rows: Map<string, FolioRow>; nodes: Map<string, FolioAclNode>; grants: Map<string, FolioGrant[]> }; |
| 116 | |
| 117 | export async function ancestry(db: D1Database, rows: readonly FolioRow[]): Promise<Ancestry> { |
| 118 | const all = new Map(rows.map((r) => [r.id, r])); |
| 119 | const missing = [...new Set(rows.flatMap((r) => pathIds(r.path)))].filter((id) => !all.has(id)); |
| 120 | if (missing.length) for (const [id, row] of await foliosById(db, missing)) all.set(id, row); |
| 121 | const grants = await grantsOf(db, [...all.keys()]); |
| 122 | return { rows: all, nodes: new Map([...all.values()].map((r) => [r.id, aclNode(r)])), grants }; |
| 123 | } |
| 124 | |
| 125 | /** What a reader's role depends on beyond the folio: their space roles and the links they opened. */ |
| 126 | export type ReaderContext = { |
| 127 | person: Person; |
| 128 | /** Their role in each space (null: none). */ |
| 129 | spaceRole: (spaceId: string) => DocRole | null; |
| 130 | /** Folio ids they opened. */ |
| 131 | visits: ReadonlySet<string>; |
| 132 | }; |
| 133 | |
| 134 | /** Of these folios, the ones whose link the person opened (or whose access root's). */ |
| 135 | export async function visitsOf(db: D1Database, userId: string, rows: readonly Pick<FolioRow, "id" | "acl_root">[]): Promise<Set<string>> { |
| 136 | const ids = [...new Set(rows.flatMap((r) => [r.id, r.acl_root]))]; |
| 137 | if (!ids.length) return new Set(); |
| 138 | const found = await db |
| 139 | .prepare("SELECT folio_id FROM folio_visits WHERE user_id = ? AND folio_id IN (SELECT value FROM json_each(?))") |
| 140 | .bind(userId, json(ids)) |
| 141 | .all<{ folio_id: string }>(); |
| 142 | return new Set(found.results.map((r) => r.folio_id)); |
| 143 | } |
| 144 | |
| 145 | /** A reader's role on each folio, from the full chain (defence in depth behind the list SQL). */ |
| 146 | export function rolesFrom(found: Ancestry, rows: readonly FolioRow[], reader: ReaderContext): Map<string, DocRole | null> { |
| 147 | const out = new Map<string, DocRole | null>(); |
| 148 | for (const row of rows) { |
| 149 | const chain = aclChain(row.id, found.nodes); |
| 150 | const root = chain[chain.length - 1]; |
| 151 | const visited = reader.visits.has(row.id) || (!!root && reader.visits.has(root.id)); |
| 152 | out.set(row.id, effectiveRole(chain, found.grants, root?.space_id ? reader.spaceRole(root.space_id) : null, reader.person, { visited })); |
| 153 | } |
| 154 | return out; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * The list filter (plan section 2.3): a folio is readable when one of the |
| 159 | * reader's keys has a `folio_access` row on it; or its access root is at |
| 160 | * the top of a space they can read and inherits it; or its access root |
| 161 | * is open to the workspace; or it is a link folio they opened. `f` is the |
| 162 | * folio and `r` its access root (`JOIN folios r ON r.id = f.acl_root`). |
| 163 | */ |
| 164 | export function readableWhere(keys: readonly string[], spaceIds: readonly string[], userId: string): { sql: string; binds: unknown[] } { |
| 165 | return { |
| 166 | sql: `(f.id IN (SELECT folio_id FROM folio_access WHERE principal IN (SELECT value FROM json_each(?))) |
| 167 | OR (r.parent_id IS NULL AND r.inherit = 1 AND r.space_id IN (SELECT value FROM json_each(?))) |
| 168 | OR r.general_access = 'workspace' |
| 169 | OR (r.general_access = 'link' AND EXISTS (SELECT 1 FROM folio_visits v WHERE v.user_id = ? AND (v.folio_id = f.id OR v.folio_id = f.acl_root))))`, |
| 170 | binds: [json(keys), json(spaceIds), userId], |
| 171 | }; |
| 172 | } |
| 173 | |
| 174 | /** Whether every member of the workspace can read a folio (events and the inbox carry its title only then). */ |
| 175 | export async function workspaceReadable(db: D1Database, folioId: string): Promise<boolean> { |
| 176 | const row = (await foliosById(db, [folioId])).get(folioId); |
| 177 | if (!row) return false; |
| 178 | const found = await ancestry(db, [row]); |
| 179 | const chain = aclChain(row.id, found.nodes); |
| 180 | const root = chain[chain.length - 1]; |
| 181 | let space = null; |
| 182 | if (root?.space_id) { |
| 183 | const s = await db.prepare("SELECT kind, team, default_role FROM spaces WHERE id = ?").bind(root.space_id).first<{ kind: "workspace" | "team" | "private"; team: string | null; default_role: DocRole | null }>(); |
| 184 | if (s) space = { ...s, members: [] }; |
| 185 | } |
| 186 | return folioReadableByWorkspace(chain, space); |
| 187 | } |
| 188 | |
| 189 | /** The index scope of each of these folios (src/access.ts `folioScope`). */ |
| 190 | export async function scopesOf(db: D1Database, rows: readonly FolioRow[]): Promise<Map<string, string>> { |
| 191 | const found = await ancestry(db, rows); |
| 192 | return new Map(rows.map((r) => [r.id, folioScope(aclChain(r.id, found.nodes), found.grants)])); |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * A subtree: the folio and everything under it, by its path. (A prefix |
| 197 | * compare, not LIKE: D1 refuses LIKE patterns over 50 bytes, which a |
| 198 | * path two deep already is.) |
| 199 | */ |
| 200 | export async function subtree(db: D1Database, root: Pick<FolioRow, "path" | "workspace_id">): Promise<FolioRow[]> { |
| 201 | return ( |
| 202 | await db |
| 203 | .prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE workspace_id = ? AND substr(path, 1, ?) = ? ORDER BY length(path)`) |
| 204 | .bind(root.workspace_id, root.path.length, root.path) |
| 205 | .all<FolioRow>() |
| 206 | ).results; |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Brings a subtree up to date after a move, a restriction, a grant or an |
| 211 | * ownership change: each folio's space (its top's), `acl_root` and `path` |
| 212 | * from its parent down, then its `folio_access` rows. `rootId`'s own |
| 213 | * parent (and space, when it has a parent) must already be set. Returns |
| 214 | * the subtree's ids, top first. |
| 215 | */ |
| 216 | export async function rebuildSubtree(db: D1Database, rootId: string): Promise<string[]> { |
| 217 | const root = (await foliosById(db, [rootId])).get(rootId); |
| 218 | if (!root) return []; |
| 219 | const below = await subtree(db, root); |
| 220 | const parent = root.parent_id ? ((await foliosById(db, [root.parent_id])).get(root.parent_id) ?? null) : null; |
| 221 | // Top down: each child's place follows its parent's new one. |
| 222 | const next = new Map<string, { space_id: string | null; acl_root: string; path: string }>(); |
| 223 | const placeOf = (row: FolioRow, up: { space_id: string | null; acl_root: string; path: string } | null) => ({ |
| 224 | space_id: up ? up.space_id : row.space_id, |
| 225 | acl_root: aclRootOf({ id: row.id, inherit: !!row.inherit, parent_id: row.parent_id }, up?.acl_root ?? null), |
| 226 | path: folioPathOf(row.id, up?.path ?? null), |
| 227 | }); |
| 228 | const byParent = new Map<string, FolioRow[]>(); |
| 229 | for (const r of below) if (r.id !== root.id && r.parent_id) byParent.set(r.parent_id, [...(byParent.get(r.parent_id) ?? []), r]); |
| 230 | const queue: FolioRow[] = [root]; |
| 231 | next.set(root.id, placeOf(root, parent ? { space_id: parent.space_id, acl_root: parent.acl_root, path: parent.path } : null)); |
| 232 | for (let i = 0; i < queue.length; i++) { |
| 233 | const at = queue[i]!; |
| 234 | for (const child of byParent.get(at.id) ?? []) { |
| 235 | next.set(child.id, placeOf(child, next.get(at.id)!)); |
| 236 | queue.push(child); |
| 237 | } |
| 238 | } |
| 239 | const ids = queue.map((r) => r.id); |
| 240 | const statements: D1PreparedStatement[] = []; |
| 241 | for (const row of queue) { |
| 242 | const place = next.get(row.id)!; |
| 243 | if (place.space_id !== row.space_id || place.acl_root !== row.acl_root || place.path !== row.path) { |
| 244 | statements.push(db.prepare("UPDATE folios SET space_id = ?, acl_root = ?, path = ? WHERE id = ?").bind(place.space_id, place.acl_root, place.path, row.id)); |
| 245 | Object.assign(row, place); |
| 246 | } |
| 247 | } |
| 248 | await runBatches(db, statements); |
| 249 | await rematerialize(db, queue); |
| 250 | return ids; |
| 251 | } |
| 252 | |
| 253 | /** Rewrites `folio_access` for these folios (their places already right). */ |
| 254 | export async function rematerialize(db: D1Database, rows: FolioRow[]): Promise<void> { |
| 255 | if (!rows.length) return; |
| 256 | const found = await ancestry(db, rows); |
| 257 | // The rows given win over what was read: they hold the places just written. |
| 258 | for (const r of rows) { |
| 259 | found.rows.set(r.id, r); |
| 260 | found.nodes.set(r.id, aclNode(r)); |
| 261 | } |
| 262 | const access = materialize( |
| 263 | rows.map((r) => r.id), |
| 264 | found.nodes, |
| 265 | found.grants, |
| 266 | ); |
| 267 | const statements: D1PreparedStatement[] = []; |
| 268 | for (let i = 0; i < rows.length; i += 500) { |
| 269 | statements.push(db.prepare("DELETE FROM folio_access WHERE folio_id IN (SELECT value FROM json_each(?))").bind(json(rows.slice(i, i + 500).map((r) => r.id)))); |
| 270 | } |
| 271 | for (const a of access) { |
| 272 | statements.push(db.prepare("INSERT OR REPLACE INTO folio_access (folio_id, principal, role, via, since) VALUES (?, ?, ?, ?, ?)").bind(a.folio_id, a.principal, a.role, a.via, a.since)); |
| 273 | } |
| 274 | await runBatches(db, statements); |
| 275 | } |