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