@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,1290 @@
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
+
20
+ import { createHmac, timingSafeEqual } from "node:crypto";
21
+ import {
22
+ getSecretVia,
23
+ CredentialError,
24
+ findConnectorCredential,
25
+ guardCredentialFetch,
26
+ withRetryAfter,
27
+ type CredentialEndpointRules,
28
+ type RateLimitRetryOptions,
29
+ } from "@open-everywhere/core/agent/adapter-api.js";
30
+ import { NOTION_CHANNEL_CREDENTIAL, NOTION_PROVIDER } from "./notion-credentials.js";
31
+ import type { NotionParentLink } from "./notion-ancestry-trust.js";
32
+
33
+ /** Notion REST base + the API version header value (override via NOTION_VERSION). The
34
+ * default is a stable version that supports the comment retrieve/create endpoints and
35
+ * rich-text `mention` objects — everything this surface needs. */
36
+ export const NOTION_API_BASE = "https://api.notion.com";
37
+ function notionVersion(): string {
38
+ const v = process.env.NOTION_VERSION;
39
+ return v && v.trim().length > 0 ? v.trim() : "2022-06-28";
40
+ }
41
+
42
+ /**
43
+ * File uploads are a NEWER Notion API surface than the rest of this module. Notion's own docs
44
+ * (developers.notion.com/reference/create-a-file-upload, confirmed live 2026-08-23) name
45
+ * `2026-03-11` as the only accepted `Notion-Version` for the file-upload endpoints — the
46
+ * deployment's regular {@link notionVersion} (default `2022-06-28`, override via NOTION_VERSION)
47
+ * covers the comment/page/block surface this module already calls and does not carry file uploads.
48
+ * So {@link createFileUpload}, {@link sendFileUploadBytes}, and a create-comment call that attaches
49
+ * files all pin THIS version, rather than inheriting a caller's `opts.version` — a deployment that
50
+ * pins an older NOTION_VERSION for its comment traffic must not also break its attachment traffic.
51
+ */
52
+ const NOTION_FILE_UPLOAD_VERSION = "2026-03-11";
53
+
54
+ /**
55
+ * Resolve the Notion CHANNEL-PLUMBING API key through the credential BROKER, so the notion
56
+ * `channel-key` credential's custody stamp — its `broker` + `secretRef` (agent/access/providers.ts) —
57
+ * governs every host-side communication-channel REST path (reply posts, the webhook two-step,
58
+ * page/comment reads), not a hard-coded env pin. Looks the credential up in the registered provider
59
+ * set (access/providers.ts findConnectorCredential, populated at boot from the app's provider list) and
60
+ * resolves it via `getSecretVia(broker, secretRef)`. There is no selected default: an api-key
61
+ * credential always carries its mandatory `broker` stamp, and an absent broker would fail closed
62
+ * (caught below ⇒ undefined).
63
+ *
64
+ * CHANNEL vs WORK-SURFACE SPLIT (decision, Tommy 2026-07-21): the notion provider carries TWO api-key
65
+ * credentials. `channel-key` (secretRef `db:notion:channel-key`, broker `db` — OPE-408 step 2) is
66
+ * this communication-channel plumbing — provisioned by the operator via `oe secret set
67
+ * db:notion:channel-key`. It is a DISTINCT ref from `team-key` (secretRef `db:notion:team-key`), the
68
+ * WORK-SURFACE credential (the notion-rest egress connection). Even if an operator stores both refs
69
+ * with the same value, they are separate credentials with separate custody. There is NO fallback
70
+ * between them: if `db:notion:channel-key` has no stored value, this resolves undefined and channel
71
+ * Notion fails closed exactly as an unset value does today.
72
+ *
73
+ * PER-DOMAIN OVERRIDE (OPE-467). `credentialId` selects WHICH notion channel credential to resolve
74
+ * and defaults to the org-default `channel-key`, so every existing call site is byte-identical. A
75
+ * domain that binds its own Notion integration (memory/domain.ts `credentials`) passes that
76
+ * integration's credential id instead — see channels/notion-credentials.ts for the one rule that
77
+ * decides it. There is still exactly ONE resolution mechanism: whichever id arrives is looked up in
78
+ * the registered provider set and resolved through its own custody stamp.
79
+ *
80
+ * 🔴 A NAMED-BUT-MISSING CREDENTIAL FAILS CLOSED, and that is the point. When a bound domain's
81
+ * credential is absent from the provider set this returns undefined and the caller refuses, exactly
82
+ * as an unset secret does. It NEVER retries under the org default: a domain bound away from the org
83
+ * key must not be handed the org key by a fallback.
84
+ *
85
+ * Fail-closed, two ways, both returning undefined — the exact unset-env semantics every caller
86
+ * already branches on, NEVER a silent fallback to the work-surface key or an env pin:
87
+ * • the provider/credential is UNREGISTERED or carries no `secretRef` (an instance that removed
88
+ * the notion provider, or a unit test that never registered one) ⇒ unconfigured ⇒ undefined;
89
+ * • the broker resolves no value (the secret is unset) ⇒ the same CredentialError-catch as before.
90
+ */
91
+ /**
92
+ * One resolved Notion credential: the key to call with, PAIRED WITH the endpoint rules that key may
93
+ * be used inside (OPE-505). Spread it straight into a {@link NotionCallOpts} — `{ ...cred, fetch }` —
94
+ * so the fence and the key can never be threaded separately.
95
+ *
96
+ * 🔴 THERE IS DELIBERATELY NO KEY-ONLY RESOLVER. An earlier `resolveNotionApiKey` returned the bare
97
+ * string, and every call site that used it produced an UNFENCED request: `guardCredentialFetch`
98
+ * returns the base fetch by identity when it is handed no rules, so a bare key silently disables the
99
+ * check rather than failing. Deleting that function is what makes the fence total on this module's
100
+ * paths — a caller cannot obtain a Notion key without also obtaining its rules.
101
+ */
102
+ export interface ResolvedNotionCredential {
103
+ readonly apiKey: string;
104
+ /** The credential's declared endpoint narrowing, or undefined when it declares none. */
105
+ readonly endpoints?: CredentialEndpointRules;
106
+ }
107
+
108
+ /**
109
+ * THE ONLY way to resolve a Notion channel credential — the key together with its endpoint rules.
110
+ * Fail-closed exactly as before: `undefined` for an unregistered provider, a credential carrying no
111
+ * `secretRef`, or a broker that resolves no value. A named-but-missing credential NEVER retries
112
+ * under the org default.
113
+ *
114
+ * WHY THE PAIR IS THE UNIT. A credential's endpoint rules are a property OF THAT KEY. Resolving the
115
+ * key through one function and the rules through another invites exactly the drift the rules exist
116
+ * to catch: a call site that threads the key from credential A and the rules from credential B has
117
+ * a fence that permits the leak it was written to block. Returning them together removes that
118
+ * failure mode by construction rather than by review.
119
+ */
120
+ export async function resolveNotionCredential(
121
+ credentialId: string = NOTION_CHANNEL_CREDENTIAL,
122
+ ): Promise<ResolvedNotionCredential | undefined> {
123
+ const cred = findConnectorCredential(NOTION_PROVIDER, credentialId);
124
+ // Unregistered provider / non-secretRef credential = unconfigured — identical to the env being
125
+ // unset. Return undefined; do NOT fall back to a process.env / env-broker pin.
126
+ if (!cred?.secretRef) return undefined;
127
+ try {
128
+ const k = await getSecretVia(cred.broker, cred.secretRef);
129
+ return k.length > 0 ? { apiKey: k, endpoints: cred.endpoints } : undefined;
130
+ } catch (err) {
131
+ if (err instanceof CredentialError) return undefined;
132
+ throw err;
133
+ }
134
+ }
135
+
136
+ // --- webhook verification ---------------------------------------------------
137
+
138
+ /**
139
+ * The one-time subscription handshake: when a webhook subscription is created, Notion POSTs
140
+ * `{ "verification_token": "secret_…" }` (and nothing else — no `type`/`event_type`) to the
141
+ * endpoint. Return that token so the route can LOG it (the operator pastes it back into
142
+ * Notion's UI to confirm the endpoint, and stores it as NOTION_WEBHOOK_SECRET — the HMAC
143
+ * key for every subsequent delivery). Returns null for a normal event payload.
144
+ */
145
+ export function parseVerificationHandshake(rawBody: string): string | null {
146
+ let parsed: unknown;
147
+ try {
148
+ parsed = JSON.parse(rawBody);
149
+ } catch {
150
+ return null;
151
+ }
152
+ if (!parsed || typeof parsed !== "object") return null;
153
+ const o = parsed as Record<string, unknown>;
154
+ const token = o.verification_token;
155
+ // Only a bare handshake (has the token, carries no event discriminator) qualifies.
156
+ if (typeof token === "string" && token.length > 0 && o.type === undefined && o.event_type === undefined) {
157
+ return token;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Verify a Notion webhook's HMAC-SHA256 signature (`X-Notion-Signature: sha256=<hex>`)
164
+ * against the verification token, over the RAW body. Constant-time compare. Mirrors
165
+ * routines/events.ts's verifyGithubSignature (same scheme, different header/secret).
166
+ */
167
+ export function verifyNotionSignature(secret: string, rawBody: string, signatureHeader: string | null | undefined): boolean {
168
+ if (!signatureHeader) return false;
169
+ const expected = "sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
170
+ const a = Buffer.from(signatureHeader);
171
+ const b = Buffer.from(expected);
172
+ return a.length === b.length && timingSafeEqual(a, b);
173
+ }
174
+
175
+ /**
176
+ * Gate an inbound Notion delivery FAIL-CLOSED. No configured secret means the sender can't
177
+ * be authenticated, so the delivery is REJECTED — this route dispatches agent sessions, and
178
+ * accepting unsigned payloads would let anyone on the internet drive the bot. The dev escape
179
+ * hatch `webhooks.allowUnverifiedNotion` restores unverified acceptance for local smoke
180
+ * tests. Returns null when the delivery may proceed, else the one-line 401 rejection reason.
181
+ * (Same shape/posture as gateGithubDelivery.)
182
+ */
183
+ export function gateNotionDelivery(
184
+ secret: string | undefined,
185
+ rawBody: string,
186
+ signatureHeader: string | null,
187
+ allowUnverified: boolean,
188
+ ): string | null {
189
+ if (!secret) {
190
+ return allowUnverified
191
+ ? null
192
+ : "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)";
193
+ }
194
+ return verifyNotionSignature(secret, rawBody, signatureHeader) ? null : "invalid signature";
195
+ }
196
+
197
+ /** One integration's inbound signing key, paired with the credential that integration calls Notion
198
+ * with (channels/notion-credentials.ts). `secret` is undefined when nothing is stored under that
199
+ * integration's operator-secret name — an unconfigured integration, skipped by the gate. */
200
+ export interface NotionSigningKey {
201
+ readonly credentialId: string;
202
+ readonly secret: string | undefined;
203
+ }
204
+
205
+ /** The gate's answer. `rejection` non-null ⇒ send the 401 and stop. `credentialId` names the
206
+ * integration whose secret VERIFIED the delivery — the identity a signature proves. `verified` is
207
+ * true only when a real signature was checked against a configured secret (never under the dev
208
+ * hatch), and it is what admits a delivery into the routing plane. */
209
+ export interface NotionGateVerdict {
210
+ readonly rejection: string | null;
211
+ readonly credentialId?: string;
212
+ readonly verified: boolean;
213
+ }
214
+
215
+ /**
216
+ * Gate an inbound delivery against EVERY configured Notion integration (OPE-467) — the
217
+ * multi-integration form of {@link gateNotionDelivery}, and the whole of the ingress-routing answer.
218
+ *
219
+ * An operator running ten Notion integrations has ten `verification_token`s, and a delivery from
220
+ * integration #7 cannot verify against integration #1's secret. So the gate tries each in turn and
221
+ * THE SECRET THAT VERIFIES IDENTIFIES THE SENDER. That is a proof, not an inference: nothing in the
222
+ * payload is consulted, so no field a caller controls can steer the routing. Trying N keys is the
223
+ * same shape `webhooks/secrets.ts` already uses for a rotation window, and each comparison stays
224
+ * constant-time (`verifyNotionSignature` → `timingSafeEqual`).
225
+ *
226
+ * Same fail-closed posture as the single-secret gate, with the same wording for the same case:
227
+ * • NO integration has a stored secret ⇒ reject, unless the dev hatch is on (and then `verified`
228
+ * is false, so the delivery still may not reach the routing plane);
229
+ * • at least one secret configured, none verifies ⇒ "invalid signature".
230
+ */
231
+ export function gateNotionDeliveryAcross(
232
+ keys: readonly NotionSigningKey[],
233
+ rawBody: string,
234
+ signatureHeader: string | null,
235
+ allowUnverified: boolean,
236
+ ): NotionGateVerdict {
237
+ const configured = keys.filter((k): k is NotionSigningKey & { secret: string } => typeof k.secret === "string" && k.secret.length > 0);
238
+ if (configured.length === 0) {
239
+ const rejection = gateNotionDelivery(undefined, rawBody, signatureHeader, allowUnverified);
240
+ return { rejection, verified: false };
241
+ }
242
+ for (const key of configured) {
243
+ if (verifyNotionSignature(key.secret, rawBody, signatureHeader)) {
244
+ return { rejection: null, credentialId: key.credentialId, verified: true };
245
+ }
246
+ }
247
+ return { rejection: "invalid signature", verified: false };
248
+ }
249
+
250
+ // --- webhook payload parsing ------------------------------------------------
251
+
252
+ export interface NotionWebhookEvent {
253
+ readonly type?: string;
254
+ /** Some API versions name the discriminator `event_type` instead of `type`. */
255
+ readonly event_type?: string;
256
+ readonly workspace_id?: string;
257
+ readonly entity?: { readonly id?: string; readonly type?: string };
258
+ readonly data?: {
259
+ readonly page_id?: string;
260
+ readonly parent?: { readonly id?: string; readonly type?: string };
261
+ };
262
+ readonly authors?: ReadonlyArray<{ readonly id?: string; readonly type?: string }>;
263
+ }
264
+
265
+ /** The event discriminator, tolerating both the `type` and `event_type` spellings. */
266
+ export function notionEventType(ev: NotionWebhookEvent): string | undefined {
267
+ return ev.type ?? ev.event_type;
268
+ }
269
+
270
+ /** The comment id a `comment.created` event points at — it lives on `entity` (a signal only;
271
+ * the comment TEXT is not on the event), not on `data`. Returns undefined if the entity
272
+ * isn't a comment (defensive: never dispatch off a mis-shaped event). */
273
+ export function commentIdFromEvent(ev: NotionWebhookEvent): string | undefined {
274
+ const e = ev.entity;
275
+ if (e && e.type === "comment" && typeof e.id === "string" && e.id.length > 0) return e.id;
276
+ return undefined;
277
+ }
278
+
279
+ /**
280
+ * The page a `comment.created` event CLAIMS to be anchored on (data.page_id, else data.parent.id).
281
+ *
282
+ * 🔴 A CLAIM, NOT A FACT — nothing authenticates a webhook body's fields, and `data.parent.id` is a
283
+ * block id as often as a page id. It must never select a session's surface, its domain, or a
284
+ * credential on a VERIFIED delivery: `notion-webhook.ts` step 8 establishes the real page from the
285
+ * comment Notion returns under the verifying integration's own key instead (OPE-496). The one
286
+ * remaining caller is the `webhooks.allowUnverifiedNotion` dev hatch, where the gate identifies
287
+ * no integration and nothing at all has been proven.
288
+ */
289
+ export function pageIdFromEvent(ev: NotionWebhookEvent): string | undefined {
290
+ return ev.data?.page_id ?? ev.data?.parent?.id;
291
+ }
292
+
293
+ // --- rich-text helpers (comment body) ---------------------------------------
294
+
295
+ /** One rich-text item as returned inside a comment's `rich_text` array. Loosely typed —
296
+ * the shapes are probed defensively (a mis-shaped item never throws). */
297
+ export interface NotionRichText {
298
+ readonly type?: string;
299
+ readonly plain_text?: string;
300
+ readonly text?: { readonly content?: string };
301
+ readonly mention?: { readonly type?: string; readonly user?: { readonly id?: string } };
302
+ }
303
+
304
+ /** Flatten a comment's rich_text to plain text (concatenated `plain_text`). */
305
+ export function richTextToPlainText(richText: ReadonlyArray<NotionRichText> | undefined): string {
306
+ if (!Array.isArray(richText)) return "";
307
+ return richText.map((r) => (typeof r.plain_text === "string" ? r.plain_text : "")).join("").trim();
308
+ }
309
+
310
+ /**
311
+ * Detect whether the comment @mentions a specific user id. A Notion @mention is a rich-text
312
+ * item `{ type:"mention", mention:{ type:"user", user:{ id } } }`; an @mention of an
313
+ * integration/agent appears as a user mention whose id is the integration's own bot-user id.
314
+ * So "was the bot mentioned?" = does any mention item's `mention.user.id` equal the bot's
315
+ * user id (resolveBotUserId). Returns false for an empty id (never dispatch on an
316
+ * unresolved bot identity — fail closed to "not mentioned").
317
+ */
318
+ export function mentionsUser(richText: ReadonlyArray<NotionRichText> | undefined, userId: string | undefined): boolean {
319
+ if (!userId || !Array.isArray(richText)) return false;
320
+ return richText.some((r) => r.type === "mention" && r.mention?.type === "user" && r.mention.user?.id === userId);
321
+ }
322
+
323
+ // --- REST calls (injectable fetch) ------------------------------------------
324
+
325
+ /** The subset of the global `fetch` this module uses — injectable so tests mock it. */
326
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
327
+
328
+ /** The credential + transport options every Notion REST helper here takes. EXPORTED so a caller that
329
+ * threads these options onward (channels/notion-webhook.ts) names this ONE shape rather than
330
+ * re-declaring a structural copy that could silently drop `endpoints` and lose the fence. */
331
+ export interface NotionCallOpts {
332
+ readonly apiKey: string;
333
+ readonly fetch?: FetchLike;
334
+ readonly version?: string;
335
+ /**
336
+ * The ENDPOINT RULES of the credential `apiKey` came from (OPE-505), carried alongside the key so
337
+ * the fence travels with it. Every REST call in this module goes out through {@link notionFetch},
338
+ * which refuses before dispatch when the URL is outside these rules.
339
+ *
340
+ * Absent ⇒ unnarrowed, byte-identical to the pre-OPE-505 path. Build the pair with
341
+ * {@link resolveNotionCredential} rather than assembling it by hand — that is what keeps the rules
342
+ * and the key from drifting apart, which is the one way this fence could silently stop being one.
343
+ */
344
+ readonly endpoints?: CredentialEndpointRules;
345
+ /**
346
+ * The 429 backoff policy {@link notionFetch} applies (OPE-1233). A caller that passes none takes
347
+ * the shared defaults from `channels/rate-limit-retry.ts`. The webhook ingress passes
348
+ * {@link NOTION_INGRESS_RETRY} because the shared defaults do not fit its ceiling — see there. A
349
+ * test injects `sleep` and `now` so a retry sequence is driven by a stub clock and waits nothing.
350
+ */
351
+ readonly retry?: Pick<RateLimitRetryOptions, "maxAttempts" | "capMs" | "fallbackMs" | "totalBudgetMs" | "sleep" | "now">;
352
+ }
353
+
354
+ /**
355
+ * The 429 policy for a call the WEBHOOK INGRESS awaits before it acks Notion (OPE-1254).
356
+ *
357
+ * 🔴 THE ARITHMETIC, AGAINST A MEASURED CEILING. `handleNotionDelivery` is awaited before the route
358
+ * acks, and it fires about 15 calls in sequence: `retrieveBotUser`, `retrieveComment`, up to five
359
+ * `retrieveBlock` parent hops, `listBlockComments`, and the ancestry walk's `notionParentLink`. On
360
+ * the shared defaults — 4 attempts, a 30 s per-wait cap, a 60 s budget PER CALL — a rate-limit
361
+ * episode costs that delivery up to ~15 minutes. The deployed Notion route runs on the INGRESS
362
+ * function, whose `maxDuration` is 300 s (measured on OPE-1289, `docs/extending/provider-rate-limits.md`
363
+ * § "Measured: the function maxDuration against the backoff"). So the shared policy does not make
364
+ * the delivery slow — it makes the function get killed mid-sequence, which is a DROPPED delivery.
365
+ *
366
+ * This policy spends at most one 2 s wait per call — `maxAttempts: 2` allows one, and the cap binds
367
+ * before the 4 s budget can, which is left in place for a later edit that raises the attempts. So
368
+ * the whole ~15-call sequence spends at most ~30 s of backoff and leaves ~270 s of the ingress
369
+ * ceiling for the work itself. Every one of those calls is covered: the four `deliver` issues
370
+ * directly, through `ingressCallOpts`, and `retrieveBotUser` behind `deps.resolveBotUserId`, which
371
+ * `notion.ts` passes this same policy (it is awaited FIRST, ahead of them all). It is Slack's answer to the
372
+ * same problem (`slack-web-api.ts`'s `PROBE_OPERATIONS`, 2 attempts / 2 s cap for the calls its
373
+ * 3-second ack awaits), with the total budget stated too so the arithmetic closes rather than
374
+ * resting on the shared 60 s.
375
+ *
376
+ * ⚠️ IT IS PER CALL, NOT PER DELIVERY. A shared budget consumed ACROSS the sequence is the stronger
377
+ * shape and it is a change to the shared primitive every provider inherits — still open on OPE-1254.
378
+ * What this bounds is the worst case, and it bounds it under the ceiling.
379
+ */
380
+ export const NOTION_INGRESS_RETRY = {
381
+ maxAttempts: 2,
382
+ capMs: 2_000,
383
+ totalBudgetMs: 4_000,
384
+ } as const satisfies NonNullable<NotionCallOpts["retry"]>;
385
+
386
+ function notionHeaders(opts: NotionCallOpts): Record<string, string> {
387
+ return {
388
+ authorization: `Bearer ${opts.apiKey}`,
389
+ "notion-version": opts.version ?? notionVersion(),
390
+ "content-type": "application/json",
391
+ };
392
+ }
393
+
394
+ /**
395
+ * The vendor operation name a Notion request carries into {@link RateLimitExhaustedError} — the
396
+ * request's method plus its endpoint SHAPE, with every id segment replaced by `{id}`.
397
+ *
398
+ * 🔴 THE REDACTION IS THE POINT, not tidiness. `RateLimitExhaustedError` states that every field on
399
+ * it is safe to log, and this string is one of those fields. A raw Notion path carries page, block
400
+ * and comment ids, which name workspace content — so the shape goes in the failure and the ids stay
401
+ * out of it. `/v1/comments/abc…` becomes `GET /v1/comments/{id}`.
402
+ *
403
+ * The query string is dropped whole for the same reason: `?block_id=…` is an id too.
404
+ *
405
+ * Every url this is handed is built from a literal in this file, save one: {@link sendFileUploadBytes}
406
+ * posts to the `upload_url` Notion itself issued. A value that does not parse as a URL falls back to
407
+ * the method alone. Naming the method is honest; guessing a path from a string that is not a URL is
408
+ * not.
409
+ */
410
+ export function notionOperation(method: string | undefined, url: string): string {
411
+ const verb = (method ?? "GET").toUpperCase();
412
+ let path: string;
413
+ try {
414
+ path = new URL(url).pathname;
415
+ } catch {
416
+ return verb;
417
+ }
418
+ const shape = path
419
+ .split("/")
420
+ // A Notion id is a UUID, sent with or without its dashes. Anything else — `v1`, `comments`,
421
+ // `me`, `children` — is a fixed segment and stays readable.
422
+ .map((seg) => (/^[0-9a-f]{32}$/i.test(seg.replaceAll("-", "")) ? "{id}" : seg))
423
+ .join("/");
424
+ return `${verb} ${shape}`;
425
+ }
426
+
427
+ /**
428
+ * THE ONE PLACE a Notion REST call leaves this host. Every helper below dispatches through here, so
429
+ * the credential's endpoint fence ({@link NotionCallOpts.endpoints}) is applied on every path
430
+ * without each call site remembering to.
431
+ *
432
+ * Funnelling matters more than the guard itself: a fence applied at six call sites is a fence a
433
+ * seventh call site will not have. With one funnel, a new helper is fenced the moment it is written.
434
+ *
435
+ * THE 429 BACKOFF RIDES THE SAME FUNNEL (OPE-1233). Notion answers a rate-limited call with HTTP 429
436
+ * and a `Retry-After` header, and nothing here read it before: the first refusal became whatever
437
+ * `${caller} failed: 429 …` message the helper throws, so a reply was simply lost. `withRetryAfter`
438
+ * now honours that delay with bounded attempts and a bounded total wait, and then throws the NAMED
439
+ * `RateLimitExhaustedError`. Every non-429 answer — a 2xx, a 404, a 5xx — comes back untouched, and
440
+ * a transport throw still propagates unretried (the request may have applied; repeating it could
441
+ * double a comment).
442
+ *
443
+ * ORDER MATTERS: the fence wraps the transport and the retry wraps the fence, so `guardCredentialFetch`
444
+ * re-checks the URL on EVERY attempt rather than once before the first.
445
+ *
446
+ * 🔴 THE BUDGET IS PER CALL, AND A PATH THAT FIRES A SEQUENCE PAYS IT ONCE EACH. The webhook ingress
447
+ * fires about 15 calls in sequence and now passes {@link NOTION_INGRESS_RETRY}, which bounds the
448
+ * whole sequence to ~30 s under that function's 300 s ceiling (OPE-1254). `readNotionComments` does
449
+ * NOT: it sweeps up to ~82 blocks at the shared 60 s per call, so ~82 minutes worst case, on the turn
450
+ * function's 800 s ceiling. That one is still open on OPE-1254.
451
+ * 🔴 AND `maxCommentPages` MULTIPLIES THAT NUMBER (OPE-1831). Each anchor may now cost up to that
452
+ * many list calls rather than one, so a caller passing 5 — the seat catch-up does — raises the same
453
+ * worst case about fivefold. It is a CEILING, not a cost: the follow stops the moment Notion reports
454
+ * no more, so every anchor under 100 comments is still exactly one call and the multiplier is paid
455
+ * only by an anchor that genuinely overflows. Reaching the ceiling needs all ~82 anchors to overflow
456
+ * at once, which no real page does.
457
+ * {@link NotionCallOpts.retry} is the seam a caller uses to tighten its own path.
458
+ * `docs/extending/provider-rate-limits.md` carries the table.
459
+ *
460
+ * A RETRY RE-SENDS `init` UNCHANGED, which is safe for every body this module builds: a JSON string,
461
+ * and {@link sendFileUploadBytes}'s `FormData` over a `Blob`. Both are values `fetch` re-serialises
462
+ * per call, not streams a first attempt would consume. A body that WAS a stream could not be replayed
463
+ * — do not introduce one here without giving `send` a fresh body per attempt.
464
+ */
465
+ function notionFetch(url: string, init: RequestInit, opts: NotionCallOpts): Promise<Response> {
466
+ const base = opts.fetch ?? (globalThis.fetch as FetchLike);
467
+ const send = guardCredentialFetch(opts.endpoints, "notion credential", base);
468
+ return withRetryAfter(() => send(url, init), {
469
+ ...opts.retry,
470
+ surface: "notion",
471
+ operation: notionOperation(init.method, url),
472
+ });
473
+ }
474
+
475
+ export interface NotionComment {
476
+ readonly id?: string;
477
+ readonly discussion_id?: string;
478
+ readonly parent?: { readonly type?: string; readonly page_id?: string; readonly block_id?: string };
479
+ readonly rich_text?: ReadonlyArray<NotionRichText>;
480
+ // `type` is a Notion user's kind ("person" | "bot"); present on a full user object, often ABSENT
481
+ // on a comment's partial `created_by` (id + object only). The ambient loop guard uses it as the
482
+ // bot-author signal WHEN present, with the self-id + fail-closed-on-unresolved checks as the
483
+ // backstop when it isn't (notion-supervisor.ts classifyNotionComment; design §4.2, §6.7).
484
+ readonly created_by?: { readonly id?: string; readonly object?: string; readonly type?: string };
485
+ /** ISO-8601 creation time, when Notion supplies it — the wake message's `sent-at`. */
486
+ readonly created_time?: string;
487
+ }
488
+
489
+ /** GET /v1/comments/{comment_id} — the second step of the two-step (webhook signal → fetch
490
+ * the actual comment body + discussion id + author). Throws on a non-2xx response. */
491
+ export async function retrieveComment(commentId: string, opts: NotionCallOpts): Promise<NotionComment> {
492
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments/${encodeURIComponent(commentId)}`, {
493
+ method: "GET",
494
+ headers: notionHeaders(opts),
495
+ }, opts);
496
+ if (!res.ok) throw new Error(`retrieveComment ${commentId} failed: ${res.status} ${await safeText(res)}`);
497
+ return (await res.json()) as NotionComment;
498
+ }
499
+
500
+ /** GET /v1/users/me — the integration's own bot user: its `id` (the loop-guard self-identity)
501
+ * AND its `name` (the display name a person types in a name-drop — a judge input, never a
502
+ * bypass; design §2.2/Step 4). Resolved once and cached by the channel; env NOTION_BOT_USER_ID /
503
+ * NOTION_BOT_NAME short-circuit these — but the ID override applies to the ORG-DEFAULT `channel-key`
504
+ * credential ONLY (OPE-1921: two Notion integrations never share a bot user, so one pinned id
505
+ * cannot stand for a sibling's, and pinning it for every credential would reopen the
506
+ * cross-integration loop). Throws on a non-2xx response. */
507
+ export async function retrieveBotUser(opts: NotionCallOpts): Promise<{ id?: string; type?: string; name?: string }> {
508
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/users/me`, { method: "GET", headers: notionHeaders(opts) }, opts);
509
+ if (!res.ok) throw new Error(`retrieveBotUser failed: ${res.status} ${await safeText(res)}`);
510
+ return (await res.json()) as { id?: string; type?: string; name?: string };
511
+ }
512
+
513
+ /** GET /v1/comments?block_id=… — list unresolved comments on a page/block, for surrounding
514
+ * thread context (best-effort). Returns the raw results array (caller filters by
515
+ * discussion + renders untrusted). Throws on a non-2xx response.
516
+ *
517
+ * 🔴 NOTION SERVES THIS ENDPOINT OLDEST-FIRST AND PAGINATED, AND ONE PAGE IS THE OLDEST 100
518
+ * (OPE-1831). A single unpaginated call therefore reads the anchor's FIRST hundred comments and can
519
+ * never reach a comment posted today — which is not a partial read on a long-lived anchor, it is a
520
+ * read that structurally cannot answer "what was said since the seat last looked". A PAGE-ANCHORED
521
+ * discussion on any long-lived doc is unreachable this way.
522
+ * 🔴 THIS IS NOT WHAT RED `J-ENGAGED-notion` ON SHIP #208 — do not carry that story forward. That
523
+ * journey's comment sits in a BLOCK-anchored discussion, which a page query never returns however
524
+ * many pages it follows; the cause there was `readNotionComments`' comment CAP ending the sweep
525
+ * before the block walk began (see that function, and `notion-catch-up.ts`). This bound is a real
526
+ * and separate one, closed while the file was open.
527
+ *
528
+ * `maxPages` bounds the follow. It DEFAULTS TO 1 — the single call every existing caller already
529
+ * made — so a caller that wants the newest comments asks for the pages, and no other read changes
530
+ * its cost. The loop stops the moment Notion reports no more, so even at a high `maxPages` an anchor
531
+ * with under 100 comments still costs exactly one call.
532
+ * 🔴 `onPageLimit` FIRES WHEN THE BOUND CUT THE ANCHOR OFF — Notion still had more and this stopped
533
+ * asking. Without it a page-limited anchor is indistinguishable from a complete read, so
534
+ * {@link readNotionComments} would report `truncated: false` over a read it knows was partial, and
535
+ * the coverage disclosure the model sees would be a false claim. A callback rather than a changed
536
+ * return type, so the eight existing call sites are untouched. */
537
+ export async function listBlockComments(
538
+ blockId: string,
539
+ opts: NotionCallOpts & { readonly maxPages?: number; readonly onPageLimit?: () => void },
540
+ ): Promise<NotionComment[]> {
541
+ const maxPages = Math.max(1, Math.trunc(opts.maxPages ?? 1));
542
+ const out: NotionComment[] = [];
543
+ let cursor: string | undefined;
544
+ for (let page = 0; page < maxPages; page++) {
545
+ const qs = new URLSearchParams({ block_id: blockId, page_size: "100" });
546
+ if (cursor) qs.set("start_cursor", cursor);
547
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments?${qs.toString()}`, {
548
+ method: "GET",
549
+ headers: notionHeaders(opts),
550
+ }, opts);
551
+ if (!res.ok) throw new Error(`listBlockComments ${blockId} failed: ${res.status} ${await safeText(res)}`);
552
+ const body = (await res.json()) as { results?: NotionComment[]; has_more?: boolean; next_cursor?: string | null };
553
+ if (Array.isArray(body.results)) out.push(...body.results);
554
+ cursor = body.has_more && body.next_cursor ? body.next_cursor : undefined;
555
+ if (!cursor) break;
556
+ // Notion has more and this was the last page the bound allows ⇒ the read is PARTIAL, and the
557
+ // caller is told rather than handed a short list that looks complete.
558
+ if (page === maxPages - 1) opts.onPageLimit?.();
559
+ }
560
+ return out;
561
+ }
562
+
563
+ // --- page comments / inline discussions read (read_notion_comments tool) --------------------
564
+ //
565
+ // The SIBLING of readNotionPage: read_notion_page returns the page BODY; this returns the page's
566
+ // COMMENTS + inline discussions. Notion anchors a discussion PER BLOCK (`parent.block_id` → a
567
+ // distinct inline discussion; page-level comments anchor on the page id itself), and there is no
568
+ // single "all comments on a page" endpoint — comments are listed per block-id. So a full sweep is
569
+ // two moves, both BOUNDED: (1) list the page's own comments (block_id = pageId), then (2) walk the
570
+ // page's top-level blocks (getBlockChildren) and list each one's comments to surface inline
571
+ // discussions. Bounded by BOTH a scanned-block cap (`maxBlockScans`) and a total-comment cap
572
+ // (`maxComments`) so a heavily-annotated page can't fan out into unbounded API calls or blow the
573
+ // context. `truncated` is true when either cap bit (or Notion still had more blocks). Every comment
574
+ // body is EXTERNAL, UNTRUSTED text (commenters include workspace guests / externals) — the caller
575
+ // (the tool's toModelOutput) MUST wrap the rendered listing through ingestUntrusted. Pure over the
576
+ // injectable fetch (no live Notion in tests).
577
+
578
+ /** One comment, flattened for the read tool. `text` is UNTRUSTED (commenter-authored); the tool
579
+ * wraps it. `authorId`/`authorKind` come off the comment's partial `created_by` (id + object,
580
+ * and `type` when Notion supplies it) — surfaced as DATA, never a trust signal. */
581
+ /** The subset of a Notion block object the domain fence reads — its `parent`, i.e. which page (or
582
+ * which enclosing block) owns it. */
583
+ export interface NotionBlockParent {
584
+ readonly id?: string;
585
+ readonly parent?: { readonly type?: string; readonly page_id?: string; readonly block_id?: string };
586
+ }
587
+
588
+ /** GET /v1/blocks/{block_id} — used ONLY to answer "which page owns this block?" for the domain
589
+ * fence (channels/notion-scope.ts). Throws on a non-2xx response. */
590
+ export async function retrieveBlock(blockId: string, opts: NotionCallOpts): Promise<NotionBlockParent> {
591
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/blocks/${encodeURIComponent(blockId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
592
+ if (!res.ok) throw new Error(`retrieveBlock ${blockId} failed: ${res.status} ${await safeText(res)}`);
593
+ return (await res.json()) as NotionBlockParent;
594
+ }
595
+
596
+ /**
597
+ * The PAGE that owns a block — walk `parent` upward until a `page_id`, or give up. The domain fence
598
+ * needs it because `readNotionComments`' `block` drill-in addresses a block DIRECTLY (it never
599
+ * touches the `pageId` argument), so without this a block id is an unfenced way into another
600
+ * domain's page. Bounded at {@link BLOCK_PARENT_MAX_HOPS} hops — the realistic case is ONE (the
601
+ * sweep only ever surfaces top-level blocks, whose parent IS the page) and an unbounded walk over
602
+ * attacker-chosen ids is its own problem.
603
+ *
604
+ * Returns `undefined` when the chain ends anywhere other than a page (a workspace/database parent),
605
+ * runs out of hops, or the lookup throws — every one of which the caller must treat as a REFUSAL,
606
+ * never as permission.
607
+ */
608
+ export const BLOCK_PARENT_MAX_HOPS = 5;
609
+ export async function resolveBlockOwningPage(blockId: string, opts: NotionCallOpts): Promise<string | undefined> {
610
+ let id = blockId;
611
+ for (let hop = 0; hop < BLOCK_PARENT_MAX_HOPS; hop++) {
612
+ let block: NotionBlockParent;
613
+ try {
614
+ block = await retrieveBlock(id, opts);
615
+ } catch {
616
+ return undefined; // an id the integration can't see resolves to nothing ⇒ the caller refuses
617
+ }
618
+ const parent = block.parent;
619
+ if (parent?.type === "page_id" && parent.page_id) return parent.page_id;
620
+ if (parent?.type === "block_id" && parent.block_id && parent.block_id !== id) {
621
+ id = parent.block_id;
622
+ continue;
623
+ }
624
+ return undefined;
625
+ }
626
+ return undefined;
627
+ }
628
+
629
+ /** The subset of a Notion PAGE object the ancestor walk reads — its `parent`, i.e. what owns it. */
630
+ export interface NotionPageParent {
631
+ readonly parent?: {
632
+ readonly type?: string;
633
+ readonly page_id?: string;
634
+ readonly block_id?: string;
635
+ readonly database_id?: string;
636
+ readonly data_source_id?: string;
637
+ readonly workspace?: boolean;
638
+ };
639
+ }
640
+
641
+ /** GET /v1/pages/{page_id} — used ONLY to answer "what owns this page?" for the ancestor walk
642
+ * (channels/notion-ancestry-trust.ts). Throws on a non-2xx response. */
643
+ export async function retrievePage(pageId: string, opts: NotionCallOpts): Promise<NotionPageParent> {
644
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/pages/${encodeURIComponent(pageId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
645
+ if (!res.ok) throw new Error(`retrievePage ${pageId} failed: ${res.status} ${await safeText(res)}`);
646
+ return (await res.json()) as NotionPageParent;
647
+ }
648
+
649
+ /**
650
+ * GET /v1/pages/{page_id}, read for the page's own DISPLAY IDENTITY — its title and its canonical
651
+ * Notion url (OPE-1714).
652
+ *
653
+ * The title is the same `properties`-by-type extraction a search hit takes ({@link notionItemTitle}),
654
+ * because a page's title property is named by whoever made the page and can only be found by type.
655
+ * The url is Notion's own `url` field, never assembled here from an id.
656
+ *
657
+ * 🔴 FAIL-SOFT, AND SEPARATE FROM THE READ IT DECORATES. A page body read must not fail because this
658
+ * extra call did, so every error answers an empty identity and the caller renders the page without a
659
+ * title or a link. Both fields are UNTRUSTED: a workspace member chooses the title.
660
+ */
661
+ export async function readNotionPageIdentity(pageId: string, opts: NotionCallOpts): Promise<{ readonly title?: string; readonly url?: string }> {
662
+ try {
663
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/pages/${encodeURIComponent(pageId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
664
+ if (!res.ok) return {};
665
+ const page = (await res.json()) as { url?: unknown; title?: unknown; properties?: unknown };
666
+ const title = notionItemTitle(page);
667
+ return {
668
+ ...(title.length > 0 ? { title } : {}),
669
+ ...(typeof page.url === "string" && page.url.length > 0 ? { url: page.url } : {}),
670
+ };
671
+ } catch {
672
+ return {};
673
+ }
674
+ }
675
+
676
+ /** Map a raw Notion `parent` object onto the closed union the ancestor walk decides over. An
677
+ * unrecognised shape maps to `unresolvable`, never to `workspace` — "we do not know what owns this"
678
+ * and "this sits at the workspace root" are different answers and only one of them ends a walk
679
+ * cleanly. `data_source_id` is the 2025-09-03 spelling of a row's database parent; both map to
680
+ * `database`, which is where the walk stops. */
681
+ export function toNotionParentLink(parent: NotionPageParent["parent"]): NotionParentLink {
682
+ if (!parent) return { kind: "unresolvable" };
683
+ if (parent.type === "page_id" && parent.page_id) return { kind: "page", pageId: parent.page_id };
684
+ if (parent.type === "block_id" && parent.block_id) return { kind: "block", blockId: parent.block_id };
685
+ if (parent.type === "database_id" && parent.database_id) return { kind: "database", databaseId: parent.database_id };
686
+ if (parent.type === "data_source_id" && parent.data_source_id) return { kind: "database", databaseId: parent.data_source_id };
687
+ if (parent.type === "workspace") return { kind: "workspace" };
688
+ return { kind: "unresolvable" };
689
+ }
690
+
691
+ /**
692
+ * What owns `id` — the live `parentOf` the ancestor walk injects (channels/notion-ancestry-trust.ts).
693
+ *
694
+ * A PAGE first (`GET /v1/pages/{id}`), because the walk starts on a page id and every level above a
695
+ * nested page is a page too. A page id that Notion will not serve as a page falls back to the BLOCK
696
+ * endpoint, which is how a page nested inside a column or a toggle keeps its chain walkable. Both
697
+ * failing is `unresolvable`, which the walk treats as a refusal — this function NEVER throws, so a
698
+ * Notion outage degrades to "not trusted" instead of to an exception on the ingestion path.
699
+ */
700
+ export async function notionParentLink(id: string, opts: NotionCallOpts): Promise<NotionParentLink> {
701
+ try {
702
+ return toNotionParentLink((await retrievePage(id, opts)).parent);
703
+ } catch {
704
+ // fall through to the block endpoint
705
+ }
706
+ try {
707
+ return toNotionParentLink((await retrieveBlock(id, opts)).parent);
708
+ } catch {
709
+ return { kind: "unresolvable" };
710
+ }
711
+ }
712
+
713
+ export interface NotionCommentSummary {
714
+ /**
715
+ * The comment's OWN Notion id. 🔴 LOAD-BEARING FOR THE SEAT CATCH-UP (OPE-1847): the wake route
716
+ * passes the trigger's comment id as `SeatCatchUpRequest.excludeId`, and removing the wake's own
717
+ * message from its catch-up window is done BY ID and never by time — on a surface that floors
718
+ * `created_time` to the minute, the trigger shares its instant with the comments the seat is
719
+ * reading for. Absent only when Notion served a comment without one.
720
+ */
721
+ readonly id?: string;
722
+ readonly discussionId?: string;
723
+ /** The block (or page) the discussion anchors to — distinct inline discussions differ here. */
724
+ readonly anchorId?: string;
725
+ /**
726
+ * The plain text of the ANCHOR BLOCK, when the sweep read the block object that carries it
727
+ * (OPE-988). It is what a Notion discussion is ABOUT, and the generic `read_channel` / `read_thread`
728
+ * render it as the discussion's topic — without it a list of discussions is a list of ids.
729
+ *
730
+ * ABSENT for a page-level comment (the anchor is the page, which has no block text) and for the
731
+ * `block` drill-in (which addresses a block by id and never reads the block object). Absence means
732
+ * "no anchor text was read", never "the block is empty".
733
+ */
734
+ readonly anchorText?: string;
735
+ readonly authorId?: string;
736
+ readonly authorKind?: string;
737
+ readonly createdTime?: string;
738
+ readonly text: string;
739
+ }
740
+
741
+ /** Flatten ONE raw comment into the read shape. Exported (OPE-988) so the read adapter's cheap
742
+ * page-level path maps a `listBlockComments` result the SAME way the sweep does — two spellings of
743
+ * one flatten is how an author field goes missing on one path only. */
744
+ export function toNotionCommentSummary(c: NotionComment, fallbackAnchor: string, anchorText?: string): NotionCommentSummary {
745
+ return {
746
+ ...(c.id === undefined ? {} : { id: c.id }),
747
+ discussionId: c.discussion_id,
748
+ anchorId: c.parent?.block_id ?? c.parent?.page_id ?? fallbackAnchor,
749
+ ...(anchorText !== undefined && anchorText.length > 0 ? { anchorText } : {}),
750
+ authorId: c.created_by?.id,
751
+ authorKind: c.created_by?.type,
752
+ createdTime: c.created_time,
753
+ text: richTextToPlainText(c.rich_text),
754
+ };
755
+ }
756
+
757
+ /**
758
+ * Read a Notion page's comments + inline discussions, bounded. When `block` is given, reads ONLY
759
+ * that block's discussion (one list call — the cheap drill-in). Otherwise sweeps the page's own
760
+ * comments then walks its top-level blocks (≤ `maxBlockScans`, default 80) listing each block's
761
+ * comments, stopping once `maxComments` (default 100) is collected. `blockScanOrder` decides WHICH
762
+ * `maxBlockScans` blocks a page past that cap spends the budget on — the page's first by default,
763
+ * its last under `"newest-first"` (OPE-1843). Returns the flattened comments
764
+ * (oldest-first per anchor), the distinct-discussion count, the number of blocks scanned, and
765
+ * `truncated` (a cap bit, or Notion still had more blocks). Comment bodies are UNTRUSTED — the
766
+ * caller wraps them. Pure over the injectable fetch.
767
+ */
768
+ export async function readNotionComments(
769
+ pageId: string,
770
+ opts: NotionCallOpts & {
771
+ readonly block?: string;
772
+ readonly maxComments?: number;
773
+ readonly maxBlockScans?: number;
774
+ /**
775
+ * Epoch ms of the OLDEST comment worth collecting. A comment older than this — or one Notion gave
776
+ * no readable `created_time` for — is skipped BEFORE the `maxComments` cap is charged.
777
+ * 🔴 THE ORDER OF THOSE TWO IS THE WHOLE POINT (OPE-1831). Notion serves comments OLDEST-FIRST,
778
+ * so a cap that counts every comment it walks past fills with the anchor's most ANCIENT ones and
779
+ * then reports `truncated` — the exact opposite of what a "since the seat last woke" read needs.
780
+ * Filtering first makes the cap count comments IN the window. Absent ⇒ no time filter, which is
781
+ * the whole-history read every existing caller already makes.
782
+ */
783
+ readonly sinceMs?: number;
784
+ /** Pages of comments to follow per anchor ({@link listBlockComments}). Default 1 — one call. */
785
+ readonly maxCommentPages?: number;
786
+ /**
787
+ * Which END of the page's top-level blocks the `maxBlockScans` budget is spent on (OPE-1843).
788
+ *
789
+ * `"document"` (the default, and what every caller got before this option existed) scans from
790
+ * the TOP of the page and stops at the cap. `"newest-first"` lists the children first, then
791
+ * spends the same budget on the LAST ones, newest block first.
792
+ *
793
+ * 🔴 A CATCH-UP READ WANTS THE OTHER END, AND THE DEFAULT ORDER MADE IT BLIND. Notion serves a
794
+ * page's children in document order and a page GROWS BY APPENDING, so on a page past
795
+ * `maxBlockScans` top-level blocks the budget is spent entirely on the page's oldest material
796
+ * and a discussion anchored on a recently appended block is never scanned. The sweep then
797
+ * returns zero comments with no error — the same answer a genuinely quiet page gives. That is
798
+ * the `J-ENGAGED-notion` shape: the journey appends a paragraph block per run and opens a
799
+ * BLOCK-anchored discussion on it (`core/scripts/e2e/scenarios.ts`), and a page-id comment
800
+ * query never returns a child block's comments, so the block walk is the only code that can
801
+ * find it.
802
+ *
803
+ * 🔴 IT MOVES THE BLIND SPOT, IT DOES NOT REMOVE IT, AND SAYING OTHERWISE WOULD BE A FALSE
804
+ * CLAIM. A comment posted TODAY on an ANCIENT block of a page past the cap is unread under
805
+ * `"newest-first"` and was read under `"document"`. Notion offers no "blocks with recent
806
+ * comments" query and a comment does not touch its block's `last_edited_time`, so no order is
807
+ * complete and only raising the cap would be — at one list call per block, against the shared
808
+ * 60 s-per-call 429 budget. `newest-first` is chosen for the CATCH-UP because the growth shape
809
+ * that crosses the cap at all is append-only, so the newest blocks are where the live
810
+ * conversation is. `truncated` still reports the read as partial either way.
811
+ *
812
+ * A page whose children fit inside `maxBlockScans` scans exactly the same blocks under both
813
+ * orders, so only an over-cap page pays or gains anything here.
814
+ */
815
+ readonly blockScanOrder?: "document" | "newest-first";
816
+ /**
817
+ * Pages of block children to list when `blockScanOrder` is `"newest-first"`. Default 10 — a
818
+ * thousand top-level blocks, because finding the LAST child means paging to the end.
819
+ * A page longer than this is scanned from the end of what was listed and reports `truncated`.
820
+ * Cheap next to the scan itself: one `getBlockChildren` call per hundred blocks against one
821
+ * `listBlockComments` call per SCANNED block.
822
+ */
823
+ readonly maxChildPages?: number;
824
+ },
825
+ ): Promise<{ readonly comments: NotionCommentSummary[]; readonly discussionCount: number; readonly blocksScanned: number; readonly truncated: boolean }> {
826
+ const maxComments = opts.maxComments ?? 100;
827
+ const maxBlockScans = opts.maxBlockScans ?? 80;
828
+ const comments: NotionCommentSummary[] = [];
829
+ const discussions = new Set<string>();
830
+ let truncated = false;
831
+ let blocksScanned = 0;
832
+ const listOpts = {
833
+ ...opts,
834
+ ...(opts.maxCommentPages === undefined ? {} : { maxPages: opts.maxCommentPages }),
835
+ // An anchor the page bound cut off makes the WHOLE read partial — `truncated` is the coverage
836
+ // disclosure `read_notion_comments` shows the model, and a cut-off anchor reported as complete
837
+ // would be a false claim about what the sweep saw.
838
+ onPageLimit: () => {
839
+ truncated = true;
840
+ },
841
+ };
842
+
843
+ /** Whether this comment is inside the caller's time window. No window ⇒ every comment is. */
844
+ const inWindow = (c: NotionComment): boolean => {
845
+ if (opts.sinceMs === undefined) return true;
846
+ const ms = Date.parse(c.created_time ?? "");
847
+ return Number.isFinite(ms) && ms >= opts.sinceMs;
848
+ };
849
+
850
+ const take = (raw: NotionComment[], anchor: string, anchorText?: string): boolean => {
851
+ // Returns true if the caller should STOP (comment cap hit).
852
+ for (const c of raw) {
853
+ if (!inWindow(c)) continue;
854
+ if (comments.length >= maxComments) {
855
+ truncated = true;
856
+ return true;
857
+ }
858
+ comments.push(toNotionCommentSummary(c, anchor, anchorText));
859
+ if (typeof c.discussion_id === "string" && c.discussion_id.length > 0) discussions.add(c.discussion_id);
860
+ }
861
+ return false;
862
+ };
863
+
864
+ // Drill-in: a single block's inline discussion.
865
+ if (opts.block) {
866
+ take(await listBlockComments(opts.block, listOpts), opts.block);
867
+ return { comments, discussionCount: discussions.size, blocksScanned: 1, truncated };
868
+ }
869
+
870
+ // Full sweep: page-level comments first, then per-block inline discussions.
871
+ if (take(await listBlockComments(pageId, listOpts), pageId)) {
872
+ return { comments, discussionCount: discussions.size, blocksScanned, truncated };
873
+ }
874
+
875
+ // NEWEST-FIRST: list the children to the end (bounded), then spend the block budget on the LAST
876
+ // ones. Separate from the streaming walk below on purpose — that one cannot know which block is
877
+ // last until it has paged past it, which is the whole reason the budget used to be spent on the
878
+ // page's oldest material. See `blockScanOrder`.
879
+ if (opts.blockScanOrder === "newest-first") {
880
+ const maxChildPages = opts.maxChildPages ?? 10;
881
+ const children: NotionBlock[] = [];
882
+ let childCursor: string | undefined;
883
+ let pagesRead = 0;
884
+ do {
885
+ const { blocks, nextCursor } = await getBlockChildren(pageId, { ...opts, startCursor: childCursor });
886
+ children.push(...blocks);
887
+ pagesRead += 1;
888
+ childCursor = nextCursor && pagesRead < maxChildPages ? nextCursor : undefined;
889
+ // More children than we listed ⇒ the "last block" we scan is not the page's last, so the read
890
+ // is partial however the budget is then spent.
891
+ if (nextCursor && childCursor === undefined) truncated = true;
892
+ } while (childCursor);
893
+ if (children.length > maxBlockScans) truncated = true;
894
+ const tail = children.slice(Math.max(0, children.length - maxBlockScans));
895
+ for (let i = tail.length - 1; i >= 0; i--) {
896
+ const b = tail[i]!;
897
+ blocksScanned += 1;
898
+ const id = typeof b.id === "string" ? b.id : undefined;
899
+ if (!id) continue;
900
+ if (take(await listBlockComments(id, listOpts), id, blockPlainText(b))) break;
901
+ }
902
+ return { comments, discussionCount: discussions.size, blocksScanned, truncated };
903
+ }
904
+
905
+ let cursor: string | undefined;
906
+ outer: do {
907
+ const { blocks, nextCursor } = await getBlockChildren(pageId, { ...opts, startCursor: cursor });
908
+ for (const b of blocks) {
909
+ if (blocksScanned >= maxBlockScans) {
910
+ truncated = true;
911
+ break outer;
912
+ }
913
+ blocksScanned += 1;
914
+ const id = typeof b.id === "string" ? b.id : undefined;
915
+ // Only blocks Notion flags as comment-bearing are worth a list call — `has_children` does NOT
916
+ // signal comments, so we probe every block, but cheaply: a comment-free block returns [].
917
+ if (!id) continue;
918
+ // The block's own text rides with its comments (OPE-988) — the anchor snippet the generic read
919
+ // verbs render as a discussion's topic. It is free here: the sweep already holds the block.
920
+ if (take(await listBlockComments(id, listOpts), id, blockPlainText(b))) break outer;
921
+ }
922
+ // More blocks we won't scan (cap or simply more) ⇒ partial read.
923
+ if (nextCursor && blocksScanned < maxBlockScans) {
924
+ cursor = nextCursor;
925
+ } else {
926
+ if (nextCursor) truncated = true;
927
+ cursor = undefined;
928
+ }
929
+ } while (cursor);
930
+
931
+ return { comments, discussionCount: discussions.size, blocksScanned, truncated };
932
+ }
933
+
934
+ // --- page body read (read_notion_page tool, ambient-surface design Step 0) -----------
935
+ //
936
+ // The doc BODY is read BY REFERENCE (chat-vs-ticketing-and-tool-uniformity.md rec #3): it is
937
+ // NEVER inlined into the wake (one big doc would blow the whole backscroll budget — the
938
+ // CEIL_BYTES lesson). The agent PULLS it with read_notion_page only when a reply needs the doc's
939
+ // prose. These helpers are the pure, injectable-fetch core behind that tool: a paginated
940
+ // block-children fetch + a rich-text flatten, bounded by a block count + a total-char cap.
941
+
942
+ /** One block as returned by GET /v1/blocks/{id}/children. Loosely typed — the per-type
943
+ * `rich_text` array is probed defensively (a mis-shaped block never throws). */
944
+ export interface NotionBlock {
945
+ readonly id?: string;
946
+ readonly type?: string;
947
+ readonly has_children?: boolean;
948
+ // Each block type nests its content under a key equal to its `type` (paragraph, heading_1, …);
949
+ // most text-bearing types carry a `rich_text` array there. Indexed access, probed by type.
950
+ readonly [key: string]: unknown;
951
+ }
952
+
953
+ /** Flatten ONE block to plain text: read the `rich_text` array nested under the block's own type
954
+ * key (`block[block.type].rich_text`) — the shape every text-bearing Notion block uses — and
955
+ * concatenate its `plain_text`. Returns "" for a block with no readable text (a divider, image,
956
+ * etc.), so a page of only such blocks flattens to empty rather than throwing. */
957
+ export function blockPlainText(block: NotionBlock): string {
958
+ const t = typeof block.type === "string" ? block.type : undefined;
959
+ if (!t) return "";
960
+ const inner = block[t];
961
+ if (!inner || typeof inner !== "object") return "";
962
+ const rich = (inner as { rich_text?: unknown }).rich_text;
963
+ return richTextToPlainText(Array.isArray(rich) ? (rich as NotionRichText[]) : undefined);
964
+ }
965
+
966
+ /** GET /v1/blocks/{block_id}/children — one page of a block's children (top-level only; nested
967
+ * children are NOT recursed here — the tool reads the doc's own top-level blocks, which is what
968
+ * a comment usually anchors to). Returns the results array + the next cursor (undefined when
969
+ * there are no more). Throws on a non-2xx response. */
970
+ export async function getBlockChildren(
971
+ blockId: string,
972
+ opts: NotionCallOpts & { readonly startCursor?: string; readonly pageSize?: number },
973
+ ): Promise<{ readonly blocks: NotionBlock[]; readonly nextCursor?: string }> {
974
+ const qs = new URLSearchParams({ page_size: String(opts.pageSize ?? 100) });
975
+ if (opts.startCursor) qs.set("start_cursor", opts.startCursor);
976
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/blocks/${encodeURIComponent(blockId)}/children?${qs.toString()}`, {
977
+ method: "GET",
978
+ headers: notionHeaders(opts),
979
+ }, opts);
980
+ if (!res.ok) throw new Error(`getBlockChildren ${blockId} failed: ${res.status} ${await safeText(res)}`);
981
+ const body = (await res.json()) as { results?: NotionBlock[]; has_more?: boolean; next_cursor?: string | null };
982
+ return {
983
+ blocks: Array.isArray(body.results) ? body.results : [],
984
+ nextCursor: body.has_more && body.next_cursor ? body.next_cursor : undefined,
985
+ };
986
+ }
987
+
988
+ /** Read a Notion page's top-level text, paginating block-children and flattening each block's
989
+ * rich-text, bounded by BOTH a block count (`maxBlocks`, default 200) and a total-char cap
990
+ * (`maxChars`, default 20,000 — well under the wake backscroll budget so the model can hold it).
991
+ * `truncated` is true when either cap was hit or Notion still had more blocks — so the agent
992
+ * knows the read is partial. Pure over the injectable fetch (no live Notion in tests). */
993
+ export async function readNotionPage(
994
+ pageId: string,
995
+ opts: NotionCallOpts & { readonly maxBlocks?: number; readonly maxChars?: number },
996
+ ): Promise<{ readonly text: string; readonly blockCount: number; readonly truncated: boolean }> {
997
+ const maxBlocks = opts.maxBlocks ?? 200;
998
+ const maxChars = opts.maxChars ?? 20_000;
999
+ const lines: string[] = [];
1000
+ let chars = 0;
1001
+ let blockCount = 0;
1002
+ let truncated = false;
1003
+ let cursor: string | undefined;
1004
+ do {
1005
+ const { blocks, nextCursor } = await getBlockChildren(pageId, { ...opts, startCursor: cursor });
1006
+ let stop = false;
1007
+ for (const b of blocks) {
1008
+ if (blockCount >= maxBlocks) {
1009
+ truncated = true;
1010
+ stop = true;
1011
+ break;
1012
+ }
1013
+ blockCount += 1;
1014
+ const text = blockPlainText(b);
1015
+ if (!text) continue;
1016
+ if (chars + text.length > maxChars && lines.length > 0) {
1017
+ truncated = true;
1018
+ stop = true;
1019
+ break;
1020
+ }
1021
+ chars += text.length;
1022
+ lines.push(text);
1023
+ }
1024
+ // A remaining page we won't read (either we stopped on a cap, or there simply is more) means
1025
+ // the read is partial — mark truncated and stop paginating.
1026
+ if (stop) {
1027
+ cursor = undefined;
1028
+ } else if (nextCursor) {
1029
+ cursor = nextCursor;
1030
+ } else {
1031
+ cursor = undefined;
1032
+ }
1033
+ } while (cursor);
1034
+ return { text: lines.join("\n"), blockCount, truncated };
1035
+ }
1036
+
1037
+ /**
1038
+ * Notion's own per-comment attachment cap (developers.notion.com/reference/create-a-comment,
1039
+ * confirmed live 2026-08-23: "attachments — Array of file objects — Maximum entries: 3"). Exported
1040
+ * so {@link notionMessageOps.postFiles} (notion-message-ops.ts) can refuse a too-large batch BEFORE
1041
+ * uploading a single byte, rather than uploading four files and discarding the fourth's upload.
1042
+ */
1043
+ export const NOTION_MAX_ATTACHMENTS = 3;
1044
+
1045
+ /** Build the `attachments` field of a create-comment body from completed `file_upload` ids, or `{}`
1046
+ * when there are none (so a spread `...attachmentsBody(...)` never adds an empty array to a plain
1047
+ * text comment). Throws over the cap — the same fail-loud posture as `MAX_FILE_BYTES` in
1048
+ * `post_file.ts`: a caller that hands too many ids gets a clear reason, never a silently truncated
1049
+ * batch or a 400 from Notion with no context. */
1050
+ function attachmentsBody(fileUploadIds: readonly string[] | undefined): { attachments: Array<{ file_upload_id: string; type: "file_upload" }> } | Record<string, never> {
1051
+ if (!fileUploadIds || fileUploadIds.length === 0) return {};
1052
+ if (fileUploadIds.length > NOTION_MAX_ATTACHMENTS) {
1053
+ throw new Error(`Notion comments accept at most ${NOTION_MAX_ATTACHMENTS} attachments; got ${fileUploadIds.length}.`);
1054
+ }
1055
+ return { attachments: fileUploadIds.map((id) => ({ file_upload_id: id, type: "file_upload" as const })) };
1056
+ }
1057
+
1058
+ /** POST /v1/comments — reply into the SAME discussion thread. Notion caps a single rich-text
1059
+ * item at 2000 chars, so the reply is chunked across items (≤100). Throws on non-2xx. Returns
1060
+ * the created comment's id (Notion echoes the comment object) so the mid-turn `reply` tool can
1061
+ * hand it back — empty string if the response carries none. Notion DOES expose an update verb
1062
+ * now (`PATCH /v1/comments/{id}`, {@link updateComment}) — the reply tool itself does not wire to
1063
+ * it (that is `edit_reply`'s seam, `channels/post-arms.ts`'s `editMessage`, a separate decision
1064
+ * with its own scope). `attachments`, when given, names completed `file_upload` ids
1065
+ * ({@link uploadNotionFile}) — see {@link attachmentsBody} for the cap. */
1066
+ export async function createCommentReply(
1067
+ args: { discussionId: string; text: string; attachments?: readonly string[] } & NotionCallOpts,
1068
+ ): Promise<string> {
1069
+ const attach = attachmentsBody(args.attachments);
1070
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments`, {
1071
+ method: "POST",
1072
+ headers: notionHeaders(Object.keys(attach).length > 0 ? { ...args, version: NOTION_FILE_UPLOAD_VERSION } : args),
1073
+ body: JSON.stringify({ discussion_id: args.discussionId, rich_text: richTextChunks(args.text), ...attach }),
1074
+ }, args);
1075
+ if (!res.ok) {
1076
+ // The numeric HTTP status rides on the error (OPE-825), so the reply/unified post spines can
1077
+ // classify a failed post here the same way they already do for Slack/Discord/Teams
1078
+ // (channels/platform-failure.ts) — a number only, never the response body or a token.
1079
+ throw Object.assign(new Error(`createCommentReply failed: ${res.status} ${await safeText(res)}`), { status: res.status });
1080
+ }
1081
+ const body = (await res.json().catch(() => ({}))) as { id?: unknown };
1082
+ return typeof body.id === "string" ? body.id : "";
1083
+ }
1084
+
1085
+ /** POST /v1/comments — start a FRESH top-level comment (a new discussion) on a PAGE, rather than
1086
+ * replying into an existing one. This is what {@link routeNotionStopComment}'s acknowledgement and
1087
+ * every other host-driven, no-discussion-in-hand notice (`notion-message-ops.ts`'s `postText`/
1088
+ * `postFiles`) use: they are handed a PAGE address, never a discussion id. Same chunking/error
1089
+ * shape and `attachments` handling as {@link createCommentReply}. */
1090
+ export async function createPageComment(
1091
+ args: { pageId: string; text: string; attachments?: readonly string[] } & NotionCallOpts,
1092
+ ): Promise<string> {
1093
+ const attach = attachmentsBody(args.attachments);
1094
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments`, {
1095
+ method: "POST",
1096
+ headers: notionHeaders(Object.keys(attach).length > 0 ? { ...args, version: NOTION_FILE_UPLOAD_VERSION } : args),
1097
+ body: JSON.stringify({ parent: { page_id: args.pageId }, rich_text: richTextChunks(args.text), ...attach }),
1098
+ }, args);
1099
+ if (!res.ok) {
1100
+ throw Object.assign(new Error(`createPageComment failed: ${res.status} ${await safeText(res)}`), { status: res.status });
1101
+ }
1102
+ const body = (await res.json().catch(() => ({}))) as { id?: unknown };
1103
+ return typeof body.id === "string" ? body.id : "";
1104
+ }
1105
+
1106
+ /** PATCH /v1/comments/{comment_id} — rewrite a comment THIS integration created, in place
1107
+ * (developers.notion.com/reference/update-a-comment, confirmed live 2026-08-23: the endpoint takes
1108
+ * exactly one of `rich_text`/`markdown`, and 404s on a comment the calling connection did not
1109
+ * create). Chunked the same way `richTextChunks` chunks a create. Throws on non-2xx, carrying the
1110
+ * numeric status the same way every other write in this module does. */
1111
+ export async function updateComment(
1112
+ args: { commentId: string; text: string } & NotionCallOpts,
1113
+ ): Promise<void> {
1114
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/comments/${encodeURIComponent(args.commentId)}`, {
1115
+ method: "PATCH",
1116
+ headers: notionHeaders(args),
1117
+ body: JSON.stringify({ rich_text: richTextChunks(args.text) }),
1118
+ }, args);
1119
+ if (!res.ok) {
1120
+ throw Object.assign(new Error(`updateComment ${args.commentId} failed: ${res.status} ${await safeText(res)}`), { status: res.status });
1121
+ }
1122
+ }
1123
+
1124
+ // --- file uploads (OPE-1047 — the transport a comment's `attachments` field needs) ---------
1125
+ //
1126
+ // Notion's attach-a-file-to-a-comment flow is TWO REST calls, not one (developers.notion.com/
1127
+ // reference/create-a-file-upload + .../send-file-upload, both confirmed live 2026-08-23):
1128
+ // 1. createFileUpload — reserve an upload slot, get back an `id` + `upload_url`
1129
+ // 2. sendFileUploadBytes — POST the bytes to that `upload_url` as multipart/form-data
1130
+ // Only once step 2 succeeds does the object reach `status: "uploaded"`, the state a comment's
1131
+ // `attachments[].file_upload_id` may reference (attachmentsBody, above createCommentReply). Both
1132
+ // calls are size-bounded to Notion's SINGLE-PART limit (20 MB) — this module never attempts the
1133
+ // multi-part flow Notion also documents for larger files, matching post_file.ts's own 10 MB
1134
+ // per-file ceiling (well under 20 MB, so no caller of uploadNotionFile can exceed it).
1135
+
1136
+ /** One Notion file-upload object, as `POST /v1/file_uploads` (step 1) and `.../send` (step 2)
1137
+ * return it. Loosely typed — only the fields this module reads. */
1138
+ interface NotionFileUploadObject {
1139
+ readonly id?: string;
1140
+ readonly upload_url?: string;
1141
+ readonly status?: string;
1142
+ }
1143
+
1144
+ /**
1145
+ * POST /v1/file_uploads — step 1: reserve an upload slot for one small (≤20 MB, single-part) file.
1146
+ * Returns the object's `id` (what a comment's `attachments` entry references, once uploaded) and the
1147
+ * `upload_url` step 2 posts bytes to (the SAME host, `api.notion.com`, so it rides the credential
1148
+ * fence like every other call here). Throws on non-2xx, or a response missing either field —
1149
+ * never invents one.
1150
+ */
1151
+ export async function createFileUpload(
1152
+ args: { readonly filename: string; readonly contentType: string } & NotionCallOpts,
1153
+ ): Promise<{ readonly id: string; readonly uploadUrl: string }> {
1154
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/file_uploads`, {
1155
+ method: "POST",
1156
+ headers: notionHeaders({ ...args, version: NOTION_FILE_UPLOAD_VERSION }),
1157
+ body: JSON.stringify({ filename: args.filename, content_type: args.contentType }),
1158
+ }, args);
1159
+ if (!res.ok) throw new Error(`createFileUpload failed: ${res.status} ${await safeText(res)}`);
1160
+ const body = (await res.json().catch(() => ({}))) as NotionFileUploadObject;
1161
+ if (typeof body.id !== "string" || body.id.length === 0 || typeof body.upload_url !== "string" || body.upload_url.length === 0) {
1162
+ throw new Error("createFileUpload: Notion response carried no usable id/upload_url.");
1163
+ }
1164
+ return { id: body.id, uploadUrl: body.upload_url };
1165
+ }
1166
+
1167
+ /**
1168
+ * POST {uploadUrl} — step 2: send the file's bytes as `multipart/form-data`, field name `file`
1169
+ * (the shape Notion's send-file-upload endpoint requires). No explicit content-type header — like
1170
+ * Discord's `postFiles` (discord-message-ops.ts), `fetch` derives the multipart boundary from the
1171
+ * FormData body itself; setting one by hand would omit the boundary and the upload would 400.
1172
+ * Throws on non-2xx.
1173
+ */
1174
+ export async function sendFileUploadBytes(
1175
+ args: { readonly uploadUrl: string; readonly filename: string; readonly contentType: string; readonly bytes: Uint8Array } & NotionCallOpts,
1176
+ ): Promise<void> {
1177
+ const form = new FormData();
1178
+ // A fresh ArrayBuffer copy so the Blob owns contiguous bytes regardless of the source view
1179
+ // (mirrors discord-message-ops.ts's postFiles — the same reason: a Uint8Array over a larger
1180
+ // backing buffer must not leak neighbouring bytes into the multipart body).
1181
+ const ab = args.bytes.buffer.slice(args.bytes.byteOffset, args.bytes.byteOffset + args.bytes.byteLength) as ArrayBuffer;
1182
+ form.append("file", new Blob([ab], { type: args.contentType }), args.filename);
1183
+ const res = await notionFetch(args.uploadUrl, {
1184
+ method: "POST",
1185
+ headers: { authorization: `Bearer ${args.apiKey}`, "notion-version": NOTION_FILE_UPLOAD_VERSION },
1186
+ body: form,
1187
+ }, args);
1188
+ if (!res.ok) throw new Error(`sendFileUploadBytes failed: ${res.status} ${await safeText(res)}`);
1189
+ }
1190
+
1191
+ /**
1192
+ * The whole two-step upload (create + send), returning the `file_upload` id — ready to attach to a
1193
+ * comment via {@link createCommentReply}/{@link createPageComment}'s `attachments` argument. ONE
1194
+ * call so a caller (notion-message-ops.ts's `postFiles`) never has to remember the two-step order
1195
+ * or thread the intermediate `upload_url` itself.
1196
+ */
1197
+ export async function uploadNotionFile(
1198
+ args: { readonly filename: string; readonly contentType: string; readonly bytes: Uint8Array } & NotionCallOpts,
1199
+ ): Promise<string> {
1200
+ const { id, uploadUrl } = await createFileUpload(args);
1201
+ await sendFileUploadBytes({ ...args, uploadUrl });
1202
+ return id;
1203
+ }
1204
+
1205
+ /** Split reply text into Notion rich-text items, each ≤2000 chars (Notion's per-item cap),
1206
+ * up to 100 items (Notion's per-comment cap). Exported for the test. */
1207
+ export function richTextChunks(text: string): Array<{ type: "text"; text: { content: string } }> {
1208
+ const MAX_ITEM = 2000;
1209
+ const MAX_ITEMS = 100;
1210
+ const body = text.length > 0 ? text : "(no content)";
1211
+ const chunks: Array<{ type: "text"; text: { content: string } }> = [];
1212
+ for (let i = 0; i < body.length && chunks.length < MAX_ITEMS; i += MAX_ITEM) {
1213
+ chunks.push({ type: "text", text: { content: body.slice(i, i + MAX_ITEM) } });
1214
+ }
1215
+ return chunks;
1216
+ }
1217
+
1218
+
1219
+ /** One hit from {@link searchNotion} — ids and a title, never page CONTENT. */
1220
+ export interface NotionSearchHit {
1221
+ readonly id: string;
1222
+ /** "page" | "database", as Notion reports it. */
1223
+ readonly object: string;
1224
+ /** The item's title in plain text, or "" when Notion returns none (an untitled page). */
1225
+ readonly title: string;
1226
+ /** Notion's own URL for the item, when it returns one. */
1227
+ readonly url?: string;
1228
+ }
1229
+
1230
+ /**
1231
+ * Search the workspace the credential is shared into (`POST /v1/search`), the WORK-plane read
1232
+ * (OPE-913).
1233
+ *
1234
+ * 🔴 THE CREDENTIAL IS THE BOUNDARY, AND OE ADDS NO SECOND ONE. Tommy ruled this on 2026-08-21:
1235
+ * "scope can only be controlled at the CREDENTIAL level; the vendor exposes one search tool and we
1236
+ * just use it. OE never filters search results as a boundary (a post-hoc filter is not
1237
+ * enforcement)." Notion enforces it properly — an internal integration sees only the page trees a
1238
+ * human shared with it — so a domain that wants a narrow search shares a narrow integration. Do NOT
1239
+ * add a domain filter over these results: it would read as a boundary while being none.
1240
+ *
1241
+ * Returns ids + titles only. A caller that wants a body reads the page through the fenced page read.
1242
+ */
1243
+ export async function searchNotion(
1244
+ query: string,
1245
+ opts: NotionCallOpts & { readonly pageSize?: number; readonly filter?: "page" | "database" },
1246
+ ): Promise<readonly NotionSearchHit[]> {
1247
+ const body: Record<string, unknown> = { page_size: Math.min(Math.max(opts.pageSize ?? 10, 1), 100) };
1248
+ if (query.length > 0) body.query = query;
1249
+ if (opts.filter) body.filter = { property: "object", value: opts.filter };
1250
+ const res = await notionFetch(`${NOTION_API_BASE}/v1/search`, {
1251
+ method: "POST",
1252
+ headers: notionHeaders(opts),
1253
+ body: JSON.stringify(body),
1254
+ }, opts);
1255
+ if (!res.ok) throw new Error(`notion search failed: ${res.status} ${await safeText(res)}`);
1256
+ const parsed = (await res.json()) as { results?: unknown };
1257
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
1258
+ const hits: NotionSearchHit[] = [];
1259
+ for (const raw of results) {
1260
+ const item = raw as { id?: unknown; object?: unknown; url?: unknown; title?: unknown; properties?: unknown };
1261
+ if (typeof item.id !== "string" || item.id.length === 0) continue;
1262
+ hits.push({
1263
+ id: item.id,
1264
+ object: typeof item.object === "string" ? item.object : "unknown",
1265
+ title: notionItemTitle(item),
1266
+ ...(typeof item.url === "string" ? { url: item.url } : {}),
1267
+ });
1268
+ }
1269
+ return hits;
1270
+ }
1271
+
1272
+ /** The plain-text title of a search hit. A DATABASE carries `title` directly; a PAGE carries it
1273
+ * under whichever `properties` entry has `type: "title"` (the name of that property is the user's
1274
+ * choice, so it must be found by type, never by a guessed key like "Name"). */
1275
+ function notionItemTitle(item: { readonly title?: unknown; readonly properties?: unknown }): string {
1276
+ if (Array.isArray(item.title)) return richTextToPlainText(item.title as NotionRichText[]);
1277
+ const props = item.properties as Record<string, { type?: unknown; title?: unknown }> | undefined;
1278
+ for (const value of Object.values(props ?? {})) {
1279
+ if (value?.type === "title" && Array.isArray(value.title)) return richTextToPlainText(value.title as NotionRichText[]);
1280
+ }
1281
+ return "";
1282
+ }
1283
+
1284
+ async function safeText(res: Response): Promise<string> {
1285
+ try {
1286
+ return (await res.text()).slice(0, 300);
1287
+ } catch {
1288
+ return "";
1289
+ }
1290
+ }