@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,587 @@
|
|
|
1
|
+
// Notion's `EventNormalizer` + `EventScopeGrant` (OPE-189) — the FIRST event-plane registration
|
|
2
|
+
// contributed by a provider BUNDLE rather than by core. It became possible only at OPE-193, which
|
|
3
|
+
// put `registerEventNormalizer`/`registerEventScopeGrant` on the adapter barrel; before that
|
|
4
|
+
// `boundary-test.ts` rule 4 hard-failed the deep import, which is the whole reason the tree's only
|
|
5
|
+
// other normalizer (`routines/github-normalizer.ts`) lives in core.
|
|
6
|
+
//
|
|
7
|
+
// The ingress it feeds ALREADY EXISTS and is already verified: `POST /eve/v1/notion` (channels/
|
|
8
|
+
// notion.ts) HMACs every delivery against the subscription's `verification_token`. This module is
|
|
9
|
+
// what turns that verified delivery into something the routing plane can match a routine against.
|
|
10
|
+
//
|
|
11
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
12
|
+
// 🔴 THE PARTITION: COMMENTS ARE CONVERSATION, STRUCTURAL CHANGES ARE WORK.
|
|
13
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
14
|
+
//
|
|
15
|
+
// One Notion delivery now reaches TWO consumers on the same route — the ambient comment supervisor
|
|
16
|
+
// and `admitTrigger`. They are DISJOINT BY EVENT TYPE, not duplicative, and that is a decision
|
|
17
|
+
// rather than a consequence of wiring order:
|
|
18
|
+
//
|
|
19
|
+
// • `comment.created` / `comment.updated` / `comment.deleted` are a CONVERSATION the agent is a
|
|
20
|
+
// participant in. That is the ambient supervisor's job, it already works, and a routine firing
|
|
21
|
+
// on the same comment would be a second, dumber reader of a thread the supervisor is reading
|
|
22
|
+
// properly. {@link notionEventNormalizer.normalize} returns **null** for the whole `comment.*`
|
|
23
|
+
// family, so a comment delivery never reaches the matcher and can never fire a routine.
|
|
24
|
+
//
|
|
25
|
+
// • `page.*` / `database.*` / `data_source.*` are WORK — a page created, properties updated, a
|
|
26
|
+
// schema changed, something moved or deleted. Those are what routines subscribe to. The
|
|
27
|
+
// supervisor never sees them: `handleNotionDelivery` (channels/notion-webhook.ts) returns
|
|
28
|
+
// `ignored` for every type that is not `comment.created`, so a structural delivery cannot
|
|
29
|
+
// double-drive it.
|
|
30
|
+
//
|
|
31
|
+
// The two halves are enforced in two INDEPENDENT places, each one-sided, so neither can silently
|
|
32
|
+
// widen into the other's territory. The rule is stated here because this is the module that owns
|
|
33
|
+
// the vocabulary; the supervisor's half is a shipped `notionEventType(ev) !== "comment.created"`
|
|
34
|
+
// check that predates it.
|
|
35
|
+
//
|
|
36
|
+
// UNRECOGNISED FAMILIES ARE NOT ROUTED. `file_upload.*` — and anything Notion adds after this was
|
|
37
|
+
// written — normalizes to null. That is the fail-closed direction: a new family silently not firing
|
|
38
|
+
// is a missing feature, while a new family silently firing is an unreviewed subscription surface.
|
|
39
|
+
// The vocabulary below is therefore a REVIEWED ALLOWLIST, the same posture as the verifier registry.
|
|
40
|
+
//
|
|
41
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
42
|
+
// 🔴 THE SCOPE PATH IS PAYLOAD-DERIVED, AND THE PAYLOAD IS NOT AUTHENTICATED (OPE-500)
|
|
43
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
44
|
+
//
|
|
45
|
+
// The rule is NOT "hook-constant, never payload-derived". It is:
|
|
46
|
+
//
|
|
47
|
+
// Scope may be derived from the payload ONLY to the extent the VERIFIER makes the sender
|
|
48
|
+
// authoritative over that namespace.
|
|
49
|
+
//
|
|
50
|
+
// 🔴 THE HMAC PROVES ONE FACT, AND IT IS NOT THAT ONE. A Notion delivery's signature proves WHICH
|
|
51
|
+
// SUBSCRIPTION'S `verification_token` signed these bytes. It does not prove that Notion composed
|
|
52
|
+
// them. This file used to claim the opposite — "Notion is authoritative about which workspace /
|
|
53
|
+
// teamspace / database / page an event came from" — and that claim is FALSE on the deployment shape
|
|
54
|
+
// OPE-467 introduced: per-domain subscription tokens are DISTRIBUTED by design, so one operator per
|
|
55
|
+
// domain holds their own domain's token. That holder can sign a body whose `workspace_id`,
|
|
56
|
+
// `data.parent.id` and `entity.id` name ANY namespace they choose. {@link notionScopePath} then
|
|
57
|
+
// emits those choices as the trigger plane's scope path.
|
|
58
|
+
//
|
|
59
|
+
// SO THE PATH BELOW IS A CLAIM, NOT A PROOF, AND TWO GATES STAND BETWEEN THE CLAIM AND A FIRE:
|
|
60
|
+
//
|
|
61
|
+
// 1. THE INGRESS FENCE — {@link notionTriggerScopeVerdict}, run by `channels/notion.ts` BEFORE
|
|
62
|
+
// `admitTrigger` sees the delivery. It refuses any delivery whose granting step belongs to a
|
|
63
|
+
// domain the SIGNING integration does not serve. Read that function for what it can and cannot
|
|
64
|
+
// separate; the short form is that it fences a domain iff that domain runs its own inbound
|
|
65
|
+
// secret.
|
|
66
|
+
// 2. THE READ GRANT — {@link notionEventScopeGrant}, which still requires the routine's own domain
|
|
67
|
+
// to own the affected surface. It was never a defence against a forged path, because a forged
|
|
68
|
+
// path names the VICTIM domain's surface and therefore satisfies the victim's own grant.
|
|
69
|
+
//
|
|
70
|
+
// A cross-check against Notion's REST API was considered and NOT taken (OPE-500). Retrieving
|
|
71
|
+
// `entity.id` under the verifying credential proves the integration can SEE that object, which is a
|
|
72
|
+
// weaker statement than the fence makes, and it leaves the `workspace_id` step unchecked: Notion's
|
|
73
|
+
// REST surface returns no workspace id on any object, so no fetch can confirm the tenant. The fence
|
|
74
|
+
// needs no fetch and closes the tenant route too, because a forged tenant only moves the granting
|
|
75
|
+
// step onto a different domain and the fence then reads that domain's credential.
|
|
76
|
+
//
|
|
77
|
+
// tenant — `workspace_id`. Present on every delivery.
|
|
78
|
+
// group — `data.parent.id` when `data.parent.type === "space"` (a TEAMSPACE).
|
|
79
|
+
// container — `data.parent.id` when the parent is a `database` / `data_source` / `page` / `block`.
|
|
80
|
+
// entity — `entity.id` when the entity is a `page` / `database` / `data_source`.
|
|
81
|
+
// sub — the ONE changed block, when `data.updated_blocks` names exactly one.
|
|
82
|
+
//
|
|
83
|
+
// 🔴 MEASURED CORRECTION TO THE TICKET'S PREMISE. OPE-189 expected this to be the first delivery
|
|
84
|
+
// that exercises all FIVE `ScopeKind` levels at once. It cannot be, and no Notion delivery can:
|
|
85
|
+
// `event-model.ts`'s five kinds were sized against Notion's PRODUCT hierarchy (workspace →
|
|
86
|
+
// teamspace → database → page → block), but Notion's WEBHOOK ENVELOPE carries exactly ONE parent
|
|
87
|
+
// object. `data.parent` is either the teamspace or the database/page — never both — so `group` and
|
|
88
|
+
// `container` are mutually exclusive on any single delivery and the deepest real path is FOUR
|
|
89
|
+
// steps. All five kinds ARE reachable across the vocabulary (a teamspace-parented page proves
|
|
90
|
+
// tenant→group→entity→sub; a database-parented page proves tenant→container→entity→sub), which is
|
|
91
|
+
// why all five are declared in `scopeKinds`. Emitting a fabricated fifth step to hit the number
|
|
92
|
+
// would be inventing containment the delivery does not prove.
|
|
93
|
+
//
|
|
94
|
+
// A shorter path makes matching COARSER, never wrongly narrower (`event-model.ts:18-21`), so every
|
|
95
|
+
// omission above is safe by construction.
|
|
96
|
+
//
|
|
97
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
98
|
+
// THE DELIVERY ID IS IN THE BODY, NOT A HEADER
|
|
99
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
100
|
+
//
|
|
101
|
+
// {@link EventNormalizer.deliveryIdHeader} is deliberately ABSENT: Notion sends no delivery-id
|
|
102
|
+
// header at all — the event's unique id is the payload's top-level `id`. `declaredDeliveryId` (the
|
|
103
|
+
// header mechanism) therefore cannot serve this source, and {@link notionEventDeliveryId} is the
|
|
104
|
+
// supported way for the ingress to obtain one. It lives in this module for the same structural
|
|
105
|
+
// reason `deliveryIdHeader` does: the delivery ENVELOPE is a fact about the vendor's contract, and
|
|
106
|
+
// the normalizer is the one module that owns it.
|
|
107
|
+
//
|
|
108
|
+
// ⚠️ ONE SIDE EFFECT OF EXISTING AT ALL. `webhooks/source-vocabulary.ts` derives the CHAT-MINTABLE
|
|
109
|
+
// source set from "has a verifier AND has a normalizer", so this module supplies the half `notion`
|
|
110
|
+
// was missing. `notion` stays UNMINTABLE anyway — its verifier declares
|
|
111
|
+
// `keyOrigin: "vendor-issued"`, so `acceptsMintedSecret` is false and a runtime hook on this source
|
|
112
|
+
// would 401 forever. That is now the ONLY thing excluding it, and it is pinned by a live test
|
|
113
|
+
// rather than by this comment: `scripts/webhook-source-vocabulary-live-test.ts` §6a refuses any
|
|
114
|
+
// mintable source whose normalizer declares no `deliveryIdHeader`, and this one declares none.
|
|
115
|
+
|
|
116
|
+
import {
|
|
117
|
+
scopeIdOf,
|
|
118
|
+
getConfig,
|
|
119
|
+
type Domain,
|
|
120
|
+
type EventNormalizer,
|
|
121
|
+
type EventScopeGrant,
|
|
122
|
+
type NormalizedEvent,
|
|
123
|
+
type ScopeStep,
|
|
124
|
+
type TriggerDelivery,
|
|
125
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
126
|
+
import { NOTION_WEBHOOK_SOURCE } from "./notion-webhook-verifier.js";
|
|
127
|
+
import { notionWorkspaceKey } from "./notion-identity.js";
|
|
128
|
+
import { notionIntegrations, notionSignerServesDomain } from "./notion-credentials.js";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The registry key for BOTH halves of the event plane, and it is the SAME string the webhook
|
|
132
|
+
* verifier registers under — one contract, spelled once. (Since OPE-198 S2 this key is the
|
|
133
|
+
* delivery's SCHEME; on the native Notion route the scheme and the hook name coincide.)
|
|
134
|
+
*/
|
|
135
|
+
export const NOTION_EVENT_SOURCE = NOTION_WEBHOOK_SOURCE;
|
|
136
|
+
|
|
137
|
+
/** Event-type families that are WORK — routed into the event plane. Prefix-matched on the
|
|
138
|
+
* verbatim `type` discriminator (`page.properties_updated`, `data_source.schema_updated`, …). */
|
|
139
|
+
export const NOTION_STRUCTURAL_PREFIXES: readonly string[] = ["page.", "database.", "data_source."];
|
|
140
|
+
|
|
141
|
+
/** Event-type families that are CONVERSATION — the ambient supervisor's, never a routine's.
|
|
142
|
+
* Named explicitly (rather than falling into the unrecognised bucket) so the partition is a
|
|
143
|
+
* reviewed statement in code and a reader can see that the omission is deliberate. */
|
|
144
|
+
export const NOTION_CONVERSATIONAL_PREFIXES: readonly string[] = ["comment."];
|
|
145
|
+
|
|
146
|
+
/** Notion's webhook envelope, probed defensively — a mis-shaped payload must yield `null`, never a
|
|
147
|
+
* throw (a normalizer that throws is caught by `normalizeDelivery`, but an ingress should not rely
|
|
148
|
+
* on that to avoid a 500). Mirrors `notion-api.ts`'s loose typing of the same envelope. */
|
|
149
|
+
export interface NotionEventEnvelope {
|
|
150
|
+
readonly id?: unknown;
|
|
151
|
+
readonly timestamp?: unknown;
|
|
152
|
+
readonly workspace_id?: unknown;
|
|
153
|
+
readonly type?: unknown;
|
|
154
|
+
readonly event_type?: unknown;
|
|
155
|
+
readonly authors?: unknown;
|
|
156
|
+
readonly entity?: { readonly id?: unknown; readonly type?: unknown };
|
|
157
|
+
readonly data?: {
|
|
158
|
+
readonly parent?: { readonly id?: unknown; readonly type?: unknown };
|
|
159
|
+
readonly updated_blocks?: unknown;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function str(v: unknown): string | undefined {
|
|
164
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parse(body: string): NotionEventEnvelope | undefined {
|
|
168
|
+
try {
|
|
169
|
+
const p: unknown = JSON.parse(body);
|
|
170
|
+
return p && typeof p === "object" ? (p as NotionEventEnvelope) : undefined;
|
|
171
|
+
} catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The verbatim event type, tolerating both spellings the REST surface uses (`notion-api.ts`'s
|
|
177
|
+
* `notionEventType` does the same for the comment path; duplicated rather than imported because
|
|
178
|
+
* that helper is typed against the comment-path envelope). */
|
|
179
|
+
function eventTypeOf(ev: NotionEventEnvelope): string | undefined {
|
|
180
|
+
return str(ev.type) ?? str(ev.event_type);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Is this event type WORK (routable), CONVERSATION (the supervisor's), or unrecognised? Exported
|
|
184
|
+
* so the disjointness can be asserted directly rather than inferred from `normalize` returning
|
|
185
|
+
* null for two different reasons. */
|
|
186
|
+
export function notionEventClass(type: string | undefined): "structural" | "conversational" | "unrecognised" {
|
|
187
|
+
if (!type) return "unrecognised";
|
|
188
|
+
if (NOTION_CONVERSATIONAL_PREFIXES.some((p) => type.startsWith(p))) return "conversational";
|
|
189
|
+
if (NOTION_STRUCTURAL_PREFIXES.some((p) => type.startsWith(p))) return "structural";
|
|
190
|
+
return "unrecognised";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The delivery id Notion carries for one event — its top-level `id`, which is in the BODY (Notion
|
|
195
|
+
* publishes no delivery-id header). THE supported way for the Notion ingress to populate
|
|
196
|
+
* {@link TriggerDelivery.deliveryId}, which is what arms `admitTrigger`'s durable replay dedupe.
|
|
197
|
+
* Returns undefined for an unparseable body and for a blank id alike — both are "this delivery has
|
|
198
|
+
* no id", which the plane already models as not-deduped rather than dropped.
|
|
199
|
+
*/
|
|
200
|
+
export function notionEventDeliveryId(rawBody: string): string | undefined {
|
|
201
|
+
return str(parse(rawBody)?.id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The scope path one envelope proves. Exported for the unit test, which drives it directly. */
|
|
205
|
+
export function notionScopePath(ev: NotionEventEnvelope): ScopeStep[] {
|
|
206
|
+
const scope: ScopeStep[] = [];
|
|
207
|
+
const workspace = str(ev.workspace_id);
|
|
208
|
+
if (workspace) scope.push({ kind: "tenant", id: workspace });
|
|
209
|
+
|
|
210
|
+
const parentId = str(ev.data?.parent?.id);
|
|
211
|
+
const parentType = str(ev.data?.parent?.type);
|
|
212
|
+
if (parentId && parentType) {
|
|
213
|
+
// `space` is Notion's word for a TEAMSPACE — the only level between a workspace and a
|
|
214
|
+
// database/page, and therefore the only thing `group` can ever mean here.
|
|
215
|
+
if (parentType === "space") scope.push({ kind: "group", id: parentId });
|
|
216
|
+
else if (parentType === "database" || parentType === "data_source" || parentType === "page" || parentType === "block") {
|
|
217
|
+
scope.push({ kind: "container", id: parentId });
|
|
218
|
+
}
|
|
219
|
+
// Any other parent type is left OUT rather than guessed into a kind — a shorter path is coarse,
|
|
220
|
+
// a wrong path is a mis-authorization.
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const entityId = str(ev.entity?.id);
|
|
224
|
+
const entityType = str(ev.entity?.type);
|
|
225
|
+
if (entityId && (entityType === "page" || entityType === "database" || entityType === "data_source")) {
|
|
226
|
+
scope.push({ kind: "entity", id: entityId });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// `sub` ONLY when the delivery proves exactly ONE block. `page.content_updated` carries an ARRAY;
|
|
230
|
+
// picking the first of several would assert containment in a block the event is not (only) about,
|
|
231
|
+
// and the path holds at most one step per kind so there is no honest way to carry several.
|
|
232
|
+
const blocks = ev.data?.updated_blocks;
|
|
233
|
+
if (Array.isArray(blocks) && blocks.length === 1) {
|
|
234
|
+
const id = str((blocks[0] as { id?: unknown } | undefined)?.id);
|
|
235
|
+
if (id) scope.push({ kind: "sub", id });
|
|
236
|
+
}
|
|
237
|
+
return scope;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Lift one Notion envelope onto the v2 event model, or null when it is not ours to route.
|
|
241
|
+
* Exported for the unit test, which drives it directly rather than through the registry. */
|
|
242
|
+
export function liftNotionEvent(ev: NotionEventEnvelope, declaredSource: string): NormalizedEvent | null {
|
|
243
|
+
const type = eventTypeOf(ev);
|
|
244
|
+
if (notionEventClass(type) !== "structural") return null;
|
|
245
|
+
const eventType = type as string;
|
|
246
|
+
|
|
247
|
+
// `page.properties_updated` ⇒ event = the verbatim type, action = `properties_updated`. The
|
|
248
|
+
// vendor's name is carried VERBATIM (never re-spelled) and the verb is ADDITIONALLY split out,
|
|
249
|
+
// because "anything created, in any family" is a subscription people actually want and the filter
|
|
250
|
+
// language has no prefix operator to express it with.
|
|
251
|
+
const dot = eventType.indexOf(".");
|
|
252
|
+
const family = dot > 0 ? eventType.slice(0, dot) : eventType;
|
|
253
|
+
const action = dot > 0 ? eventType.slice(dot + 1) : undefined;
|
|
254
|
+
|
|
255
|
+
const scope = notionScopePath(ev);
|
|
256
|
+
const workspace = scopeIdOf(scope, "tenant");
|
|
257
|
+
const parent = scopeIdOf(scope, "container") ?? scopeIdOf(scope, "group");
|
|
258
|
+
const entity = scopeIdOf(scope, "entity");
|
|
259
|
+
|
|
260
|
+
const fields: Record<string, string> = { event: eventType, entity_type: family };
|
|
261
|
+
if (action) fields["action"] = action;
|
|
262
|
+
if (workspace) fields["workspace"] = workspace;
|
|
263
|
+
if (parent) fields["parent"] = parent;
|
|
264
|
+
if (entity) fields["entity"] = entity;
|
|
265
|
+
|
|
266
|
+
// TERMINAL, from Notion's OWN signal — never inferred from a failed fetch. `*.deleted` is the
|
|
267
|
+
// vendor's word for a trashed page/database; `*.undeleted` is its restore and is NOT terminal.
|
|
268
|
+
const gone = action === "deleted";
|
|
269
|
+
|
|
270
|
+
// SUMMARY IS BUILT FROM IDS AND THE EVENT TYPE ONLY. Notion's envelope also carries
|
|
271
|
+
// `workspace_name` (member-authored prose); it is deliberately never read, so this line contains
|
|
272
|
+
// no attacker-chosen characters even though it is emitted through `access/untrusted.ts`'s fence.
|
|
273
|
+
const summary = `notion ${eventType}${entity ? ` on ${entity}` : ""}${workspace ? ` (workspace ${workspace})` : ""}`;
|
|
274
|
+
|
|
275
|
+
// ACTOR: emitted only when the payload names exactly ONE author. `authors` is an ARRAY (a batched
|
|
276
|
+
// content update can carry several) and `actor` is a single slot, so picking the first of many
|
|
277
|
+
// would attribute the event to one of its authors as though they were the only one.
|
|
278
|
+
const authors: ReadonlyArray<{ id?: unknown }> = Array.isArray(ev.authors) ? (ev.authors as ReadonlyArray<{ id?: unknown }>) : [];
|
|
279
|
+
const soleAuthor = authors.length === 1 ? str(authors[0]?.id) : undefined;
|
|
280
|
+
const deliveryId = str(ev.id);
|
|
281
|
+
const occurredAt = str(ev.timestamp);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
source: declaredSource,
|
|
285
|
+
event: eventType,
|
|
286
|
+
...(action ? { action } : {}),
|
|
287
|
+
scope,
|
|
288
|
+
...(soleAuthor ? { actor: { id: soleAuthor } } : {}),
|
|
289
|
+
fields,
|
|
290
|
+
...(deliveryId ? { deliveryId } : {}),
|
|
291
|
+
...(occurredAt ? { occurredAt } : {}),
|
|
292
|
+
summary,
|
|
293
|
+
// POINTER: named for the day a Notion `EntityReadAdapter` exists. There is none today, so
|
|
294
|
+
// `hydratePointer` reports `unsupported` and the fire renders unhydrated — a documented no-op,
|
|
295
|
+
// not a claim that a re-fetch happens. `gone` short-circuits hydration entirely.
|
|
296
|
+
...(entity ? { pointer: { platform: "notion", id: entity, ...(gone ? { gone: true } : {}) } } : {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const notionEventNormalizer: EventNormalizer = {
|
|
301
|
+
source: NOTION_EVENT_SOURCE,
|
|
302
|
+
provider: "notion",
|
|
303
|
+
// `author` is DELIBERATELY NOT FILTERABLE even though the payload carries it. `authors` is an
|
|
304
|
+
// ARRAY, and the match language pins "an absent field makes every `in` FALSE and every `notIn`
|
|
305
|
+
// TRUE" — so on a multi-author event an `author notIn [bot]` clause would evaluate TRUE, i.e. the
|
|
306
|
+
// one motivating use of `notIn` (do not re-trigger on our own writes) would fail OPEN on exactly
|
|
307
|
+
// the deliveries where the bot co-authored. Shipping a filter key whose guard silently inverts is
|
|
308
|
+
// worse than not shipping it. `actor` still carries a sole author for rendering/audit.
|
|
309
|
+
matchKeys: ["event", "action", "entity_type", "workspace", "parent", "entity"],
|
|
310
|
+
// 🔴 `workspace` IS NOT A SCOPE KEY, on purpose. `scopeKeys` feeds the MANDATORY-NARROWING rule
|
|
311
|
+
// (event-match.ts): an `in` clause on one of these counts as "this subscription narrowed". If
|
|
312
|
+
// `workspace` were listed, `{ all: [{ key: "workspace", in: [ws] }] }` would satisfy the rule
|
|
313
|
+
// while subscribing to EVERY event in the entire workspace — precisely the default the rule
|
|
314
|
+
// exists to refuse. GitHub makes the same call (`scopeKeys: ["repo"]`, never the owner).
|
|
315
|
+
scopeKeys: ["parent", "entity"],
|
|
316
|
+
// All five, because all five are reachable across the vocabulary — but see the module header:
|
|
317
|
+
// no SINGLE delivery proves more than four, because Notion's envelope carries one parent.
|
|
318
|
+
scopeKinds: ["tenant", "group", "container", "entity", "sub"],
|
|
319
|
+
// Notion's `authors[]` genuinely identifies who caused the event (`{id, type: person|bot|agent}`),
|
|
320
|
+
// so the honest answer is yes — even though nothing reads this field today (OPE-192).
|
|
321
|
+
resolvesActor: true,
|
|
322
|
+
// ABSENT ON PURPOSE — Notion publishes no delivery-id header; see the module header and
|
|
323
|
+
// {@link notionEventDeliveryId}.
|
|
324
|
+
normalize(d: TriggerDelivery): NormalizedEvent | null {
|
|
325
|
+
const ev = parse(d.body);
|
|
326
|
+
if (!ev) return null; // an unparseable body is not routable; the ingress logs its own reason
|
|
327
|
+
return liftNotionEvent(ev, d.source);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
333
|
+
* 🔴 NOTION'S READ GRANT — AND THE BOUNDARY IT IS *NOT*.
|
|
334
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
335
|
+
*
|
|
336
|
+
* WHAT OE ACTUALLY HOLDS, MEASURED (a design note §1, §6; `notion-api.ts`
|
|
337
|
+
* `resolveNotionCredential`): an INTERNAL INTEGRATION SECRET (`db:notion:channel-key`, broker `db`) —
|
|
338
|
+
* one for the whole deployment by default, or one per domain that binds its own (OPE-467). Each is
|
|
339
|
+
* bearer-token-flat: there is no scopes string, it is bound to a single workspace at creation, and
|
|
340
|
+
* what it can see is a SHARE LIST maintained on NOTION'S side, page by page, by workspace members.
|
|
341
|
+
*
|
|
342
|
+
* 🔴 THEREFORE THE CREDENTIAL CANNOT BE THE BOUNDARY, AND THIS ADAPTER DOES NOT PRETEND IT IS.
|
|
343
|
+
* Three properties make that unavoidable, and each one alone would be enough:
|
|
344
|
+
* 1. A domain need not bind its own integration, and by default none does — ten domains then
|
|
345
|
+
* reach Notion with the same bytes, so nothing credential-derived can differ between them.
|
|
346
|
+
* A grant rule that held only for BOUND domains would be a rule with a config-shaped hole.
|
|
347
|
+
* 2. The share list is invisible to OE — it is not in any config we hold and there is no API that
|
|
348
|
+
* would let a PURE, SYNC `grantsRead` consult it even if it were.
|
|
349
|
+
* 3. `EffectiveAccess` has no Notion-shaped granted resource to express it over (`repos` is a
|
|
350
|
+
* hardcoded GitHub `string[]`), and inventing one for a credential nobody varies per domain
|
|
351
|
+
* would be schema for a distinction that does not exist.
|
|
352
|
+
* A `grantsRead: () => true` with those facts in a comment is the failure class this tree has a
|
|
353
|
+
* name for — a documented absence of a boundary that LOOKS like a boundary — so it is not what
|
|
354
|
+
* ships here.
|
|
355
|
+
*
|
|
356
|
+
* WHAT *IS* A REAL, PER-DOMAIN, DEPLOYER-OWNED NOTION BOUNDARY: which domain a Notion surface
|
|
357
|
+
* belongs to. `domainsForSurface` reads the static `domains` registry in the app tree — deployer
|
|
358
|
+
* config, merged by PR, validated at module load, with a surface belonging to at most ONE domain.
|
|
359
|
+
* It is the SAME authority, over the SAME `notion/<workspace>/<page>` surface key, that already
|
|
360
|
+
* gates whether the ambient supervisor may engage on that page at all (`notion.ts`
|
|
361
|
+
* `notionAmbientEnabled`). So the rule is:
|
|
362
|
+
*
|
|
363
|
+
* A routine may be triggered by a Notion event iff the routine's partition is in a domain the
|
|
364
|
+
* deployer put the affected Notion surface in.
|
|
365
|
+
*
|
|
366
|
+
* It REFUSES things that are real: a routine in domain `payments` cannot subscribe to a page the
|
|
367
|
+
* deployer put in `security`; a page in NO domain cannot trigger anything anywhere; a delivery
|
|
368
|
+
* proving only a workspace is refused outright, because a workspace is not a surface anybody
|
|
369
|
+
* configured.
|
|
370
|
+
*
|
|
371
|
+
* INNERMOST-CONFIGURED-WINS, AND IT NEVER FALLS THROUGH A REFUSAL. The walk goes inwards→outwards
|
|
372
|
+
* and the FIRST step the registry knows about is authoritative: it grants or it refuses, and the
|
|
373
|
+
* walk stops. A step the registry says nothing about is skipped (deployers configure pages and
|
|
374
|
+
* databases, not every descendant), which is what makes the rule usable — but continuing PAST a
|
|
375
|
+
* refusal would let a parent-database grant authorize a child page the deployer deliberately put
|
|
376
|
+
* somewhere else, which is the widening this ordering exists to prevent.
|
|
377
|
+
*
|
|
378
|
+
* ⚠️ TWO ASSUMPTIONS, STATED BECAUSE THEY ARE THE ONES MOST LIKELY TO SHIFT.
|
|
379
|
+
* • `loadDomains` is explicitly a swappable seam ("the seam a DB/dashboard would swap behind",
|
|
380
|
+
* memory/config/domains.ts). This grant's authority class is TIED to that seam staying deployer-owned:
|
|
381
|
+
* the day a dashboard lets a non-deployer edit domain membership, this rule silently weakens to
|
|
382
|
+
* whatever authorizes that editor.
|
|
383
|
+
* • Notion's own share list is a SECOND fence underneath this one, not a substitute for it: a
|
|
384
|
+
* subscription only delivers events for objects the connection was shared onto, so a delivery
|
|
385
|
+
* arriving at all already proves OE can see the object. That fact is OE-wide, never per-domain,
|
|
386
|
+
* which is exactly why it cannot be the grant.
|
|
387
|
+
*
|
|
388
|
+
* ⚠️ IT READS CONFIG, WHICH GITHUB'S GRANT DOES NOT NEED TO. `grantsRead` is documented PURE — no
|
|
389
|
+
* I/O, no ctx, no fetch — and this stays inside that: `getConfig()` is a registry read and
|
|
390
|
+
* `domainsForSurface` reads static, module-load-validated config. It is the SAME read
|
|
391
|
+
* `requestForPartition` (event-authz.ts) already performs on this exact fire path, so it introduces
|
|
392
|
+
* no new authority class and no new I/O. An unregistered config throws, `checkEventReadGrant`
|
|
393
|
+
* catches it as `unresolvable`, and the delivery fails CLOSED.
|
|
394
|
+
*/
|
|
395
|
+
/**
|
|
396
|
+
* The scope steps that can DECIDE a Notion delivery, innermost first. The first step any domain owns
|
|
397
|
+
* is authoritative — it grants or it refuses, and the walk stops there.
|
|
398
|
+
*
|
|
399
|
+
* ⭐ ONE LIST, TWO READERS, ON PURPOSE. {@link notionEventScopeGrant} walks it to find the step that
|
|
400
|
+
* AUTHORIZES, and {@link notionTriggerScopeVerdict} walks it to find the step it must check the
|
|
401
|
+
* SIGNER against. If the two walked different orders, the fence would check one step and the grant
|
|
402
|
+
* would authorize another, which is the exact shape of a fence that looks applied and is not.
|
|
403
|
+
*
|
|
404
|
+
* 🔴 `tenant` IS DELIBERATELY ABSENT. The grant's own comment holds the standing reason; the fence
|
|
405
|
+
* inherits it, and inherits the consequence too — a forged `workspace_id` is never checked directly.
|
|
406
|
+
* It does not need to be. The tenant only changes WHICH domain a step below resolves to, and the
|
|
407
|
+
* fence then reads that domain's credential.
|
|
408
|
+
*/
|
|
409
|
+
export const NOTION_GRANT_STEPS = ["sub", "entity", "container", "group"] as const;
|
|
410
|
+
|
|
411
|
+
export const notionEventScopeGrant: EventScopeGrant = {
|
|
412
|
+
source: NOTION_EVENT_SOURCE,
|
|
413
|
+
provider: "notion",
|
|
414
|
+
grantsRead(scope, access) {
|
|
415
|
+
const workspace = scopeIdOf(scope, "tenant");
|
|
416
|
+
if (workspace === undefined) return false;
|
|
417
|
+
const workspaceKey = notionWorkspaceKey(workspace);
|
|
418
|
+
const cfg = getConfig();
|
|
419
|
+
// INNERMOST-FIRST. `tenant` is deliberately absent from this list. The reason has been REWRITTEN
|
|
420
|
+
// (OPE-223, 2026-07-28) because OPE-205 falsified the original one, which read: "a workspace id
|
|
421
|
+
// is not a surface anybody configures, and admitting it would turn 'the deployer named this
|
|
422
|
+
// workspace once' into a grant over every page in it."
|
|
423
|
+
//
|
|
424
|
+
// Both halves of that are now out of date. A deployer CAN configure a workspace id — that is
|
|
425
|
+
// exactly what `containers: ["notion/<ws>"]` is — and a grant over every page in it is precisely
|
|
426
|
+
// what such a claim is FOR. Worse, the widening the old sentence feared has already happened by
|
|
427
|
+
// a route it never mentions: `domainsForSurface` composes `notion/<ws>/<stepId>` and
|
|
428
|
+
// `resolveDomainForChannel` falls back on that key's two-segment PREFIX, which for every step
|
|
429
|
+
// here IS `notion/<ws>`. So under a workspace claim EVERY step below resolves to the claiming
|
|
430
|
+
// domain, and the loop grants at the innermost proved step without `tenant` being admitted at
|
|
431
|
+
// all. (Pinned in notion-event-normalizer-test.ts §5b.)
|
|
432
|
+
//
|
|
433
|
+
// THE STANDING REASON, which none of that touches: admitting `tenant` would not extend the
|
|
434
|
+
// claim's reach by a single page — reach is already total — it would only change the verdict on
|
|
435
|
+
// a delivery that identifies NO surface, i.e. one whose subject is unknown. That is a change in
|
|
436
|
+
// what counts as PROOF, not in how far a grant reaches, and it is the one thing this walk should
|
|
437
|
+
// never concede: it would make Notion the only provider where "I cannot tell you what this is
|
|
438
|
+
// about" authorizes a wake, against GitHub's refusal of owner-only deliveries
|
|
439
|
+
// (github-normalizer.ts) and core's own `no-scope` arm, and it would convert a legible
|
|
440
|
+
// `grant_refused` audit row into a silent fire. So `tenant` stays out, on the narrower and more
|
|
441
|
+
// durable ground.
|
|
442
|
+
for (const kind of NOTION_GRANT_STEPS) {
|
|
443
|
+
const id = scopeIdOf(scope, kind);
|
|
444
|
+
if (id === undefined) continue;
|
|
445
|
+
const owning = cfg.domainsForSurface({ workspaceKey, surface: id });
|
|
446
|
+
if (owning.length === 0) continue; // the registry says nothing about this step — ask the next one out
|
|
447
|
+
return owning.some((d) => access.domains.includes(d));
|
|
448
|
+
}
|
|
449
|
+
return false; // no step of the proved path is a surface the deployer put in any domain
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* One id, safe to put in a log line. The fence's `reason` names the SCOPE STEP that refused, and a
|
|
455
|
+
* scope step id is a payload field the SENDER chose — the very thing this whole function exists
|
|
456
|
+
* because nothing authenticates. A raw newline in it would forge a second log line, so the value is
|
|
457
|
+
* bounded and stripped of every control character before it is quoted.
|
|
458
|
+
*
|
|
459
|
+
* Notion ids are UUIDs, so 64 characters is far more than a real one needs and is short enough that
|
|
460
|
+
* a padded id cannot push the rest of the line out of a truncated log.
|
|
461
|
+
*/
|
|
462
|
+
function quote(id: string | undefined): string {
|
|
463
|
+
if (id === undefined) return '"(none)"';
|
|
464
|
+
// The class is written in \u escapes on purpose: a raw control byte in a source file is its own
|
|
465
|
+
// defect class in this tree (`lint:invisible-bytes`).
|
|
466
|
+
return JSON.stringify(id.slice(0, 64).replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, "?"));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** {@link notionTriggerScopeVerdict}'s answer. `ok` is the ONLY field a caller may branch on; the
|
|
470
|
+
* `reason` is operator-facing prose for the log line and carries no control flow. */
|
|
471
|
+
export type NotionTriggerScopeVerdict =
|
|
472
|
+
| { readonly ok: true; readonly detail: string }
|
|
473
|
+
| { readonly ok: false; readonly reason: string };
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
477
|
+
* 🔴 THE INGRESS FENCE (OPE-500) — MAY THIS SIGNER ROUTE A DELIVERY ABOUT THIS SCOPE?
|
|
478
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
479
|
+
*
|
|
480
|
+
* `channels/notion.ts` calls this on every VERIFIED structural delivery, BEFORE `admitTrigger`. A
|
|
481
|
+
* refusal drops the delivery out of the trigger plane. It never affects the ambient comment path,
|
|
482
|
+
* which has its own OPE-496 resolution and never reaches this function.
|
|
483
|
+
*
|
|
484
|
+
* THE RULE, in one sentence:
|
|
485
|
+
*
|
|
486
|
+
* A delivery may arm a routine on a scope only when the domain that owns that scope is a domain
|
|
487
|
+
* the SIGNING integration serves.
|
|
488
|
+
*
|
|
489
|
+
* WHY THAT IS THE RIGHT AUTHORITY. The signing integration is the ONE authenticated fact a delivery
|
|
490
|
+
* carries (`gateNotionDeliveryAcross` — the secret that verifies identifies the sender). The scope
|
|
491
|
+
* path is not authenticated at all. `notion-credentials.ts` already establishes which integration a
|
|
492
|
+
* domain binds, and that binding is deployer config merged by PR. So this asks the payload's claim
|
|
493
|
+
* one question the payload cannot answer for itself, and answers it from config.
|
|
494
|
+
*
|
|
495
|
+
* WORKED THROUGH, because the point is what it REFUSES. Domain `payments` binds integration
|
|
496
|
+
* `notion-payments` with its own inbound secret. A holder of `notion-payments`'s `verification_token`
|
|
497
|
+
* signs a body naming a `security` page. The granting step resolves to `security`, whose credential
|
|
498
|
+
* is `notion-security` (or the org default). That is not the signer, and the two do not share an
|
|
499
|
+
* inbound secret, so the delivery routes nothing.
|
|
500
|
+
*
|
|
501
|
+
* 🔴 THE ONE THING IT CANNOT SEPARATE, STATED PLAINLY. Two integrations that share an INBOUND SECRET
|
|
502
|
+
* are indistinguishable to the HMAC gate — `connectorIntegrations` defaults a domain's
|
|
503
|
+
* `inboundSecret` to the deployment default, so a domain that binds a credential WITHOUT declaring
|
|
504
|
+
* its own inbound secret signs exactly like the org default. This function accepts that case rather
|
|
505
|
+
* than refusing it, because refusing would break a supported config in which the domain runs no
|
|
506
|
+
* subscription of its own. The honest statement of the property is therefore:
|
|
507
|
+
*
|
|
508
|
+
* A domain is fenced from another domain's forged deliveries IFF it runs its own inbound secret.
|
|
509
|
+
*
|
|
510
|
+
* That is the same condition `notion-credentials.ts` already states for the read boundary, and the
|
|
511
|
+
* runbook already tells operators to satisfy it. It is not a hole this function opens; it is the
|
|
512
|
+
* limit of what the HMAC can tell one sender from another.
|
|
513
|
+
*
|
|
514
|
+
* FAIL CLOSED, AND THE REFUSALS ARE REAL. No signer (the dev hatch), an unparseable body, and a path
|
|
515
|
+
* no domain owns all refuse. The last one costs nothing: {@link notionEventScopeGrant} would refuse
|
|
516
|
+
* the same delivery a moment later, so dropping it here only makes the refusal legible.
|
|
517
|
+
*
|
|
518
|
+
* PURE. `resolveSurface` defaults to the same `getConfig().domainsForSurface` the grant reads, and is
|
|
519
|
+
* injectable so the test drives every arm with no registry.
|
|
520
|
+
*/
|
|
521
|
+
export function notionTriggerScopeVerdict(
|
|
522
|
+
rawBody: string,
|
|
523
|
+
signerCredentialId: string | undefined,
|
|
524
|
+
domains: readonly Domain[],
|
|
525
|
+
resolveSurface: (p: { workspaceKey: string; surface: string }) => readonly string[] = (p) => getConfig().domainsForSurface(p),
|
|
526
|
+
): NotionTriggerScopeVerdict {
|
|
527
|
+
const ev = parse(rawBody);
|
|
528
|
+
if (!ev) return { ok: false, reason: "the delivery body did not parse" };
|
|
529
|
+
|
|
530
|
+
// NOT OURS TO JUDGE. A `comment.*` or unrecognised body normalizes to null, so it can never fire a
|
|
531
|
+
// routine — refusing it here would log a security refusal for every ordinary comment.
|
|
532
|
+
if (notionEventClass(eventTypeOf(ev)) !== "structural") return { ok: true, detail: "not a structural event" };
|
|
533
|
+
|
|
534
|
+
if (!signerCredentialId) return { ok: false, reason: "no integration's secret identified this delivery" };
|
|
535
|
+
|
|
536
|
+
const scope = notionScopePath(ev);
|
|
537
|
+
const workspace = scopeIdOf(scope, "tenant");
|
|
538
|
+
if (workspace === undefined) return { ok: false, reason: "the delivery names no workspace" };
|
|
539
|
+
const workspaceKey = notionWorkspaceKey(workspace);
|
|
540
|
+
|
|
541
|
+
// The inbound secret each integration signs with. Two entries sharing one name sign identically,
|
|
542
|
+
// which is what the "cannot separate" paragraph above is about. The comparison itself lives in
|
|
543
|
+
// `notion-credentials.ts` (notionSignerServesDomain) because the COMMENT plane's fence
|
|
544
|
+
// (notionDeliveryScopeVerdict, OPE-502) asks the identical question, and two implementations of
|
|
545
|
+
// "does this signer serve that domain?" is how one plane would come to answer it differently.
|
|
546
|
+
const integrations = notionIntegrations(domains);
|
|
547
|
+
|
|
548
|
+
for (const kind of NOTION_GRANT_STEPS) {
|
|
549
|
+
const id = scopeIdOf(scope, kind);
|
|
550
|
+
if (id === undefined) continue;
|
|
551
|
+
const owning = resolveSurface({ workspaceKey, surface: id });
|
|
552
|
+
if (owning.length === 0) continue; // the registry says nothing about this step — ask the next one out
|
|
553
|
+
|
|
554
|
+
// THE STEP THAT WILL AUTHORIZE. EVERY domain that owns it has to be one the signer serves.
|
|
555
|
+
// Exclusivity makes this list at most one entry, but the fence does not lean on that: `some`
|
|
556
|
+
// here would let a second owner carry a delivery the first owner's credential never signed for.
|
|
557
|
+
let viaSharedSecret = false;
|
|
558
|
+
for (const name of owning) {
|
|
559
|
+
const owner = domains.find((d) => d.name === name);
|
|
560
|
+
if (!owner) {
|
|
561
|
+
// The surface resolver named a domain the registry passed here does not hold. That is a
|
|
562
|
+
// config seam, and on a fence the only safe reading of "I cannot resolve this" is no.
|
|
563
|
+
return { ok: false, reason: `${kind} ${quote(id)} resolved to domain ${quote(name)}, which this registry does not hold` };
|
|
564
|
+
}
|
|
565
|
+
// The one thing the HMAC cannot separate: two integrations sharing one inbound secret sign
|
|
566
|
+
// identically. Accepted, and named in the detail so the log says which limit was hit.
|
|
567
|
+
const served = notionSignerServesDomain(owner, signerCredentialId, integrations);
|
|
568
|
+
if (served.served) {
|
|
569
|
+
if (served.via === "shared-secret") viaSharedSecret = true;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
reason:
|
|
575
|
+
`${kind} ${quote(id)} belongs to domain ${quote(name)}, which is served by notion credential ${quote(served.expected)} — ` +
|
|
576
|
+
`this delivery was signed by ${quote(signerCredentialId)}. A signed body cannot arm another domain's routines.`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
ok: true,
|
|
581
|
+
detail: viaSharedSecret
|
|
582
|
+
? `${kind} ${quote(id)} is served by an integration that shares this deployment's inbound secret`
|
|
583
|
+
: `${kind} ${quote(id)} is served by ${quote(signerCredentialId)}`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return { ok: false, reason: "no step of the claimed path is a surface the deployer put in any domain" };
|
|
587
|
+
}
|