@firedrill-tools/notion 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +402 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounded.scenario.json +19 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/notion-bounded.drill.json +318 -0
  8. package/firedrill/notion-byte-budget.drill.json +116 -0
  9. package/firedrill/notion-mcp-aliases.drill.json +150 -0
  10. package/firedrill/notion-page-authoring.drill.json +254 -0
  11. package/firedrill/notion-rate-limited.drill.json +118 -0
  12. package/firedrill/notion-schema-growth.drill.json +88 -0
  13. package/firedrill/notion-scope-agent-only.drill.json +131 -0
  14. package/firedrill/notion-scope-auditor.drill.json +86 -0
  15. package/firedrill/notion-scope-board-bot.drill.json +128 -0
  16. package/firedrill/notion-scope-notes-bot.drill.json +303 -0
  17. package/firedrill/notion-scope-stranger.drill.json +773 -0
  18. package/firedrill/notion-task-triage.drill.json +277 -0
  19. package/firedrill/notion-trash-and-restore.drill.json +186 -0
  20. package/firedrill/notion-update-lost.drill.json +88 -0
  21. package/firedrill/notion-workspace-read.drill.json +258 -0
  22. package/firedrill/notion-write-unavailable.drill.json +161 -0
  23. package/firedrill/rate-limited.scenario.json +11 -0
  24. package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
  25. package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
  26. package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
  27. package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
  28. package/firedrill/tools/notion/app/assets/notion.svg +1 -0
  29. package/firedrill/tools/notion/app/site/app.js +797 -0
  30. package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
  31. package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
  32. package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
  33. package/firedrill/tools/notion/app/site/chrome.js +104 -0
  34. package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
  35. package/firedrill/tools/notion/app/site/database.js +648 -0
  36. package/firedrill/tools/notion/app/site/editors.js +320 -0
  37. package/firedrill/tools/notion/app/site/format-bar.js +97 -0
  38. package/firedrill/tools/notion/app/site/icons.js +131 -0
  39. package/firedrill/tools/notion/app/site/index.html +125 -0
  40. package/firedrill/tools/notion/app/site/page.js +826 -0
  41. package/firedrill/tools/notion/app/site/rich.js +159 -0
  42. package/firedrill/tools/notion/app/site/state.js +170 -0
  43. package/firedrill/tools/notion/app/site/styles.css +826 -0
  44. package/firedrill/tools/notion/app/site/ui.js +418 -0
  45. package/firedrill/tools/notion/behavior.mjs +1123 -0
  46. package/firedrill/tools/notion/lib/blocks.mjs +371 -0
  47. package/firedrill/tools/notion/lib/identity.mjs +123 -0
  48. package/firedrill/tools/notion/lib/ids.mjs +63 -0
  49. package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
  50. package/firedrill/tools/notion/lib/markdown.mjs +381 -0
  51. package/firedrill/tools/notion/lib/properties.mjs +513 -0
  52. package/firedrill/tools/notion/lib/query.mjs +272 -0
  53. package/firedrill/tools/notion/lib/render.mjs +137 -0
  54. package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
  55. package/firedrill/tools/notion/lib/size.mjs +44 -0
  56. package/firedrill/tools/notion/lib/state.mjs +192 -0
  57. package/firedrill/tools/notion/lib/wire.mjs +89 -0
  58. package/firedrill/tools/notion/notion.tool.json +9837 -0
  59. package/firedrill/update-lost.scenario.json +11 -0
  60. package/firedrill/world.json +7039 -0
  61. package/firedrill/write-unavailable.scenario.json +11 -0
  62. package/firedrill.json +5 -0
  63. package/package.json +63 -0
  64. package/starter.json +6482 -0
  65. package/test/conformance.mjs +1186 -0
@@ -0,0 +1,371 @@
1
+ // Blocks: request validation (types, nesting), the ordered tree under a page or block, appends with
2
+ // `after`, trash/restore of subtrees and the bookkeeping Notion does (positions, has_children, page
3
+ // last_edited_*). Stored content lives under the fixed `content` key; rendering puts it under `[type]`.
4
+ import { nextId } from "./ids.mjs";
5
+ import { COLORS, normalizeRichText } from "./rich-text.mjs";
6
+ import { jsonBytes, REQUEST_CONTENT_BYTES } from "./size.mjs";
7
+ import { allRows, shown, validationError, withinBudget } from "./state.mjs";
8
+
9
+ export const TEXT_BLOCK_TYPES = Object.freeze([
10
+ "paragraph", "heading_1", "heading_2", "heading_3", "bulleted_list_item", "numbered_list_item", "to_do", "toggle", "quote", "callout", "code",
11
+ ]);
12
+ export const APPENDABLE_BLOCK_TYPES = Object.freeze([...TEXT_BLOCK_TYPES, "divider", "bookmark", "image"]);
13
+ export const BLOCK_TYPES = Object.freeze([...APPENDABLE_BLOCK_TYPES, "child_page", "child_database"]);
14
+ export const CONTAINER_TYPES = Object.freeze(["paragraph", "bulleted_list_item", "numbered_list_item", "to_do", "toggle", "quote", "callout"]);
15
+ /** Nesting levels one request may add below its top-level blocks (Notion's documented limit). */
16
+ export const MAX_DEPTH = 2;
17
+ /**
18
+ * Deepest stored block below a page: a top-level block is level 1. Appends that would go deeper fail with a
19
+ * validation error, so every walk over a stored tree (render, parse of the rendered markdown, trash, parent
20
+ * lookup) is bounded and never recurses on caller-built depth.
21
+ */
22
+ export const MAX_TREE_LEVELS = 64;
23
+ const HEADINGS = ["heading_1", "heading_2", "heading_3"];
24
+ const CODE_LANGUAGES = Object.freeze([
25
+ "abap", "arduino", "bash", "basic", "c", "clojure", "coffeescript", "c++", "c#", "css", "dart", "diff", "docker", "elixir", "elm", "erlang",
26
+ "flow", "fortran", "f#", "gherkin", "glsl", "go", "graphql", "groovy", "haskell", "html", "java", "javascript", "json", "julia", "kotlin",
27
+ "latex", "less", "lisp", "livescript", "lua", "makefile", "markdown", "markup", "matlab", "mermaid", "nix", "objective-c", "ocaml", "pascal",
28
+ "perl", "php", "plain text", "powershell", "prolog", "protobuf", "python", "r", "reason", "ruby", "rust", "sass", "scala", "scheme", "scss",
29
+ "shell", "sql", "swift", "typescript", "vb.net", "verilog", "vhdl", "visual basic", "webassembly", "xml", "yaml", "java/c/c++/c#",
30
+ ]);
31
+
32
+ function isObject(value) {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function color(context, value, path) {
37
+ if (value === undefined) return "default";
38
+ if (!COLORS.includes(value)) return validationError(context, `body failed validation: body.${path}.color should be a valid color, instead was \`${shown(value)}\`.`);
39
+ return value;
40
+ }
41
+
42
+ function emojiIcon(context, value, path) {
43
+ if (value === undefined || value === null) return null;
44
+ if (!isObject(value) || value.type !== "emoji" || typeof value.emoji !== "string" || value.emoji.length === 0) {
45
+ return validationError(context, `body failed validation: body.${path}.icon should be an emoji icon ({ type: "emoji", emoji }).`);
46
+ }
47
+ return { type: "emoji", emoji: value.emoji };
48
+ }
49
+
50
+ /** Validate the content object of one block type; returns the stored `content`. */
51
+ /** Validated block content, bounded by the bytes it renders to so one block always fits one response. */
52
+ export function normalizeContent(context, type, raw, path) {
53
+ const content = contentOf(context, type, raw, path);
54
+ return withinBudget(context, content, `body.${path.length > 0 ? `${path}.` : ""}${type}`);
55
+ }
56
+
57
+ function contentOf(context, type, raw, path) {
58
+ const body = raw === undefined ? {} : raw;
59
+ if (!isObject(body)) return validationError(context, `body failed validation: body.${path}.${type} should be an object.`);
60
+ if (TEXT_BLOCK_TYPES.includes(type)) {
61
+ const richText = normalizeRichText(context, body.rich_text ?? [], `${path}.${type}.rich_text`);
62
+ if (type === "code") {
63
+ const language = body.language ?? "plain text";
64
+ if (!CODE_LANGUAGES.includes(language)) return validationError(context, `body failed validation: body.${path}.code.language should be a supported language, instead was \`${shown(language)}\`.`);
65
+ return { rich_text: richText, caption: normalizeRichText(context, body.caption ?? [], `${path}.code.caption`), language };
66
+ }
67
+ const content = { rich_text: richText, color: color(context, body.color, `${path}.${type}`) };
68
+ if (type === "to_do") {
69
+ if (body.checked !== undefined && typeof body.checked !== "boolean") return validationError(context, `body failed validation: body.${path}.to_do.checked should be a boolean.`);
70
+ content.checked = body.checked === true;
71
+ }
72
+ if (HEADINGS.includes(type)) {
73
+ if (body.is_toggleable !== undefined && typeof body.is_toggleable !== "boolean") return validationError(context, `body failed validation: body.${path}.${type}.is_toggleable should be a boolean.`);
74
+ content.is_toggleable = body.is_toggleable === true;
75
+ }
76
+ if (type === "callout") content.icon = emojiIcon(context, body.icon, `${path}.callout`);
77
+ return content;
78
+ }
79
+ if (type === "divider") return {};
80
+ if (type === "bookmark") {
81
+ if (typeof body.url !== "string" || body.url.length === 0) return validationError(context, `body failed validation: body.${path}.bookmark.url should be a non-empty string.`);
82
+ return { url: body.url, caption: normalizeRichText(context, body.caption ?? [], `${path}.bookmark.caption`) };
83
+ }
84
+ if (type === "image") {
85
+ if (body.type !== undefined && body.type !== "external") return validationError(context, `body failed validation: body.${path}.image.type should be "external" (file uploads are not supported).`);
86
+ if (!isObject(body.external) || typeof body.external.url !== "string" || body.external.url.length === 0) {
87
+ return validationError(context, `body failed validation: body.${path}.image.external.url should be a non-empty string.`);
88
+ }
89
+ return { type: "external", external: { url: body.external.url }, caption: normalizeRichText(context, body.caption ?? [], `${path}.image.caption`) };
90
+ }
91
+ return validationError(context, `body failed validation: body.${path}.type should be one of ${APPENDABLE_BLOCK_TYPES.join(", ")}, instead was \`${type}\`.`);
92
+ }
93
+
94
+ /** The declared validation error for `children` below `itemPath` that exceed the per-request nesting limit. */
95
+ export function nestingError(context, itemPath) {
96
+ return validationError(context, `body failed validation: body.${itemPath}.children nests blocks deeper than ${MAX_DEPTH} levels in one request.`);
97
+ }
98
+
99
+ /** Validate an array of block request objects (nested `children` up to two levels below the top). */
100
+ export function normalizeBlockInputs(context, raw, path, depth = 0, budget = { bytes: 0 }) {
101
+ if (!Array.isArray(raw)) return validationError(context, `body failed validation: body.${path} should be an array.`);
102
+ const inputs = [];
103
+ for (let index = 0; index < raw.length; index += 1) {
104
+ const item = raw[index];
105
+ const itemPath = `${path}[${index}]`;
106
+ if (!isObject(item)) return validationError(context, `body failed validation: body.${itemPath} should be an object.`);
107
+ const type = typeof item.type === "string" ? item.type : BLOCK_TYPES.find((candidate) => item[candidate] !== undefined);
108
+ if (type === undefined) return validationError(context, `body failed validation: body.${itemPath}.type should be defined, instead was \`undefined\`.`);
109
+ if (!APPENDABLE_BLOCK_TYPES.includes(type)) {
110
+ return validationError(context, `body failed validation: body.${itemPath}.type should be one of ${APPENDABLE_BLOCK_TYPES.join(", ")}, instead was \`${type}\`.`);
111
+ }
112
+ const content = normalizeContent(context, type, item[type], itemPath);
113
+ budget.bytes += jsonBytes(content);
114
+ if (budget.bytes > REQUEST_CONTENT_BYTES) {
115
+ return validationError(context, `body failed validation: body.${path} adds more than ${REQUEST_CONTENT_BYTES} bytes of rendered block content in one request (at body.${itemPath}).`);
116
+ }
117
+ let children = [];
118
+ const nested = item.children ?? (isObject(item[type]) ? item[type].children : undefined);
119
+ if (nested !== undefined) {
120
+ if (!CONTAINER_TYPES.includes(type)) return validationError(context, `body failed validation: body.${itemPath}.children is not supported for ${type} blocks.`);
121
+ if (depth + 1 > MAX_DEPTH) return nestingError(context, itemPath);
122
+ children = normalizeBlockInputs(context, nested, `${itemPath}.children`, depth + 1, budget);
123
+ }
124
+ inputs.push({ type, content, children });
125
+ }
126
+ return inputs;
127
+ }
128
+
129
+ export function parentKey(parent) {
130
+ return parent.page_id ?? parent.block_id ?? parent.data_source_id ?? parent.database_id;
131
+ }
132
+
133
+ /**
134
+ * Every block row of one operation, indexed once: `byId` (row id → mutable row copy) and `byParent` (parent id → rows
135
+ * under it). Lookups and child listings cost the size of one sibling list, never a scan of every block row, so an
136
+ * operation touching n blocks stays O(n log n) however many (trashed) rows the workspace holds.
137
+ */
138
+ export function indexBlocks(list) {
139
+ const index = { byId: new Map(), byParent: new Map() };
140
+ for (const row of list) addBlockRow(index, row);
141
+ return index;
142
+ }
143
+
144
+ export function addBlockRow(index, row) {
145
+ index.byId.set(row.id, row);
146
+ const key = parentKey(row.parent);
147
+ const siblings = index.byParent.get(key);
148
+ if (siblings === undefined) index.byParent.set(key, [row]);
149
+ else siblings.push(row);
150
+ }
151
+
152
+ export function removeBlockRow(index, id) {
153
+ const row = index.byId.get(id);
154
+ if (row === undefined) return;
155
+ index.byId.delete(id);
156
+ const siblings = index.byParent.get(parentKey(row.parent));
157
+ const at = siblings === undefined ? -1 : siblings.indexOf(row);
158
+ if (at >= 0) siblings.splice(at, 1);
159
+ }
160
+
161
+ const byPosition = (left, right) => left.position - right.position || (left.id < right.id ? -1 : 1);
162
+
163
+ /** Live (non-trashed) children of a page or block in position order. `rows` = the operation's block index. */
164
+ export function childrenOf(rows, parentId, includeTrashed = false) {
165
+ const siblings = rows.byParent.get(parentId);
166
+ if (siblings === undefined) return [];
167
+ return (includeTrashed ? siblings.slice() : siblings.filter((block) => !block.in_trash)).sort(byPosition);
168
+ }
169
+
170
+ /** Position for a block appended after every live child of `parentId`. */
171
+ export function nextPosition(rows, parentId) {
172
+ let last = -1;
173
+ for (const block of rows.byParent.get(parentId) ?? []) if (!block.in_trash && block.position > last) last = block.position;
174
+ return last + 1;
175
+ }
176
+
177
+ function hasLiveChildren(rows, parentId) {
178
+ for (const block of rows.byParent.get(parentId) ?? []) if (!block.in_trash) return true;
179
+ return false;
180
+ }
181
+
182
+ /**
183
+ * Give `ordered` (live siblings in their new order) the gapless positions 0..n-1, writing only rows whose position
184
+ * changes. Trashed siblings keep their last position and are never rewritten, so repeated replaces do not grow the work.
185
+ */
186
+ export function placeInOrder(context, ordered) {
187
+ ordered.forEach((block, position) => {
188
+ if (block.position === position) return;
189
+ block.position = position;
190
+ const stored = context.state.get("blocks", block.id);
191
+ if (stored !== null) context.state.put("blocks", block.id, { ...stored, position });
192
+ });
193
+ }
194
+
195
+ /** The page containing a block (walking block parents). */
196
+ export function pageOfBlock(context, block) {
197
+ let current = block;
198
+ for (let depth = 0; depth < MAX_TREE_LEVELS; depth += 1) {
199
+ if (current.parent.type === "page_id") return current.parent.page_id;
200
+ const next = context.state.get("blocks", current.parent.block_id);
201
+ if (next === null) return undefined;
202
+ current = next;
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ /** Level of a stored block below its page (a top-level block is 1); at most MAX_TREE_LEVELS + 1 lookups. */
208
+ export function blockLevel(context, block) {
209
+ let current = block;
210
+ let level = 1;
211
+ while (current.parent.type === "block_id" && level <= MAX_TREE_LEVELS) {
212
+ const next = context.state.get("blocks", current.parent.block_id);
213
+ if (next === null) break;
214
+ current = next;
215
+ level += 1;
216
+ }
217
+ return level;
218
+ }
219
+
220
+ /** Levels a validated request adds below its top-level blocks (0 when none has children; at most MAX_DEPTH). */
221
+ export function requestDepth(inputs) {
222
+ let deepest = 0;
223
+ for (const input of inputs) if (input.children.length > 0) deepest = Math.max(deepest, 1 + requestDepth(input.children));
224
+ return deepest;
225
+ }
226
+
227
+ export function touchPage(context, pageId, now, authorId) {
228
+ const page = context.state.get("pages", pageId);
229
+ if (page === null) return;
230
+ context.state.put("pages", pageId, { ...page, last_edited_time: now, last_edited_by: { object: "user", id: authorId } });
231
+ }
232
+
233
+ function setHasChildren(context, rows, parentBlockId) {
234
+ const parent = context.state.get("blocks", parentBlockId);
235
+ if (parent === null) return;
236
+ const hasChildren = hasLiveChildren(rows, parentBlockId);
237
+ if (parent.has_children !== hasChildren) context.state.put("blocks", parentBlockId, { ...parent, has_children: hasChildren });
238
+ const indexed = rows.byId.get(parentBlockId);
239
+ if (indexed !== undefined) indexed.has_children = hasChildren;
240
+ }
241
+
242
+ /**
243
+ * Create block rows from validated inputs under `parent` (a `{type:"page_id"}` or `{type:"block_id"}`
244
+ * reference). `afterId` places them after that live sibling; otherwise they go last. Returns the new
245
+ * top-level rows in order. `rows` is the operation's block index (kept in sync). One call does one sort of the live
246
+ * siblings and writes each new row once plus the live siblings whose position actually moves.
247
+ */
248
+ export function createBlocks(context, parent, inputs, { afterId, now, authorId, rows }) {
249
+ const parentId = parentKey(parent);
250
+ const siblings = childrenOf(rows, parentId);
251
+ let insertAt = siblings.length;
252
+ if (afterId !== undefined) {
253
+ const index = siblings.findIndex((block) => block.id === afterId);
254
+ if (index < 0) return validationError(context, `body failed validation: body.after should be the id of a child block of ${parentId}, instead was \`${afterId}\`.`);
255
+ insertAt = index + 1;
256
+ }
257
+ const stamp = { created_time: now, last_edited_time: now, created_by: { object: "user", id: authorId }, last_edited_by: { object: "user", id: authorId } };
258
+ const build = (blockParent, list, depth) => {
259
+ const out = [];
260
+ list.forEach((input, index) => {
261
+ const id = nextId(context, "blocks");
262
+ const row = {
263
+ id,
264
+ parent: blockParent,
265
+ type: input.type,
266
+ content: input.content,
267
+ position: depth === 0 ? insertAt + index : index,
268
+ has_children: input.children.length > 0,
269
+ in_trash: false,
270
+ ...stamp,
271
+ };
272
+ context.state.put("blocks", id, row);
273
+ addBlockRow(rows, row);
274
+ out.push(row);
275
+ if (input.children.length > 0) build({ type: "block_id", block_id: id }, input.children, depth + 1);
276
+ });
277
+ return out;
278
+ };
279
+ const created = build(parent, inputs, 0);
280
+ placeInOrder(context, [...siblings.slice(0, insertAt), ...created, ...siblings.slice(insertAt)]);
281
+ if (parent.type === "block_id") setHasChildren(context, rows, parent.block_id);
282
+ return created;
283
+ }
284
+
285
+ /** Mark every block below `parentId` (and the pages/databases they embed) as trashed or restored. */
286
+ export function setSubtreeTrashed(context, rows, parentId, trashed, now, authorId, visited = new Set()) {
287
+ for (const block of childrenOf(rows, parentId, true)) {
288
+ if (visited.has(block.id)) continue;
289
+ visited.add(block.id);
290
+ if (block.in_trash !== trashed) {
291
+ const updated = { ...block, in_trash: trashed, last_edited_time: now, last_edited_by: { object: "user", id: authorId } };
292
+ context.state.put("blocks", block.id, updated);
293
+ Object.assign(block, updated);
294
+ }
295
+ if (block.type === "child_page") setPageTrashed(context, rows, block.id, trashed, now, authorId, visited);
296
+ else if (block.type === "child_database") setDatabaseTrashed(context, rows, block.id, trashed, now, authorId, visited);
297
+ else setSubtreeTrashed(context, rows, block.id, trashed, now, authorId, visited);
298
+ }
299
+ }
300
+
301
+ /** The child_page / child_database block that represents an object in its parent page shares its id. */
302
+ function setRepresentingBlockTrashed(context, rows, id, trashed, now, authorId) {
303
+ const block = context.state.get("blocks", id);
304
+ if (block === null || block.in_trash === trashed) return;
305
+ const updated = { ...block, in_trash: trashed, last_edited_time: now, last_edited_by: { object: "user", id: authorId } };
306
+ context.state.put("blocks", id, updated);
307
+ const stored = rows.byId.get(id);
308
+ if (stored !== undefined) Object.assign(stored, updated);
309
+ }
310
+
311
+ export function setPageTrashed(context, rows, pageId, trashed, now, authorId, visited = new Set()) {
312
+ const page = context.state.get("pages", pageId);
313
+ if (page === null) return;
314
+ if (page.in_trash !== trashed) {
315
+ context.state.put("pages", pageId, { ...page, in_trash: trashed, last_edited_time: now, last_edited_by: { object: "user", id: authorId } });
316
+ }
317
+ setRepresentingBlockTrashed(context, rows, pageId, trashed, now, authorId);
318
+ setSubtreeTrashed(context, rows, pageId, trashed, now, authorId, visited);
319
+ }
320
+
321
+ export function setDataSourceTrashed(context, rows, sourceId, trashed, now, authorId, visited = new Set()) {
322
+ const source = context.state.get("data-sources", sourceId);
323
+ if (source === null) return;
324
+ if (source.in_trash !== trashed) {
325
+ context.state.put("data-sources", sourceId, { ...source, in_trash: trashed, last_edited_time: now, last_edited_by: { object: "user", id: authorId } });
326
+ }
327
+ for (const page of allRows(context, "pages")) {
328
+ if (page.parent.type === "data_source_id" && page.parent.data_source_id === sourceId) setPageTrashed(context, rows, page.id, trashed, now, authorId, visited);
329
+ }
330
+ }
331
+
332
+ export function setDatabaseTrashed(context, rows, databaseId, trashed, now, authorId, visited = new Set()) {
333
+ const database = context.state.get("databases", databaseId);
334
+ if (database === null) return;
335
+ if (database.in_trash !== trashed) {
336
+ context.state.put("databases", databaseId, { ...database, in_trash: trashed, last_edited_time: now, last_edited_by: { object: "user", id: authorId } });
337
+ }
338
+ setRepresentingBlockTrashed(context, rows, databaseId, trashed, now, authorId);
339
+ for (const sourceId of database.data_source_ids) setDataSourceTrashed(context, rows, sourceId, trashed, now, authorId, visited);
340
+ }
341
+
342
+ /**
343
+ * Trash or restore one block with its descendants and fix the parent's `has_children` (skipped with
344
+ * `{ updateParent: false }` when the caller trashes a whole sibling list and settles the parent once).
345
+ */
346
+ export function setBlockTrashed(context, rows, block, trashed, now, authorId, { updateParent = true } = {}) {
347
+ const updated = { ...block, in_trash: trashed, last_edited_time: now, last_edited_by: { object: "user", id: authorId } };
348
+ context.state.put("blocks", block.id, updated);
349
+ const stored = rows.byId.get(block.id);
350
+ if (stored !== undefined) Object.assign(stored, updated);
351
+ setSubtreeTrashed(context, rows, block.id, trashed, now, authorId);
352
+ if (updateParent && block.parent.type === "block_id") setHasChildren(context, rows, block.parent.block_id);
353
+ return updated;
354
+ }
355
+
356
+ /** Every descendant block id of a page or block (trashed ones included), depth-first in order. */
357
+ export function descendantIds(rows, parentId, out = []) {
358
+ for (const block of childrenOf(rows, parentId, true)) {
359
+ out.push(block.id);
360
+ if (block.type !== "child_page" && block.type !== "child_database") descendantIds(rows, block.id, out);
361
+ }
362
+ return out;
363
+ }
364
+
365
+ /** Nested tree `{ block, children }` of live blocks under a page or block. */
366
+ export function blockTree(rows, parentId) {
367
+ return childrenOf(rows, parentId).map((block) => ({
368
+ block,
369
+ children: block.type === "child_page" || block.type === "child_database" ? [] : blockTree(rows, block.id),
370
+ }));
371
+ }
@@ -0,0 +1,123 @@
1
+ // Who is calling: the integration (capabilities + visibility scope) and the attributed user. Resolved from
2
+ // the actor attributes `integrationId` / `userId` with the documented fallback (first integrations row).
3
+ import { normalizeId } from "./ids.mjs";
4
+ import { fail, notFound, restricted, shown, validationError } from "./state.mjs";
5
+
6
+ const INVALID_TOKEN = "The bearer token is not valid.";
7
+ const CONTENT_LEVELS = Object.freeze({ none: 0, read: 1, read_update: 2, read_update_insert: 3 });
8
+ const COMMENT_LEVELS = Object.freeze({ none: 0, read: 1, read_insert: 2 });
9
+
10
+ export function requireIdentity(context) {
11
+ const attributes = context.actor.attributes ?? {};
12
+ let integration = null;
13
+ if (attributes.integrationId !== undefined) {
14
+ const id = normalizeId(attributes.integrationId);
15
+ integration = id === undefined ? null : context.state.get("integrations", id);
16
+ if (integration === null) return fail(context, "UNAUTHORIZED", INVALID_TOKEN);
17
+ } else {
18
+ const first = context.state.scan("integrations", { limit: 1 });
19
+ if (first.length === 0) return fail(context, "UNAUTHORIZED", INVALID_TOKEN);
20
+ integration = first[0].value;
21
+ }
22
+ const bot = context.state.get("users", integration.bot_user_id);
23
+ if (bot === null) return fail(context, "UNAUTHORIZED", INVALID_TOKEN);
24
+ let user = bot;
25
+ if (attributes.userId !== undefined) {
26
+ const id = normalizeId(attributes.userId);
27
+ const person = id === undefined ? null : context.state.get("users", id);
28
+ if (person === null || person.type !== "person") return fail(context, "UNAUTHORIZED", INVALID_TOKEN);
29
+ user = person;
30
+ }
31
+ return { integration, bot, user, capabilities: integration.capabilities, access: integration.access };
32
+ }
33
+
34
+ export function requireContent(context, identity, level) {
35
+ if (CONTENT_LEVELS[identity.capabilities.content] >= CONTENT_LEVELS[level]) return;
36
+ const verb = level === "read" ? "read" : level === "read_update" ? "update" : "insert";
37
+ return restricted(context, `This integration does not have ${verb} content capabilities.`);
38
+ }
39
+
40
+ export function requireComments(context, identity, level) {
41
+ if (COMMENT_LEVELS[identity.capabilities.comments] >= COMMENT_LEVELS[level]) return;
42
+ const verb = level === "read" ? "read" : "insert";
43
+ return restricted(context, `This integration does not have ${verb} comment capabilities.`);
44
+ }
45
+
46
+ export function requireUserInformation(context, identity) {
47
+ if (identity.capabilities.user_information !== "none") return;
48
+ return restricted(context, "This integration does not have user information capabilities.");
49
+ }
50
+
51
+ /** Ids from the object itself up to the workspace root (page → data source → database → page → …). */
52
+ export function ancestry(context, row) {
53
+ const ids = [row.id];
54
+ let parent = row.parent;
55
+ for (let depth = 0; depth < 64 && parent !== undefined && parent !== null && parent.type !== "workspace"; depth += 1) {
56
+ if (parent.type === "page_id") {
57
+ const page = context.state.get("pages", parent.page_id);
58
+ if (page === null) break;
59
+ ids.push(page.id);
60
+ parent = page.parent;
61
+ } else if (parent.type === "block_id") {
62
+ const block = context.state.get("blocks", parent.block_id);
63
+ if (block === null) break;
64
+ ids.push(block.id);
65
+ parent = block.parent;
66
+ } else if (parent.type === "data_source_id") {
67
+ const source = context.state.get("data-sources", parent.data_source_id);
68
+ if (source === null) break;
69
+ ids.push(source.id);
70
+ parent = source.parent;
71
+ } else if (parent.type === "database_id") {
72
+ const database = context.state.get("databases", parent.database_id);
73
+ if (database === null) break;
74
+ ids.push(database.id);
75
+ parent = database.parent;
76
+ } else {
77
+ break;
78
+ }
79
+ }
80
+ return ids;
81
+ }
82
+
83
+ export function isVisible(context, identity, row) {
84
+ if (identity.access.type === "workspace") return true;
85
+ const roots = identity.access.root_ids;
86
+ return ancestry(context, row).some((id) => roots.includes(id));
87
+ }
88
+
89
+ /**
90
+ * Validate an id parameter's shape the way Notion does (`path.page_id should be a valid uuid`). `name` may carry the
91
+ * section the value came from (`body.parent.page_id`, `query.block_id`); a bare name is a path parameter, so the
92
+ * message never claims a body or query value sat in the path.
93
+ */
94
+ export function requireUuid(context, value, name) {
95
+ const id = normalizeId(value);
96
+ if (id === undefined) {
97
+ const field = /^(path|body|query)\./.test(name) ? name : `path.${name}`;
98
+ const section = field.slice(0, field.indexOf("."));
99
+ return validationError(context, `${section} failed validation: ${field} should be a valid uuid, instead was \`${shown(value)}\`.`);
100
+ }
101
+ return id;
102
+ }
103
+
104
+ function load(context, identity, namespace, kind, value, name) {
105
+ const id = requireUuid(context, value, name);
106
+ const row = context.state.get(namespace, id);
107
+ if (row === null || !isVisible(context, identity, row)) return notFound(context, kind, id);
108
+ return row;
109
+ }
110
+
111
+ export const requirePage = (context, identity, value, name = "page_id") => load(context, identity, "pages", "page", value, name);
112
+ export const requireDatabase = (context, identity, value, name = "database_id") => load(context, identity, "databases", "database", value, name);
113
+ export const requireDataSource = (context, identity, value, name = "data_source_id") =>
114
+ load(context, identity, "data-sources", "data-source", value, name);
115
+ export const requireBlockRow = (context, identity, value, name = "block_id") => load(context, identity, "blocks", "block", value, name);
116
+
117
+ /** A person or bot user visible to every integration (users are never scoped, only e-mails are). */
118
+ export function requireUser(context, value) {
119
+ const id = requireUuid(context, value, "user_id");
120
+ const row = context.state.get("users", id);
121
+ if (row === null) return notFound(context, "user", id);
122
+ return row;
123
+ }
@@ -0,0 +1,63 @@
1
+ // Row ids are UUID-shaped strings `fd000000-0000-4000-8000-KKKKNNNNNNNN` (version nibble 4, variant 8):
2
+ // KKKK is the kind code and NNNNNNNN a hexadecimal sequence, so creation order equals row-id order and
3
+ // `format: uuid` clients accept them. Starter rows use sequences below 0x1000; generated rows take the
4
+ // next sequence from the `meta/counters` row. Everything here is pure or reads/writes context.state.
5
+
6
+ export const KIND = Object.freeze({
7
+ users: "0001",
8
+ pages: "0002",
9
+ databases: "0003",
10
+ "data-sources": "0004",
11
+ blocks: "0005",
12
+ comments: "0006",
13
+ integrations: "0007",
14
+ discussions: "0008",
15
+ });
16
+
17
+ const PREFIX = "fd000000-0000-4000-8000-";
18
+ const DASHED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
19
+ const COMPACT = /^[0-9a-f]{32}$/;
20
+ const DEFAULT_COUNTERS = Object.freeze({ next_id: 4096 });
21
+
22
+ export function makeId(kind, sequence) {
23
+ return `${PREFIX}${KIND[kind]}${sequence.toString(16).padStart(8, "0")}`;
24
+ }
25
+
26
+ /** Dashed lowercase UUID for a dashed or 32-hex input; undefined when the value is not UUID-shaped. */
27
+ export function normalizeId(value) {
28
+ if (typeof value !== "string") return undefined;
29
+ const lower = value.trim().toLowerCase();
30
+ if (DASHED.test(lower)) return lower;
31
+ if (COMPACT.test(lower)) {
32
+ return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ export function compactId(id) {
38
+ return id.replaceAll("-", "");
39
+ }
40
+
41
+ export function counters(context) {
42
+ const stored = context.state.get("meta", "counters");
43
+ return stored === null ? { ...DEFAULT_COUNTERS } : { ...stored };
44
+ }
45
+
46
+ /** Allocate the next row id of `kind` (skipping any sequence that is already taken, bounded probe). */
47
+ export function nextId(context, kind) {
48
+ const meta = counters(context);
49
+ let sequence = meta.next_id;
50
+ for (let probe = 0; probe < 1000; probe += 1) {
51
+ if (kind === "discussions" || context.state.get(kind, makeId(kind, sequence)) === null) break;
52
+ sequence += 1;
53
+ }
54
+ context.state.put("meta", "counters", { next_id: sequence + 1 });
55
+ return makeId(kind, sequence);
56
+ }
57
+
58
+ /** Short base-36 ids for schema properties and select options (Notion uses opaque short ids). */
59
+ export function nextShortId(context) {
60
+ const meta = counters(context);
61
+ context.state.put("meta", "counters", { next_id: meta.next_id + 1 });
62
+ return meta.next_id.toString(36).padStart(4, "0");
63
+ }
@@ -0,0 +1,26 @@
1
+ // Bounded JSON nesting for route codecs. The framework validates operation arguments recursively, so a body nested
2
+ // thousands of levels deep would overflow the stack and answer an opaque 500. Every JSON route's decode measures the
3
+ // parsed body iteratively (explicit stack, never recursion) and refuses it past MAX_JSON_DEPTH, answering 400.
4
+ export const MAX_JSON_DEPTH = 512;
5
+
6
+ /** Nesting depth of a parsed JSON value (scalars are 0), stopping as soon as it exceeds `limit`. */
7
+ export function jsonDepth(value, limit = MAX_JSON_DEPTH) {
8
+ let max = 0;
9
+ const stack = [[value, 1]];
10
+ while (stack.length > 0) {
11
+ const [node, depth] = stack.pop();
12
+ if (node === null || typeof node !== "object") continue;
13
+ if (depth > max) max = depth;
14
+ if (max > limit) return max;
15
+ for (const child of Array.isArray(node) ? node : Object.values(node)) {
16
+ if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
17
+ }
18
+ }
19
+ return max;
20
+ }
21
+
22
+ /** Throws (request mapping error, 400) when a parsed JSON body nests deeper than MAX_JSON_DEPTH. */
23
+ export function assertJsonDepth(value) {
24
+ if (jsonDepth(value) > MAX_JSON_DEPTH) throw new TypeError(`body failed validation: JSON nesting is deeper than the maximum depth of ${MAX_JSON_DEPTH}`);
25
+ return value;
26
+ }