@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,417 @@
|
|
|
1
|
+
// WHICH NOTION INTEGRATION SERVES THIS PAGE (OPE-467) — the one place the Notion bundle decides
|
|
2
|
+
// which credential a call runs under, and which integrations may sign an inbound delivery.
|
|
3
|
+
//
|
|
4
|
+
// THE PROBLEM. Notion the vendor supports MANY internal integrations, and each one only sees the
|
|
5
|
+
// page trees a human explicitly shared with it. That is a real, vendor-enforced boundary — the
|
|
6
|
+
// strongest isolation any of OE's six surfaces can buy. Until this module OE wired ONE integration
|
|
7
|
+
// for every domain, so the boundary was app-layer only: `notion-scope.ts` fenced the domain, and
|
|
8
|
+
// the credential underneath could see every page in the workspace. `notion-scope.ts` still says so
|
|
9
|
+
// in its own header, and it stays true for a domain on the org default.
|
|
10
|
+
//
|
|
11
|
+
// THE RULE FOR WORK THE AGENT INITIATES — a read tool, a proactive post: **the domain that owns the
|
|
12
|
+
// page decides the credential.**
|
|
13
|
+
// • the page's domain declares a `credentials` override for notion `channel-key` ⇒ that credential;
|
|
14
|
+
// • it declares none (or the page is in no domain) ⇒ the org default `channel-key`.
|
|
15
|
+
//
|
|
16
|
+
// THE RULE FOR A DELIVERY, and for everything downstream of one: **the integration that VERIFIED it
|
|
17
|
+
// serves it.** Each integration signs its deliveries with its OWN vendor-issued
|
|
18
|
+
// `verification_token`, so a second integration's deliveries cannot verify against the first's
|
|
19
|
+
// secret. {@link notionIntegrations} enumerates the configured integrations and the route tries each
|
|
20
|
+
// one's secret in turn — the same "ordered keys, current first" shape `webhooks/secrets.ts` uses for
|
|
21
|
+
// a rotation window — and THE SECRET THAT VERIFIES IDENTIFIES THE INTEGRATION THAT SENT THE
|
|
22
|
+
// DELIVERY. That is the only authenticated fact a delivery carries.
|
|
23
|
+
//
|
|
24
|
+
// 🔴 THAT RULE REACHES THE READS TOO (OPE-499, ruled by Tommy 2026-08-13). A delivery-woken session's
|
|
25
|
+
// READ TOOLS run under the SAME carried credential as its fetch and its reply — never a fresh lookup
|
|
26
|
+
// off the page's domain binding. {@link notionCallerCredential} reads the `credential_id` auth
|
|
27
|
+
// attribute the delivery stowed (notion-identity.ts buildNotionAuth) and prefers it when present. The
|
|
28
|
+
// domain binding stays the rule for an AGENT-INITIATED session — a routine, a proactive post — where
|
|
29
|
+
// no delivery ever ran and there is no carried credential to prefer.
|
|
30
|
+
//
|
|
31
|
+
// WHY THIS CLOSES A REAL GAP. An operator who shares one page with BOTH the org-default integration
|
|
32
|
+
// and a domain's own integration lets either subscription's `verification_token` holder produce a
|
|
33
|
+
// verified delivery for that page. Before this rule, the session that delivery woke still read under
|
|
34
|
+
// the PAGE's domain binding — so a holder of the WEAKER (org) credential could ride a double-shared
|
|
35
|
+
// page into every page the domain's OWN, narrower integration can see. Carrying the delivery's own
|
|
36
|
+
// credential into the reads means a session only ever reads what the credential that actually
|
|
37
|
+
// verified it was shared into — the vendor's own share-list boundary, working as designed.
|
|
38
|
+
//
|
|
39
|
+
// 🔴 WHY A BINDING DOES NOT OUTRANK THE HMAC. A delivery's page id is read off the payload, and
|
|
40
|
+
// nothing authenticates the payload. If the page's binding decided, a forger holding integration
|
|
41
|
+
// A's `verification_token` could name a domain-B page and have the delivery run under B's
|
|
42
|
+
// credential. So what a binding actually buys on ingress is narrower than "this page is served by
|
|
43
|
+
// this integration": **a binding routes the deliveries the bound subscription itself sent.** Both
|
|
44
|
+
// rules meet at one credential id per delivery, and the REPLY carries that id forward (the auth
|
|
45
|
+
// attributes stow it) — so the credential that fetched a comment is the credential that answers it,
|
|
46
|
+
// on every path.
|
|
47
|
+
//
|
|
48
|
+
// A page shared with two integrations produces a delivery on EACH subscription. That is legitimate,
|
|
49
|
+
// and each delivery correctly runs under its own verifying integration — which is why a disagreement
|
|
50
|
+
// between the HMAC and the binding PREFERS the HMAC rather than refusing.
|
|
51
|
+
//
|
|
52
|
+
// 🔴 WHAT THIS DOES NOT BUY. Binding a domain to its own integration only fences the pages that
|
|
53
|
+
// integration was SHARED into. If the operator leaves a payments page shared with the org-default
|
|
54
|
+
// integration as well, that key can still read it — Notion enforces the share list, and OE cannot
|
|
55
|
+
// widen or narrow it. The runbook says this plainly: bind the domain AND unshare the page from
|
|
56
|
+
// every other integration.
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
domainCredentialId,
|
|
60
|
+
OperatorSecretNotSetError,
|
|
61
|
+
resolveDomainForChannel,
|
|
62
|
+
connectorIntegrations,
|
|
63
|
+
resolvePartition,
|
|
64
|
+
type BindableContext,
|
|
65
|
+
type Domain,
|
|
66
|
+
type ConnectorIntegration,
|
|
67
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
68
|
+
import { notionSurfaceKey } from "./notion-identity.js";
|
|
69
|
+
import { notionPageDomain } from "./notion-scope.js";
|
|
70
|
+
|
|
71
|
+
/** The provider name in `agent/access/providers.ts`. */
|
|
72
|
+
export const NOTION_PROVIDER = "notion";
|
|
73
|
+
|
|
74
|
+
/** The ORG-DEFAULT channel-plumbing credential id — the role a domain override replaces. */
|
|
75
|
+
export const NOTION_CHANNEL_CREDENTIAL = "channel-key";
|
|
76
|
+
|
|
77
|
+
/** The operator-secret name holding the DEFAULT integration's inbound signing key (Notion's
|
|
78
|
+
* subscription `verification_token`). A domain that runs its own subscription names its own with
|
|
79
|
+
* `inboundSecret`. Resolved through the operator-secret resolver, so a value stored with
|
|
80
|
+
* `oe secret set NOTION_WEBHOOK_SECRET` wins over an env var of the same name. */
|
|
81
|
+
const NOTION_WEBHOOK_SECRET_NAME = "NOTION_WEBHOOK_SECRET";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The notion `channel-key` credential id a page's domain binds, or **undefined when it binds none**.
|
|
85
|
+
*
|
|
86
|
+
* Undefined is a load-bearing answer, not a failure: it means "this page's domain asked for no
|
|
87
|
+
* override", which every caller reads as "use the org default". Keeping it distinct from a resolved
|
|
88
|
+
* id is what lets the webhook route fall through to its own arms (the verifying integration, then
|
|
89
|
+
* the org default) instead of treating "no binding" as a refusal.
|
|
90
|
+
*
|
|
91
|
+
* Pure — `domains` is injected, exactly like `notionPageDomain`'s, so the whole decision is
|
|
92
|
+
* unit-testable with no config and no store.
|
|
93
|
+
*/
|
|
94
|
+
export function notionChannelCredentialFor(
|
|
95
|
+
workspace: string | undefined,
|
|
96
|
+
pageId: string | undefined,
|
|
97
|
+
domains: readonly Domain[],
|
|
98
|
+
): string | undefined {
|
|
99
|
+
if (!pageId) return undefined;
|
|
100
|
+
const domain = notionPageDomain(workspace, pageId, domains).domain;
|
|
101
|
+
if (!domain) return undefined;
|
|
102
|
+
const id = domainCredentialId(domain, NOTION_PROVIDER, NOTION_CHANNEL_CREDENTIAL);
|
|
103
|
+
return id === NOTION_CHANNEL_CREDENTIAL ? undefined : id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* {@link notionChannelCredentialFor} for a session's own surface key (`notion/<workspace>/<page>`) —
|
|
108
|
+
* the form `resolvePartition(ctx).surfaceKey` produces.
|
|
109
|
+
*
|
|
110
|
+
* The read tools resolve the CALLER'S surface, not the page argument. That is deliberate and it is
|
|
111
|
+
* not a weaker check: `notion-scope.ts` has already established that an admitted read is either the
|
|
112
|
+
* caller's own page or a page in the caller's own domain, so the two resolve to the same domain by
|
|
113
|
+
* construction — and the caller's surface is known BEFORE the fence runs, which the block-resolution
|
|
114
|
+
* walk needs (it must make a Notion call to find a block's owning page).
|
|
115
|
+
*/
|
|
116
|
+
export function notionChannelCredentialForSurface(surfaceKey: string, domains: readonly Domain[]): string | undefined {
|
|
117
|
+
const parts = surfaceKey.split("/");
|
|
118
|
+
if (parts.length !== 3 || parts[0] !== "notion") return undefined;
|
|
119
|
+
return notionChannelCredentialFor(parts[1] || undefined, parts[2] || undefined, domains);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The `credential_id` auth attribute a DELIVERY stowed on this session (notion-identity.ts
|
|
124
|
+
* buildNotionAuth), or undefined when there is none — a plain, LOCAL structural read of
|
|
125
|
+
* `ctx.session.auth`, the same shape notion-identity.ts's own `notionAuthOf`/`notionAttr` probes
|
|
126
|
+
* read (kept as a separate three-line copy rather than an import for the same reason theirs is not a
|
|
127
|
+
* barrel port: a mapper/credential decision reads its own platform's attributes itself). `current` is
|
|
128
|
+
* this turn's auth; `initiator` is the fallback for a context that only carries the session's
|
|
129
|
+
* original one.
|
|
130
|
+
*/
|
|
131
|
+
function notionDeliveryCredential(ctx: BindableContext): string | undefined {
|
|
132
|
+
const auth = ctx.session?.auth?.current ?? ctx.session?.auth?.initiator;
|
|
133
|
+
const v = auth?.attributes?.credential_id;
|
|
134
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
135
|
+
if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The credential a READ TOOL runs under (OPE-499). **The delivery's own carried credential wins**
|
|
141
|
+
* when this session was woken by one — the same `credential_id` auth attribute the reply arm already
|
|
142
|
+
* reads (notion-post-arm.ts) — so a session only ever reads what the integration that verified its
|
|
143
|
+
* delivery was actually shared into, never a wider domain binding a double-shared page could reach.
|
|
144
|
+
*
|
|
145
|
+
* Falls back to the CALLING SESSION's own domain binding when no delivery credential rides the
|
|
146
|
+
* context — an agent-initiated session (a routine, a proactive post) never carried one, and that is
|
|
147
|
+
* the pre-OPE-499 rule, unchanged. Fail-soft throughout: a synthetic or identity-less ctx has no
|
|
148
|
+
* partition to read, so it resolves undefined and the caller falls back to the org default, exactly
|
|
149
|
+
* like an unbound domain. That is the correct direction here: the DOMAIN FENCE (notion-scope.ts) is
|
|
150
|
+
* what refuses an out-of-scope read, and it runs whether or not a credential was selected.
|
|
151
|
+
*/
|
|
152
|
+
export function notionCallerCredential(ctx: BindableContext, domains: readonly Domain[]): string | undefined {
|
|
153
|
+
const delivered = notionDeliveryCredential(ctx);
|
|
154
|
+
if (delivered) return delivered;
|
|
155
|
+
try {
|
|
156
|
+
return notionChannelCredentialForSurface(resolvePartition(ctx).surfaceKey, domains);
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Every Notion integration this deployment is reachable by — the ORG DEFAULT FIRST, then one per
|
|
164
|
+
* domain that binds its own. The ingress tries each entry's inbound secret in this order.
|
|
165
|
+
*/
|
|
166
|
+
export function notionIntegrations(domains: readonly Domain[]): readonly ConnectorIntegration[] {
|
|
167
|
+
return connectorIntegrations(domains, NOTION_PROVIDER, NOTION_CHANNEL_CREDENTIAL, NOTION_WEBHOOK_SECRET_NAME);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Does the integration that SIGNED a delivery serve `owner`? The one comparison behind both signer
|
|
172
|
+
* fences — the trigger plane's {@link notionTriggerScopeVerdict} and the comment plane's
|
|
173
|
+
* {@link notionDeliveryScopeVerdict} — extracted so the two can never answer it differently.
|
|
174
|
+
*
|
|
175
|
+
* Three outcomes, because the caller's log line needs to tell them apart:
|
|
176
|
+
* • `"credential"` — the signer IS the credential this domain binds. The ordinary yes.
|
|
177
|
+
* • `"shared-secret"` — a different credential, signed with the SAME inbound secret. Accepted, and
|
|
178
|
+
* named, because `connectorIntegrations` defaults a domain's `inboundSecret` to the deployment
|
|
179
|
+
* default: a domain that binds a credential without declaring its own secret signs exactly like
|
|
180
|
+
* the org default, and refusing would break a supported config in which the domain runs no
|
|
181
|
+
* subscription of its own.
|
|
182
|
+
* • `served: false` — neither, so the signer cannot speak for this domain.
|
|
183
|
+
*
|
|
184
|
+
* 🔴 THE PROPERTY THIS BUYS, STATED HONESTLY: **a domain is fenced from another domain's forged
|
|
185
|
+
* deliveries IFF it runs its own inbound secret.** That is not a hole either fence opens. It is the
|
|
186
|
+
* limit of what an HMAC can tell one sender from another when two senders hold the same key.
|
|
187
|
+
*
|
|
188
|
+
* `expected` rides on every outcome so a refusal can name the credential that WOULD have served.
|
|
189
|
+
*/
|
|
190
|
+
export type NotionSignerVerdict =
|
|
191
|
+
| { readonly served: true; readonly via: "credential" | "shared-secret"; readonly expected: string }
|
|
192
|
+
| { readonly served: false; readonly expected: string };
|
|
193
|
+
|
|
194
|
+
export function notionSignerServesDomain(
|
|
195
|
+
owner: Domain,
|
|
196
|
+
signerCredentialId: string,
|
|
197
|
+
integrations: readonly ConnectorIntegration[],
|
|
198
|
+
): NotionSignerVerdict {
|
|
199
|
+
const expected = domainCredentialId(owner, NOTION_PROVIDER, NOTION_CHANNEL_CREDENTIAL);
|
|
200
|
+
if (expected === signerCredentialId) return { served: true, via: "credential", expected };
|
|
201
|
+
const secretOf = (credentialId: string): string | undefined => integrations.find((i) => i.credentialId === credentialId)?.inboundSecret;
|
|
202
|
+
const signerSecret = secretOf(signerCredentialId);
|
|
203
|
+
if (signerSecret !== undefined && secretOf(expected) === signerSecret) return { served: true, via: "shared-secret", expected };
|
|
204
|
+
return { served: false, expected };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Every domain a COMMENT delivery reaches THROUGH ITS WORKSPACE CLAIM — the domains the payload's
|
|
209
|
+
* `workspace_id` can steer, and therefore the only ones {@link notionDeliveryScopeVerdict} fences.
|
|
210
|
+
*
|
|
211
|
+
* 🔴 TWO RESOLVERS ANSWER "WHICH DOMAIN?" DOWNSTREAM, AND THEY DO NOT ALWAYS AGREE. Both are
|
|
212
|
+
* consulted on the session the delivery opens, so a check on one alone would leave the other open:
|
|
213
|
+
*
|
|
214
|
+
* • {@link notionPageDomain} — the credential binding (agent-initiated work) and the read fence
|
|
215
|
+
* (notion-scope.ts). Its roster leg compares the PAGE-ID segment alone.
|
|
216
|
+
* • `resolveDomainForChannel` — the ambient gate, the engagement tier, the supervisor grain and
|
|
217
|
+
* the external-author policy, all keyed on the `notion/<workspace>/<page>` surface key. Its
|
|
218
|
+
* roster leg compares the WHOLE key, so a workspace that does not match the `reach` entry
|
|
219
|
+
* falls THROUGH to the claim leg.
|
|
220
|
+
*
|
|
221
|
+
* 🔴 WHY THE ROSTER LEGS ARE EXEMPT, AND THIS IS THE WHOLE DESIGN. A roster hit is not steerable by
|
|
222
|
+
* the payload's workspace:
|
|
223
|
+
* • {@link notionPageDomain}'s roster leg ignores the workspace outright, and OPE-496 established
|
|
224
|
+
* the page id from the comment Notion returned under the verified credential. Authenticated fact
|
|
225
|
+
* in, config match out.
|
|
226
|
+
* • `resolveDomainForChannel`'s roster leg needs the WHOLE key to appear in `reach`, so the
|
|
227
|
+
* workspace it accepts is one the deployer typed themselves.
|
|
228
|
+
* A claim leg is the opposite: a container entry names a workspace, the payload names a workspace, and a
|
|
229
|
+
* match hands over every page under it.
|
|
230
|
+
*
|
|
231
|
+
* KEEPING THE ROSTER LEGS OUT IS LOAD-BEARING, not a shortcut. A page shared with two integrations
|
|
232
|
+
* produces a delivery on EACH subscription, and each one must run under its own verifying integration
|
|
233
|
+
* (the header's "prefer, don't refuse" rule, and the escalation OPE-499 closes by carrying the
|
|
234
|
+
* delivery's credential into the reads). Fencing a rostered page on the signer would refuse the
|
|
235
|
+
* second subscription's delivery outright and delete that behaviour.
|
|
236
|
+
*
|
|
237
|
+
* Pure — `domains` is injected, exactly like {@link notionPageDomain}'s.
|
|
238
|
+
*/
|
|
239
|
+
export function notionDeliveryClaimedDomains(
|
|
240
|
+
workspaceId: string | undefined,
|
|
241
|
+
pageId: string,
|
|
242
|
+
domains: readonly Domain[],
|
|
243
|
+
): readonly Domain[] {
|
|
244
|
+
const out: Domain[] = [];
|
|
245
|
+
const add = (d: Domain | undefined): void => {
|
|
246
|
+
if (d && !out.some((seen) => seen.name === d.name)) out.push(d);
|
|
247
|
+
};
|
|
248
|
+
const page = notionPageDomain(workspaceId, pageId, domains);
|
|
249
|
+
if (page.via === "container") add(page.domain);
|
|
250
|
+
const channel = resolveDomainForChannel(notionSurfaceKey(workspaceId, pageId), domains);
|
|
251
|
+
if (channel.via.startsWith("container:")) add(channel.domain);
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** {@link notionDeliveryScopeVerdict}'s answer. `ok` is the ONLY field a caller may branch on; the
|
|
256
|
+
* `reason` and the `detail` are operator-facing prose for the log line and carry no control flow. */
|
|
257
|
+
export type NotionDeliveryScopeVerdict =
|
|
258
|
+
| { readonly ok: true; readonly detail: string }
|
|
259
|
+
| { readonly ok: false; readonly reason: string };
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
263
|
+
* 🔴 THE COMMENT-PLANE INGRESS FENCE (OPE-502) — MAY THIS SIGNER OPEN A SESSION ON THIS PAGE?
|
|
264
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
265
|
+
*
|
|
266
|
+
* `notion-webhook.ts` calls this after the page is established (OPE-496) and BEFORE the surface key
|
|
267
|
+
* decides anything. The rule is the trigger plane's rule, on the plane next door:
|
|
268
|
+
*
|
|
269
|
+
* A delivery may open a session on a domain it reaches THROUGH ITS WORKSPACE CLAIM only when that
|
|
270
|
+
* domain is one the SIGNING integration serves.
|
|
271
|
+
*
|
|
272
|
+
* WHY THE WORKSPACE NEEDS THIS AND THE PAGE DOES NOT. OPE-496 established the PAGE from the comment
|
|
273
|
+
* Notion returned under the verified credential, so the page is an authenticated fact. It left the
|
|
274
|
+
* WORKSPACE where it found it — `ev.workspace_id`, a payload field nothing authenticates — and the
|
|
275
|
+
* two are composed into `notion/<workspace>/<page>`. There is no verified source for the second
|
|
276
|
+
* half: Notion's REST surface returns no workspace id on any object (`/v1/users/me` gives
|
|
277
|
+
* `bot.workspace_name`, not an id), so no fetch under any credential can confirm the claim. Pinning
|
|
278
|
+
* the workspace to a config constant is wrong for a different reason — one deployment may hold
|
|
279
|
+
* several workspaces. So the claim is fenced instead of proved, exactly as OPE-500 fenced the
|
|
280
|
+
* trigger plane's scope path.
|
|
281
|
+
*
|
|
282
|
+
* WORKED THROUGH, because the point is what it REFUSES. Domain `security` claims workspace
|
|
283
|
+
* `notion/W-sec` in a container entry and binds integration `notion-security` with its own inbound
|
|
284
|
+
* secret. A holder of domain `payments`'s `verification_token` comments on a page their OWN
|
|
285
|
+
* integration can see, then signs a delivery whose `workspace_id` reads `W-sec`. The page reaches
|
|
286
|
+
* `security` through that claim, so without this fence the session opens on `security` — with
|
|
287
|
+
* `security`'s ambient flag, `security`'s read scope over every page under the claim, and (for an
|
|
288
|
+
* agent-initiated call) `security`'s credential. The fence reads that domain's credential, finds
|
|
289
|
+
* `notion-security` against a `notion-payments` signature, and refuses.
|
|
290
|
+
*
|
|
291
|
+
* FAIL CLOSED, AND THE REFUSALS ARE REAL:
|
|
292
|
+
* • NO SIGNER ⇒ refuse. `webhooks.allowUnverifiedNotion` accepts a delivery with no secret
|
|
293
|
+
* configured anywhere, so nothing identifies a sender and the fence has no fact to read. A fence
|
|
294
|
+
* that says yes when it cannot tell is not a fence. The consequence is stated in the runbook: with
|
|
295
|
+
* that dev hatch on, a CLAIMED page opens no session. A rostered page is unaffected, so the local
|
|
296
|
+
* exercise the hatch exists for still runs.
|
|
297
|
+
* • NO CLAIMED DOMAIN ⇒ allow. Either the page is rostered — see
|
|
298
|
+
* {@link notionDeliveryClaimedDomains} for why a roster hit is not steerable — or it is in no
|
|
299
|
+
* domain at all, where the `org` pseudo-domain answers and no payload field can steer that
|
|
300
|
+
* either.
|
|
301
|
+
*
|
|
302
|
+
* THE PARTITION IS DECIDED SEPARATELY (OPE-254 §2, folding OPE-537). This fence decides the domain
|
|
303
|
+
* BOUNDARY; which partition a delivery lands in is notion-webhook.ts's step 8c, which composes the
|
|
304
|
+
* key from the workspace segment of the `reach` entry that MATCHED — `notionReachWorkspace`, on both
|
|
305
|
+
* legs. A page named outright therefore keys on config's workspace whatever a delivery claims. A
|
|
306
|
+
* page reached by a CONTAINER only matches when the payload's workspace equals the container's own
|
|
307
|
+
* text, so there the derived value equals the payload's by construction; a delivery naming a
|
|
308
|
+
* workspace no domain reaches matches nothing, and its key belongs to no domain — no memory, no
|
|
309
|
+
* bundle, and the ambient gate refuses it — rather than opening a second partition inside one.
|
|
310
|
+
*
|
|
311
|
+
* 🔴 WHY THE CONTAINER LEG NEEDED A CONFIG RULE, NOT JUST THE SAME READ. The exact-key match is
|
|
312
|
+
* workspace-BLIND — it finds a page by id alone, so the workspace read back off the matched entry
|
|
313
|
+
* is a signal INDEPENDENT of whatever the payload said. The container leg is the opposite shape:
|
|
314
|
+
* `notionPageDomain`'s container match composes `notion/<payload-workspace>/<page>` and only admits
|
|
315
|
+
* it when a container entry's tenant segment equals THAT SAME payload workspace verbatim
|
|
316
|
+
* (surface-key-grammar.ts refuses a glob on the tenant segment, so the compare is always exact). So
|
|
317
|
+
* whenever a container admits a delivery, the matched entry's workspace text and the payload's are,
|
|
318
|
+
* by construction, the identical string — reading the entry back is a no-op IN VALUE.
|
|
319
|
+
*
|
|
320
|
+
* What it is not is a no-op in PROPERTY, and that is the point. The remaining way one page could
|
|
321
|
+
* still key two ways was a domain reaching MORE THAN ONE Notion workspace: two honestly-signed
|
|
322
|
+
* deliveries for the same page, one naming each, opened two partitions. memory/domain.ts's
|
|
323
|
+
* `partitionTenantProblems` now refuses that config at load, so the matched entry names exactly one
|
|
324
|
+
* workspace per domain and the partition is a fact about config rather than about a delivery.
|
|
325
|
+
* No live re-keying follows: on every configuration that still loads, the derived value equals the
|
|
326
|
+
* value the payload carried, so no existing `channel_session` or memory row changes key.
|
|
327
|
+
*
|
|
328
|
+
* Pure — `domains` is injected, so the whole decision is unit-testable with no config and no store.
|
|
329
|
+
*/
|
|
330
|
+
export function notionDeliveryScopeVerdict(
|
|
331
|
+
workspaceId: string | undefined,
|
|
332
|
+
pageId: string,
|
|
333
|
+
signerCredentialId: string | undefined,
|
|
334
|
+
domains: readonly Domain[],
|
|
335
|
+
): NotionDeliveryScopeVerdict {
|
|
336
|
+
const owners = notionDeliveryClaimedDomains(workspaceId, pageId, domains);
|
|
337
|
+
if (owners.length === 0) return { ok: true, detail: "no domain reaches this page through a workspace claim" };
|
|
338
|
+
if (!signerCredentialId) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
reason: `no integration's secret identified this delivery, and page ${quoteNotionId(pageId)} is claimed through workspace ${quoteNotionId(workspaceId)}`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const integrations = notionIntegrations(domains);
|
|
345
|
+
let viaSharedSecret = false;
|
|
346
|
+
for (const owner of owners) {
|
|
347
|
+
const verdict = notionSignerServesDomain(owner, signerCredentialId, integrations);
|
|
348
|
+
if (!verdict.served) {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
reason:
|
|
352
|
+
`page ${quoteNotionId(pageId)} reaches domain ${quoteNotionId(owner.name)} through its claim on workspace ` +
|
|
353
|
+
`${quoteNotionId(workspaceId)}, and that domain is served by notion credential ${quoteNotionId(verdict.expected)} — ` +
|
|
354
|
+
`this delivery was signed by ${quoteNotionId(signerCredentialId)}. A signed body cannot open a session on another domain.`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (verdict.via === "shared-secret") viaSharedSecret = true;
|
|
358
|
+
}
|
|
359
|
+
const named = owners.map((d) => quoteNotionId(d.name)).join(", ");
|
|
360
|
+
return {
|
|
361
|
+
ok: true,
|
|
362
|
+
detail: viaSharedSecret
|
|
363
|
+
? `domain ${named} is served by an integration that shares this deployment's inbound secret`
|
|
364
|
+
: `domain ${named} is served by ${quoteNotionId(signerCredentialId)}`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* One id, safe to put in a log line. A refusal names the workspace and the page the SENDER chose —
|
|
370
|
+
* the very fields nothing authenticates — so a raw newline in one would forge a second log line. The
|
|
371
|
+
* value is bounded and stripped of every control character before it is quoted.
|
|
372
|
+
*
|
|
373
|
+
* Notion ids are UUIDs, so 64 characters is far more than a real one needs and short enough that a
|
|
374
|
+
* padded id cannot push the rest of the line out of a truncated log. Same helper the trigger plane's
|
|
375
|
+
* fence keeps for the same reason (notion-event-normalizer.ts `quote`); the two are three lines each
|
|
376
|
+
* and live beside the refusal they protect rather than in a shared string module.
|
|
377
|
+
*/
|
|
378
|
+
function quoteNotionId(id: string | undefined): string {
|
|
379
|
+
if (id === undefined) return '"(none)"';
|
|
380
|
+
// The class is written in \u escapes on purpose: a raw control byte in a source file is its own
|
|
381
|
+
// defect class in this tree (`lint:invisible-bytes`).
|
|
382
|
+
return JSON.stringify(id.slice(0, 64).replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, "?"));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* One integration's INBOUND signing key by operator-secret name, or undefined — and the ONE place
|
|
387
|
+
* that decides whether a failure is REPORTED.
|
|
388
|
+
*
|
|
389
|
+
* 🔴 THE FAILURE THAT MUST BE LOUD. "Nobody stored this name" is the ordinary state for an
|
|
390
|
+
* integration the operator never configured: it contributes no key, a delivery it would have signed
|
|
391
|
+
* simply fails the gate, and saying so on every POST would be noise. Every OTHER failure — an
|
|
392
|
+
* unarmed `OE_CREDENTIAL_ENCRYPTION_KEY`, a store that will not open — makes EVERY delivery on that
|
|
393
|
+
* integration 401 with nothing in the log to explain it. It is reported.
|
|
394
|
+
*
|
|
395
|
+
* The two are told apart by the ERROR CLASS ({@link OperatorSecretNotSetError}), never by the
|
|
396
|
+
* message: the unarmed-key message ALSO contains the words "is not set", so the substring test this
|
|
397
|
+
* replaces reported neither and a broken credential store went silent.
|
|
398
|
+
*
|
|
399
|
+
* `get` and `log` are injected so the decision is testable with no store and no eve boot; the route
|
|
400
|
+
* passes `getOperatorSecret` and its own channel logger. The report names the SECRET NAME and the
|
|
401
|
+
* reason, never a value. Both arms return undefined — this function never throws into a webhook.
|
|
402
|
+
*/
|
|
403
|
+
export async function resolveNotionInboundSecret(
|
|
404
|
+
name: string,
|
|
405
|
+
get: (secretName: string) => Promise<{ readonly value: string }>,
|
|
406
|
+
log: (message: string) => void,
|
|
407
|
+
): Promise<string | undefined> {
|
|
408
|
+
try {
|
|
409
|
+
const resolved = await get(name);
|
|
410
|
+
return resolved.value.length > 0 ? resolved.value : undefined;
|
|
411
|
+
} catch (err) {
|
|
412
|
+
if (err instanceof OperatorSecretNotSetError) return undefined;
|
|
413
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
414
|
+
log(`inbound signing key "${name}" did not resolve — deliveries signed with it will 401: ${message}`);
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
}
|