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