@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,655 @@
1
+ import { type CredentialEndpointRules, type RateLimitRetryOptions } from "@open-everywhere/core/agent/adapter-api.js";
2
+ import type { NotionParentLink } from "./notion-ancestry-trust.js";
3
+ /** Notion REST base + the API version header value (override via NOTION_VERSION). The
4
+ * default is a stable version that supports the comment retrieve/create endpoints and
5
+ * rich-text `mention` objects — everything this surface needs. */
6
+ export declare const NOTION_API_BASE = "https://api.notion.com";
7
+ /**
8
+ * Resolve the Notion CHANNEL-PLUMBING API key through the credential BROKER, so the notion
9
+ * `channel-key` credential's custody stamp — its `broker` + `secretRef` (agent/access/providers.ts) —
10
+ * governs every host-side communication-channel REST path (reply posts, the webhook two-step,
11
+ * page/comment reads), not a hard-coded env pin. Looks the credential up in the registered provider
12
+ * set (access/providers.ts findConnectorCredential, populated at boot from the app's provider list) and
13
+ * resolves it via `getSecretVia(broker, secretRef)`. There is no selected default: an api-key
14
+ * credential always carries its mandatory `broker` stamp, and an absent broker would fail closed
15
+ * (caught below ⇒ undefined).
16
+ *
17
+ * CHANNEL vs WORK-SURFACE SPLIT (decision, Tommy 2026-07-21): the notion provider carries TWO api-key
18
+ * credentials. `channel-key` (secretRef `db:notion:channel-key`, broker `db` — OPE-408 step 2) is
19
+ * this communication-channel plumbing — provisioned by the operator via `oe secret set
20
+ * db:notion:channel-key`. It is a DISTINCT ref from `team-key` (secretRef `db:notion:team-key`), the
21
+ * WORK-SURFACE credential (the notion-rest egress connection). Even if an operator stores both refs
22
+ * with the same value, they are separate credentials with separate custody. There is NO fallback
23
+ * between them: if `db:notion:channel-key` has no stored value, this resolves undefined and channel
24
+ * Notion fails closed exactly as an unset value does today.
25
+ *
26
+ * PER-DOMAIN OVERRIDE (OPE-467). `credentialId` selects WHICH notion channel credential to resolve
27
+ * and defaults to the org-default `channel-key`, so every existing call site is byte-identical. A
28
+ * domain that binds its own Notion integration (memory/domain.ts `credentials`) passes that
29
+ * integration's credential id instead — see channels/notion-credentials.ts for the one rule that
30
+ * decides it. There is still exactly ONE resolution mechanism: whichever id arrives is looked up in
31
+ * the registered provider set and resolved through its own custody stamp.
32
+ *
33
+ * 🔴 A NAMED-BUT-MISSING CREDENTIAL FAILS CLOSED, and that is the point. When a bound domain's
34
+ * credential is absent from the provider set this returns undefined and the caller refuses, exactly
35
+ * as an unset secret does. It NEVER retries under the org default: a domain bound away from the org
36
+ * key must not be handed the org key by a fallback.
37
+ *
38
+ * Fail-closed, two ways, both returning undefined — the exact unset-env semantics every caller
39
+ * already branches on, NEVER a silent fallback to the work-surface key or an env pin:
40
+ * • the provider/credential is UNREGISTERED or carries no `secretRef` (an instance that removed
41
+ * the notion provider, or a unit test that never registered one) ⇒ unconfigured ⇒ undefined;
42
+ * • the broker resolves no value (the secret is unset) ⇒ the same CredentialError-catch as before.
43
+ */
44
+ /**
45
+ * One resolved Notion credential: the key to call with, PAIRED WITH the endpoint rules that key may
46
+ * be used inside (OPE-505). Spread it straight into a {@link NotionCallOpts} — `{ ...cred, fetch }` —
47
+ * so the fence and the key can never be threaded separately.
48
+ *
49
+ * 🔴 THERE IS DELIBERATELY NO KEY-ONLY RESOLVER. An earlier `resolveNotionApiKey` returned the bare
50
+ * string, and every call site that used it produced an UNFENCED request: `guardCredentialFetch`
51
+ * returns the base fetch by identity when it is handed no rules, so a bare key silently disables the
52
+ * check rather than failing. Deleting that function is what makes the fence total on this module's
53
+ * paths — a caller cannot obtain a Notion key without also obtaining its rules.
54
+ */
55
+ export interface ResolvedNotionCredential {
56
+ readonly apiKey: string;
57
+ /** The credential's declared endpoint narrowing, or undefined when it declares none. */
58
+ readonly endpoints?: CredentialEndpointRules;
59
+ }
60
+ /**
61
+ * THE ONLY way to resolve a Notion channel credential — the key together with its endpoint rules.
62
+ * Fail-closed exactly as before: `undefined` for an unregistered provider, a credential carrying no
63
+ * `secretRef`, or a broker that resolves no value. A named-but-missing credential NEVER retries
64
+ * under the org default.
65
+ *
66
+ * WHY THE PAIR IS THE UNIT. A credential's endpoint rules are a property OF THAT KEY. Resolving the
67
+ * key through one function and the rules through another invites exactly the drift the rules exist
68
+ * to catch: a call site that threads the key from credential A and the rules from credential B has
69
+ * a fence that permits the leak it was written to block. Returning them together removes that
70
+ * failure mode by construction rather than by review.
71
+ */
72
+ export declare function resolveNotionCredential(credentialId?: string): Promise<ResolvedNotionCredential | undefined>;
73
+ /**
74
+ * The one-time subscription handshake: when a webhook subscription is created, Notion POSTs
75
+ * `{ "verification_token": "secret_…" }` (and nothing else — no `type`/`event_type`) to the
76
+ * endpoint. Return that token so the route can LOG it (the operator pastes it back into
77
+ * Notion's UI to confirm the endpoint, and stores it as NOTION_WEBHOOK_SECRET — the HMAC
78
+ * key for every subsequent delivery). Returns null for a normal event payload.
79
+ */
80
+ export declare function parseVerificationHandshake(rawBody: string): string | null;
81
+ /**
82
+ * Verify a Notion webhook's HMAC-SHA256 signature (`X-Notion-Signature: sha256=<hex>`)
83
+ * against the verification token, over the RAW body. Constant-time compare. Mirrors
84
+ * routines/events.ts's verifyGithubSignature (same scheme, different header/secret).
85
+ */
86
+ export declare function verifyNotionSignature(secret: string, rawBody: string, signatureHeader: string | null | undefined): boolean;
87
+ /**
88
+ * Gate an inbound Notion delivery FAIL-CLOSED. No configured secret means the sender can't
89
+ * be authenticated, so the delivery is REJECTED — this route dispatches agent sessions, and
90
+ * accepting unsigned payloads would let anyone on the internet drive the bot. The dev escape
91
+ * hatch `webhooks.allowUnverifiedNotion` restores unverified acceptance for local smoke
92
+ * tests. Returns null when the delivery may proceed, else the one-line 401 rejection reason.
93
+ * (Same shape/posture as gateGithubDelivery.)
94
+ */
95
+ export declare function gateNotionDelivery(secret: string | undefined, rawBody: string, signatureHeader: string | null, allowUnverified: boolean): string | null;
96
+ /** One integration's inbound signing key, paired with the credential that integration calls Notion
97
+ * with (channels/notion-credentials.ts). `secret` is undefined when nothing is stored under that
98
+ * integration's operator-secret name — an unconfigured integration, skipped by the gate. */
99
+ export interface NotionSigningKey {
100
+ readonly credentialId: string;
101
+ readonly secret: string | undefined;
102
+ }
103
+ /** The gate's answer. `rejection` non-null ⇒ send the 401 and stop. `credentialId` names the
104
+ * integration whose secret VERIFIED the delivery — the identity a signature proves. `verified` is
105
+ * true only when a real signature was checked against a configured secret (never under the dev
106
+ * hatch), and it is what admits a delivery into the routing plane. */
107
+ export interface NotionGateVerdict {
108
+ readonly rejection: string | null;
109
+ readonly credentialId?: string;
110
+ readonly verified: boolean;
111
+ }
112
+ /**
113
+ * Gate an inbound delivery against EVERY configured Notion integration (OPE-467) — the
114
+ * multi-integration form of {@link gateNotionDelivery}, and the whole of the ingress-routing answer.
115
+ *
116
+ * An operator running ten Notion integrations has ten `verification_token`s, and a delivery from
117
+ * integration #7 cannot verify against integration #1's secret. So the gate tries each in turn and
118
+ * THE SECRET THAT VERIFIES IDENTIFIES THE SENDER. That is a proof, not an inference: nothing in the
119
+ * payload is consulted, so no field a caller controls can steer the routing. Trying N keys is the
120
+ * same shape `webhooks/secrets.ts` already uses for a rotation window, and each comparison stays
121
+ * constant-time (`verifyNotionSignature` → `timingSafeEqual`).
122
+ *
123
+ * Same fail-closed posture as the single-secret gate, with the same wording for the same case:
124
+ * • NO integration has a stored secret ⇒ reject, unless the dev hatch is on (and then `verified`
125
+ * is false, so the delivery still may not reach the routing plane);
126
+ * • at least one secret configured, none verifies ⇒ "invalid signature".
127
+ */
128
+ export declare function gateNotionDeliveryAcross(keys: readonly NotionSigningKey[], rawBody: string, signatureHeader: string | null, allowUnverified: boolean): NotionGateVerdict;
129
+ export interface NotionWebhookEvent {
130
+ readonly type?: string;
131
+ /** Some API versions name the discriminator `event_type` instead of `type`. */
132
+ readonly event_type?: string;
133
+ readonly workspace_id?: string;
134
+ readonly entity?: {
135
+ readonly id?: string;
136
+ readonly type?: string;
137
+ };
138
+ readonly data?: {
139
+ readonly page_id?: string;
140
+ readonly parent?: {
141
+ readonly id?: string;
142
+ readonly type?: string;
143
+ };
144
+ };
145
+ readonly authors?: ReadonlyArray<{
146
+ readonly id?: string;
147
+ readonly type?: string;
148
+ }>;
149
+ }
150
+ /** The event discriminator, tolerating both the `type` and `event_type` spellings. */
151
+ export declare function notionEventType(ev: NotionWebhookEvent): string | undefined;
152
+ /** The comment id a `comment.created` event points at — it lives on `entity` (a signal only;
153
+ * the comment TEXT is not on the event), not on `data`. Returns undefined if the entity
154
+ * isn't a comment (defensive: never dispatch off a mis-shaped event). */
155
+ export declare function commentIdFromEvent(ev: NotionWebhookEvent): string | undefined;
156
+ /**
157
+ * The page a `comment.created` event CLAIMS to be anchored on (data.page_id, else data.parent.id).
158
+ *
159
+ * 🔴 A CLAIM, NOT A FACT — nothing authenticates a webhook body's fields, and `data.parent.id` is a
160
+ * block id as often as a page id. It must never select a session's surface, its domain, or a
161
+ * credential on a VERIFIED delivery: `notion-webhook.ts` step 8 establishes the real page from the
162
+ * comment Notion returns under the verifying integration's own key instead (OPE-496). The one
163
+ * remaining caller is the `webhooks.allowUnverifiedNotion` dev hatch, where the gate identifies
164
+ * no integration and nothing at all has been proven.
165
+ */
166
+ export declare function pageIdFromEvent(ev: NotionWebhookEvent): string | undefined;
167
+ /** One rich-text item as returned inside a comment's `rich_text` array. Loosely typed —
168
+ * the shapes are probed defensively (a mis-shaped item never throws). */
169
+ export interface NotionRichText {
170
+ readonly type?: string;
171
+ readonly plain_text?: string;
172
+ readonly text?: {
173
+ readonly content?: string;
174
+ };
175
+ readonly mention?: {
176
+ readonly type?: string;
177
+ readonly user?: {
178
+ readonly id?: string;
179
+ };
180
+ };
181
+ }
182
+ /** Flatten a comment's rich_text to plain text (concatenated `plain_text`). */
183
+ export declare function richTextToPlainText(richText: ReadonlyArray<NotionRichText> | undefined): string;
184
+ /**
185
+ * Detect whether the comment @mentions a specific user id. A Notion @mention is a rich-text
186
+ * item `{ type:"mention", mention:{ type:"user", user:{ id } } }`; an @mention of an
187
+ * integration/agent appears as a user mention whose id is the integration's own bot-user id.
188
+ * So "was the bot mentioned?" = does any mention item's `mention.user.id` equal the bot's
189
+ * user id (resolveBotUserId). Returns false for an empty id (never dispatch on an
190
+ * unresolved bot identity — fail closed to "not mentioned").
191
+ */
192
+ export declare function mentionsUser(richText: ReadonlyArray<NotionRichText> | undefined, userId: string | undefined): boolean;
193
+ /** The subset of the global `fetch` this module uses — injectable so tests mock it. */
194
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
195
+ /** The credential + transport options every Notion REST helper here takes. EXPORTED so a caller that
196
+ * threads these options onward (channels/notion-webhook.ts) names this ONE shape rather than
197
+ * re-declaring a structural copy that could silently drop `endpoints` and lose the fence. */
198
+ export interface NotionCallOpts {
199
+ readonly apiKey: string;
200
+ readonly fetch?: FetchLike;
201
+ readonly version?: string;
202
+ /**
203
+ * The ENDPOINT RULES of the credential `apiKey` came from (OPE-505), carried alongside the key so
204
+ * the fence travels with it. Every REST call in this module goes out through {@link notionFetch},
205
+ * which refuses before dispatch when the URL is outside these rules.
206
+ *
207
+ * Absent ⇒ unnarrowed, byte-identical to the pre-OPE-505 path. Build the pair with
208
+ * {@link resolveNotionCredential} rather than assembling it by hand — that is what keeps the rules
209
+ * and the key from drifting apart, which is the one way this fence could silently stop being one.
210
+ */
211
+ readonly endpoints?: CredentialEndpointRules;
212
+ /**
213
+ * The 429 backoff policy {@link notionFetch} applies (OPE-1233). A caller that passes none takes
214
+ * the shared defaults from `channels/rate-limit-retry.ts`. The webhook ingress passes
215
+ * {@link NOTION_INGRESS_RETRY} because the shared defaults do not fit its ceiling — see there. A
216
+ * test injects `sleep` and `now` so a retry sequence is driven by a stub clock and waits nothing.
217
+ */
218
+ readonly retry?: Pick<RateLimitRetryOptions, "maxAttempts" | "capMs" | "fallbackMs" | "totalBudgetMs" | "sleep" | "now">;
219
+ }
220
+ /**
221
+ * The 429 policy for a call the WEBHOOK INGRESS awaits before it acks Notion (OPE-1254).
222
+ *
223
+ * 🔴 THE ARITHMETIC, AGAINST A MEASURED CEILING. `handleNotionDelivery` is awaited before the route
224
+ * acks, and it fires about 15 calls in sequence: `retrieveBotUser`, `retrieveComment`, up to five
225
+ * `retrieveBlock` parent hops, `listBlockComments`, and the ancestry walk's `notionParentLink`. On
226
+ * the shared defaults — 4 attempts, a 30 s per-wait cap, a 60 s budget PER CALL — a rate-limit
227
+ * episode costs that delivery up to ~15 minutes. The deployed Notion route runs on the INGRESS
228
+ * function, whose `maxDuration` is 300 s (measured on OPE-1289, `docs/extending/provider-rate-limits.md`
229
+ * § "Measured: the function maxDuration against the backoff"). So the shared policy does not make
230
+ * the delivery slow — it makes the function get killed mid-sequence, which is a DROPPED delivery.
231
+ *
232
+ * This policy spends at most one 2 s wait per call — `maxAttempts: 2` allows one, and the cap binds
233
+ * before the 4 s budget can, which is left in place for a later edit that raises the attempts. So
234
+ * the whole ~15-call sequence spends at most ~30 s of backoff and leaves ~270 s of the ingress
235
+ * ceiling for the work itself. Every one of those calls is covered: the four `deliver` issues
236
+ * directly, through `ingressCallOpts`, and `retrieveBotUser` behind `deps.resolveBotUserId`, which
237
+ * `notion.ts` passes this same policy (it is awaited FIRST, ahead of them all). It is Slack's answer to the
238
+ * same problem (`slack-web-api.ts`'s `PROBE_OPERATIONS`, 2 attempts / 2 s cap for the calls its
239
+ * 3-second ack awaits), with the total budget stated too so the arithmetic closes rather than
240
+ * resting on the shared 60 s.
241
+ *
242
+ * ⚠️ IT IS PER CALL, NOT PER DELIVERY. A shared budget consumed ACROSS the sequence is the stronger
243
+ * shape and it is a change to the shared primitive every provider inherits — still open on OPE-1254.
244
+ * What this bounds is the worst case, and it bounds it under the ceiling.
245
+ */
246
+ export declare const NOTION_INGRESS_RETRY: {
247
+ readonly maxAttempts: 2;
248
+ readonly capMs: 2000;
249
+ readonly totalBudgetMs: 4000;
250
+ };
251
+ /**
252
+ * The vendor operation name a Notion request carries into {@link RateLimitExhaustedError} — the
253
+ * request's method plus its endpoint SHAPE, with every id segment replaced by `{id}`.
254
+ *
255
+ * 🔴 THE REDACTION IS THE POINT, not tidiness. `RateLimitExhaustedError` states that every field on
256
+ * it is safe to log, and this string is one of those fields. A raw Notion path carries page, block
257
+ * and comment ids, which name workspace content — so the shape goes in the failure and the ids stay
258
+ * out of it. `/v1/comments/abc…` becomes `GET /v1/comments/{id}`.
259
+ *
260
+ * The query string is dropped whole for the same reason: `?block_id=…` is an id too.
261
+ *
262
+ * Every url this is handed is built from a literal in this file, save one: {@link sendFileUploadBytes}
263
+ * posts to the `upload_url` Notion itself issued. A value that does not parse as a URL falls back to
264
+ * the method alone. Naming the method is honest; guessing a path from a string that is not a URL is
265
+ * not.
266
+ */
267
+ export declare function notionOperation(method: string | undefined, url: string): string;
268
+ export interface NotionComment {
269
+ readonly id?: string;
270
+ readonly discussion_id?: string;
271
+ readonly parent?: {
272
+ readonly type?: string;
273
+ readonly page_id?: string;
274
+ readonly block_id?: string;
275
+ };
276
+ readonly rich_text?: ReadonlyArray<NotionRichText>;
277
+ readonly created_by?: {
278
+ readonly id?: string;
279
+ readonly object?: string;
280
+ readonly type?: string;
281
+ };
282
+ /** ISO-8601 creation time, when Notion supplies it — the wake message's `sent-at`. */
283
+ readonly created_time?: string;
284
+ }
285
+ /** GET /v1/comments/{comment_id} — the second step of the two-step (webhook signal → fetch
286
+ * the actual comment body + discussion id + author). Throws on a non-2xx response. */
287
+ export declare function retrieveComment(commentId: string, opts: NotionCallOpts): Promise<NotionComment>;
288
+ /** GET /v1/users/me — the integration's own bot user: its `id` (the loop-guard self-identity)
289
+ * AND its `name` (the display name a person types in a name-drop — a judge input, never a
290
+ * bypass; design §2.2/Step 4). Resolved once and cached by the channel; env NOTION_BOT_USER_ID /
291
+ * NOTION_BOT_NAME short-circuit these — but the ID override applies to the ORG-DEFAULT `channel-key`
292
+ * credential ONLY (OPE-1921: two Notion integrations never share a bot user, so one pinned id
293
+ * cannot stand for a sibling's, and pinning it for every credential would reopen the
294
+ * cross-integration loop). Throws on a non-2xx response. */
295
+ export declare function retrieveBotUser(opts: NotionCallOpts): Promise<{
296
+ id?: string;
297
+ type?: string;
298
+ name?: string;
299
+ }>;
300
+ /** GET /v1/comments?block_id=… — list unresolved comments on a page/block, for surrounding
301
+ * thread context (best-effort). Returns the raw results array (caller filters by
302
+ * discussion + renders untrusted). Throws on a non-2xx response.
303
+ *
304
+ * 🔴 NOTION SERVES THIS ENDPOINT OLDEST-FIRST AND PAGINATED, AND ONE PAGE IS THE OLDEST 100
305
+ * (OPE-1831). A single unpaginated call therefore reads the anchor's FIRST hundred comments and can
306
+ * never reach a comment posted today — which is not a partial read on a long-lived anchor, it is a
307
+ * read that structurally cannot answer "what was said since the seat last looked". A PAGE-ANCHORED
308
+ * discussion on any long-lived doc is unreachable this way.
309
+ * 🔴 THIS IS NOT WHAT RED `J-ENGAGED-notion` ON SHIP #208 — do not carry that story forward. That
310
+ * journey's comment sits in a BLOCK-anchored discussion, which a page query never returns however
311
+ * many pages it follows; the cause there was `readNotionComments`' comment CAP ending the sweep
312
+ * before the block walk began (see that function, and `notion-catch-up.ts`). This bound is a real
313
+ * and separate one, closed while the file was open.
314
+ *
315
+ * `maxPages` bounds the follow. It DEFAULTS TO 1 — the single call every existing caller already
316
+ * made — so a caller that wants the newest comments asks for the pages, and no other read changes
317
+ * its cost. The loop stops the moment Notion reports no more, so even at a high `maxPages` an anchor
318
+ * with under 100 comments still costs exactly one call.
319
+ * 🔴 `onPageLimit` FIRES WHEN THE BOUND CUT THE ANCHOR OFF — Notion still had more and this stopped
320
+ * asking. Without it a page-limited anchor is indistinguishable from a complete read, so
321
+ * {@link readNotionComments} would report `truncated: false` over a read it knows was partial, and
322
+ * the coverage disclosure the model sees would be a false claim. A callback rather than a changed
323
+ * return type, so the eight existing call sites are untouched. */
324
+ export declare function listBlockComments(blockId: string, opts: NotionCallOpts & {
325
+ readonly maxPages?: number;
326
+ readonly onPageLimit?: () => void;
327
+ }): Promise<NotionComment[]>;
328
+ /** One comment, flattened for the read tool. `text` is UNTRUSTED (commenter-authored); the tool
329
+ * wraps it. `authorId`/`authorKind` come off the comment's partial `created_by` (id + object,
330
+ * and `type` when Notion supplies it) — surfaced as DATA, never a trust signal. */
331
+ /** The subset of a Notion block object the domain fence reads — its `parent`, i.e. which page (or
332
+ * which enclosing block) owns it. */
333
+ export interface NotionBlockParent {
334
+ readonly id?: string;
335
+ readonly parent?: {
336
+ readonly type?: string;
337
+ readonly page_id?: string;
338
+ readonly block_id?: string;
339
+ };
340
+ }
341
+ /** GET /v1/blocks/{block_id} — used ONLY to answer "which page owns this block?" for the domain
342
+ * fence (channels/notion-scope.ts). Throws on a non-2xx response. */
343
+ export declare function retrieveBlock(blockId: string, opts: NotionCallOpts): Promise<NotionBlockParent>;
344
+ /**
345
+ * The PAGE that owns a block — walk `parent` upward until a `page_id`, or give up. The domain fence
346
+ * needs it because `readNotionComments`' `block` drill-in addresses a block DIRECTLY (it never
347
+ * touches the `pageId` argument), so without this a block id is an unfenced way into another
348
+ * domain's page. Bounded at {@link BLOCK_PARENT_MAX_HOPS} hops — the realistic case is ONE (the
349
+ * sweep only ever surfaces top-level blocks, whose parent IS the page) and an unbounded walk over
350
+ * attacker-chosen ids is its own problem.
351
+ *
352
+ * Returns `undefined` when the chain ends anywhere other than a page (a workspace/database parent),
353
+ * runs out of hops, or the lookup throws — every one of which the caller must treat as a REFUSAL,
354
+ * never as permission.
355
+ */
356
+ export declare const BLOCK_PARENT_MAX_HOPS = 5;
357
+ export declare function resolveBlockOwningPage(blockId: string, opts: NotionCallOpts): Promise<string | undefined>;
358
+ /** The subset of a Notion PAGE object the ancestor walk reads — its `parent`, i.e. what owns it. */
359
+ export interface NotionPageParent {
360
+ readonly parent?: {
361
+ readonly type?: string;
362
+ readonly page_id?: string;
363
+ readonly block_id?: string;
364
+ readonly database_id?: string;
365
+ readonly data_source_id?: string;
366
+ readonly workspace?: boolean;
367
+ };
368
+ }
369
+ /** GET /v1/pages/{page_id} — used ONLY to answer "what owns this page?" for the ancestor walk
370
+ * (channels/notion-ancestry-trust.ts). Throws on a non-2xx response. */
371
+ export declare function retrievePage(pageId: string, opts: NotionCallOpts): Promise<NotionPageParent>;
372
+ /**
373
+ * GET /v1/pages/{page_id}, read for the page's own DISPLAY IDENTITY — its title and its canonical
374
+ * Notion url (OPE-1714).
375
+ *
376
+ * The title is the same `properties`-by-type extraction a search hit takes ({@link notionItemTitle}),
377
+ * because a page's title property is named by whoever made the page and can only be found by type.
378
+ * The url is Notion's own `url` field, never assembled here from an id.
379
+ *
380
+ * 🔴 FAIL-SOFT, AND SEPARATE FROM THE READ IT DECORATES. A page body read must not fail because this
381
+ * extra call did, so every error answers an empty identity and the caller renders the page without a
382
+ * title or a link. Both fields are UNTRUSTED: a workspace member chooses the title.
383
+ */
384
+ export declare function readNotionPageIdentity(pageId: string, opts: NotionCallOpts): Promise<{
385
+ readonly title?: string;
386
+ readonly url?: string;
387
+ }>;
388
+ /** Map a raw Notion `parent` object onto the closed union the ancestor walk decides over. An
389
+ * unrecognised shape maps to `unresolvable`, never to `workspace` — "we do not know what owns this"
390
+ * and "this sits at the workspace root" are different answers and only one of them ends a walk
391
+ * cleanly. `data_source_id` is the 2025-09-03 spelling of a row's database parent; both map to
392
+ * `database`, which is where the walk stops. */
393
+ export declare function toNotionParentLink(parent: NotionPageParent["parent"]): NotionParentLink;
394
+ /**
395
+ * What owns `id` — the live `parentOf` the ancestor walk injects (channels/notion-ancestry-trust.ts).
396
+ *
397
+ * A PAGE first (`GET /v1/pages/{id}`), because the walk starts on a page id and every level above a
398
+ * nested page is a page too. A page id that Notion will not serve as a page falls back to the BLOCK
399
+ * endpoint, which is how a page nested inside a column or a toggle keeps its chain walkable. Both
400
+ * failing is `unresolvable`, which the walk treats as a refusal — this function NEVER throws, so a
401
+ * Notion outage degrades to "not trusted" instead of to an exception on the ingestion path.
402
+ */
403
+ export declare function notionParentLink(id: string, opts: NotionCallOpts): Promise<NotionParentLink>;
404
+ export interface NotionCommentSummary {
405
+ /**
406
+ * The comment's OWN Notion id. 🔴 LOAD-BEARING FOR THE SEAT CATCH-UP (OPE-1847): the wake route
407
+ * passes the trigger's comment id as `SeatCatchUpRequest.excludeId`, and removing the wake's own
408
+ * message from its catch-up window is done BY ID and never by time — on a surface that floors
409
+ * `created_time` to the minute, the trigger shares its instant with the comments the seat is
410
+ * reading for. Absent only when Notion served a comment without one.
411
+ */
412
+ readonly id?: string;
413
+ readonly discussionId?: string;
414
+ /** The block (or page) the discussion anchors to — distinct inline discussions differ here. */
415
+ readonly anchorId?: string;
416
+ /**
417
+ * The plain text of the ANCHOR BLOCK, when the sweep read the block object that carries it
418
+ * (OPE-988). It is what a Notion discussion is ABOUT, and the generic `read_channel` / `read_thread`
419
+ * render it as the discussion's topic — without it a list of discussions is a list of ids.
420
+ *
421
+ * ABSENT for a page-level comment (the anchor is the page, which has no block text) and for the
422
+ * `block` drill-in (which addresses a block by id and never reads the block object). Absence means
423
+ * "no anchor text was read", never "the block is empty".
424
+ */
425
+ readonly anchorText?: string;
426
+ readonly authorId?: string;
427
+ readonly authorKind?: string;
428
+ readonly createdTime?: string;
429
+ readonly text: string;
430
+ }
431
+ /** Flatten ONE raw comment into the read shape. Exported (OPE-988) so the read adapter's cheap
432
+ * page-level path maps a `listBlockComments` result the SAME way the sweep does — two spellings of
433
+ * one flatten is how an author field goes missing on one path only. */
434
+ export declare function toNotionCommentSummary(c: NotionComment, fallbackAnchor: string, anchorText?: string): NotionCommentSummary;
435
+ /**
436
+ * Read a Notion page's comments + inline discussions, bounded. When `block` is given, reads ONLY
437
+ * that block's discussion (one list call — the cheap drill-in). Otherwise sweeps the page's own
438
+ * comments then walks its top-level blocks (≤ `maxBlockScans`, default 80) listing each block's
439
+ * comments, stopping once `maxComments` (default 100) is collected. `blockScanOrder` decides WHICH
440
+ * `maxBlockScans` blocks a page past that cap spends the budget on — the page's first by default,
441
+ * its last under `"newest-first"` (OPE-1843). Returns the flattened comments
442
+ * (oldest-first per anchor), the distinct-discussion count, the number of blocks scanned, and
443
+ * `truncated` (a cap bit, or Notion still had more blocks). Comment bodies are UNTRUSTED — the
444
+ * caller wraps them. Pure over the injectable fetch.
445
+ */
446
+ export declare function readNotionComments(pageId: string, opts: NotionCallOpts & {
447
+ readonly block?: string;
448
+ readonly maxComments?: number;
449
+ readonly maxBlockScans?: number;
450
+ /**
451
+ * Epoch ms of the OLDEST comment worth collecting. A comment older than this — or one Notion gave
452
+ * no readable `created_time` for — is skipped BEFORE the `maxComments` cap is charged.
453
+ * 🔴 THE ORDER OF THOSE TWO IS THE WHOLE POINT (OPE-1831). Notion serves comments OLDEST-FIRST,
454
+ * so a cap that counts every comment it walks past fills with the anchor's most ANCIENT ones and
455
+ * then reports `truncated` — the exact opposite of what a "since the seat last woke" read needs.
456
+ * Filtering first makes the cap count comments IN the window. Absent ⇒ no time filter, which is
457
+ * the whole-history read every existing caller already makes.
458
+ */
459
+ readonly sinceMs?: number;
460
+ /** Pages of comments to follow per anchor ({@link listBlockComments}). Default 1 — one call. */
461
+ readonly maxCommentPages?: number;
462
+ /**
463
+ * Which END of the page's top-level blocks the `maxBlockScans` budget is spent on (OPE-1843).
464
+ *
465
+ * `"document"` (the default, and what every caller got before this option existed) scans from
466
+ * the TOP of the page and stops at the cap. `"newest-first"` lists the children first, then
467
+ * spends the same budget on the LAST ones, newest block first.
468
+ *
469
+ * 🔴 A CATCH-UP READ WANTS THE OTHER END, AND THE DEFAULT ORDER MADE IT BLIND. Notion serves a
470
+ * page's children in document order and a page GROWS BY APPENDING, so on a page past
471
+ * `maxBlockScans` top-level blocks the budget is spent entirely on the page's oldest material
472
+ * and a discussion anchored on a recently appended block is never scanned. The sweep then
473
+ * returns zero comments with no error — the same answer a genuinely quiet page gives. That is
474
+ * the `J-ENGAGED-notion` shape: the journey appends a paragraph block per run and opens a
475
+ * BLOCK-anchored discussion on it (`core/scripts/e2e/scenarios.ts`), and a page-id comment
476
+ * query never returns a child block's comments, so the block walk is the only code that can
477
+ * find it.
478
+ *
479
+ * 🔴 IT MOVES THE BLIND SPOT, IT DOES NOT REMOVE IT, AND SAYING OTHERWISE WOULD BE A FALSE
480
+ * CLAIM. A comment posted TODAY on an ANCIENT block of a page past the cap is unread under
481
+ * `"newest-first"` and was read under `"document"`. Notion offers no "blocks with recent
482
+ * comments" query and a comment does not touch its block's `last_edited_time`, so no order is
483
+ * complete and only raising the cap would be — at one list call per block, against the shared
484
+ * 60 s-per-call 429 budget. `newest-first` is chosen for the CATCH-UP because the growth shape
485
+ * that crosses the cap at all is append-only, so the newest blocks are where the live
486
+ * conversation is. `truncated` still reports the read as partial either way.
487
+ *
488
+ * A page whose children fit inside `maxBlockScans` scans exactly the same blocks under both
489
+ * orders, so only an over-cap page pays or gains anything here.
490
+ */
491
+ readonly blockScanOrder?: "document" | "newest-first";
492
+ /**
493
+ * Pages of block children to list when `blockScanOrder` is `"newest-first"`. Default 10 — a
494
+ * thousand top-level blocks, because finding the LAST child means paging to the end.
495
+ * A page longer than this is scanned from the end of what was listed and reports `truncated`.
496
+ * Cheap next to the scan itself: one `getBlockChildren` call per hundred blocks against one
497
+ * `listBlockComments` call per SCANNED block.
498
+ */
499
+ readonly maxChildPages?: number;
500
+ }): Promise<{
501
+ readonly comments: NotionCommentSummary[];
502
+ readonly discussionCount: number;
503
+ readonly blocksScanned: number;
504
+ readonly truncated: boolean;
505
+ }>;
506
+ /** One block as returned by GET /v1/blocks/{id}/children. Loosely typed — the per-type
507
+ * `rich_text` array is probed defensively (a mis-shaped block never throws). */
508
+ export interface NotionBlock {
509
+ readonly id?: string;
510
+ readonly type?: string;
511
+ readonly has_children?: boolean;
512
+ readonly [key: string]: unknown;
513
+ }
514
+ /** Flatten ONE block to plain text: read the `rich_text` array nested under the block's own type
515
+ * key (`block[block.type].rich_text`) — the shape every text-bearing Notion block uses — and
516
+ * concatenate its `plain_text`. Returns "" for a block with no readable text (a divider, image,
517
+ * etc.), so a page of only such blocks flattens to empty rather than throwing. */
518
+ export declare function blockPlainText(block: NotionBlock): string;
519
+ /** GET /v1/blocks/{block_id}/children — one page of a block's children (top-level only; nested
520
+ * children are NOT recursed here — the tool reads the doc's own top-level blocks, which is what
521
+ * a comment usually anchors to). Returns the results array + the next cursor (undefined when
522
+ * there are no more). Throws on a non-2xx response. */
523
+ export declare function getBlockChildren(blockId: string, opts: NotionCallOpts & {
524
+ readonly startCursor?: string;
525
+ readonly pageSize?: number;
526
+ }): Promise<{
527
+ readonly blocks: NotionBlock[];
528
+ readonly nextCursor?: string;
529
+ }>;
530
+ /** Read a Notion page's top-level text, paginating block-children and flattening each block's
531
+ * rich-text, bounded by BOTH a block count (`maxBlocks`, default 200) and a total-char cap
532
+ * (`maxChars`, default 20,000 — well under the wake backscroll budget so the model can hold it).
533
+ * `truncated` is true when either cap was hit or Notion still had more blocks — so the agent
534
+ * knows the read is partial. Pure over the injectable fetch (no live Notion in tests). */
535
+ export declare function readNotionPage(pageId: string, opts: NotionCallOpts & {
536
+ readonly maxBlocks?: number;
537
+ readonly maxChars?: number;
538
+ }): Promise<{
539
+ readonly text: string;
540
+ readonly blockCount: number;
541
+ readonly truncated: boolean;
542
+ }>;
543
+ /**
544
+ * Notion's own per-comment attachment cap (developers.notion.com/reference/create-a-comment,
545
+ * confirmed live 2026-08-23: "attachments — Array of file objects — Maximum entries: 3"). Exported
546
+ * so {@link notionMessageOps.postFiles} (notion-message-ops.ts) can refuse a too-large batch BEFORE
547
+ * uploading a single byte, rather than uploading four files and discarding the fourth's upload.
548
+ */
549
+ export declare const NOTION_MAX_ATTACHMENTS = 3;
550
+ /** POST /v1/comments — reply into the SAME discussion thread. Notion caps a single rich-text
551
+ * item at 2000 chars, so the reply is chunked across items (≤100). Throws on non-2xx. Returns
552
+ * the created comment's id (Notion echoes the comment object) so the mid-turn `reply` tool can
553
+ * hand it back — empty string if the response carries none. Notion DOES expose an update verb
554
+ * now (`PATCH /v1/comments/{id}`, {@link updateComment}) — the reply tool itself does not wire to
555
+ * it (that is `edit_reply`'s seam, `channels/post-arms.ts`'s `editMessage`, a separate decision
556
+ * with its own scope). `attachments`, when given, names completed `file_upload` ids
557
+ * ({@link uploadNotionFile}) — see {@link attachmentsBody} for the cap. */
558
+ export declare function createCommentReply(args: {
559
+ discussionId: string;
560
+ text: string;
561
+ attachments?: readonly string[];
562
+ } & NotionCallOpts): Promise<string>;
563
+ /** POST /v1/comments — start a FRESH top-level comment (a new discussion) on a PAGE, rather than
564
+ * replying into an existing one. This is what {@link routeNotionStopComment}'s acknowledgement and
565
+ * every other host-driven, no-discussion-in-hand notice (`notion-message-ops.ts`'s `postText`/
566
+ * `postFiles`) use: they are handed a PAGE address, never a discussion id. Same chunking/error
567
+ * shape and `attachments` handling as {@link createCommentReply}. */
568
+ export declare function createPageComment(args: {
569
+ pageId: string;
570
+ text: string;
571
+ attachments?: readonly string[];
572
+ } & NotionCallOpts): Promise<string>;
573
+ /** PATCH /v1/comments/{comment_id} — rewrite a comment THIS integration created, in place
574
+ * (developers.notion.com/reference/update-a-comment, confirmed live 2026-08-23: the endpoint takes
575
+ * exactly one of `rich_text`/`markdown`, and 404s on a comment the calling connection did not
576
+ * create). Chunked the same way `richTextChunks` chunks a create. Throws on non-2xx, carrying the
577
+ * numeric status the same way every other write in this module does. */
578
+ export declare function updateComment(args: {
579
+ commentId: string;
580
+ text: string;
581
+ } & NotionCallOpts): Promise<void>;
582
+ /**
583
+ * POST /v1/file_uploads — step 1: reserve an upload slot for one small (≤20 MB, single-part) file.
584
+ * Returns the object's `id` (what a comment's `attachments` entry references, once uploaded) and the
585
+ * `upload_url` step 2 posts bytes to (the SAME host, `api.notion.com`, so it rides the credential
586
+ * fence like every other call here). Throws on non-2xx, or a response missing either field —
587
+ * never invents one.
588
+ */
589
+ export declare function createFileUpload(args: {
590
+ readonly filename: string;
591
+ readonly contentType: string;
592
+ } & NotionCallOpts): Promise<{
593
+ readonly id: string;
594
+ readonly uploadUrl: string;
595
+ }>;
596
+ /**
597
+ * POST {uploadUrl} — step 2: send the file's bytes as `multipart/form-data`, field name `file`
598
+ * (the shape Notion's send-file-upload endpoint requires). No explicit content-type header — like
599
+ * Discord's `postFiles` (discord-message-ops.ts), `fetch` derives the multipart boundary from the
600
+ * FormData body itself; setting one by hand would omit the boundary and the upload would 400.
601
+ * Throws on non-2xx.
602
+ */
603
+ export declare function sendFileUploadBytes(args: {
604
+ readonly uploadUrl: string;
605
+ readonly filename: string;
606
+ readonly contentType: string;
607
+ readonly bytes: Uint8Array;
608
+ } & NotionCallOpts): Promise<void>;
609
+ /**
610
+ * The whole two-step upload (create + send), returning the `file_upload` id — ready to attach to a
611
+ * comment via {@link createCommentReply}/{@link createPageComment}'s `attachments` argument. ONE
612
+ * call so a caller (notion-message-ops.ts's `postFiles`) never has to remember the two-step order
613
+ * or thread the intermediate `upload_url` itself.
614
+ */
615
+ export declare function uploadNotionFile(args: {
616
+ readonly filename: string;
617
+ readonly contentType: string;
618
+ readonly bytes: Uint8Array;
619
+ } & NotionCallOpts): Promise<string>;
620
+ /** Split reply text into Notion rich-text items, each ≤2000 chars (Notion's per-item cap),
621
+ * up to 100 items (Notion's per-comment cap). Exported for the test. */
622
+ export declare function richTextChunks(text: string): Array<{
623
+ type: "text";
624
+ text: {
625
+ content: string;
626
+ };
627
+ }>;
628
+ /** One hit from {@link searchNotion} — ids and a title, never page CONTENT. */
629
+ export interface NotionSearchHit {
630
+ readonly id: string;
631
+ /** "page" | "database", as Notion reports it. */
632
+ readonly object: string;
633
+ /** The item's title in plain text, or "" when Notion returns none (an untitled page). */
634
+ readonly title: string;
635
+ /** Notion's own URL for the item, when it returns one. */
636
+ readonly url?: string;
637
+ }
638
+ /**
639
+ * Search the workspace the credential is shared into (`POST /v1/search`), the WORK-plane read
640
+ * (OPE-913).
641
+ *
642
+ * 🔴 THE CREDENTIAL IS THE BOUNDARY, AND OE ADDS NO SECOND ONE. Tommy ruled this on 2026-08-21:
643
+ * "scope can only be controlled at the CREDENTIAL level; the vendor exposes one search tool and we
644
+ * just use it. OE never filters search results as a boundary (a post-hoc filter is not
645
+ * enforcement)." Notion enforces it properly — an internal integration sees only the page trees a
646
+ * human shared with it — so a domain that wants a narrow search shares a narrow integration. Do NOT
647
+ * add a domain filter over these results: it would read as a boundary while being none.
648
+ *
649
+ * Returns ids + titles only. A caller that wants a body reads the page through the fenced page read.
650
+ */
651
+ export declare function searchNotion(query: string, opts: NotionCallOpts & {
652
+ readonly pageSize?: number;
653
+ readonly filter?: "page" | "database";
654
+ }): Promise<readonly NotionSearchHit[]>;
655
+ //# sourceMappingURL=notion-api.d.ts.map