@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,192 @@
1
+ // Failure helpers with Notion's wording, bounded scans and virtual-time formatting. No module state.
2
+
3
+ import { jsonBytes, MAX_RESPONSE_BYTES, OBJECT_BUDGET_BYTES, PAGE_BUDGET_BYTES } from "./size.mjs";
4
+
5
+ export const SCAN_STEP = 500;
6
+ const DEFAULT_LIMITS = Object.freeze({
7
+ max_rows_per_namespace: 10000,
8
+ max_page_size: 100,
9
+ max_children_per_append: 100,
10
+ max_markdown_bytes: 102400,
11
+ });
12
+
13
+ // The framework's outcome contract caps an error message at 1000 characters; messages echo caller input (ids, property
14
+ // keys), so a long value would otherwise make the whole response unmappable (HTTP 500).
15
+ const MAX_MESSAGE = 1000;
16
+
17
+ function capMessage(message) {
18
+ if (message.length <= MAX_MESSAGE) return message;
19
+ let end = MAX_MESSAGE - 1;
20
+ const unit = message.charCodeAt(end - 1);
21
+ if (unit >= 0xd800 && unit <= 0xdbff) end -= 1;
22
+ return `${message.slice(0, end)}…`;
23
+ }
24
+
25
+ export function fail(context, code, message, details) {
26
+ const text = capMessage(message);
27
+ return context.fail(details === undefined ? { code, message: text } : { code, message: text, details });
28
+ }
29
+
30
+ /** A caller value as Notion prints it in validation messages: strings verbatim, everything else as JSON. */
31
+ export function shown(value) {
32
+ if (typeof value === "string") return value;
33
+ if (value === undefined) return "undefined";
34
+ try {
35
+ const text = JSON.stringify(value);
36
+ if (typeof text !== "string") return String(typeof value);
37
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text;
38
+ } catch {
39
+ return String(typeof value);
40
+ }
41
+ }
42
+
43
+ export function validationError(context, message) {
44
+ return fail(context, "VALIDATION_ERROR", message);
45
+ }
46
+
47
+ export function restricted(context, message) {
48
+ return fail(context, "RESTRICTED_RESOURCE", message);
49
+ }
50
+
51
+ const SHARE_HINT = "Make sure the relevant pages and databases are shared with your integration.";
52
+
53
+ export function notFound(context, kind, id) {
54
+ const label = kind === "data-source" ? "data source" : kind;
55
+ const suffix = kind === "user" ? "" : ` ${SHARE_HINT}`;
56
+ return fail(context, "OBJECT_NOT_FOUND", `Could not find ${label} with ID: ${id}.${suffix}`);
57
+ }
58
+
59
+ export function limits(context) {
60
+ const stored = context.state.get("meta", "limits");
61
+ return stored === null ? { ...DEFAULT_LIMITS } : { ...DEFAULT_LIMITS, ...stored };
62
+ }
63
+
64
+ /** Every row of a namespace in row-id order; fails FAILED_PRECONDITION instead of truncating at the bound. */
65
+ export function allRows(context, namespace) {
66
+ const bound = limits(context).max_rows_per_namespace;
67
+ const rows = [];
68
+ let after;
69
+ for (;;) {
70
+ const batch = context.state.scan(namespace, { ...(after === undefined ? {} : { afterRowId: after }), limit: SCAN_STEP });
71
+ if (batch.length === 0) return rows;
72
+ for (const record of batch) {
73
+ after = record.rowId;
74
+ rows.push(record.value);
75
+ if (rows.length > bound) {
76
+ return fail(context, "FAILED_PRECONDITION", `state exceeds the supported bound of ${bound} rows in ${namespace}`);
77
+ }
78
+ }
79
+ if (batch.length < SCAN_STEP) return rows;
80
+ }
81
+ }
82
+
83
+ export function isoFromUs(us) {
84
+ return new Date(Math.floor(us / 1000)).toISOString();
85
+ }
86
+
87
+ export function isoNow(context) {
88
+ return isoFromUs(context.clock.nowUs());
89
+ }
90
+
91
+ /**
92
+ * Validate `page_size` (1..max, default max). Provider-shaped query routes pass a query value that is not a plain
93
+ * decimal integer through as the raw string, so it fails here with Notion's validation_error instead of a mapping
94
+ * error. `field` names the parameter the way the caller sent it (`query.page_size` on GET routes,
95
+ * `body.page_size` on POST routes), so the message points at the input the caller actually wrote.
96
+ */
97
+ export function pageSize(context, input, field = "query.page_size") {
98
+ const max = limits(context).max_page_size;
99
+ const value = input.page_size;
100
+ const section = field.split(".")[0];
101
+ const printed = typeof value === "string" ? JSON.stringify(value) : shown(value);
102
+ if (value === undefined) return max;
103
+ if (typeof value !== "number" || !Number.isFinite(value)) {
104
+ return validationError(context, `${section} failed validation: ${field} should be a number, instead was \`${printed}\`.`);
105
+ }
106
+ if (!Number.isInteger(value)) {
107
+ return validationError(context, `${section} failed validation: ${field} should be an integer, instead was ${value}.`);
108
+ }
109
+ if (value < 1) {
110
+ return validationError(context, `${section} failed validation: ${field} should be \u2265 1, instead was ${value}.`);
111
+ }
112
+ if (value > max) {
113
+ return validationError(context, `${section} failed validation: ${field} should be \u2264 ${max}, instead was ${value}.`);
114
+ }
115
+ return value;
116
+ }
117
+
118
+ /**
119
+ * True when a caller string carries U+FFFD. The framework decodes query strings and form bodies leniently, so
120
+ * malformed percent-encoding arrives as the replacement character; in a query-language input that means the
121
+ * request was mangled in transit, never a literal the caller meant to match.
122
+ */
123
+ export function isMangled(value) {
124
+ return typeof value === "string" && value.includes("\uFFFD");
125
+ }
126
+
127
+ /** Notion-shaped validation_error for a query-language input that arrived mangled. */
128
+ export function mangledError(context, field) {
129
+ const section = field.split(".")[0];
130
+ return validationError(context, `${section} failed validation: ${field} contains an invalid character (U+FFFD); check the request's percent-encoding.`);
131
+ }
132
+
133
+ /**
134
+ * Slice an ordered list the way Notion paginates (cursor = id of the last returned item). A page ends at
135
+ * `page_size` items or once the encoded results would pass PAGE_BUDGET_BYTES, whichever comes first, so
136
+ * `next_cursor` always names the last item returned and the next page starts exactly at the first item omitted.
137
+ * `render` produces the returned shape; each item is measured as it is rendered, never an internal summary.
138
+ */
139
+ export function paginate(context, items, input, itemId = (item) => item.id, render = (item) => item, field = "query.page_size") {
140
+ const size = pageSize(context, input, field);
141
+ let start = 0;
142
+ if (input.start_cursor !== undefined) {
143
+ const index = items.findIndex((item) => itemId(item) === input.start_cursor);
144
+ if (index < 0) return validationError(context, "The start_cursor provided is invalid.");
145
+ start = index + 1;
146
+ }
147
+ const results = [];
148
+ let bytes = 0;
149
+ let index = start;
150
+ while (index < items.length && results.length < size) {
151
+ const rendered = render(items[index]);
152
+ const itemBytes = jsonBytes(rendered) + 1;
153
+ if (results.length > 0 && bytes + itemBytes > PAGE_BUDGET_BYTES) break;
154
+ if (itemBytes > MAX_RESPONSE_BYTES - 4096) tooLarge(context, itemBytes, "one result of this list");
155
+ results.push(rendered);
156
+ bytes += itemBytes;
157
+ index += 1;
158
+ }
159
+ const hasMore = index < items.length;
160
+ return { page: items.slice(start, index), results, next_cursor: hasMore && results.length > 0 ? itemId(items[index - 1]) : null, has_more: hasMore };
161
+ }
162
+
163
+ /** Notion's validation_error for a read whose encoded response would pass the response bound. */
164
+ export function tooLarge(context, bytes, what) {
165
+ return validationError(context, `The response is too large: ${what} would be ${bytes} bytes, and one response may be at most ${MAX_RESPONSE_BYTES} bytes.`);
166
+ }
167
+
168
+ /** Return `value` when its encoded form fits one response; otherwise answer validation_error. */
169
+ export function sized(context, value, what) {
170
+ const bytes = jsonBytes(value);
171
+ return bytes > MAX_RESPONSE_BYTES ? tooLarge(context, bytes, what) : value;
172
+ }
173
+
174
+ /** Write-time bound: a stored object whose rendered form would pass OBJECT_BUDGET_BYTES is refused. */
175
+ export function withinBudget(context, rendered, what, budget = OBJECT_BUDGET_BYTES) {
176
+ const bytes = jsonBytes(rendered);
177
+ if (bytes > budget) return validationError(context, `body failed validation: ${what} would be ${bytes} bytes once rendered; the limit is ${budget} bytes.`);
178
+ return rendered;
179
+ }
180
+
181
+ export function compareStrings(left, right) {
182
+ return left < right ? -1 : left > right ? 1 : 0;
183
+ }
184
+
185
+ export function slug(text) {
186
+ const cleaned = text
187
+ .normalize("NFKD")
188
+ .replace(/[^A-Za-z0-9]+/g, "-")
189
+ .replace(/^-+|-+$/g, "")
190
+ .slice(0, 60);
191
+ return cleaned.replace(/-+$/g, "");
192
+ }
@@ -0,0 +1,89 @@
1
+ // Pure HTTP wire helpers: request decoding into the flattened canonical input, and Notion's error
2
+ // envelope `{ object: "error", status, code, message, request_id }`. No state, no clock.
3
+
4
+ import { assertJsonDepth } from "./json-depth.mjs";
5
+
6
+ const STATUS = Object.freeze({
7
+ VALIDATION_ERROR: [400, "validation_error"],
8
+ FAILED_PRECONDITION: [400, "validation_error"],
9
+ UNAUTHORIZED: [401, "unauthorized"],
10
+ RESTRICTED_RESOURCE: [403, "restricted_resource"],
11
+ OBJECT_NOT_FOUND: [404, "object_not_found"],
12
+ CONFLICT_ERROR: [409, "conflict_error"],
13
+ RATE_LIMITED: [429, "rate_limited"],
14
+ SERVICE_UNAVAILABLE: [503, "service_unavailable"],
15
+ });
16
+
17
+ function last(values) {
18
+ return values === undefined || values.length === 0 ? undefined : values[values.length - 1];
19
+ }
20
+
21
+ export function str(query, name) {
22
+ return last(query[name]);
23
+ }
24
+
25
+ /**
26
+ * Integer query parameter. A plain decimal integer (at most 15 digits, so it is exact) becomes a number; any other
27
+ * value (empty, `abc`, `1e999`, `1.5`) is passed through as the raw string so the handler answers Notion's
28
+ * validation_error envelope. Never throws.
29
+ */
30
+ export function intOrRaw(query, name) {
31
+ const value = last(query[name]);
32
+ if (value === undefined) return undefined;
33
+ return /^-?[0-9]{1,15}$/.test(value) ? Number(value) : value;
34
+ }
35
+
36
+ /** Repeatable query parameter (`filter_properties=a&filter_properties=b`, also comma-separated). */
37
+ export function list(query, name) {
38
+ const values = query[name];
39
+ if (values === undefined || values.length === 0) return undefined;
40
+ const items = [];
41
+ for (const value of values) for (const part of value.split(",")) if (part.trim().length > 0) items.push(part.trim());
42
+ return items;
43
+ }
44
+
45
+ export function jsonBody(request) {
46
+ const value = request.body.kind === "json" ? assertJsonDepth(request.body.value) : undefined;
47
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
48
+ }
49
+
50
+ export function defined(object) {
51
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
52
+ }
53
+
54
+ /** Mutations accept an optional X-Firedrill-Idempotency-Key header (Notion clients never send one). */
55
+ export function operationInput(request, args) {
56
+ const key = last(request.headers["x-firedrill-idempotency-key"]);
57
+ return key === undefined || key.length === 0 ? { arguments: args } : { arguments: args, idempotencyKey: key };
58
+ }
59
+
60
+ export function isRateLimited(outcome) {
61
+ return outcome.status === "tool_error" && outcome.error?.code === "tool.RATE_LIMITED";
62
+ }
63
+
64
+ /** Notion's error body for a non-ok outcome; HTTP statuses themselves are declared per route. */
65
+ export function notionError(outcome, requestId) {
66
+ const error = outcome.error ?? {};
67
+ const message = typeof error.message === "string" ? error.message : "";
68
+ if (outcome.status === "denied") {
69
+ return { object: "error", status: 403, code: "restricted_resource", message: "This operation is not granted to the calling actor in this Firedrill world.", request_id: requestId };
70
+ }
71
+ if (outcome.status === "unsupported") {
72
+ return { object: "error", status: 404, code: "object_not_found", message: "Could not find the requested resource.", request_id: requestId };
73
+ }
74
+ if (outcome.status === "invalid") {
75
+ return { object: "error", status: 400, code: "validation_error", message: message.length > 0 ? `body failed validation: ${message}` : "body failed validation.", request_id: requestId };
76
+ }
77
+ const code = String(error.code ?? "").replace(/^tool\./, "");
78
+ const [status, notionCode] = STATUS[code] ?? [500, "internal_server_error"];
79
+ return { object: "error", status, code: notionCode, message: message.length > 0 ? message : "Internal server error.", request_id: requestId };
80
+ }
81
+
82
+ /** Encode any outcome as a Notion-shaped JSON response. */
83
+ export function encodeOutcome({ invocation, outcome }) {
84
+ if (outcome.status === "ok") return { body: { kind: "json", value: outcome.value } };
85
+ return {
86
+ ...(isRateLimited(outcome) ? { headers: { "retry-after": "1" } } : {}),
87
+ body: { kind: "json", value: notionError(outcome, invocation.correlationId) },
88
+ };
89
+ }