@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.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +48 -0
- package/agent/channels/notion-acting-identity.ts +88 -0
- package/agent/channels/notion-ancestry-trust.ts +458 -0
- package/agent/channels/notion-api.ts +1290 -0
- package/agent/channels/notion-bot-user.ts +250 -0
- package/agent/channels/notion-catch-up.ts +339 -0
- package/agent/channels/notion-credentials.ts +417 -0
- package/agent/channels/notion-engagement.ts +372 -0
- package/agent/channels/notion-event-normalizer.ts +587 -0
- package/agent/channels/notion-identity.ts +271 -0
- package/agent/channels/notion-ingress-drain.ts +289 -0
- package/agent/channels/notion-ingress.ts +182 -0
- package/agent/channels/notion-message-ops.ts +140 -0
- package/agent/channels/notion-origin-codec.ts +38 -0
- package/agent/channels/notion-post-arm.ts +189 -0
- package/agent/channels/notion-question-return.ts +223 -0
- package/agent/channels/notion-read.ts +283 -0
- package/agent/channels/notion-scope.ts +560 -0
- package/agent/channels/notion-stop.ts +169 -0
- package/agent/channels/notion-supervisor.ts +856 -0
- package/agent/channels/notion-webhook-timing.ts +131 -0
- package/agent/channels/notion-webhook-verifier.ts +55 -0
- package/agent/channels/notion-webhook.ts +963 -0
- package/agent/channels/notion-work-search.ts +74 -0
- package/agent/channels/notion-work.ts +144 -0
- package/agent/channels/notion.ts +1242 -0
- package/agent/contributions.ts +39 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/memory/blocks.ts +295 -0
- package/agent/memory/memory.ts +622 -0
- package/agent/provider-events.ts +198 -0
- package/agent/register.ts +144 -0
- package/agent/schedules/notion-ingress.ts +20 -0
- package/agent/surface-parity.ts +105 -0
- package/agent/tools/notion_search.ts +110 -0
- package/agent/tools/read_notion_comments.ts +250 -0
- package/agent/tools/read_notion_page.ts +351 -0
- package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
- package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-acting-identity.js +85 -0
- package/dist/agent/channels/notion-acting-identity.js.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.js +331 -0
- package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
- package/dist/agent/channels/notion-api.d.ts +655 -0
- package/dist/agent/channels/notion-api.d.ts.map +1 -0
- package/dist/agent/channels/notion-api.js +938 -0
- package/dist/agent/channels/notion-api.js.map +1 -0
- package/dist/agent/channels/notion-bot-user.d.ts +107 -0
- package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
- package/dist/agent/channels/notion-bot-user.js +192 -0
- package/dist/agent/channels/notion-bot-user.js.map +1 -0
- package/dist/agent/channels/notion-catch-up.d.ts +64 -0
- package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/notion-catch-up.js +289 -0
- package/dist/agent/channels/notion-catch-up.js.map +1 -0
- package/dist/agent/channels/notion-credentials.d.ts +210 -0
- package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
- package/dist/agent/channels/notion-credentials.js +360 -0
- package/dist/agent/channels/notion-credentials.js.map +1 -0
- package/dist/agent/channels/notion-engagement.d.ts +219 -0
- package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
- package/dist/agent/channels/notion-engagement.js +283 -0
- package/dist/agent/channels/notion-engagement.js.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.js +542 -0
- package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
- package/dist/agent/channels/notion-identity.d.ts +119 -0
- package/dist/agent/channels/notion-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-identity.js +205 -0
- package/dist/agent/channels/notion-identity.js.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.js +185 -0
- package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
- package/dist/agent/channels/notion-ingress.d.ts +64 -0
- package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress.js +130 -0
- package/dist/agent/channels/notion-ingress.js.map +1 -0
- package/dist/agent/channels/notion-message-ops.d.ts +3 -0
- package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/notion-message-ops.js +126 -0
- package/dist/agent/channels/notion-message-ops.js.map +1 -0
- package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
- package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/notion-origin-codec.js +32 -0
- package/dist/agent/channels/notion-origin-codec.js.map +1 -0
- package/dist/agent/channels/notion-post-arm.d.ts +3 -0
- package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/notion-post-arm.js +185 -0
- package/dist/agent/channels/notion-post-arm.js.map +1 -0
- package/dist/agent/channels/notion-question-return.d.ts +77 -0
- package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
- package/dist/agent/channels/notion-question-return.js +183 -0
- package/dist/agent/channels/notion-question-return.js.map +1 -0
- package/dist/agent/channels/notion-read.d.ts +62 -0
- package/dist/agent/channels/notion-read.d.ts.map +1 -0
- package/dist/agent/channels/notion-read.js +257 -0
- package/dist/agent/channels/notion-read.js.map +1 -0
- package/dist/agent/channels/notion-scope.d.ts +188 -0
- package/dist/agent/channels/notion-scope.d.ts.map +1 -0
- package/dist/agent/channels/notion-scope.js +433 -0
- package/dist/agent/channels/notion-scope.js.map +1 -0
- package/dist/agent/channels/notion-stop.d.ts +48 -0
- package/dist/agent/channels/notion-stop.d.ts.map +1 -0
- package/dist/agent/channels/notion-stop.js +141 -0
- package/dist/agent/channels/notion-stop.js.map +1 -0
- package/dist/agent/channels/notion-supervisor.d.ts +462 -0
- package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/notion-supervisor.js +482 -0
- package/dist/agent/channels/notion-supervisor.js.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.js +99 -0
- package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.js +53 -0
- package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/notion-webhook.d.ts +242 -0
- package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook.js +675 -0
- package/dist/agent/channels/notion-webhook.js.map +1 -0
- package/dist/agent/channels/notion-work-search.d.ts +9 -0
- package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
- package/dist/agent/channels/notion-work-search.js +69 -0
- package/dist/agent/channels/notion-work-search.js.map +1 -0
- package/dist/agent/channels/notion-work.d.ts +68 -0
- package/dist/agent/channels/notion-work.d.ts.map +1 -0
- package/dist/agent/channels/notion-work.js +126 -0
- package/dist/agent/channels/notion-work.js.map +1 -0
- package/dist/agent/channels/notion.d.ts +95 -0
- package/dist/agent/channels/notion.d.ts.map +1 -0
- package/dist/agent/channels/notion.js +1081 -0
- package/dist/agent/channels/notion.js.map +1 -0
- package/dist/agent/contributions.d.ts +32 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +37 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/memory/blocks.d.ts +58 -0
- package/dist/agent/memory/blocks.d.ts.map +1 -0
- package/dist/agent/memory/blocks.js +264 -0
- package/dist/agent/memory/blocks.js.map +1 -0
- package/dist/agent/memory/memory.d.ts +157 -0
- package/dist/agent/memory/memory.d.ts.map +1 -0
- package/dist/agent/memory/memory.js +471 -0
- package/dist/agent/memory/memory.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +196 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +128 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/schedules/notion-ingress.d.ts +6 -0
- package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
- package/dist/agent/schedules/notion-ingress.js +16 -0
- package/dist/agent/schedules/notion-ingress.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +99 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/dist/agent/tools/notion_search.d.ts +56 -0
- package/dist/agent/tools/notion_search.d.ts.map +1 -0
- package/dist/agent/tools/notion_search.js +100 -0
- package/dist/agent/tools/notion_search.js.map +1 -0
- package/dist/agent/tools/read_notion_comments.d.ts +57 -0
- package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_comments.js +236 -0
- package/dist/agent/tools/read_notion_comments.js.map +1 -0
- package/dist/agent/tools/read_notion_page.d.ts +53 -0
- package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_page.js +327 -0
- package/dist/agent/tools/read_notion_page.js.map +1 -0
- package/package.json +231 -0
- package/tsconfig.build.json +22 -0
- 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
|
+
}
|