@open-everywhere/provider-notion 0.1.0

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 (186) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +48 -0
  4. package/agent/channels/notion-acting-identity.ts +88 -0
  5. package/agent/channels/notion-ancestry-trust.ts +458 -0
  6. package/agent/channels/notion-api.ts +1290 -0
  7. package/agent/channels/notion-bot-user.ts +250 -0
  8. package/agent/channels/notion-catch-up.ts +339 -0
  9. package/agent/channels/notion-credentials.ts +417 -0
  10. package/agent/channels/notion-engagement.ts +372 -0
  11. package/agent/channels/notion-event-normalizer.ts +587 -0
  12. package/agent/channels/notion-identity.ts +271 -0
  13. package/agent/channels/notion-ingress-drain.ts +289 -0
  14. package/agent/channels/notion-ingress.ts +182 -0
  15. package/agent/channels/notion-message-ops.ts +140 -0
  16. package/agent/channels/notion-origin-codec.ts +38 -0
  17. package/agent/channels/notion-post-arm.ts +189 -0
  18. package/agent/channels/notion-question-return.ts +223 -0
  19. package/agent/channels/notion-read.ts +283 -0
  20. package/agent/channels/notion-scope.ts +560 -0
  21. package/agent/channels/notion-stop.ts +169 -0
  22. package/agent/channels/notion-supervisor.ts +856 -0
  23. package/agent/channels/notion-webhook-timing.ts +131 -0
  24. package/agent/channels/notion-webhook-verifier.ts +55 -0
  25. package/agent/channels/notion-webhook.ts +963 -0
  26. package/agent/channels/notion-work-search.ts +74 -0
  27. package/agent/channels/notion-work.ts +144 -0
  28. package/agent/channels/notion.ts +1242 -0
  29. package/agent/contributions.ts +39 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/memory/blocks.ts +295 -0
  32. package/agent/memory/memory.ts +622 -0
  33. package/agent/provider-events.ts +198 -0
  34. package/agent/register.ts +144 -0
  35. package/agent/schedules/notion-ingress.ts +20 -0
  36. package/agent/surface-parity.ts +105 -0
  37. package/agent/tools/notion_search.ts +110 -0
  38. package/agent/tools/read_notion_comments.ts +250 -0
  39. package/agent/tools/read_notion_page.ts +351 -0
  40. package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
  41. package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
  42. package/dist/agent/channels/notion-acting-identity.js +85 -0
  43. package/dist/agent/channels/notion-acting-identity.js.map +1 -0
  44. package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
  45. package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
  46. package/dist/agent/channels/notion-ancestry-trust.js +331 -0
  47. package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
  48. package/dist/agent/channels/notion-api.d.ts +655 -0
  49. package/dist/agent/channels/notion-api.d.ts.map +1 -0
  50. package/dist/agent/channels/notion-api.js +938 -0
  51. package/dist/agent/channels/notion-api.js.map +1 -0
  52. package/dist/agent/channels/notion-bot-user.d.ts +107 -0
  53. package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
  54. package/dist/agent/channels/notion-bot-user.js +192 -0
  55. package/dist/agent/channels/notion-bot-user.js.map +1 -0
  56. package/dist/agent/channels/notion-catch-up.d.ts +64 -0
  57. package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
  58. package/dist/agent/channels/notion-catch-up.js +289 -0
  59. package/dist/agent/channels/notion-catch-up.js.map +1 -0
  60. package/dist/agent/channels/notion-credentials.d.ts +210 -0
  61. package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
  62. package/dist/agent/channels/notion-credentials.js +360 -0
  63. package/dist/agent/channels/notion-credentials.js.map +1 -0
  64. package/dist/agent/channels/notion-engagement.d.ts +219 -0
  65. package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
  66. package/dist/agent/channels/notion-engagement.js +283 -0
  67. package/dist/agent/channels/notion-engagement.js.map +1 -0
  68. package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
  69. package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
  70. package/dist/agent/channels/notion-event-normalizer.js +542 -0
  71. package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
  72. package/dist/agent/channels/notion-identity.d.ts +119 -0
  73. package/dist/agent/channels/notion-identity.d.ts.map +1 -0
  74. package/dist/agent/channels/notion-identity.js +205 -0
  75. package/dist/agent/channels/notion-identity.js.map +1 -0
  76. package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
  77. package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
  78. package/dist/agent/channels/notion-ingress-drain.js +185 -0
  79. package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
  80. package/dist/agent/channels/notion-ingress.d.ts +64 -0
  81. package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
  82. package/dist/agent/channels/notion-ingress.js +130 -0
  83. package/dist/agent/channels/notion-ingress.js.map +1 -0
  84. package/dist/agent/channels/notion-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/notion-message-ops.js +126 -0
  87. package/dist/agent/channels/notion-message-ops.js.map +1 -0
  88. package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
  89. package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/notion-origin-codec.js +32 -0
  91. package/dist/agent/channels/notion-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/notion-post-arm.d.ts +3 -0
  93. package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/notion-post-arm.js +185 -0
  95. package/dist/agent/channels/notion-post-arm.js.map +1 -0
  96. package/dist/agent/channels/notion-question-return.d.ts +77 -0
  97. package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
  98. package/dist/agent/channels/notion-question-return.js +183 -0
  99. package/dist/agent/channels/notion-question-return.js.map +1 -0
  100. package/dist/agent/channels/notion-read.d.ts +62 -0
  101. package/dist/agent/channels/notion-read.d.ts.map +1 -0
  102. package/dist/agent/channels/notion-read.js +257 -0
  103. package/dist/agent/channels/notion-read.js.map +1 -0
  104. package/dist/agent/channels/notion-scope.d.ts +188 -0
  105. package/dist/agent/channels/notion-scope.d.ts.map +1 -0
  106. package/dist/agent/channels/notion-scope.js +433 -0
  107. package/dist/agent/channels/notion-scope.js.map +1 -0
  108. package/dist/agent/channels/notion-stop.d.ts +48 -0
  109. package/dist/agent/channels/notion-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/notion-stop.js +141 -0
  111. package/dist/agent/channels/notion-stop.js.map +1 -0
  112. package/dist/agent/channels/notion-supervisor.d.ts +462 -0
  113. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/notion-supervisor.js +482 -0
  115. package/dist/agent/channels/notion-supervisor.js.map +1 -0
  116. package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
  117. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
  118. package/dist/agent/channels/notion-webhook-timing.js +99 -0
  119. package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
  120. package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
  121. package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
  122. package/dist/agent/channels/notion-webhook-verifier.js +53 -0
  123. package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
  124. package/dist/agent/channels/notion-webhook.d.ts +242 -0
  125. package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
  126. package/dist/agent/channels/notion-webhook.js +675 -0
  127. package/dist/agent/channels/notion-webhook.js.map +1 -0
  128. package/dist/agent/channels/notion-work-search.d.ts +9 -0
  129. package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
  130. package/dist/agent/channels/notion-work-search.js +69 -0
  131. package/dist/agent/channels/notion-work-search.js.map +1 -0
  132. package/dist/agent/channels/notion-work.d.ts +68 -0
  133. package/dist/agent/channels/notion-work.d.ts.map +1 -0
  134. package/dist/agent/channels/notion-work.js +126 -0
  135. package/dist/agent/channels/notion-work.js.map +1 -0
  136. package/dist/agent/channels/notion.d.ts +95 -0
  137. package/dist/agent/channels/notion.d.ts.map +1 -0
  138. package/dist/agent/channels/notion.js +1081 -0
  139. package/dist/agent/channels/notion.js.map +1 -0
  140. package/dist/agent/contributions.d.ts +32 -0
  141. package/dist/agent/contributions.d.ts.map +1 -0
  142. package/dist/agent/contributions.js +37 -0
  143. package/dist/agent/contributions.js.map +1 -0
  144. package/dist/agent/docs-generated.d.ts +3 -0
  145. package/dist/agent/docs-generated.d.ts.map +1 -0
  146. package/dist/agent/docs-generated.js +13 -0
  147. package/dist/agent/docs-generated.js.map +1 -0
  148. package/dist/agent/memory/blocks.d.ts +58 -0
  149. package/dist/agent/memory/blocks.d.ts.map +1 -0
  150. package/dist/agent/memory/blocks.js +264 -0
  151. package/dist/agent/memory/blocks.js.map +1 -0
  152. package/dist/agent/memory/memory.d.ts +157 -0
  153. package/dist/agent/memory/memory.d.ts.map +1 -0
  154. package/dist/agent/memory/memory.js +471 -0
  155. package/dist/agent/memory/memory.js.map +1 -0
  156. package/dist/agent/provider-events.d.ts +3 -0
  157. package/dist/agent/provider-events.d.ts.map +1 -0
  158. package/dist/agent/provider-events.js +196 -0
  159. package/dist/agent/provider-events.js.map +1 -0
  160. package/dist/agent/register.d.ts +5 -0
  161. package/dist/agent/register.d.ts.map +1 -0
  162. package/dist/agent/register.js +128 -0
  163. package/dist/agent/register.js.map +1 -0
  164. package/dist/agent/schedules/notion-ingress.d.ts +6 -0
  165. package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
  166. package/dist/agent/schedules/notion-ingress.js +16 -0
  167. package/dist/agent/schedules/notion-ingress.js.map +1 -0
  168. package/dist/agent/surface-parity.d.ts +3 -0
  169. package/dist/agent/surface-parity.d.ts.map +1 -0
  170. package/dist/agent/surface-parity.js +99 -0
  171. package/dist/agent/surface-parity.js.map +1 -0
  172. package/dist/agent/tools/notion_search.d.ts +56 -0
  173. package/dist/agent/tools/notion_search.d.ts.map +1 -0
  174. package/dist/agent/tools/notion_search.js +100 -0
  175. package/dist/agent/tools/notion_search.js.map +1 -0
  176. package/dist/agent/tools/read_notion_comments.d.ts +57 -0
  177. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
  178. package/dist/agent/tools/read_notion_comments.js +236 -0
  179. package/dist/agent/tools/read_notion_comments.js.map +1 -0
  180. package/dist/agent/tools/read_notion_page.d.ts +53 -0
  181. package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
  182. package/dist/agent/tools/read_notion_page.js +327 -0
  183. package/dist/agent/tools/read_notion_page.js.map +1 -0
  184. package/package.json +231 -0
  185. package/tsconfig.build.json +22 -0
  186. package/tsconfig.json +15 -0
@@ -0,0 +1,938 @@
1
+ // Pure Notion webhook + REST helpers — the testable core behind the Notion comment
2
+ // surface (notion.ts). Everything here is either a pure function over a payload/string or
3
+ // a thin REST call with an INJECTABLE `fetch` (defaults to global fetch), so the whole
4
+ // inbound→reply loop can be unit-tested against a mock Notion API with no live calls.
5
+ //
6
+ // The loop this backs (see notion.ts for the wiring):
7
+ // webhook → verify HMAC (verifyNotionSignature) / handle the one-time handshake
8
+ // comment.created is a SIGNAL ONLY (no text/mentions on the event) →
9
+ // retrieveComment(entity.id) → richText → mentionsUser(botUserId)? → dispatch
10
+ // model reply → createCommentReply(discussionId) back into the SAME thread
11
+ //
12
+ // Signature scheme (Notion Docs — Webhooks): every delivery carries
13
+ // X-Notion-Signature: sha256=<hex>
14
+ // which is HMAC-SHA256 of the EXACT RAW request body, keyed by the `verification_token`
15
+ // Notion issued once at subscription time (echoed to the endpoint, then a permanent
16
+ // signing secret). It MUST be computed over the bytes as they arrived — re-serialising the
17
+ // parsed JSON produces different bytes and fails verification — so notion.ts reads
18
+ // `req.text()` and hands that raw string here untouched.
19
+ import { createHmac, timingSafeEqual } from "node:crypto";
20
+ import { getSecretVia, CredentialError, findConnectorCredential, guardCredentialFetch, withRetryAfter, } from "@open-everywhere/core/agent/adapter-api.js";
21
+ import { NOTION_CHANNEL_CREDENTIAL, NOTION_PROVIDER } from "./notion-credentials.js";
22
+ /** Notion REST base + the API version header value (override via NOTION_VERSION). The
23
+ * default is a stable version that supports the comment retrieve/create endpoints and
24
+ * rich-text `mention` objects — everything this surface needs. */
25
+ export const NOTION_API_BASE = "https://api.notion.com";
26
+ function notionVersion() {
27
+ const v = process.env.NOTION_VERSION;
28
+ return v && v.trim().length > 0 ? v.trim() : "2022-06-28";
29
+ }
30
+ /**
31
+ * File uploads are a NEWER Notion API surface than the rest of this module. Notion's own docs
32
+ * (developers.notion.com/reference/create-a-file-upload, confirmed live 2026-08-23) name
33
+ * `2026-03-11` as the only accepted `Notion-Version` for the file-upload endpoints — the
34
+ * deployment's regular {@link notionVersion} (default `2022-06-28`, override via NOTION_VERSION)
35
+ * covers the comment/page/block surface this module already calls and does not carry file uploads.
36
+ * So {@link createFileUpload}, {@link sendFileUploadBytes}, and a create-comment call that attaches
37
+ * files all pin THIS version, rather than inheriting a caller's `opts.version` — a deployment that
38
+ * pins an older NOTION_VERSION for its comment traffic must not also break its attachment traffic.
39
+ */
40
+ const NOTION_FILE_UPLOAD_VERSION = "2026-03-11";
41
+ /**
42
+ * THE ONLY way to resolve a Notion channel credential — the key together with its endpoint rules.
43
+ * Fail-closed exactly as before: `undefined` for an unregistered provider, a credential carrying no
44
+ * `secretRef`, or a broker that resolves no value. A named-but-missing credential NEVER retries
45
+ * under the org default.
46
+ *
47
+ * WHY THE PAIR IS THE UNIT. A credential's endpoint rules are a property OF THAT KEY. Resolving the
48
+ * key through one function and the rules through another invites exactly the drift the rules exist
49
+ * to catch: a call site that threads the key from credential A and the rules from credential B has
50
+ * a fence that permits the leak it was written to block. Returning them together removes that
51
+ * failure mode by construction rather than by review.
52
+ */
53
+ export async function resolveNotionCredential(credentialId = NOTION_CHANNEL_CREDENTIAL) {
54
+ const cred = findConnectorCredential(NOTION_PROVIDER, credentialId);
55
+ // Unregistered provider / non-secretRef credential = unconfigured — identical to the env being
56
+ // unset. Return undefined; do NOT fall back to a process.env / env-broker pin.
57
+ if (!cred?.secretRef)
58
+ return undefined;
59
+ try {
60
+ const k = await getSecretVia(cred.broker, cred.secretRef);
61
+ return k.length > 0 ? { apiKey: k, endpoints: cred.endpoints } : undefined;
62
+ }
63
+ catch (err) {
64
+ if (err instanceof CredentialError)
65
+ return undefined;
66
+ throw err;
67
+ }
68
+ }
69
+ // --- webhook verification ---------------------------------------------------
70
+ /**
71
+ * The one-time subscription handshake: when a webhook subscription is created, Notion POSTs
72
+ * `{ "verification_token": "secret_…" }` (and nothing else — no `type`/`event_type`) to the
73
+ * endpoint. Return that token so the route can LOG it (the operator pastes it back into
74
+ * Notion's UI to confirm the endpoint, and stores it as NOTION_WEBHOOK_SECRET — the HMAC
75
+ * key for every subsequent delivery). Returns null for a normal event payload.
76
+ */
77
+ export function parseVerificationHandshake(rawBody) {
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(rawBody);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ if (!parsed || typeof parsed !== "object")
86
+ return null;
87
+ const o = parsed;
88
+ const token = o.verification_token;
89
+ // Only a bare handshake (has the token, carries no event discriminator) qualifies.
90
+ if (typeof token === "string" && token.length > 0 && o.type === undefined && o.event_type === undefined) {
91
+ return token;
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * Verify a Notion webhook's HMAC-SHA256 signature (`X-Notion-Signature: sha256=<hex>`)
97
+ * against the verification token, over the RAW body. Constant-time compare. Mirrors
98
+ * routines/events.ts's verifyGithubSignature (same scheme, different header/secret).
99
+ */
100
+ export function verifyNotionSignature(secret, rawBody, signatureHeader) {
101
+ if (!signatureHeader)
102
+ return false;
103
+ const expected = "sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
104
+ const a = Buffer.from(signatureHeader);
105
+ const b = Buffer.from(expected);
106
+ return a.length === b.length && timingSafeEqual(a, b);
107
+ }
108
+ /**
109
+ * Gate an inbound Notion delivery FAIL-CLOSED. No configured secret means the sender can't
110
+ * be authenticated, so the delivery is REJECTED — this route dispatches agent sessions, and
111
+ * accepting unsigned payloads would let anyone on the internet drive the bot. The dev escape
112
+ * hatch `webhooks.allowUnverifiedNotion` restores unverified acceptance for local smoke
113
+ * tests. Returns null when the delivery may proceed, else the one-line 401 rejection reason.
114
+ * (Same shape/posture as gateGithubDelivery.)
115
+ */
116
+ export function gateNotionDelivery(secret, rawBody, signatureHeader, allowUnverified) {
117
+ if (!secret) {
118
+ return allowUnverified
119
+ ? null
120
+ : "NOTION_WEBHOOK_SECRET is not set — rejecting unverified delivery (set the verification token, or turn on webhooks.allowUnverifiedNotion in agent/settings/settings.ts for local dev)";
121
+ }
122
+ return verifyNotionSignature(secret, rawBody, signatureHeader) ? null : "invalid signature";
123
+ }
124
+ /**
125
+ * Gate an inbound delivery against EVERY configured Notion integration (OPE-467) — the
126
+ * multi-integration form of {@link gateNotionDelivery}, and the whole of the ingress-routing answer.
127
+ *
128
+ * An operator running ten Notion integrations has ten `verification_token`s, and a delivery from
129
+ * integration #7 cannot verify against integration #1's secret. So the gate tries each in turn and
130
+ * THE SECRET THAT VERIFIES IDENTIFIES THE SENDER. That is a proof, not an inference: nothing in the
131
+ * payload is consulted, so no field a caller controls can steer the routing. Trying N keys is the
132
+ * same shape `webhooks/secrets.ts` already uses for a rotation window, and each comparison stays
133
+ * constant-time (`verifyNotionSignature` → `timingSafeEqual`).
134
+ *
135
+ * Same fail-closed posture as the single-secret gate, with the same wording for the same case:
136
+ * • NO integration has a stored secret ⇒ reject, unless the dev hatch is on (and then `verified`
137
+ * is false, so the delivery still may not reach the routing plane);
138
+ * • at least one secret configured, none verifies ⇒ "invalid signature".
139
+ */
140
+ export function gateNotionDeliveryAcross(keys, rawBody, signatureHeader, allowUnverified) {
141
+ const configured = keys.filter((k) => typeof k.secret === "string" && k.secret.length > 0);
142
+ if (configured.length === 0) {
143
+ const rejection = gateNotionDelivery(undefined, rawBody, signatureHeader, allowUnverified);
144
+ return { rejection, verified: false };
145
+ }
146
+ for (const key of configured) {
147
+ if (verifyNotionSignature(key.secret, rawBody, signatureHeader)) {
148
+ return { rejection: null, credentialId: key.credentialId, verified: true };
149
+ }
150
+ }
151
+ return { rejection: "invalid signature", verified: false };
152
+ }
153
+ /** The event discriminator, tolerating both the `type` and `event_type` spellings. */
154
+ export function notionEventType(ev) {
155
+ return ev.type ?? ev.event_type;
156
+ }
157
+ /** The comment id a `comment.created` event points at — it lives on `entity` (a signal only;
158
+ * the comment TEXT is not on the event), not on `data`. Returns undefined if the entity
159
+ * isn't a comment (defensive: never dispatch off a mis-shaped event). */
160
+ export function commentIdFromEvent(ev) {
161
+ const e = ev.entity;
162
+ if (e && e.type === "comment" && typeof e.id === "string" && e.id.length > 0)
163
+ return e.id;
164
+ return undefined;
165
+ }
166
+ /**
167
+ * The page a `comment.created` event CLAIMS to be anchored on (data.page_id, else data.parent.id).
168
+ *
169
+ * 🔴 A CLAIM, NOT A FACT — nothing authenticates a webhook body's fields, and `data.parent.id` is a
170
+ * block id as often as a page id. It must never select a session's surface, its domain, or a
171
+ * credential on a VERIFIED delivery: `notion-webhook.ts` step 8 establishes the real page from the
172
+ * comment Notion returns under the verifying integration's own key instead (OPE-496). The one
173
+ * remaining caller is the `webhooks.allowUnverifiedNotion` dev hatch, where the gate identifies
174
+ * no integration and nothing at all has been proven.
175
+ */
176
+ export function pageIdFromEvent(ev) {
177
+ return ev.data?.page_id ?? ev.data?.parent?.id;
178
+ }
179
+ /** Flatten a comment's rich_text to plain text (concatenated `plain_text`). */
180
+ export function richTextToPlainText(richText) {
181
+ if (!Array.isArray(richText))
182
+ return "";
183
+ return richText.map((r) => (typeof r.plain_text === "string" ? r.plain_text : "")).join("").trim();
184
+ }
185
+ /**
186
+ * Detect whether the comment @mentions a specific user id. A Notion @mention is a rich-text
187
+ * item `{ type:"mention", mention:{ type:"user", user:{ id } } }`; an @mention of an
188
+ * integration/agent appears as a user mention whose id is the integration's own bot-user id.
189
+ * So "was the bot mentioned?" = does any mention item's `mention.user.id` equal the bot's
190
+ * user id (resolveBotUserId). Returns false for an empty id (never dispatch on an
191
+ * unresolved bot identity — fail closed to "not mentioned").
192
+ */
193
+ export function mentionsUser(richText, userId) {
194
+ if (!userId || !Array.isArray(richText))
195
+ return false;
196
+ return richText.some((r) => r.type === "mention" && r.mention?.type === "user" && r.mention.user?.id === userId);
197
+ }
198
+ /**
199
+ * The 429 policy for a call the WEBHOOK INGRESS awaits before it acks Notion (OPE-1254).
200
+ *
201
+ * 🔴 THE ARITHMETIC, AGAINST A MEASURED CEILING. `handleNotionDelivery` is awaited before the route
202
+ * acks, and it fires about 15 calls in sequence: `retrieveBotUser`, `retrieveComment`, up to five
203
+ * `retrieveBlock` parent hops, `listBlockComments`, and the ancestry walk's `notionParentLink`. On
204
+ * the shared defaults — 4 attempts, a 30 s per-wait cap, a 60 s budget PER CALL — a rate-limit
205
+ * episode costs that delivery up to ~15 minutes. The deployed Notion route runs on the INGRESS
206
+ * function, whose `maxDuration` is 300 s (measured on OPE-1289, `docs/extending/provider-rate-limits.md`
207
+ * § "Measured: the function maxDuration against the backoff"). So the shared policy does not make
208
+ * the delivery slow — it makes the function get killed mid-sequence, which is a DROPPED delivery.
209
+ *
210
+ * This policy spends at most one 2 s wait per call — `maxAttempts: 2` allows one, and the cap binds
211
+ * before the 4 s budget can, which is left in place for a later edit that raises the attempts. So
212
+ * the whole ~15-call sequence spends at most ~30 s of backoff and leaves ~270 s of the ingress
213
+ * ceiling for the work itself. Every one of those calls is covered: the four `deliver` issues
214
+ * directly, through `ingressCallOpts`, and `retrieveBotUser` behind `deps.resolveBotUserId`, which
215
+ * `notion.ts` passes this same policy (it is awaited FIRST, ahead of them all). It is Slack's answer to the
216
+ * same problem (`slack-web-api.ts`'s `PROBE_OPERATIONS`, 2 attempts / 2 s cap for the calls its
217
+ * 3-second ack awaits), with the total budget stated too so the arithmetic closes rather than
218
+ * resting on the shared 60 s.
219
+ *
220
+ * ⚠️ IT IS PER CALL, NOT PER DELIVERY. A shared budget consumed ACROSS the sequence is the stronger
221
+ * shape and it is a change to the shared primitive every provider inherits — still open on OPE-1254.
222
+ * What this bounds is the worst case, and it bounds it under the ceiling.
223
+ */
224
+ export const NOTION_INGRESS_RETRY = {
225
+ maxAttempts: 2,
226
+ capMs: 2_000,
227
+ totalBudgetMs: 4_000,
228
+ };
229
+ function notionHeaders(opts) {
230
+ return {
231
+ authorization: `Bearer ${opts.apiKey}`,
232
+ "notion-version": opts.version ?? notionVersion(),
233
+ "content-type": "application/json",
234
+ };
235
+ }
236
+ /**
237
+ * The vendor operation name a Notion request carries into {@link RateLimitExhaustedError} — the
238
+ * request's method plus its endpoint SHAPE, with every id segment replaced by `{id}`.
239
+ *
240
+ * 🔴 THE REDACTION IS THE POINT, not tidiness. `RateLimitExhaustedError` states that every field on
241
+ * it is safe to log, and this string is one of those fields. A raw Notion path carries page, block
242
+ * and comment ids, which name workspace content — so the shape goes in the failure and the ids stay
243
+ * out of it. `/v1/comments/abc…` becomes `GET /v1/comments/{id}`.
244
+ *
245
+ * The query string is dropped whole for the same reason: `?block_id=…` is an id too.
246
+ *
247
+ * Every url this is handed is built from a literal in this file, save one: {@link sendFileUploadBytes}
248
+ * posts to the `upload_url` Notion itself issued. A value that does not parse as a URL falls back to
249
+ * the method alone. Naming the method is honest; guessing a path from a string that is not a URL is
250
+ * not.
251
+ */
252
+ export function notionOperation(method, url) {
253
+ const verb = (method ?? "GET").toUpperCase();
254
+ let path;
255
+ try {
256
+ path = new URL(url).pathname;
257
+ }
258
+ catch {
259
+ return verb;
260
+ }
261
+ const shape = path
262
+ .split("/")
263
+ // A Notion id is a UUID, sent with or without its dashes. Anything else — `v1`, `comments`,
264
+ // `me`, `children` — is a fixed segment and stays readable.
265
+ .map((seg) => (/^[0-9a-f]{32}$/i.test(seg.replaceAll("-", "")) ? "{id}" : seg))
266
+ .join("/");
267
+ return `${verb} ${shape}`;
268
+ }
269
+ /**
270
+ * THE ONE PLACE a Notion REST call leaves this host. Every helper below dispatches through here, so
271
+ * the credential's endpoint fence ({@link NotionCallOpts.endpoints}) is applied on every path
272
+ * without each call site remembering to.
273
+ *
274
+ * Funnelling matters more than the guard itself: a fence applied at six call sites is a fence a
275
+ * seventh call site will not have. With one funnel, a new helper is fenced the moment it is written.
276
+ *
277
+ * THE 429 BACKOFF RIDES THE SAME FUNNEL (OPE-1233). Notion answers a rate-limited call with HTTP 429
278
+ * and a `Retry-After` header, and nothing here read it before: the first refusal became whatever
279
+ * `${caller} failed: 429 …` message the helper throws, so a reply was simply lost. `withRetryAfter`
280
+ * now honours that delay with bounded attempts and a bounded total wait, and then throws the NAMED
281
+ * `RateLimitExhaustedError`. Every non-429 answer — a 2xx, a 404, a 5xx — comes back untouched, and
282
+ * a transport throw still propagates unretried (the request may have applied; repeating it could
283
+ * double a comment).
284
+ *
285
+ * ORDER MATTERS: the fence wraps the transport and the retry wraps the fence, so `guardCredentialFetch`
286
+ * re-checks the URL on EVERY attempt rather than once before the first.
287
+ *
288
+ * 🔴 THE BUDGET IS PER CALL, AND A PATH THAT FIRES A SEQUENCE PAYS IT ONCE EACH. The webhook ingress
289
+ * fires about 15 calls in sequence and now passes {@link NOTION_INGRESS_RETRY}, which bounds the
290
+ * whole sequence to ~30 s under that function's 300 s ceiling (OPE-1254). `readNotionComments` does
291
+ * NOT: it sweeps up to ~82 blocks at the shared 60 s per call, so ~82 minutes worst case, on the turn
292
+ * function's 800 s ceiling. That one is still open on OPE-1254.
293
+ * 🔴 AND `maxCommentPages` MULTIPLIES THAT NUMBER (OPE-1831). Each anchor may now cost up to that
294
+ * many list calls rather than one, so a caller passing 5 — the seat catch-up does — raises the same
295
+ * worst case about fivefold. It is a CEILING, not a cost: the follow stops the moment Notion reports
296
+ * no more, so every anchor under 100 comments is still exactly one call and the multiplier is paid
297
+ * only by an anchor that genuinely overflows. Reaching the ceiling needs all ~82 anchors to overflow
298
+ * at once, which no real page does.
299
+ * {@link NotionCallOpts.retry} is the seam a caller uses to tighten its own path.
300
+ * `docs/extending/provider-rate-limits.md` carries the table.
301
+ *
302
+ * A RETRY RE-SENDS `init` UNCHANGED, which is safe for every body this module builds: a JSON string,
303
+ * and {@link sendFileUploadBytes}'s `FormData` over a `Blob`. Both are values `fetch` re-serialises
304
+ * per call, not streams a first attempt would consume. A body that WAS a stream could not be replayed
305
+ * — do not introduce one here without giving `send` a fresh body per attempt.
306
+ */
307
+ function notionFetch(url, init, opts) {
308
+ const base = opts.fetch ?? globalThis.fetch;
309
+ const send = guardCredentialFetch(opts.endpoints, "notion credential", base);
310
+ return withRetryAfter(() => send(url, init), {
311
+ ...opts.retry,
312
+ surface: "notion",
313
+ operation: notionOperation(init.method, url),
314
+ });
315
+ }
316
+ /** GET /v1/comments/{comment_id} — the second step of the two-step (webhook signal → fetch
317
+ * the actual comment body + discussion id + author). Throws on a non-2xx response. */
318
+ export async function retrieveComment(commentId, opts) {
319
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments/${encodeURIComponent(commentId)}`, {
320
+ method: "GET",
321
+ headers: notionHeaders(opts),
322
+ }, opts);
323
+ if (!res.ok)
324
+ throw new Error(`retrieveComment ${commentId} failed: ${res.status} ${await safeText(res)}`);
325
+ return (await res.json());
326
+ }
327
+ /** GET /v1/users/me — the integration's own bot user: its `id` (the loop-guard self-identity)
328
+ * AND its `name` (the display name a person types in a name-drop — a judge input, never a
329
+ * bypass; design §2.2/Step 4). Resolved once and cached by the channel; env NOTION_BOT_USER_ID /
330
+ * NOTION_BOT_NAME short-circuit these — but the ID override applies to the ORG-DEFAULT `channel-key`
331
+ * credential ONLY (OPE-1921: two Notion integrations never share a bot user, so one pinned id
332
+ * cannot stand for a sibling's, and pinning it for every credential would reopen the
333
+ * cross-integration loop). Throws on a non-2xx response. */
334
+ export async function retrieveBotUser(opts) {
335
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/users/me`, { method: "GET", headers: notionHeaders(opts) }, opts);
336
+ if (!res.ok)
337
+ throw new Error(`retrieveBotUser failed: ${res.status} ${await safeText(res)}`);
338
+ return (await res.json());
339
+ }
340
+ /** GET /v1/comments?block_id=… — list unresolved comments on a page/block, for surrounding
341
+ * thread context (best-effort). Returns the raw results array (caller filters by
342
+ * discussion + renders untrusted). Throws on a non-2xx response.
343
+ *
344
+ * 🔴 NOTION SERVES THIS ENDPOINT OLDEST-FIRST AND PAGINATED, AND ONE PAGE IS THE OLDEST 100
345
+ * (OPE-1831). A single unpaginated call therefore reads the anchor's FIRST hundred comments and can
346
+ * never reach a comment posted today — which is not a partial read on a long-lived anchor, it is a
347
+ * read that structurally cannot answer "what was said since the seat last looked". A PAGE-ANCHORED
348
+ * discussion on any long-lived doc is unreachable this way.
349
+ * 🔴 THIS IS NOT WHAT RED `J-ENGAGED-notion` ON SHIP #208 — do not carry that story forward. That
350
+ * journey's comment sits in a BLOCK-anchored discussion, which a page query never returns however
351
+ * many pages it follows; the cause there was `readNotionComments`' comment CAP ending the sweep
352
+ * before the block walk began (see that function, and `notion-catch-up.ts`). This bound is a real
353
+ * and separate one, closed while the file was open.
354
+ *
355
+ * `maxPages` bounds the follow. It DEFAULTS TO 1 — the single call every existing caller already
356
+ * made — so a caller that wants the newest comments asks for the pages, and no other read changes
357
+ * its cost. The loop stops the moment Notion reports no more, so even at a high `maxPages` an anchor
358
+ * with under 100 comments still costs exactly one call.
359
+ * 🔴 `onPageLimit` FIRES WHEN THE BOUND CUT THE ANCHOR OFF — Notion still had more and this stopped
360
+ * asking. Without it a page-limited anchor is indistinguishable from a complete read, so
361
+ * {@link readNotionComments} would report `truncated: false` over a read it knows was partial, and
362
+ * the coverage disclosure the model sees would be a false claim. A callback rather than a changed
363
+ * return type, so the eight existing call sites are untouched. */
364
+ export async function listBlockComments(blockId, opts) {
365
+ const maxPages = Math.max(1, Math.trunc(opts.maxPages ?? 1));
366
+ const out = [];
367
+ let cursor;
368
+ for (let page = 0; page < maxPages; page++) {
369
+ const qs = new URLSearchParams({ block_id: blockId, page_size: "100" });
370
+ if (cursor)
371
+ qs.set("start_cursor", cursor);
372
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments?${qs.toString()}`, {
373
+ method: "GET",
374
+ headers: notionHeaders(opts),
375
+ }, opts);
376
+ if (!res.ok)
377
+ throw new Error(`listBlockComments ${blockId} failed: ${res.status} ${await safeText(res)}`);
378
+ const body = (await res.json());
379
+ if (Array.isArray(body.results))
380
+ out.push(...body.results);
381
+ cursor = body.has_more && body.next_cursor ? body.next_cursor : undefined;
382
+ if (!cursor)
383
+ break;
384
+ // Notion has more and this was the last page the bound allows ⇒ the read is PARTIAL, and the
385
+ // caller is told rather than handed a short list that looks complete.
386
+ if (page === maxPages - 1)
387
+ opts.onPageLimit?.();
388
+ }
389
+ return out;
390
+ }
391
+ /** GET /v1/blocks/{block_id} — used ONLY to answer "which page owns this block?" for the domain
392
+ * fence (channels/notion-scope.ts). Throws on a non-2xx response. */
393
+ export async function retrieveBlock(blockId, opts) {
394
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/blocks/${encodeURIComponent(blockId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
395
+ if (!res.ok)
396
+ throw new Error(`retrieveBlock ${blockId} failed: ${res.status} ${await safeText(res)}`);
397
+ return (await res.json());
398
+ }
399
+ /**
400
+ * The PAGE that owns a block — walk `parent` upward until a `page_id`, or give up. The domain fence
401
+ * needs it because `readNotionComments`' `block` drill-in addresses a block DIRECTLY (it never
402
+ * touches the `pageId` argument), so without this a block id is an unfenced way into another
403
+ * domain's page. Bounded at {@link BLOCK_PARENT_MAX_HOPS} hops — the realistic case is ONE (the
404
+ * sweep only ever surfaces top-level blocks, whose parent IS the page) and an unbounded walk over
405
+ * attacker-chosen ids is its own problem.
406
+ *
407
+ * Returns `undefined` when the chain ends anywhere other than a page (a workspace/database parent),
408
+ * runs out of hops, or the lookup throws — every one of which the caller must treat as a REFUSAL,
409
+ * never as permission.
410
+ */
411
+ export const BLOCK_PARENT_MAX_HOPS = 5;
412
+ export async function resolveBlockOwningPage(blockId, opts) {
413
+ let id = blockId;
414
+ for (let hop = 0; hop < BLOCK_PARENT_MAX_HOPS; hop++) {
415
+ let block;
416
+ try {
417
+ block = await retrieveBlock(id, opts);
418
+ }
419
+ catch {
420
+ return undefined; // an id the integration can't see resolves to nothing ⇒ the caller refuses
421
+ }
422
+ const parent = block.parent;
423
+ if (parent?.type === "page_id" && parent.page_id)
424
+ return parent.page_id;
425
+ if (parent?.type === "block_id" && parent.block_id && parent.block_id !== id) {
426
+ id = parent.block_id;
427
+ continue;
428
+ }
429
+ return undefined;
430
+ }
431
+ return undefined;
432
+ }
433
+ /** GET /v1/pages/{page_id} — used ONLY to answer "what owns this page?" for the ancestor walk
434
+ * (channels/notion-ancestry-trust.ts). Throws on a non-2xx response. */
435
+ export async function retrievePage(pageId, opts) {
436
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/pages/${encodeURIComponent(pageId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
437
+ if (!res.ok)
438
+ throw new Error(`retrievePage ${pageId} failed: ${res.status} ${await safeText(res)}`);
439
+ return (await res.json());
440
+ }
441
+ /**
442
+ * GET /v1/pages/{page_id}, read for the page's own DISPLAY IDENTITY — its title and its canonical
443
+ * Notion url (OPE-1714).
444
+ *
445
+ * The title is the same `properties`-by-type extraction a search hit takes ({@link notionItemTitle}),
446
+ * because a page's title property is named by whoever made the page and can only be found by type.
447
+ * The url is Notion's own `url` field, never assembled here from an id.
448
+ *
449
+ * 🔴 FAIL-SOFT, AND SEPARATE FROM THE READ IT DECORATES. A page body read must not fail because this
450
+ * extra call did, so every error answers an empty identity and the caller renders the page without a
451
+ * title or a link. Both fields are UNTRUSTED: a workspace member chooses the title.
452
+ */
453
+ export async function readNotionPageIdentity(pageId, opts) {
454
+ try {
455
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/pages/${encodeURIComponent(pageId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
456
+ if (!res.ok)
457
+ return {};
458
+ const page = (await res.json());
459
+ const title = notionItemTitle(page);
460
+ return {
461
+ ...(title.length > 0 ? { title } : {}),
462
+ ...(typeof page.url === "string" && page.url.length > 0 ? { url: page.url } : {}),
463
+ };
464
+ }
465
+ catch {
466
+ return {};
467
+ }
468
+ }
469
+ /** Map a raw Notion `parent` object onto the closed union the ancestor walk decides over. An
470
+ * unrecognised shape maps to `unresolvable`, never to `workspace` — "we do not know what owns this"
471
+ * and "this sits at the workspace root" are different answers and only one of them ends a walk
472
+ * cleanly. `data_source_id` is the 2025-09-03 spelling of a row's database parent; both map to
473
+ * `database`, which is where the walk stops. */
474
+ export function toNotionParentLink(parent) {
475
+ if (!parent)
476
+ return { kind: "unresolvable" };
477
+ if (parent.type === "page_id" && parent.page_id)
478
+ return { kind: "page", pageId: parent.page_id };
479
+ if (parent.type === "block_id" && parent.block_id)
480
+ return { kind: "block", blockId: parent.block_id };
481
+ if (parent.type === "database_id" && parent.database_id)
482
+ return { kind: "database", databaseId: parent.database_id };
483
+ if (parent.type === "data_source_id" && parent.data_source_id)
484
+ return { kind: "database", databaseId: parent.data_source_id };
485
+ if (parent.type === "workspace")
486
+ return { kind: "workspace" };
487
+ return { kind: "unresolvable" };
488
+ }
489
+ /**
490
+ * What owns `id` — the live `parentOf` the ancestor walk injects (channels/notion-ancestry-trust.ts).
491
+ *
492
+ * A PAGE first (`GET /v1/pages/{id}`), because the walk starts on a page id and every level above a
493
+ * nested page is a page too. A page id that Notion will not serve as a page falls back to the BLOCK
494
+ * endpoint, which is how a page nested inside a column or a toggle keeps its chain walkable. Both
495
+ * failing is `unresolvable`, which the walk treats as a refusal — this function NEVER throws, so a
496
+ * Notion outage degrades to "not trusted" instead of to an exception on the ingestion path.
497
+ */
498
+ export async function notionParentLink(id, opts) {
499
+ try {
500
+ return toNotionParentLink((await retrievePage(id, opts)).parent);
501
+ }
502
+ catch {
503
+ // fall through to the block endpoint
504
+ }
505
+ try {
506
+ return toNotionParentLink((await retrieveBlock(id, opts)).parent);
507
+ }
508
+ catch {
509
+ return { kind: "unresolvable" };
510
+ }
511
+ }
512
+ /** Flatten ONE raw comment into the read shape. Exported (OPE-988) so the read adapter's cheap
513
+ * page-level path maps a `listBlockComments` result the SAME way the sweep does — two spellings of
514
+ * one flatten is how an author field goes missing on one path only. */
515
+ export function toNotionCommentSummary(c, fallbackAnchor, anchorText) {
516
+ return {
517
+ ...(c.id === undefined ? {} : { id: c.id }),
518
+ discussionId: c.discussion_id,
519
+ anchorId: c.parent?.block_id ?? c.parent?.page_id ?? fallbackAnchor,
520
+ ...(anchorText !== undefined && anchorText.length > 0 ? { anchorText } : {}),
521
+ authorId: c.created_by?.id,
522
+ authorKind: c.created_by?.type,
523
+ createdTime: c.created_time,
524
+ text: richTextToPlainText(c.rich_text),
525
+ };
526
+ }
527
+ /**
528
+ * Read a Notion page's comments + inline discussions, bounded. When `block` is given, reads ONLY
529
+ * that block's discussion (one list call — the cheap drill-in). Otherwise sweeps the page's own
530
+ * comments then walks its top-level blocks (≤ `maxBlockScans`, default 80) listing each block's
531
+ * comments, stopping once `maxComments` (default 100) is collected. `blockScanOrder` decides WHICH
532
+ * `maxBlockScans` blocks a page past that cap spends the budget on — the page's first by default,
533
+ * its last under `"newest-first"` (OPE-1843). Returns the flattened comments
534
+ * (oldest-first per anchor), the distinct-discussion count, the number of blocks scanned, and
535
+ * `truncated` (a cap bit, or Notion still had more blocks). Comment bodies are UNTRUSTED — the
536
+ * caller wraps them. Pure over the injectable fetch.
537
+ */
538
+ export async function readNotionComments(pageId, opts) {
539
+ const maxComments = opts.maxComments ?? 100;
540
+ const maxBlockScans = opts.maxBlockScans ?? 80;
541
+ const comments = [];
542
+ const discussions = new Set();
543
+ let truncated = false;
544
+ let blocksScanned = 0;
545
+ const listOpts = {
546
+ ...opts,
547
+ ...(opts.maxCommentPages === undefined ? {} : { maxPages: opts.maxCommentPages }),
548
+ // An anchor the page bound cut off makes the WHOLE read partial — `truncated` is the coverage
549
+ // disclosure `read_notion_comments` shows the model, and a cut-off anchor reported as complete
550
+ // would be a false claim about what the sweep saw.
551
+ onPageLimit: () => {
552
+ truncated = true;
553
+ },
554
+ };
555
+ /** Whether this comment is inside the caller's time window. No window ⇒ every comment is. */
556
+ const inWindow = (c) => {
557
+ if (opts.sinceMs === undefined)
558
+ return true;
559
+ const ms = Date.parse(c.created_time ?? "");
560
+ return Number.isFinite(ms) && ms >= opts.sinceMs;
561
+ };
562
+ const take = (raw, anchor, anchorText) => {
563
+ // Returns true if the caller should STOP (comment cap hit).
564
+ for (const c of raw) {
565
+ if (!inWindow(c))
566
+ continue;
567
+ if (comments.length >= maxComments) {
568
+ truncated = true;
569
+ return true;
570
+ }
571
+ comments.push(toNotionCommentSummary(c, anchor, anchorText));
572
+ if (typeof c.discussion_id === "string" && c.discussion_id.length > 0)
573
+ discussions.add(c.discussion_id);
574
+ }
575
+ return false;
576
+ };
577
+ // Drill-in: a single block's inline discussion.
578
+ if (opts.block) {
579
+ take(await listBlockComments(opts.block, listOpts), opts.block);
580
+ return { comments, discussionCount: discussions.size, blocksScanned: 1, truncated };
581
+ }
582
+ // Full sweep: page-level comments first, then per-block inline discussions.
583
+ if (take(await listBlockComments(pageId, listOpts), pageId)) {
584
+ return { comments, discussionCount: discussions.size, blocksScanned, truncated };
585
+ }
586
+ // NEWEST-FIRST: list the children to the end (bounded), then spend the block budget on the LAST
587
+ // ones. Separate from the streaming walk below on purpose — that one cannot know which block is
588
+ // last until it has paged past it, which is the whole reason the budget used to be spent on the
589
+ // page's oldest material. See `blockScanOrder`.
590
+ if (opts.blockScanOrder === "newest-first") {
591
+ const maxChildPages = opts.maxChildPages ?? 10;
592
+ const children = [];
593
+ let childCursor;
594
+ let pagesRead = 0;
595
+ do {
596
+ const { blocks, nextCursor } = await getBlockChildren(pageId, { ...opts, startCursor: childCursor });
597
+ children.push(...blocks);
598
+ pagesRead += 1;
599
+ childCursor = nextCursor && pagesRead < maxChildPages ? nextCursor : undefined;
600
+ // More children than we listed ⇒ the "last block" we scan is not the page's last, so the read
601
+ // is partial however the budget is then spent.
602
+ if (nextCursor && childCursor === undefined)
603
+ truncated = true;
604
+ } while (childCursor);
605
+ if (children.length > maxBlockScans)
606
+ truncated = true;
607
+ const tail = children.slice(Math.max(0, children.length - maxBlockScans));
608
+ for (let i = tail.length - 1; i >= 0; i--) {
609
+ const b = tail[i];
610
+ blocksScanned += 1;
611
+ const id = typeof b.id === "string" ? b.id : undefined;
612
+ if (!id)
613
+ continue;
614
+ if (take(await listBlockComments(id, listOpts), id, blockPlainText(b)))
615
+ break;
616
+ }
617
+ return { comments, discussionCount: discussions.size, blocksScanned, truncated };
618
+ }
619
+ let cursor;
620
+ outer: do {
621
+ const { blocks, nextCursor } = await getBlockChildren(pageId, { ...opts, startCursor: cursor });
622
+ for (const b of blocks) {
623
+ if (blocksScanned >= maxBlockScans) {
624
+ truncated = true;
625
+ break outer;
626
+ }
627
+ blocksScanned += 1;
628
+ const id = typeof b.id === "string" ? b.id : undefined;
629
+ // Only blocks Notion flags as comment-bearing are worth a list call — `has_children` does NOT
630
+ // signal comments, so we probe every block, but cheaply: a comment-free block returns [].
631
+ if (!id)
632
+ continue;
633
+ // The block's own text rides with its comments (OPE-988) — the anchor snippet the generic read
634
+ // verbs render as a discussion's topic. It is free here: the sweep already holds the block.
635
+ if (take(await listBlockComments(id, listOpts), id, blockPlainText(b)))
636
+ break outer;
637
+ }
638
+ // More blocks we won't scan (cap or simply more) ⇒ partial read.
639
+ if (nextCursor && blocksScanned < maxBlockScans) {
640
+ cursor = nextCursor;
641
+ }
642
+ else {
643
+ if (nextCursor)
644
+ truncated = true;
645
+ cursor = undefined;
646
+ }
647
+ } while (cursor);
648
+ return { comments, discussionCount: discussions.size, blocksScanned, truncated };
649
+ }
650
+ /** Flatten ONE block to plain text: read the `rich_text` array nested under the block's own type
651
+ * key (`block[block.type].rich_text`) — the shape every text-bearing Notion block uses — and
652
+ * concatenate its `plain_text`. Returns "" for a block with no readable text (a divider, image,
653
+ * etc.), so a page of only such blocks flattens to empty rather than throwing. */
654
+ export function blockPlainText(block) {
655
+ const t = typeof block.type === "string" ? block.type : undefined;
656
+ if (!t)
657
+ return "";
658
+ const inner = block[t];
659
+ if (!inner || typeof inner !== "object")
660
+ return "";
661
+ const rich = inner.rich_text;
662
+ return richTextToPlainText(Array.isArray(rich) ? rich : undefined);
663
+ }
664
+ /** GET /v1/blocks/{block_id}/children — one page of a block's children (top-level only; nested
665
+ * children are NOT recursed here — the tool reads the doc's own top-level blocks, which is what
666
+ * a comment usually anchors to). Returns the results array + the next cursor (undefined when
667
+ * there are no more). Throws on a non-2xx response. */
668
+ export async function getBlockChildren(blockId, opts) {
669
+ const qs = new URLSearchParams({ page_size: String(opts.pageSize ?? 100) });
670
+ if (opts.startCursor)
671
+ qs.set("start_cursor", opts.startCursor);
672
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/blocks/${encodeURIComponent(blockId)}/children?${qs.toString()}`, {
673
+ method: "GET",
674
+ headers: notionHeaders(opts),
675
+ }, opts);
676
+ if (!res.ok)
677
+ throw new Error(`getBlockChildren ${blockId} failed: ${res.status} ${await safeText(res)}`);
678
+ const body = (await res.json());
679
+ return {
680
+ blocks: Array.isArray(body.results) ? body.results : [],
681
+ nextCursor: body.has_more && body.next_cursor ? body.next_cursor : undefined,
682
+ };
683
+ }
684
+ /** Read a Notion page's top-level text, paginating block-children and flattening each block's
685
+ * rich-text, bounded by BOTH a block count (`maxBlocks`, default 200) and a total-char cap
686
+ * (`maxChars`, default 20,000 — well under the wake backscroll budget so the model can hold it).
687
+ * `truncated` is true when either cap was hit or Notion still had more blocks — so the agent
688
+ * knows the read is partial. Pure over the injectable fetch (no live Notion in tests). */
689
+ export async function readNotionPage(pageId, opts) {
690
+ const maxBlocks = opts.maxBlocks ?? 200;
691
+ const maxChars = opts.maxChars ?? 20_000;
692
+ const lines = [];
693
+ let chars = 0;
694
+ let blockCount = 0;
695
+ let truncated = false;
696
+ let cursor;
697
+ do {
698
+ const { blocks, nextCursor } = await getBlockChildren(pageId, { ...opts, startCursor: cursor });
699
+ let stop = false;
700
+ for (const b of blocks) {
701
+ if (blockCount >= maxBlocks) {
702
+ truncated = true;
703
+ stop = true;
704
+ break;
705
+ }
706
+ blockCount += 1;
707
+ const text = blockPlainText(b);
708
+ if (!text)
709
+ continue;
710
+ if (chars + text.length > maxChars && lines.length > 0) {
711
+ truncated = true;
712
+ stop = true;
713
+ break;
714
+ }
715
+ chars += text.length;
716
+ lines.push(text);
717
+ }
718
+ // A remaining page we won't read (either we stopped on a cap, or there simply is more) means
719
+ // the read is partial — mark truncated and stop paginating.
720
+ if (stop) {
721
+ cursor = undefined;
722
+ }
723
+ else if (nextCursor) {
724
+ cursor = nextCursor;
725
+ }
726
+ else {
727
+ cursor = undefined;
728
+ }
729
+ } while (cursor);
730
+ return { text: lines.join("\n"), blockCount, truncated };
731
+ }
732
+ /**
733
+ * Notion's own per-comment attachment cap (developers.notion.com/reference/create-a-comment,
734
+ * confirmed live 2026-08-23: "attachments — Array of file objects — Maximum entries: 3"). Exported
735
+ * so {@link notionMessageOps.postFiles} (notion-message-ops.ts) can refuse a too-large batch BEFORE
736
+ * uploading a single byte, rather than uploading four files and discarding the fourth's upload.
737
+ */
738
+ export const NOTION_MAX_ATTACHMENTS = 3;
739
+ /** Build the `attachments` field of a create-comment body from completed `file_upload` ids, or `{}`
740
+ * when there are none (so a spread `...attachmentsBody(...)` never adds an empty array to a plain
741
+ * text comment). Throws over the cap — the same fail-loud posture as `MAX_FILE_BYTES` in
742
+ * `post_file.ts`: a caller that hands too many ids gets a clear reason, never a silently truncated
743
+ * batch or a 400 from Notion with no context. */
744
+ function attachmentsBody(fileUploadIds) {
745
+ if (!fileUploadIds || fileUploadIds.length === 0)
746
+ return {};
747
+ if (fileUploadIds.length > NOTION_MAX_ATTACHMENTS) {
748
+ throw new Error(`Notion comments accept at most ${NOTION_MAX_ATTACHMENTS} attachments; got ${fileUploadIds.length}.`);
749
+ }
750
+ return { attachments: fileUploadIds.map((id) => ({ file_upload_id: id, type: "file_upload" })) };
751
+ }
752
+ /** POST /v1/comments — reply into the SAME discussion thread. Notion caps a single rich-text
753
+ * item at 2000 chars, so the reply is chunked across items (≤100). Throws on non-2xx. Returns
754
+ * the created comment's id (Notion echoes the comment object) so the mid-turn `reply` tool can
755
+ * hand it back — empty string if the response carries none. Notion DOES expose an update verb
756
+ * now (`PATCH /v1/comments/{id}`, {@link updateComment}) — the reply tool itself does not wire to
757
+ * it (that is `edit_reply`'s seam, `channels/post-arms.ts`'s `editMessage`, a separate decision
758
+ * with its own scope). `attachments`, when given, names completed `file_upload` ids
759
+ * ({@link uploadNotionFile}) — see {@link attachmentsBody} for the cap. */
760
+ export async function createCommentReply(args) {
761
+ const attach = attachmentsBody(args.attachments);
762
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments`, {
763
+ method: "POST",
764
+ headers: notionHeaders(Object.keys(attach).length > 0 ? { ...args, version: NOTION_FILE_UPLOAD_VERSION } : args),
765
+ body: JSON.stringify({ discussion_id: args.discussionId, rich_text: richTextChunks(args.text), ...attach }),
766
+ }, args);
767
+ if (!res.ok) {
768
+ // The numeric HTTP status rides on the error (OPE-825), so the reply/unified post spines can
769
+ // classify a failed post here the same way they already do for Slack/Discord/Teams
770
+ // (channels/platform-failure.ts) — a number only, never the response body or a token.
771
+ throw Object.assign(new Error(`createCommentReply failed: ${res.status} ${await safeText(res)}`), { status: res.status });
772
+ }
773
+ const body = (await res.json().catch(() => ({})));
774
+ return typeof body.id === "string" ? body.id : "";
775
+ }
776
+ /** POST /v1/comments — start a FRESH top-level comment (a new discussion) on a PAGE, rather than
777
+ * replying into an existing one. This is what {@link routeNotionStopComment}'s acknowledgement and
778
+ * every other host-driven, no-discussion-in-hand notice (`notion-message-ops.ts`'s `postText`/
779
+ * `postFiles`) use: they are handed a PAGE address, never a discussion id. Same chunking/error
780
+ * shape and `attachments` handling as {@link createCommentReply}. */
781
+ export async function createPageComment(args) {
782
+ const attach = attachmentsBody(args.attachments);
783
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments`, {
784
+ method: "POST",
785
+ headers: notionHeaders(Object.keys(attach).length > 0 ? { ...args, version: NOTION_FILE_UPLOAD_VERSION } : args),
786
+ body: JSON.stringify({ parent: { page_id: args.pageId }, rich_text: richTextChunks(args.text), ...attach }),
787
+ }, args);
788
+ if (!res.ok) {
789
+ throw Object.assign(new Error(`createPageComment failed: ${res.status} ${await safeText(res)}`), { status: res.status });
790
+ }
791
+ const body = (await res.json().catch(() => ({})));
792
+ return typeof body.id === "string" ? body.id : "";
793
+ }
794
+ /** PATCH /v1/comments/{comment_id} — rewrite a comment THIS integration created, in place
795
+ * (developers.notion.com/reference/update-a-comment, confirmed live 2026-08-23: the endpoint takes
796
+ * exactly one of `rich_text`/`markdown`, and 404s on a comment the calling connection did not
797
+ * create). Chunked the same way `richTextChunks` chunks a create. Throws on non-2xx, carrying the
798
+ * numeric status the same way every other write in this module does. */
799
+ export async function updateComment(args) {
800
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments/${encodeURIComponent(args.commentId)}`, {
801
+ method: "PATCH",
802
+ headers: notionHeaders(args),
803
+ body: JSON.stringify({ rich_text: richTextChunks(args.text) }),
804
+ }, args);
805
+ if (!res.ok) {
806
+ throw Object.assign(new Error(`updateComment ${args.commentId} failed: ${res.status} ${await safeText(res)}`), { status: res.status });
807
+ }
808
+ }
809
+ /**
810
+ * POST /v1/file_uploads — step 1: reserve an upload slot for one small (≤20 MB, single-part) file.
811
+ * Returns the object's `id` (what a comment's `attachments` entry references, once uploaded) and the
812
+ * `upload_url` step 2 posts bytes to (the SAME host, `api.notion.com`, so it rides the credential
813
+ * fence like every other call here). Throws on non-2xx, or a response missing either field —
814
+ * never invents one.
815
+ */
816
+ export async function createFileUpload(args) {
817
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/file_uploads`, {
818
+ method: "POST",
819
+ headers: notionHeaders({ ...args, version: NOTION_FILE_UPLOAD_VERSION }),
820
+ body: JSON.stringify({ filename: args.filename, content_type: args.contentType }),
821
+ }, args);
822
+ if (!res.ok)
823
+ throw new Error(`createFileUpload failed: ${res.status} ${await safeText(res)}`);
824
+ const body = (await res.json().catch(() => ({})));
825
+ if (typeof body.id !== "string" || body.id.length === 0 || typeof body.upload_url !== "string" || body.upload_url.length === 0) {
826
+ throw new Error("createFileUpload: Notion response carried no usable id/upload_url.");
827
+ }
828
+ return { id: body.id, uploadUrl: body.upload_url };
829
+ }
830
+ /**
831
+ * POST {uploadUrl} — step 2: send the file's bytes as `multipart/form-data`, field name `file`
832
+ * (the shape Notion's send-file-upload endpoint requires). No explicit content-type header — like
833
+ * Discord's `postFiles` (discord-message-ops.ts), `fetch` derives the multipart boundary from the
834
+ * FormData body itself; setting one by hand would omit the boundary and the upload would 400.
835
+ * Throws on non-2xx.
836
+ */
837
+ export async function sendFileUploadBytes(args) {
838
+ const form = new FormData();
839
+ // A fresh ArrayBuffer copy so the Blob owns contiguous bytes regardless of the source view
840
+ // (mirrors discord-message-ops.ts's postFiles — the same reason: a Uint8Array over a larger
841
+ // backing buffer must not leak neighbouring bytes into the multipart body).
842
+ const ab = args.bytes.buffer.slice(args.bytes.byteOffset, args.bytes.byteOffset + args.bytes.byteLength);
843
+ form.append("file", new Blob([ab], { type: args.contentType }), args.filename);
844
+ const res = await notionFetch(args.uploadUrl, {
845
+ method: "POST",
846
+ headers: { authorization: `Bearer ${args.apiKey}`, "notion-version": NOTION_FILE_UPLOAD_VERSION },
847
+ body: form,
848
+ }, args);
849
+ if (!res.ok)
850
+ throw new Error(`sendFileUploadBytes failed: ${res.status} ${await safeText(res)}`);
851
+ }
852
+ /**
853
+ * The whole two-step upload (create + send), returning the `file_upload` id — ready to attach to a
854
+ * comment via {@link createCommentReply}/{@link createPageComment}'s `attachments` argument. ONE
855
+ * call so a caller (notion-message-ops.ts's `postFiles`) never has to remember the two-step order
856
+ * or thread the intermediate `upload_url` itself.
857
+ */
858
+ export async function uploadNotionFile(args) {
859
+ const { id, uploadUrl } = await createFileUpload(args);
860
+ await sendFileUploadBytes({ ...args, uploadUrl });
861
+ return id;
862
+ }
863
+ /** Split reply text into Notion rich-text items, each ≤2000 chars (Notion's per-item cap),
864
+ * up to 100 items (Notion's per-comment cap). Exported for the test. */
865
+ export function richTextChunks(text) {
866
+ const MAX_ITEM = 2000;
867
+ const MAX_ITEMS = 100;
868
+ const body = text.length > 0 ? text : "(no content)";
869
+ const chunks = [];
870
+ for (let i = 0; i < body.length && chunks.length < MAX_ITEMS; i += MAX_ITEM) {
871
+ chunks.push({ type: "text", text: { content: body.slice(i, i + MAX_ITEM) } });
872
+ }
873
+ return chunks;
874
+ }
875
+ /**
876
+ * Search the workspace the credential is shared into (`POST /v1/search`), the WORK-plane read
877
+ * (OPE-913).
878
+ *
879
+ * 🔴 THE CREDENTIAL IS THE BOUNDARY, AND OE ADDS NO SECOND ONE. Tommy ruled this on 2026-08-21:
880
+ * "scope can only be controlled at the CREDENTIAL level; the vendor exposes one search tool and we
881
+ * just use it. OE never filters search results as a boundary (a post-hoc filter is not
882
+ * enforcement)." Notion enforces it properly — an internal integration sees only the page trees a
883
+ * human shared with it — so a domain that wants a narrow search shares a narrow integration. Do NOT
884
+ * add a domain filter over these results: it would read as a boundary while being none.
885
+ *
886
+ * Returns ids + titles only. A caller that wants a body reads the page through the fenced page read.
887
+ */
888
+ export async function searchNotion(query, opts) {
889
+ const body = { page_size: Math.min(Math.max(opts.pageSize ?? 10, 1), 100) };
890
+ if (query.length > 0)
891
+ body.query = query;
892
+ if (opts.filter)
893
+ body.filter = { property: "object", value: opts.filter };
894
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/search`, {
895
+ method: "POST",
896
+ headers: notionHeaders(opts),
897
+ body: JSON.stringify(body),
898
+ }, opts);
899
+ if (!res.ok)
900
+ throw new Error(`notion search failed: ${res.status} ${await safeText(res)}`);
901
+ const parsed = (await res.json());
902
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
903
+ const hits = [];
904
+ for (const raw of results) {
905
+ const item = raw;
906
+ if (typeof item.id !== "string" || item.id.length === 0)
907
+ continue;
908
+ hits.push({
909
+ id: item.id,
910
+ object: typeof item.object === "string" ? item.object : "unknown",
911
+ title: notionItemTitle(item),
912
+ ...(typeof item.url === "string" ? { url: item.url } : {}),
913
+ });
914
+ }
915
+ return hits;
916
+ }
917
+ /** The plain-text title of a search hit. A DATABASE carries `title` directly; a PAGE carries it
918
+ * under whichever `properties` entry has `type: "title"` (the name of that property is the user's
919
+ * choice, so it must be found by type, never by a guessed key like "Name"). */
920
+ function notionItemTitle(item) {
921
+ if (Array.isArray(item.title))
922
+ return richTextToPlainText(item.title);
923
+ const props = item.properties;
924
+ for (const value of Object.values(props ?? {})) {
925
+ if (value?.type === "title" && Array.isArray(value.title))
926
+ return richTextToPlainText(value.title);
927
+ }
928
+ return "";
929
+ }
930
+ async function safeText(res) {
931
+ try {
932
+ return (await res.text()).slice(0, 300);
933
+ }
934
+ catch {
935
+ return "";
936
+ }
937
+ }
938
+ //# sourceMappingURL=notion-api.js.map