@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,1242 @@
|
|
|
1
|
+
// The Notion comment surface — an AMBIENT-JUDGED conversational channel. The agent is ambiently
|
|
2
|
+
// present on any doc it's connected to: a standing per-doc SUPERVISOR session reads EVERY comment
|
|
3
|
+
// and intelligently decides whether to engage, reusing OE's Slack channel-watch judge wholesale.
|
|
4
|
+
// Built as a custom `defineChannel` HTTP transport (eve ships no Notion channel) over Notion's
|
|
5
|
+
// public REST API + an integration API key. The eve-free orchestration is the tested core in
|
|
6
|
+
// notion-webhook.ts (classify) + notion-supervisor.ts (session + wake); this file is the thin
|
|
7
|
+
// route that wires the store, the domain opt-in, and the reply post-back.
|
|
8
|
+
//
|
|
9
|
+
// THE LOOP:
|
|
10
|
+
// POST /eve/v1/notion
|
|
11
|
+
// → one-time subscription HANDSHAKE ({verification_token}) is CAPTURED for the operator —
|
|
12
|
+
// stored in the encrypted vault, logged only as a sha256 fingerprint + a one-shot reveal
|
|
13
|
+
// link, NEVER as a value (OPE-195; it is the permanent HMAC key, not a bootstrap nonce)
|
|
14
|
+
// → every real delivery is HMAC-verified (X-Notion-Signature = sha256(rawBody) keyed by
|
|
15
|
+
// the verification token) — fail closed on a bad/missing signature or unset secret
|
|
16
|
+
// → a comment.created event is a SIGNAL ONLY (no text/mentions), so we RETRIEVE the
|
|
17
|
+
// comment, gate on the doc's domain having ambientSupervisor ON, then CLASSIFY it
|
|
18
|
+
// (self / bot / unidentified-author and unresolved-bot-id both fail closed / @mention→direct
|
|
19
|
+
// / else→judged), take the ONE-WAKE-PER-COMMENT guard, and dispatch to the standing doc
|
|
20
|
+
// supervisor under its reserved :oe-sup:g token
|
|
21
|
+
// → the supervisor either replies (POST /v1/comments into the SAME discussion_id) or stays
|
|
22
|
+
// audibly silent (a notion.no_reply audit row — parity with Slack)
|
|
23
|
+
// → SEPARATELY, and DISJOINTLY (OPE-189): every VERIFIED delivery is also handed to
|
|
24
|
+
// admitTrigger. Comments are conversation (the supervisor's, above) and STRUCTURAL changes —
|
|
25
|
+
// page/database/data_source created, moved, deleted, properties or schema updated — are work
|
|
26
|
+
// (a routine's). The partition is enforced twice, one-sidedly: the supervisor path ignores
|
|
27
|
+
// everything that is not comment.created, and the normalizer returns null for the whole
|
|
28
|
+
// comment.* family. No single delivery ever does both. See channels/notion-event-normalizer.ts
|
|
29
|
+
// All host-side REST calls use the CHANNEL-PLUMBING integration API key — the notion provider's
|
|
30
|
+
// `channel-key` credential, resolved via notion-api.ts resolveNotionCredential, stored under
|
|
31
|
+
// `db:notion:channel-key` (OPE-408 step 2); distinct from the work-surface `team-key` credential
|
|
32
|
+
// (`db:notion:team-key`) that backs the notion-rest egress connection.
|
|
33
|
+
// 🔴 …UNLESS A DOMAIN BINDS ITS OWN INTEGRATION (OPE-467). Then TWO rules split the calls:
|
|
34
|
+
// a DELIVERY and everything it triggers — the two-step fetch and the reply — runs under the
|
|
35
|
+
// integration whose secret VERIFIED the delivery (the HMAC-proven fact; a payload field must
|
|
36
|
+
// never pick the credential). Agent-initiated READS and proactive posts have no verifying
|
|
37
|
+
// delivery, so they run under the page's domain binding, else the org default
|
|
38
|
+
// (channels/notion-credentials.ts holds that decision). Notion's own share list is the fence
|
|
39
|
+
// either way. Inbound deliveries are verified against EVERY configured integration's secret.
|
|
40
|
+
//
|
|
41
|
+
// ─── GOVERNANCE: the accepted tier ──────────────────────────────────────────────────────
|
|
42
|
+
// The "doc brain" memory UX (a page that remembers every prior discussion) fully lights up only
|
|
43
|
+
// when the operator VOUCHES the workspace: bindNotion pins visibility=private and leaves trust
|
|
44
|
+
// undefined → bindFrom fails closed to `external` (shared/workspace memory gated off) unless the
|
|
45
|
+
// workspace is vouched (identity/trust.ts / OE_TRUST=internal). Ambient does NOT relax this —
|
|
46
|
+
// reading more comments never grants more trust. Unvouched, the agent still judges + replies, but
|
|
47
|
+
// without a shared page silo. Accountability is OE-AUDITED-ONLY: every turn (dispatch, reply, AND
|
|
48
|
+
// judged silence) is in OUR append-only log; Notion's own audit log is Enterprise-admin-only and
|
|
49
|
+
// not integration-queryable, so OE's log is the sole record. This is interim until Notion's
|
|
50
|
+
// waitlisted External Agents API lands (a first-class, Notion-audited agent participant with
|
|
51
|
+
// native HITL) — see design notes notion-surface-and-platform-research.md.
|
|
52
|
+
//
|
|
53
|
+
// Live e2e: scripts/e2e/scenarios.ts J-NOT (capability "notionAmbient" — self-skips without
|
|
54
|
+
// creds). It forge-replays a signed comment.created at a human-authored anchor → judged reply or
|
|
55
|
+
// audited silence (notion.no_reply over GET /control/v1/audit), verifies no self-loop across the
|
|
56
|
+
// reply, and proves reply-targeting across two discussions on one page supervisor session. Still
|
|
57
|
+
// needs a live Notion workspace + human anchor comments + Tommy for provisioning (see
|
|
58
|
+
// notion-ambient-surface-design.md Step 5 / the e2e-issue-surfaces driver investigation).
|
|
59
|
+
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
import { defineChannel, POST, type RouteHandlerArgs } from "eve/channels";
|
|
62
|
+
import {
|
|
63
|
+
receiptEventTime,
|
|
64
|
+
spreadVendorTs,
|
|
65
|
+
appendAudit,
|
|
66
|
+
ingressSpendGate,
|
|
67
|
+
auditAmbientSkip,
|
|
68
|
+
deploymentOrg,
|
|
69
|
+
resolveWakeTrustFromContext,
|
|
70
|
+
finalMessageActionForTurn,
|
|
71
|
+
completeSupervisorTurn,
|
|
72
|
+
getConfig,
|
|
73
|
+
getSettings,
|
|
74
|
+
ambientSupervisorEnabled,
|
|
75
|
+
engagementTierFor,
|
|
76
|
+
resolveExternalPolicy,
|
|
77
|
+
externalPolicyOverrideFor,
|
|
78
|
+
liveMemoryDeltaDep,
|
|
79
|
+
getOperationalStore,
|
|
80
|
+
capabilitiesForPlatform,
|
|
81
|
+
consumeSupervisorReplyMark,
|
|
82
|
+
declaredTrustForKeys,
|
|
83
|
+
supervisorGrainFor,
|
|
84
|
+
mintRotationToken,
|
|
85
|
+
claimRuntimeRotation,
|
|
86
|
+
stampMintedRuntime,
|
|
87
|
+
grainUsesUnifiedWindow,
|
|
88
|
+
divertInboundToUnifiedWindow,
|
|
89
|
+
healPrunedDeploymentStrand,
|
|
90
|
+
captureHandshakeSecret,
|
|
91
|
+
admitTrigger,
|
|
92
|
+
NOTION_HANDSHAKE_REF_NAMESPACE,
|
|
93
|
+
getOperatorSecret,
|
|
94
|
+
type Domain,
|
|
95
|
+
type SupervisorGrain,
|
|
96
|
+
type SurfaceTrust,
|
|
97
|
+
carryTailForward,
|
|
98
|
+
type WakeMessageInput,
|
|
99
|
+
recordWakeTail,
|
|
100
|
+
postHostNotice,
|
|
101
|
+
detectBridgeableInputRequest,
|
|
102
|
+
renderQuestionForSurface,
|
|
103
|
+
QUESTION_ANSWERED_AUDIT_ACTION,
|
|
104
|
+
QUESTION_ANSWER_DROPPED_AUDIT_ACTION,
|
|
105
|
+
seatCatchUp,
|
|
106
|
+
dispatchNativeWakeWithRetry,
|
|
107
|
+
readCappedBody,
|
|
108
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
109
|
+
import {
|
|
110
|
+
forgetNotionQuestion,
|
|
111
|
+
rememberNotionQuestion,
|
|
112
|
+
resolveNotionInboundAnswer,
|
|
113
|
+
type NotionInboundAnswer,
|
|
114
|
+
} from "./notion-question-return.js";
|
|
115
|
+
import { notionChannelCredentialFor, notionIntegrations, resolveNotionInboundSecret, NOTION_CHANNEL_CREDENTIAL } from "./notion-credentials.js";
|
|
116
|
+
import { resolveNotionBotUser } from "./notion-bot-user.js";
|
|
117
|
+
import { NOTION_EVENT_SOURCE, notionEventDeliveryId, notionTriggerScopeVerdict } from "./notion-event-normalizer.js";
|
|
118
|
+
import { createCommentReply, resolveNotionCredential, type ResolvedNotionCredential } from "./notion-api.js";
|
|
119
|
+
import { notionWorkspaceKey } from "./notion-identity.js";
|
|
120
|
+
import { notionTrustRoots } from "./notion-ancestry-trust.js";
|
|
121
|
+
import { handleAcceptedNotionDelivery, handleNotionDelivery, type NotionDeliveryOutcome, type NotionReplyTarget, type NotionSessionState } from "./notion-webhook.js";
|
|
122
|
+
import { acknowledgeNotionIngress, NOTION_INGRESS_MAX_BODY_BYTES } from "./notion-ingress.js";
|
|
123
|
+
import {
|
|
124
|
+
acceptNotionIngressDrainRequest,
|
|
125
|
+
createNotionCommentDispatchGuard,
|
|
126
|
+
notionIngressDrainAvailable,
|
|
127
|
+
NOTION_INGRESS_DRAIN_PATH,
|
|
128
|
+
postNotionIngressDrain,
|
|
129
|
+
settleNotionIngressDrain,
|
|
130
|
+
type AcceptedNotionIngressDrain,
|
|
131
|
+
type NotionIngressDisposition,
|
|
132
|
+
} from "./notion-ingress-drain.js";
|
|
133
|
+
import {
|
|
134
|
+
createNotionWebhookTiming,
|
|
135
|
+
NOTION_WEBHOOK_TIMING_ACTION,
|
|
136
|
+
runNotionWebhookTimed,
|
|
137
|
+
type NotionWebhookTimingDetail,
|
|
138
|
+
type NotionWebhookTimingRecorder,
|
|
139
|
+
} from "./notion-webhook-timing.js";
|
|
140
|
+
import {
|
|
141
|
+
handleNotionSupervisorDispatch,
|
|
142
|
+
notionSupervisorToken,
|
|
143
|
+
planNotionSingleWindow,
|
|
144
|
+
shouldRotate,
|
|
145
|
+
type NotionSupervisorDeps,
|
|
146
|
+
type NotionUntieredSupervisorDispatch,
|
|
147
|
+
type NotionUntieredWakeMessage,
|
|
148
|
+
} from "./notion-supervisor.js";
|
|
149
|
+
import { notionDispatchJoinDetail, notionWakeAuditDetail } from "./notion-engagement.js";
|
|
150
|
+
|
|
151
|
+
const ROUTE = "/eve/v1/notion";
|
|
152
|
+
|
|
153
|
+
function log(msg: string): void {
|
|
154
|
+
console.log(`[open-everywhere:notion] ${msg}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The bot's own Notion user — its id (the loop guard's self-identity) AND its display name (the
|
|
158
|
+
// judge's name-drop input: a person typing the bot's name is a STRONG signal to the channel-watch
|
|
159
|
+
// preamble's "names you", NOT a bypass — the wake stays judged). An integration's bot-user is
|
|
160
|
+
// stable, so resolve once (env overrides NOTION_BOT_USER_ID / NOTION_BOT_NAME win for tests /
|
|
161
|
+
// deploys that know them — but the ID override covers the ORG-DEFAULT `channel-key` credential ONLY,
|
|
162
|
+
// because two Notion integrations never share a bot user and one pinned id standing for a sibling's
|
|
163
|
+
// would reopen the OPE-1921 cross-integration loop; NOTION_BOT_NAME stays unscoped, being a display
|
|
164
|
+
// name and never an identity) and cache both. Best-effort: an unresolved id makes classify fail CLOSED
|
|
165
|
+
// (nothing dispatches — the hardened loop guard) rather than crash; an unresolved name just means
|
|
166
|
+
// the judge relies on the comment text alone (it already contains the name).
|
|
167
|
+
// 🔴 THE RESOLVER AND ITS PER-CREDENTIAL CACHE MOVED TO `notion-bot-user.ts` (OPE-1857), because the
|
|
168
|
+
// SEAT CATCH-UP needs the same identity: the seat's own previous reply sits inside the window it
|
|
169
|
+
// reads, and Notion's comment payload cannot tell our reply from a person's by shape. Two copies of
|
|
170
|
+
// one identity would be the class that ticket closes.
|
|
171
|
+
|
|
172
|
+
/** Reset the cached bot users (tests only) — re-exported at its historical name. */
|
|
173
|
+
export { _resetNotionBotUserCache } from "./notion-bot-user.js";
|
|
174
|
+
|
|
175
|
+
// The domain registry, fail-soft. Every OPE-467 credential decision reads it, and a config-load
|
|
176
|
+
// error must degrade to "no domain binds anything" (⇒ the org default everywhere) rather than throw
|
|
177
|
+
// inside a webhook — the same fail-soft posture notionAmbientEnabled / notionGrainFor already take.
|
|
178
|
+
function notionDomains(): readonly Domain[] {
|
|
179
|
+
try {
|
|
180
|
+
return getConfig().loadDomains();
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The Notion channel credential for an AGENT-INITIATED call on a page — the page's domain binding,
|
|
188
|
+
* else the org default (channels/notion-credentials.ts holds the decision itself). This is the
|
|
189
|
+
* fallback rule only: a delivery and its reply carry the VERIFYING integration's credential
|
|
190
|
+
* instead (notion-webhook.ts selects it once; the reply arms read the carried id).
|
|
191
|
+
*/
|
|
192
|
+
function notionCredentialForPage(workspaceId: string | undefined, pageId: string | undefined): string {
|
|
193
|
+
return notionChannelCredentialFor(workspaceId, pageId, notionDomains()) ?? NOTION_CHANNEL_CREDENTIAL;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* One integration's INBOUND signing key, by operator-secret name. Resolved through
|
|
198
|
+
* `getOperatorSecret` so a value stored with `oe secret set` wins over an env var of the same name
|
|
199
|
+
* (the ruled OPE-408 order) — this is the one call that moved `NOTION_WEBHOOK_SECRET` off a bare
|
|
200
|
+
* `process.env` read. Undefined when nothing is stored under either home: an unconfigured
|
|
201
|
+
* integration contributes no key, and a delivery it would have signed simply fails the gate.
|
|
202
|
+
*
|
|
203
|
+
* The report-or-stay-silent decision lives in channels/notion-credentials.ts
|
|
204
|
+
* (resolveNotionInboundSecret), where it is testable without a store — see its header for why the
|
|
205
|
+
* error CLASS, not the message, is the seam.
|
|
206
|
+
*/
|
|
207
|
+
function notionInboundSecret(name: string): Promise<string | undefined> {
|
|
208
|
+
return resolveNotionInboundSecret(name, getOperatorSecret, log);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Is the ambient supervisor enabled for a Notion doc's surfaceKey? Domain-level opt-in
|
|
212
|
+
// (ambientSupervisorEnabled, default OFF). Fail-closed: any config-load error → OFF.
|
|
213
|
+
function notionAmbientEnabled(surfaceKey: string): boolean {
|
|
214
|
+
try {
|
|
215
|
+
return ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains());
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// UNIFIED cross-surface grain (channels/unified-supervisor.ts) — the one hop the session deps +
|
|
222
|
+
// wake stamp share (the Slack reference's `grainFor`). FAIL-SOFT to the per-container grain (the
|
|
223
|
+
// byte-identical path) on any config-load error.
|
|
224
|
+
function notionGrainFor(surfaceKey: string): SupervisorGrain {
|
|
225
|
+
try {
|
|
226
|
+
return supervisorGrainFor(surfaceKey, getConfig().loadDomains());
|
|
227
|
+
} catch {
|
|
228
|
+
return { unified: false, registryKey: surfaceKey };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The Notion arm of the SINGLE-WINDOW PRIVACY FLOOR — the one decision that admits a page into the
|
|
234
|
+
* shared cross-surface window. Pure and SYNCHRONOUS: every input is a fact the pipeline already
|
|
235
|
+
* resolved, so no network call enters this gate. It holds the same role as the Discord and Teams
|
|
236
|
+
* arms (discordSingleWindowPrivacyOk / teamsSingleWindowPrivacyOk) and is a unit-tested truth table
|
|
237
|
+
* the same way. It takes a `declared` resolver where those take a boolean `vouched` one, because
|
|
238
|
+
* this floor must tell a `public` DEMOTION apart from "nothing declared" — see below.
|
|
239
|
+
*
|
|
240
|
+
* THREE POSITIVE SIGNALS, and each one is an OPERATOR DECLARATION. None is an inference from a
|
|
241
|
+
* Notion API field: a page's `public_url` is null for the common guest-shared vector, so a floor
|
|
242
|
+
* that read it would leak private cross-surface context to an external guest.
|
|
243
|
+
* 1. `OE_TRUST=internal`, the blanket switch for an internal-only deployment;
|
|
244
|
+
* 2. the per-surface VOUCH (identity/trust.ts) — an `internal` declaration on the workspaceKey
|
|
245
|
+
* `notion/<ws>` or on the full surfaceKey `notion/<ws>/<page>`, most-specific-wins;
|
|
246
|
+
* 3. the ANCESTOR-TRUST verdict for this delivery (OPE-492): the page sits under a page the
|
|
247
|
+
* operator declared a TRUST ROOT.
|
|
248
|
+
*
|
|
249
|
+
* 🔴 SIGNAL 3 IS HANDED DOWN, NEVER RE-DERIVED (OPE-540). The webhook core walks the parent chain
|
|
250
|
+
* ONCE per delivery, under the credential that verified it, and the dispatch carries the verdict
|
|
251
|
+
* here — the same value the `notion.dispatched` audit row records. The floor only reads it, so the
|
|
252
|
+
* floor stays a synchronous predicate.
|
|
253
|
+
*
|
|
254
|
+
* 🔴 FAIL-CLOSED ON ABSENCE. `trust` is undefined on the `no-roots` verdict, because the webhook
|
|
255
|
+
* core omits the field there. `trusted` is false on every other refusal. Both read as "not
|
|
256
|
+
* internal": no throw, no pass.
|
|
257
|
+
*
|
|
258
|
+
* 🔴 A `public` DECLARATION DEMOTES ABOVE THE TRUST-ROOT VERDICT, as it does on the binding plane:
|
|
259
|
+
* identity/binding.ts orders `declaredExternal ?? raw.trust ?? … ?? vouched`, so `bindFrom` puts the
|
|
260
|
+
* operator's `public` carve-out above the walk's `internal`. A page declared `public` inside a
|
|
261
|
+
* trusted subtree keeps the shared org brain switched off, and this floor keeps it out of the shared
|
|
262
|
+
* window for the same reason.
|
|
263
|
+
*
|
|
264
|
+
* ⚠️ THE TWO PLANES STILL DIVERGE UNDER `OE_TRUST=internal`. The blanket switch is this floor's FIRST
|
|
265
|
+
* leg, above the demotion — exactly as on the Discord and Teams floors — while on the binding plane
|
|
266
|
+
* `local.trust` sits BELOW the demotion. So under the blanket, a `public`-declared page is admitted
|
|
267
|
+
* to the shared window while its org-brain binding stays external. Pre-existing admission-gate
|
|
268
|
+
* behaviour, kept deliberately for cross-platform consistency (docs/how-it-works/identity.md).
|
|
269
|
+
*/
|
|
270
|
+
export function notionSingleWindowPrivacyOk(
|
|
271
|
+
input: {
|
|
272
|
+
/** `notion/<ws>` — a workspace-level declaration covers every page in the workspace. */
|
|
273
|
+
readonly workspaceKey: string;
|
|
274
|
+
/** `notion/<ws>/<page>` — a page-level declaration narrows to one page and beats the workspace. */
|
|
275
|
+
readonly surfaceKey: string;
|
|
276
|
+
/** The ancestor-walk verdict this delivery carries. Undefined ⇒ the operator declared no roots. */
|
|
277
|
+
readonly trust?: { readonly trusted: boolean };
|
|
278
|
+
},
|
|
279
|
+
deps: {
|
|
280
|
+
/** The declaration resolver, injected by tests. Returns the most-specific declared value, or
|
|
281
|
+
* undefined when the operator declared nothing for this page. */
|
|
282
|
+
readonly declared?: (workspaceKey: string, surfaceKey: string) => SurfaceTrust | undefined;
|
|
283
|
+
readonly trustEnv?: string | undefined;
|
|
284
|
+
} = {},
|
|
285
|
+
): boolean {
|
|
286
|
+
const trustEnv = "trustEnv" in deps ? deps.trustEnv : process.env.OE_TRUST;
|
|
287
|
+
if (trustEnv === "internal") return true; // the blanket internal-only-deploy switch
|
|
288
|
+
const declared = (deps.declared ?? declaredTrustForKeys)(input.workspaceKey, input.surfaceKey);
|
|
289
|
+
if (declared === "public") return false; // the DEMOTION — above the trust-root verdict
|
|
290
|
+
if (declared === "internal") return true; // the per-surface vouch
|
|
291
|
+
return input.trust?.trusted === true; // the resolved trust-root verdict (OPE-540)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Per-session state: the discussion thread + page/workspace so message.completed can post the
|
|
295
|
+
// reply back into the SAME thread (Notion's create-comment targets discussion_id).
|
|
296
|
+
type NotionState = NotionSessionState;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Live supervisor deps (mirrors slack-events.ts liveSupervisorDeps): create-or-fetch / rotate /
|
|
300
|
+
* force-rotate over the (org, surfaceKey) channel_session registry with the reserved :oe-sup:g
|
|
301
|
+
* token, and a `wake` that continues the session under that token. org is the deployment org (a
|
|
302
|
+
* deployment fact — never crosses the eve-free core boundary). Rotation reuses shouldRotate AS-IS.
|
|
303
|
+
* A rotation carries the OUTGOING generation's recorded TAIL forward (OPE-794).
|
|
304
|
+
*/
|
|
305
|
+
// Exported (not just internal to the route) so a test can drive the `wake` leg directly — the SAME
|
|
306
|
+
// reason liveGitHubSupervisorDeps/liveLinearSupervisorDeps are testable without a live webhook.
|
|
307
|
+
// `respond` is the OPE-1305 answer leg, and it is OPTIONAL for one reason: it is the only primitive
|
|
308
|
+
// that reaches a delegated worker, so a caller that cannot supply it has no leg to run. Absent ⇒ the
|
|
309
|
+
// decision is not even attempted — no store read, and no admission call that could write a refusal
|
|
310
|
+
// row against a person whose answer could never have been delivered anyway. The live route supplies
|
|
311
|
+
// it; a test driving the send leg alone need not.
|
|
312
|
+
export function liveNotionSupervisorDeps(
|
|
313
|
+
send: SendFn,
|
|
314
|
+
surfaceKey: string,
|
|
315
|
+
to?: RouteHandlerArgs["to"],
|
|
316
|
+
respond?: RespondFn,
|
|
317
|
+
timing?: NotionWebhookTimingRecorder,
|
|
318
|
+
): NotionSupervisorDeps {
|
|
319
|
+
const org = deploymentOrg();
|
|
320
|
+
const store = getOperationalStore();
|
|
321
|
+
// UNIFIED re-key (the Slack reference's exact shape, slack-events.ts liveSupervisorDeps): when
|
|
322
|
+
// the page's domain has opted in, the registry ROW is the synthetic `oe-domain::<name>` key and
|
|
323
|
+
// the token is `oe-dom:<name>:oe-sup:g<gen>` — one row + one token per generation for the whole
|
|
324
|
+
// team. Flag-off, notionGrainFor returns the per-doc grain + token — byte-identical.
|
|
325
|
+
return {
|
|
326
|
+
async getOrCreateSession(surfaceKey, pageId, atIso) {
|
|
327
|
+
const grain = notionGrainFor(surfaceKey);
|
|
328
|
+
// OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the
|
|
329
|
+
// mint arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
|
|
330
|
+
const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey, domainName: grain.domainName, atIso });
|
|
331
|
+
const existing = await store.getChannelSession(org, grain.registryKey);
|
|
332
|
+
if (!existing) {
|
|
333
|
+
const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, 1, () => notionSupervisorToken(pageId, 1)), atIso);
|
|
334
|
+
// OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
|
|
335
|
+
// wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
|
|
336
|
+
await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey, domainName: grain.domainName, atIso });
|
|
337
|
+
return { token: row.token, generation: row.generation, freshGeneration: true, tail: [] as readonly WakeMessageInput[] };
|
|
338
|
+
}
|
|
339
|
+
if (shouldRotate(existing, atIso, { runtimeChanged })) {
|
|
340
|
+
const gen = existing.generation + 1;
|
|
341
|
+
// OPE-794: read the OUTGOING generation's recorded tail BEFORE minting its successor.
|
|
342
|
+
const tail = await carryTailForward(org, grain.registryKey, existing.token);
|
|
343
|
+
const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, gen, () => notionSupervisorToken(pageId, gen)), atIso);
|
|
344
|
+
return { token: row.token, generation: row.generation, freshGeneration: true, tail };
|
|
345
|
+
}
|
|
346
|
+
// A resume whose first wake never delivered (first_wake_at NULL) is still "fresh" (H7c
|
|
347
|
+
// self-heal), so the next wake re-carries backscroll. Its tail is empty — carried and cleared
|
|
348
|
+
// at the rotation that minted this row.
|
|
349
|
+
const fresh = existing.firstWakeAt == null;
|
|
350
|
+
// On a NON-fresh generation the row's lastWakeAt IS the previous wake (touchSession advances it
|
|
351
|
+
// only at dispatch end, AFTER this read) — the memory-delta cursor. Fresh ⇒ no prior wake ⇒ omit.
|
|
352
|
+
return { token: existing.token, generation: existing.generation, freshGeneration: fresh, tail: [] as readonly WakeMessageInput[], ...(fresh ? {} : { lastWakeAt: existing.lastWakeAt }) };
|
|
353
|
+
},
|
|
354
|
+
touchSession: (surfaceKey, atIso) => store.touchChannelSession(org, notionGrainFor(surfaceKey).registryKey, atIso),
|
|
355
|
+
// THE SINCE-LAST-WAKE CATCH-UP (OPE-1726), through the shared path core holds. Notion's own fetch
|
|
356
|
+
// is registered at bundle registration; the admission policy is the DOMAIN's, resolved by the
|
|
357
|
+
// shared road itself from the request's own channel key (OPE-1857). No `trustFor`: this surface
|
|
358
|
+
// has no synchronous roster probe for a comment author, so the shared core's FAIL-CLOSED
|
|
359
|
+
// per-sender default applies — a human sibling reads `unauth` and stays fenced, the honest floor
|
|
360
|
+
// for an author the deployment cannot vouch at wake time.
|
|
361
|
+
catchUp: (req) => seatCatchUp("notion", req),
|
|
362
|
+
// OPE-794 — file this wake on the live generation's tail ring. Same key derivation as the
|
|
363
|
+
// rotation read and the spend counter, so all three target one row.
|
|
364
|
+
recordWake: (entry, token, surfaceKey) => recordWakeTail(org, notionGrainFor(surfaceKey).registryKey, token, entry),
|
|
365
|
+
// The "changed since your last wake" memory-delta PUSH — armed ONLY under the unified org gate
|
|
366
|
+
// (undefined otherwise ⇒ dispatch never computes ⇒ byte-identical). Keyed by the page's surface
|
|
367
|
+
// so the delta names ONLY silos this session reads.
|
|
368
|
+
memoryDelta: liveMemoryDeltaDep(org, surfaceKey),
|
|
369
|
+
now: () => new Date().toISOString(),
|
|
370
|
+
async wake({ token, auth, message, context, state, window }) {
|
|
371
|
+
// SINGLE-WINDOW diversion (channels/unified-window.ts, mirroring linear.ts's wake dep): a
|
|
372
|
+
// window-carrying wake goes through eve's cross-channel `to(...).send(...)` into the synthetic
|
|
373
|
+
// `unified` channel, so its effective token is `unified:<raw>`, shared with the domain's
|
|
374
|
+
// Slack/GitHub/Linear wakes: ONE context window. The unified channel's `receive` hook registers
|
|
375
|
+
// the wakeId→origin map entry (durably, BEFORE the send) and re-checks the gates
|
|
376
|
+
// fail-closed. A failure propagates to the route's dispatch (fail-closed: never delivered
|
|
377
|
+
// without a routable reply target). This IS the native-session suppression on Notion —
|
|
378
|
+
// there is no separate per-doc mention channel to stand down; the one dispatch path either
|
|
379
|
+
// sends here (native) or diverts (window), off the one seam verdict (planNotionSingleWindow).
|
|
380
|
+
if (window) {
|
|
381
|
+
// A window means the wake carries a `direct-mention`/wake-id envelope AND the reply target
|
|
382
|
+
// must be registered in the map (divertToUnifiedWindow → unified.receive). Without a
|
|
383
|
+
// cross-channel `to` we CANNOT register it, so a native send would post an
|
|
384
|
+
// unresolvable wake-id — refuse VISIBLY instead (the route always supplies `to`, so this
|
|
385
|
+
// is defense-in-depth, never reached in prod).
|
|
386
|
+
if (!to) throw new Error("notion single-window wake has no cross-channel dispatch — refusing (would post an unregistered wake-id)");
|
|
387
|
+
// THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's
|
|
388
|
+
// front route has always used. A wake landing in a discussion a live task worker or thread
|
|
389
|
+
// seat OWNS is dispatched to that owner's token instead of the standing seat's. Notion is
|
|
390
|
+
// thread-first: every wake arrives scoped to one discussion, so every wake on this leg
|
|
391
|
+
// consults ownership. Flag-off, and on every unowned discussion, this is a byte-identical
|
|
392
|
+
// passthrough to `divertToUnifiedWindow`.
|
|
393
|
+
const divert = async () => {
|
|
394
|
+
// Keep the core divert explicitly awaited at the provider boundary. Besides preserving
|
|
395
|
+
// rejection timing inside this phase, this is the source-level proof behind Notion's
|
|
396
|
+
// `owned-thread-ingress` capability declaration.
|
|
397
|
+
await divertInboundToUnifiedWindow(to, {
|
|
398
|
+
message,
|
|
399
|
+
context: [...context],
|
|
400
|
+
token,
|
|
401
|
+
window,
|
|
402
|
+
surfaceKey,
|
|
403
|
+
auth: auth as unknown as { attributes?: Record<string, unknown> },
|
|
404
|
+
});
|
|
405
|
+
};
|
|
406
|
+
if (timing) await timing.measure("eveSend", divert);
|
|
407
|
+
else await divert();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// ── THE ANSWER LEG (OPE-1305) ────────────────────────────────────────────────────────────
|
|
411
|
+
// 🔴 `respond` IS THE ONLY PRIMITIVE THAT REACHES A DELEGATED WORKER. eve routes an answer to a
|
|
412
|
+
// child solely through `payload.inputResponses` entries whose `requestId` is in the parent
|
|
413
|
+
// session's proxy map; a plain `send` falls to `forSelf` — the parent — every time. So this
|
|
414
|
+
// branch is the whole return leg on the native route, and everything else here was already
|
|
415
|
+
// present. channels/notion-question-return.ts owns the decision and fails soft in one
|
|
416
|
+
// direction: no row, another discussion, a refused answerer ⇒ an ordinary send.
|
|
417
|
+
//
|
|
418
|
+
// THE ANSWER TEXT IS THE RENDERED WAKE, deliberately — byte-exactly what eve consumed as the
|
|
419
|
+
// answer before this branch existed, so the change is WHERE the comment is addressed, not what
|
|
420
|
+
// the model reads. It keeps the envelope's provenance fence around the human's words.
|
|
421
|
+
//
|
|
422
|
+
// NEVER DROP A COMMENT. A `respond` that throws falls through to the ordinary send with the row
|
|
423
|
+
// LEFT IN PLACE, so the discussion is still heard and the question is still answerable.
|
|
424
|
+
if (respond !== undefined) {
|
|
425
|
+
const answer = await resolveNotionInboundAnswer({ org, bareToken: token, auth });
|
|
426
|
+
if (answer) {
|
|
427
|
+
const deliverAnswer = () => deliverNotionAnswer({ answerOrg: org, token, auth, message, context, answer, respond, surfaceKey });
|
|
428
|
+
if (timing ? await timing.measure("eveSend", deliverAnswer) : await deliverAnswer()) return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const workspace = surfaceKey.split("/")[1] ?? "unknown";
|
|
433
|
+
const dispatchWake = () => dispatchNativeWakeWithRetry(
|
|
434
|
+
() => send({ message, context: [...context] }, { auth, continuationToken: token, state }),
|
|
435
|
+
{ platform: "notion", workspace, surfaceKey },
|
|
436
|
+
);
|
|
437
|
+
if (timing) await timing.measure("eveSend", dispatchWake);
|
|
438
|
+
else await dispatchWake();
|
|
439
|
+
} catch (err) {
|
|
440
|
+
// OPE-971: the NATIVE (flag-off) send has no window and no divert. `notionGrainFor(surfaceKey)
|
|
441
|
+
// .registryKey === surfaceKey` flag-off (the exact row `getOrCreateSession` above already
|
|
442
|
+
// rotates), so it's passed straight through — see the GitHub/Linear/Slack siblings.
|
|
443
|
+
await healPrunedDeploymentStrand({
|
|
444
|
+
token,
|
|
445
|
+
registryKey: surfaceKey,
|
|
446
|
+
surfaceKey,
|
|
447
|
+
workspace: surfaceKey.split("/")[1] ?? "unknown",
|
|
448
|
+
platform: "notion",
|
|
449
|
+
atIso: new Date().toISOString(),
|
|
450
|
+
err,
|
|
451
|
+
});
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The channel `send` shape this route uses (structural — avoids importing eve's SendPayload, and
|
|
459
|
+
* since eve 0.31 there is no public `SendFn` to import at all: the address moved onto
|
|
460
|
+
* `from(address)` and `context` moved from the payload into the send options. {@link notionSendFrom}
|
|
461
|
+
* is the one adapter that bridges the two shapes). */
|
|
462
|
+
type SendFn = (
|
|
463
|
+
payload: { message: string; context?: string[] },
|
|
464
|
+
opts: { auth: unknown; continuationToken: string; state: NotionState },
|
|
465
|
+
) => Promise<void>;
|
|
466
|
+
|
|
467
|
+
/** Adapt eve 0.31's `from(address).send(message, options)` to {@link SendFn}. */
|
|
468
|
+
function notionSendFrom(from: RouteHandlerArgs<NotionState>["from"]): SendFn {
|
|
469
|
+
return async (payload, opts) => {
|
|
470
|
+
await from(opts.continuationToken).send(payload.message, {
|
|
471
|
+
auth: opts.auth,
|
|
472
|
+
state: opts.state,
|
|
473
|
+
...(payload.context && payload.context.length > 0 ? { context: payload.context } : {}),
|
|
474
|
+
} as never);
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The ANSWER delivery (OPE-1305) — `from(token).respond([{requestId, text}])`, the one primitive
|
|
479
|
+
* that reaches a delegated worker. Structural, for the same reason {@link SendFn} is: eve exports no
|
|
480
|
+
* public type for it. */
|
|
481
|
+
type RespondFn = (input: { requestId: string; text: string }, opts: { auth: unknown; continuationToken: string; context?: readonly string[] }) => Promise<void>;
|
|
482
|
+
|
|
483
|
+
/** Adapt eve's `from(address).respond(responses, options)` to {@link RespondFn}. */
|
|
484
|
+
function notionRespondFrom(from: RouteHandlerArgs<NotionState>["from"]): RespondFn {
|
|
485
|
+
return async (input, opts) => {
|
|
486
|
+
await (from(opts.continuationToken) as unknown as { respond: (r: readonly { requestId: string; text: string }[], o: unknown) => Promise<unknown> }).respond(
|
|
487
|
+
[{ requestId: input.requestId, text: input.text }],
|
|
488
|
+
{
|
|
489
|
+
auth: opts.auth,
|
|
490
|
+
// The side-channel context rides the answer for the same reason it rides the send: a comment
|
|
491
|
+
// that happens to resolve a question must not lose the context every other wake carries.
|
|
492
|
+
...(opts.context && opts.context.length > 0 ? { context: [...opts.context] } : {}),
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
type NotionWebhookRouteArgs = Pick<RouteHandlerArgs<NotionState>, "from" | "waitUntil" | "to" | "attachSession">;
|
|
499
|
+
|
|
500
|
+
/** Request-wide timing finalizer: early credential reads, delivery, and late route work share one
|
|
501
|
+
* emit-once failure boundary, while the wrapped handler keeps the route's original semantics. */
|
|
502
|
+
function timedNotionWebhookRoute(
|
|
503
|
+
handler: (
|
|
504
|
+
req: Request,
|
|
505
|
+
args: NotionWebhookRouteArgs,
|
|
506
|
+
timing: NotionWebhookTimingRecorder,
|
|
507
|
+
finishTiming: (outcome: string) => void,
|
|
508
|
+
) => Promise<Response>,
|
|
509
|
+
): (req: Request, args: NotionWebhookRouteArgs) => Promise<Response> {
|
|
510
|
+
return async (req, args) => {
|
|
511
|
+
const timing = createNotionWebhookTiming();
|
|
512
|
+
const emit = (detail: NotionWebhookTimingDetail): void => {
|
|
513
|
+
try {
|
|
514
|
+
args.waitUntil(
|
|
515
|
+
appendAudit({
|
|
516
|
+
org: deploymentOrg(),
|
|
517
|
+
platform: "notion",
|
|
518
|
+
workspace: "unknown",
|
|
519
|
+
surface: "unknown",
|
|
520
|
+
action: NOTION_WEBHOOK_TIMING_ACTION,
|
|
521
|
+
detail,
|
|
522
|
+
}).catch(() => undefined),
|
|
523
|
+
);
|
|
524
|
+
} catch {
|
|
525
|
+
// Telemetry must never replace the delivery's own response or thrown failure.
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
return runNotionWebhookTimed(timing, emit, (finish) => handler(req, args, timing, finish));
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Deliver one inbound comment as the ANSWER to the session's outstanding question, and say so in the
|
|
534
|
+
* audit trail. Returns whether it landed — `false` means the caller must fall through to the
|
|
535
|
+
* ordinary send, with the outstanding-question row deliberately LEFT IN PLACE.
|
|
536
|
+
*
|
|
537
|
+
* 🔴 THE ROW IS CONSUMED ONLY AFTER eve ACCEPTED THE DELIVERY. It is what makes the NEXT comment an
|
|
538
|
+
* ordinary wake again, so dropping it before the `respond` returns would leave a question answered
|
|
539
|
+
* and a row saying it is still open — or, worse, consume it for a delivery that never happened.
|
|
540
|
+
*
|
|
541
|
+
* 🔴 IDS, ENUMS AND COUNTS ONLY. The answer text is a person's prose in a discussion thread and is
|
|
542
|
+
* never audited, the same redaction rule the question side states. Both outcomes write a row;
|
|
543
|
+
* neither is silent.
|
|
544
|
+
*/
|
|
545
|
+
async function deliverNotionAnswer(input: {
|
|
546
|
+
readonly answerOrg: string;
|
|
547
|
+
readonly token: string;
|
|
548
|
+
readonly auth: unknown;
|
|
549
|
+
readonly message: string;
|
|
550
|
+
readonly context: readonly string[];
|
|
551
|
+
readonly answer: NotionInboundAnswer;
|
|
552
|
+
readonly respond: RespondFn;
|
|
553
|
+
readonly surfaceKey: string;
|
|
554
|
+
}): Promise<boolean> {
|
|
555
|
+
const detail = {
|
|
556
|
+
requestId: input.answer.requestId,
|
|
557
|
+
floor: input.answer.floor,
|
|
558
|
+
chars: input.message.length,
|
|
559
|
+
surfaceKey: input.surfaceKey,
|
|
560
|
+
route: "native",
|
|
561
|
+
...(input.answer.taskId !== undefined ? { taskId: input.answer.taskId } : {}),
|
|
562
|
+
...(input.answer.wakeId !== undefined ? { wakeId: input.answer.wakeId } : {}),
|
|
563
|
+
};
|
|
564
|
+
// The SAME partition every other Notion row takes (`notion.dispatched` above): the workspace id
|
|
565
|
+
// and the PAGE, both read off the surface key `notion/<workspace>/<page>`. The full key rides the
|
|
566
|
+
// detail instead, so an operator can still join these rows to the dispatch that carried them.
|
|
567
|
+
const [, workspace = "unknown", surface = "unknown"] = input.surfaceKey.split("/");
|
|
568
|
+
try {
|
|
569
|
+
await input.respond(
|
|
570
|
+
{ requestId: input.answer.requestId, text: input.message },
|
|
571
|
+
{ auth: input.auth, continuationToken: input.token, context: input.context },
|
|
572
|
+
);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
const error = (err instanceof Error ? err.message : String(err)).slice(0, 300);
|
|
575
|
+
log(`answer ${input.answer.requestId} could NOT be delivered (${error}) — falling back to an ordinary wake, the question stays open`);
|
|
576
|
+
await appendAudit({
|
|
577
|
+
org: input.answerOrg,
|
|
578
|
+
platform: "notion",
|
|
579
|
+
workspace,
|
|
580
|
+
surface,
|
|
581
|
+
action: QUESTION_ANSWER_DROPPED_AUDIT_ACTION,
|
|
582
|
+
detail: { ...detail, error },
|
|
583
|
+
}).catch(() => undefined);
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
await forgetNotionQuestion(input.answerOrg, input.token);
|
|
587
|
+
log(`answer ${input.answer.requestId} delivered to ${input.token} (floor ${input.answer.floor})`);
|
|
588
|
+
await appendAudit({
|
|
589
|
+
org: input.answerOrg,
|
|
590
|
+
platform: "notion",
|
|
591
|
+
workspace,
|
|
592
|
+
surface,
|
|
593
|
+
action: QUESTION_ANSWERED_AUDIT_ACTION,
|
|
594
|
+
detail,
|
|
595
|
+
}).catch(() => undefined);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function notionIngressDisposition(outcome: NotionDeliveryOutcome, eventPlaneRefused: boolean): NotionIngressDisposition {
|
|
600
|
+
if (outcome.action === "no-api-key" || outcome.action === "error") return "retry";
|
|
601
|
+
if (
|
|
602
|
+
eventPlaneRefused ||
|
|
603
|
+
outcome.action === "ambient-disabled" ||
|
|
604
|
+
outcome.action === "skipped-self" ||
|
|
605
|
+
outcome.action === "skipped-bot" ||
|
|
606
|
+
outcome.action === "dropped" ||
|
|
607
|
+
outcome.action === "unresolved-page" ||
|
|
608
|
+
outcome.action === "stood-down" ||
|
|
609
|
+
outcome.action === "scope-refused"
|
|
610
|
+
) {
|
|
611
|
+
// These gates all precede the comment-dispatch lease. Deleting the primary row preserves the
|
|
612
|
+
// prior contract: a later provider redelivery may be reconsidered after config/state changes.
|
|
613
|
+
return "discard";
|
|
614
|
+
}
|
|
615
|
+
return "complete";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const notionWebhookRouteHandler = timedNotionWebhookRoute(
|
|
619
|
+
async (req, { from, waitUntil, to, attachSession }, timing, finishTiming) => {
|
|
620
|
+
const send = notionSendFrom(from);
|
|
621
|
+
// The OPE-1305 answer leg. Built beside `send` from the same `from`, so the two primitives
|
|
622
|
+
// always address the same session.
|
|
623
|
+
const respond = notionRespondFrom(from);
|
|
624
|
+
const internalDrain = new URL(req.url).pathname === NOTION_INGRESS_DRAIN_PATH;
|
|
625
|
+
let acceptedIngress: AcceptedNotionIngressDrain | undefined;
|
|
626
|
+
let rawBody: string;
|
|
627
|
+
let signature: string | null;
|
|
628
|
+
if (internalDrain) {
|
|
629
|
+
// Cap and authenticate the ORIGINAL stream before reading provider config or secrets. A
|
|
630
|
+
// reconstructed Request here would make the 16 KiB cap cosmetic after an unbounded read.
|
|
631
|
+
const admitted = await acceptNotionIngressDrainRequest(req, {
|
|
632
|
+
org: deploymentOrg(),
|
|
633
|
+
store: getOperationalStore(),
|
|
634
|
+
});
|
|
635
|
+
if ("response" in admitted) {
|
|
636
|
+
finishTiming(admitted.response.ok ? "drain-idle" : "drain-refused");
|
|
637
|
+
return admitted.response;
|
|
638
|
+
}
|
|
639
|
+
acceptedIngress = admitted.accepted;
|
|
640
|
+
rawBody = acceptedIngress.rawBody;
|
|
641
|
+
signature = null;
|
|
642
|
+
} else {
|
|
643
|
+
const read = await timing.measure("credential", () => readCappedBody(req, NOTION_INGRESS_MAX_BODY_BYTES));
|
|
644
|
+
if ("tooLarge" in read) {
|
|
645
|
+
finishTiming("accept-refused");
|
|
646
|
+
return new Response("payload too large", { status: 413 });
|
|
647
|
+
}
|
|
648
|
+
rawBody = read.body;
|
|
649
|
+
signature = req.headers.get("x-notion-signature");
|
|
650
|
+
}
|
|
651
|
+
// EVERY configured integration, org default first (OPE-467). One entry on a single-
|
|
652
|
+
// integration deployment, so the gate below does exactly what it always did.
|
|
653
|
+
const integrations = notionIntegrations(notionDomains());
|
|
654
|
+
// Resolve each SECRET NAME at most once per delivery — the same memo shape `apiKeys` below
|
|
655
|
+
// uses. Two integrations may legitimately share one inbound secret name (a domain that binds
|
|
656
|
+
// its own credential but omits `inboundSecret` keeps the deployment default), and this runs
|
|
657
|
+
// on an UNAUTHENTICATED POST, before the HMAC gate: without the memo each such pair costs a
|
|
658
|
+
// second credential-store read that an anonymous caller can drive. It cannot go below one
|
|
659
|
+
// read per DISTINCT name — trying every configured secret is what identifies the sender.
|
|
660
|
+
const secrets = new Map<string, Promise<string | undefined>>();
|
|
661
|
+
const inboundSecretFor = (name: string): Promise<string | undefined> => {
|
|
662
|
+
const hit = secrets.get(name);
|
|
663
|
+
if (hit) return hit;
|
|
664
|
+
const resolved = notionInboundSecret(name);
|
|
665
|
+
secrets.set(name, resolved);
|
|
666
|
+
return resolved;
|
|
667
|
+
};
|
|
668
|
+
const signingKeys = await timing.measure("credential", () => Promise.all(
|
|
669
|
+
integrations.map(async (i) => ({ credentialId: i.credentialId, secret: await inboundSecretFor(i.inboundSecret) })),
|
|
670
|
+
));
|
|
671
|
+
|
|
672
|
+
if (!internalDrain) {
|
|
673
|
+
const admission = await acknowledgeNotionIngress(rawBody, signature, {
|
|
674
|
+
org: deploymentOrg(),
|
|
675
|
+
signingKeys,
|
|
676
|
+
allowUnverified: getSettings().webhooks.allowUnverifiedNotion,
|
|
677
|
+
drainAvailable: notionIngressDrainAvailable(),
|
|
678
|
+
enqueue: (org, write) => getOperationalStore().enqueueIngressDelivery(org, write),
|
|
679
|
+
drain: (key) => postNotionIngressDrain(key),
|
|
680
|
+
waitUntil,
|
|
681
|
+
});
|
|
682
|
+
if (admission.headers.get("x-oe-notion-ingress") !== "synchronous") {
|
|
683
|
+
finishTiming(admission.ok ? "accepted" : "accept-refused");
|
|
684
|
+
return admission;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Resolve each integration's token AT MOST ONCE per delivery. The dispatch closure, the spend
|
|
688
|
+
// gate's refusal post and the two-step fetch all ask for the same credential id.
|
|
689
|
+
const credentials = new Map<string, Promise<ResolvedNotionCredential | undefined>>();
|
|
690
|
+
// OPE-505: the memo holds the key WITH its endpoint rules. Resolving the bare token here would
|
|
691
|
+
// hand every REST call below an UNFENCED fetch, because guardCredentialFetch returns the base
|
|
692
|
+
// fetch by identity when it is given no rules.
|
|
693
|
+
const credentialFor = (credentialId: string): Promise<ResolvedNotionCredential | undefined> => {
|
|
694
|
+
const hit = credentials.get(credentialId);
|
|
695
|
+
if (hit) return hit;
|
|
696
|
+
const resolved = resolveNotionCredential(credentialId);
|
|
697
|
+
credentials.set(credentialId, resolved);
|
|
698
|
+
return resolved;
|
|
699
|
+
};
|
|
700
|
+
// The bot user for ONE integration — its own id (the loop guard) and its own display name.
|
|
701
|
+
const botUserFor = (credentialId: string): Promise<{ id?: string; name?: string }> =>
|
|
702
|
+
resolveNotionBotUser(credentialId, () => credentialFor(credentialId))();
|
|
703
|
+
|
|
704
|
+
// Route the classified comment to the standing doc supervisor, then audit the dispatch.
|
|
705
|
+
// The unified grain is stamped HERE (the webhook core stays grain-free): it drives the
|
|
706
|
+
// wake's platform address + P5 disclosure and the session's `oeUnified` seed marker; the
|
|
707
|
+
// registry/token re-key lives in the deps — all off the same fail-soft grain resolution.
|
|
708
|
+
const dispatch = async (d: NotionUntieredSupervisorDispatch): Promise<void> => {
|
|
709
|
+
const grain = notionGrainFor(d.surfaceKey);
|
|
710
|
+
// The integration that serves THIS page (OPE-467). Taken from the DISPATCH, not re-derived:
|
|
711
|
+
// the webhook core selected it once for this delivery, and the spend gate's visible refusal
|
|
712
|
+
// post is a real Notion comment that has to go out under that same integration. Re-deriving
|
|
713
|
+
// it here would be a second rule that agrees by luck.
|
|
714
|
+
const pageCredential = await credentialFor(d.credentialId);
|
|
715
|
+
// SINGLE-WINDOW plan (channels/unified-window.ts via planNotionSingleWindow — the shared
|
|
716
|
+
// seam): on a `singleWindow` grain, resolve the PRIVACY FLOOR through the pure predicate
|
|
717
|
+
// above ({@link notionSingleWindowPrivacyOk}), which reads the operator-vouch register, the
|
|
718
|
+
// OE_TRUST blanket switch, and THIS DELIVERY'S ancestor-trust verdict. The verdict arrives
|
|
719
|
+
// on the dispatch (`d.trust`), already resolved by the webhook core under the credential
|
|
720
|
+
// that verified the delivery — so the floor stays a synchronous lookup with NO network, and
|
|
721
|
+
// a page that binds `internal` through a trust root now joins the same cross-surface window
|
|
722
|
+
// its memory already writes to (OPE-540). Fail-CLOSED: no positive signal → privacyOk=false
|
|
723
|
+
// → native path. Off the singleWindow grain this is a no-op — byte-identical. (`to` is
|
|
724
|
+
// always present on this route, so a minted wake-id is always registerable; the wake dep
|
|
725
|
+
// additionally refuses a window with no `to` — belt-and-braces against a dangling wake-id
|
|
726
|
+
// on the native send.)
|
|
727
|
+
const usesWindow = grainUsesUnifiedWindow(grain); // cross-surface OR solo (byte-identical flag-off)
|
|
728
|
+
const privacyOk =
|
|
729
|
+
usesWindow &&
|
|
730
|
+
notionSingleWindowPrivacyOk({
|
|
731
|
+
workspaceKey: notionWorkspaceKey(d.workspaceId ?? "unknown"),
|
|
732
|
+
surfaceKey: d.surfaceKey,
|
|
733
|
+
trust: d.trust,
|
|
734
|
+
});
|
|
735
|
+
const window = usesWindow
|
|
736
|
+
? planNotionSingleWindow(true, d.addressing === "direct", privacyOk, {
|
|
737
|
+
discussionId: d.discussionId,
|
|
738
|
+
pageId: d.pageId || undefined,
|
|
739
|
+
workspaceId: d.workspaceId,
|
|
740
|
+
// Freeze the DELIVERY's integration into the origin, so the unified reply posts under
|
|
741
|
+
// the credential that fetched — the same id the native path carries on the auth.
|
|
742
|
+
credentialId: d.credentialId,
|
|
743
|
+
// THE FRONT-DOOR RECEIPT (OPE-1716) — a Notion delivery carries no vendor clock this
|
|
744
|
+
// seam can read, so the ingress row records our own instant and marks it `receipt`,
|
|
745
|
+
// which the split reporter renders as a LOWER BOUND and never as a measurement.
|
|
746
|
+
// 🔴 SAY WHAT IT IS: taken HERE, at the divert decision, not at the route's first byte
|
|
747
|
+
// — so on Notion the vendor→ingress segment is a floor near zero and carries no
|
|
748
|
+
// information about the vendor's own hop. It is the truthful floor, not a measure of it.
|
|
749
|
+
...spreadVendorTs(receiptEventTime(Date.now())),
|
|
750
|
+
})
|
|
751
|
+
: undefined;
|
|
752
|
+
// PER-MESSAGE TRUST (identity/wake-trust.ts — audit-wedge F3): resolved off the
|
|
753
|
+
// SAME auth capture the session binds with (Notion's mapper leaves trust
|
|
754
|
+
// undefined, so the gate fail-closes external — relay — unless the operator
|
|
755
|
+
// vouches the workspace / OE_TRUST) + the config-as-code admin rosters. Applies
|
|
756
|
+
// to the trigger AND the discussion-sibling backscroll; the bot's own `self`
|
|
757
|
+
// comments are relay (machine-authored). Directness stays the `mention` flag —
|
|
758
|
+
// it no longer implies the principal tier.
|
|
759
|
+
const wakeCtx = { channel: { kind: "notion" }, session: { auth: { current: d.auth as unknown as { attributes?: Readonly<Record<string, string>> } } } };
|
|
760
|
+
// A tier above `relay` requires an IDENTIFIED HUMAN author: `from === "human"` AND a
|
|
761
|
+
// concrete authorId. A sibling with no author id — or a non-person/bot author, which
|
|
762
|
+
// notion-webhook.ts leaves id-less (F3) — resolves `relay`. isBot:false is safe here
|
|
763
|
+
// (the trigger passed classify's person gate; a sibling only carries an id when it's a
|
|
764
|
+
// person). NO principalFallback: a per-message sibling must never borrow the trigger
|
|
765
|
+
// author's principal id (which would render an unattributed sibling as the trigger's tier).
|
|
766
|
+
const trustOf = (m: NotionUntieredWakeMessage) =>
|
|
767
|
+
m.from === "human" && m.authorId ? resolveWakeTrustFromContext(wakeCtx, { id: m.authorId, isBot: false }) : ("relay" as const);
|
|
768
|
+
|
|
769
|
+
// SPEND CAP (pre-flight — the ONE shared gate, spend/ingress-gate.ts, the same function every
|
|
770
|
+
// other surface and the cross-surface divert call): an over-budget scope DECLINES the turn
|
|
771
|
+
// with a VISIBLE refusal, never a silent drop, before the model turn. CHECK fails OPEN; a
|
|
772
|
+
// definite OVER fails CLOSED unless the sender is an admin over this scope.
|
|
773
|
+
//
|
|
774
|
+
// POSITION: BEFORE handleNotionSupervisorDispatch (and its notion.dispatched audit) so a
|
|
775
|
+
// decline skips both. Notion has no separate native mention leg — every Notion comment wake
|
|
776
|
+
// flows through THIS closure — but a single-window wake still passes the divert's own gate
|
|
777
|
+
// afterwards (channels/unified.ts, door "window"), which is what covers the surfaces whose
|
|
778
|
+
// window leg bypasses their native one. Here the two simply agree.
|
|
779
|
+
if ((await ingressSpendGate({
|
|
780
|
+
bindable: wakeCtx,
|
|
781
|
+
door: "mention",
|
|
782
|
+
...(d.auth.principalId ? { principalId: d.auth.principalId } : {}),
|
|
783
|
+
post: async (text) => { await createCommentReply({ discussionId: d.discussionId, text, apiKey: pageCredential?.apiKey ?? "", endpoints: pageCredential?.endpoints }); },
|
|
784
|
+
})) === "declined") {
|
|
785
|
+
return; // drop — no model turn
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
await handleNotionSupervisorDispatch(
|
|
789
|
+
{
|
|
790
|
+
...d,
|
|
791
|
+
unified: grain.unified,
|
|
792
|
+
// SINGLE-WINDOW plumbing: the core renders the wake-id into the envelope and the wake
|
|
793
|
+
// dep diverts the delivery into the unified channel. `directMention` is the SEAM's
|
|
794
|
+
// addressed verdict (window.addressing) — the same one decision drives the divert,
|
|
795
|
+
// the addressed render, and the owed-reply stamp (which since OPE-1982 classifies a
|
|
796
|
+
// dropped turn rather than selecting a delivery route).
|
|
797
|
+
window,
|
|
798
|
+
...(window?.addressing === "direct-mention" ? { directMention: true } : {}),
|
|
799
|
+
trigger: { ...d.trigger, trust: trustOf(d.trigger) },
|
|
800
|
+
threadComments: d.threadComments.map((m) => ({ ...m, trust: trustOf(m) })),
|
|
801
|
+
// THE EXTERNAL-AUTHOR POLICY (OPE-507), resolved HERE because the supervisor core stays
|
|
802
|
+
// config-free — the same split `engagementTier` uses. Org default from the settings
|
|
803
|
+
// block, domain override from `Domain.externalPolicy`, applied field by field.
|
|
804
|
+
externalPolicy: resolveExternalPolicy(externalPolicyOverrideFor(d.surfaceKey, getConfig().loadDomains())),
|
|
805
|
+
},
|
|
806
|
+
liveNotionSupervisorDeps(send, d.surfaceKey, to, respond, timing),
|
|
807
|
+
);
|
|
808
|
+
// LOUD, because the alternative is silence (OPE-492). A declared trust root that Notion
|
|
809
|
+
// serves as neither a page nor a block matches nothing the walk ever sees, so it grants
|
|
810
|
+
// exactly nothing — and a config line that quietly grants nothing is the failure this warning
|
|
811
|
+
// exists to end. The usual cause is a TEAMSPACE id, which is a 32-hex uuid and therefore
|
|
812
|
+
// indistinguishable from a page id at config time.
|
|
813
|
+
if (d.trust?.unreachableRoots && d.trust.unreachableRoots.length > 0) {
|
|
814
|
+
console.warn(
|
|
815
|
+
`[open-everywhere:notion] declared trust root(s) ${d.trust.unreachableRoots.join(", ")} resolve as neither a page nor a block under integration "${d.credentialId}" — such a root can never match, so it grants nothing. If it is a TEAMSPACE id, declare the top PAGE of the teamspace instead.`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
await timing.measure("finalAudit", () => appendAudit({
|
|
819
|
+
org: deploymentOrg(),
|
|
820
|
+
platform: "notion",
|
|
821
|
+
workspace: d.workspaceId ?? "unknown",
|
|
822
|
+
surface: d.pageId || d.discussionId || "unknown",
|
|
823
|
+
principalId: d.auth.principalId || undefined,
|
|
824
|
+
action: "notion.dispatched",
|
|
825
|
+
detail: {
|
|
826
|
+
// WHAT WOKE IT and HOW THE PAGE IS DECLARED (OPE-1510, notion-engagement.ts).
|
|
827
|
+
// `addressing` is not what the classifier called it — an engaged follow-up reads
|
|
828
|
+
// `thread-follow` here while the turn stays judged. `via` is the domain origin the
|
|
829
|
+
// addressed door gated on, so a name-drop admitted by a `container` claim is
|
|
830
|
+
// distinguishable from one on a `roster` page an operator listed.
|
|
831
|
+
...notionWakeAuditDetail(d.addressing, d.engagedFollowUp, d.domainVia),
|
|
832
|
+
// THE EXACT-JOIN IDS (OPE-1907). Ids only — never a byte of the comment. They are what
|
|
833
|
+
// lets ONE delivery be joined to the turn it woke: `deliveryId` separates two
|
|
834
|
+
// deliveries of the same comment, and `wakeId` is the key `wake.ingress` and
|
|
835
|
+
// `wake.turn_start` already carry, so the row bridges the front door to the session.
|
|
836
|
+
...notionDispatchJoinDetail({
|
|
837
|
+
deliveryId: notionEventDeliveryId(rawBody),
|
|
838
|
+
commentId: d.trigger.id,
|
|
839
|
+
...(window ? { wakeId: window.wakeId } : {}),
|
|
840
|
+
}),
|
|
841
|
+
discussionId: d.discussionId,
|
|
842
|
+
...(window ? { singleWindow: true } : {}),
|
|
843
|
+
// The ancestor-trust verdict (OPE-492), when roots are declared. The REASON is the point:
|
|
844
|
+
// `database-boundary` / `hop-limit` / `cycle` / `unresolvable` / `workspace-root` each say
|
|
845
|
+
// a different thing about why a page did not inherit trust, and a row that recorded only
|
|
846
|
+
// "untrusted" would send an operator to look at the wrong thing. `unreachableRoots` names a
|
|
847
|
+
// declared root Notion serves as neither page nor block — the teamspace-id case.
|
|
848
|
+
...(d.trust
|
|
849
|
+
? {
|
|
850
|
+
trust: {
|
|
851
|
+
trusted: d.trust.trusted,
|
|
852
|
+
reason: d.trust.reason,
|
|
853
|
+
hops: d.trust.hops,
|
|
854
|
+
...(d.trust.matchedRoot ? { matchedRoot: d.trust.matchedRoot } : {}),
|
|
855
|
+
...(d.trust.unreachableRoots ? { unreachableRoots: d.trust.unreachableRoots } : {}),
|
|
856
|
+
},
|
|
857
|
+
}
|
|
858
|
+
: {}),
|
|
859
|
+
},
|
|
860
|
+
}));
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const deliveryDeps = {
|
|
864
|
+
signingKeys,
|
|
865
|
+
allowUnverified: getSettings().webhooks.allowUnverifiedNotion,
|
|
866
|
+
credentialFor,
|
|
867
|
+
credentialForPage: (workspaceId, pageId) => notionChannelCredentialFor(workspaceId, pageId, notionDomains()),
|
|
868
|
+
// The registry the OPE-502 signer fence reads (step 8b). Passed as data, not as a verdict —
|
|
869
|
+
// the fence itself lives in the tested core so no wiring can weaken it.
|
|
870
|
+
domains: notionDomains(),
|
|
871
|
+
// The operator's declared TRUST ROOTS (OPE-492) — the `subtree: true` lines of the shared
|
|
872
|
+
// trust registry, handed to this bundle at boot (OPE-539). Empty on a deployment that
|
|
873
|
+
// declares none, which makes step 10b a no-op with no API call.
|
|
874
|
+
trustRoots: notionTrustRoots(),
|
|
875
|
+
resolveBotUserId: async (credentialId) => (await botUserFor(credentialId)).id,
|
|
876
|
+
resolveBotName: async (credentialId) => (await botUserFor(credentialId)).name,
|
|
877
|
+
ambientEnabled: notionAmbientEnabled,
|
|
878
|
+
// The ambient-everywhere calibration dial (explicit domain ← org ← platform default);
|
|
879
|
+
// handleNotionDelivery fail-softs any throw here to neutral.
|
|
880
|
+
engagementTier: (surfaceKey) => engagementTierFor(surfaceKey, getConfig().loadDomains()),
|
|
881
|
+
timing,
|
|
882
|
+
dispatch,
|
|
883
|
+
...(acceptedIngress
|
|
884
|
+
? {
|
|
885
|
+
dispatchGuard: createNotionCommentDispatchGuard({
|
|
886
|
+
org: deploymentOrg(),
|
|
887
|
+
store: getOperationalStore(),
|
|
888
|
+
}),
|
|
889
|
+
}
|
|
890
|
+
: {}),
|
|
891
|
+
// THE STOP LEG's cancel door (OPE-989) — eve's channel-agnostic `attachSession`, the same
|
|
892
|
+
// one github.ts's wrapRoute and teams-stop.ts's route pass to their own stop arms.
|
|
893
|
+
attachSession,
|
|
894
|
+
} satisfies Parameters<typeof handleNotionDelivery>[2];
|
|
895
|
+
let outcome: NotionDeliveryOutcome;
|
|
896
|
+
try {
|
|
897
|
+
outcome = acceptedIngress
|
|
898
|
+
? await handleAcceptedNotionDelivery(rawBody, acceptedIngress.signerCredentialId, deliveryDeps)
|
|
899
|
+
: await handleNotionDelivery(rawBody, signature, deliveryDeps);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
if (acceptedIngress) {
|
|
902
|
+
await settleNotionIngressDrain(acceptedIngress, "retry", {
|
|
903
|
+
org: deploymentOrg(),
|
|
904
|
+
store: getOperationalStore(),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
throw err;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ─── THE EVENT-PLANE LEG (OPE-189) ──────────────────────────────────────────────────────
|
|
911
|
+
// The SECOND consumer of this one delivery, and it is DISJOINT from the supervisor above by
|
|
912
|
+
// event type: `comment.*` is conversation (handled above; the normalizer returns null for it,
|
|
913
|
+
// so it can never fire a routine) and `page.*`/`database.*`/`data_source.*` is work (ignored
|
|
914
|
+
// above; only routines see it). The rule lives in ONE place — notion-event-normalizer.ts —
|
|
915
|
+
// rather than being re-spelled here as a second event-type list that could drift out of step
|
|
916
|
+
// with it; this route hands over EVERY verified delivery and lets the normalizer decide.
|
|
917
|
+
//
|
|
918
|
+
// 🔴 GATED ON `verified`, NOT on the gate having passed: the unverified-hatch acceptance
|
|
919
|
+
// (`webhooks.allowUnverifiedNotion` with no secret) must never fire a routine. See
|
|
920
|
+
// NotionDeliveryOutcome.verified.
|
|
921
|
+
//
|
|
922
|
+
// `waitUntil` because routing is not part of the response the vendor is waiting on, and
|
|
923
|
+
// `admitTrigger` NEVER throws for a bad delivery (every "can't route this" case is a report),
|
|
924
|
+
// so a structural event nobody subscribes to costs one normalize and stops.
|
|
925
|
+
//
|
|
926
|
+
// `headers: {}` is honest, not lazy — the Notion normalizer reads NOTHING from headers.
|
|
927
|
+
// Notion carries its event type, its parents AND its delivery id in the body, which is why
|
|
928
|
+
// `deliveryIdHeader` is absent on the normalizer and `notionEventDeliveryId` exists instead.
|
|
929
|
+
// 🔴 AND FENCED ON THE SIGNER (OPE-500). `verified` proves that SOME configured integration's
|
|
930
|
+
// secret signed these bytes. It does not prove that Notion composed them, so every scope field
|
|
931
|
+
// the normalizer reads is still the sender's choice. Per-domain subscription tokens are
|
|
932
|
+
// distributed by design (OPE-467), so the sender who chose them may be one domain's operator
|
|
933
|
+
// reaching for another domain's routines. notionTriggerScopeVerdict answers the one question
|
|
934
|
+
// that closes it: is the domain that owns the claimed scope a domain THIS signer serves?
|
|
935
|
+
//
|
|
936
|
+
// A refusal drops the delivery out of the trigger plane and says why in the log. It is not a
|
|
937
|
+
// 4xx — the bytes verified, and making Notion retry a refusal that will repeat is wrong.
|
|
938
|
+
let eventPlaneRefused = false;
|
|
939
|
+
if (outcome.verified) {
|
|
940
|
+
const fence = notionTriggerScopeVerdict(rawBody, outcome.signerCredentialId, notionDomains());
|
|
941
|
+
if (!fence.ok) {
|
|
942
|
+
eventPlaneRefused = true;
|
|
943
|
+
log(`event plane: delivery refused — ${fence.reason}`);
|
|
944
|
+
} else {
|
|
945
|
+
const deliveryId = notionEventDeliveryId(rawBody);
|
|
946
|
+
const eventPlane = admitTrigger(
|
|
947
|
+
{
|
|
948
|
+
source: NOTION_EVENT_SOURCE,
|
|
949
|
+
headers: {},
|
|
950
|
+
body: rawBody,
|
|
951
|
+
receivedAt: new Date().toISOString(),
|
|
952
|
+
...(deliveryId ? { deliveryId } : {}),
|
|
953
|
+
},
|
|
954
|
+
{ to },
|
|
955
|
+
).then((report) => {
|
|
956
|
+
if (report.matched > 0) log(`event plane: ${report.matched} matched -> ${report.fired} fired, ${report.skipped} skipped`);
|
|
957
|
+
});
|
|
958
|
+
if (acceptedIngress) {
|
|
959
|
+
try {
|
|
960
|
+
await eventPlane;
|
|
961
|
+
} catch (err) {
|
|
962
|
+
await settleNotionIngressDrain(acceptedIngress, "retry", {
|
|
963
|
+
org: deploymentOrg(),
|
|
964
|
+
store: getOperationalStore(),
|
|
965
|
+
});
|
|
966
|
+
throw err;
|
|
967
|
+
}
|
|
968
|
+
} else {
|
|
969
|
+
waitUntil(eventPlane.catch((err) => log(`event plane: admitTrigger threw (routing lost for this delivery): ${String(err)}`)));
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (outcome.action === "handshake") {
|
|
975
|
+
// 🔴 THE TOKEN IS NEVER LOGGED (OPE-195). It is the PERMANENT HMAC key for every subsequent
|
|
976
|
+
// delivery on this subscription, so printing it — which this line used to do, and which
|
|
977
|
+
// .env.example and the Notion runbook used to instruct operators to rely on — put the
|
|
978
|
+
// signing key of a fail-closed ingress into log retention, forgeable by anyone with log
|
|
979
|
+
// access for as long as logs are kept. captureHandshakeSecret stores it in the encrypted
|
|
980
|
+
// vault under a RESERVED ref and logs a redaction + sha256 fingerprint + a single-use,
|
|
981
|
+
// 15-minute reveal link instead. That link is BEARER-ONLY (there is no clicker at handshake
|
|
982
|
+
// time) — see the module header. THE ONE CALL SITE: moving this ingress onto the
|
|
983
|
+
// handshake-capture plane deletes this branch and webhooks/handshake-capture.ts together.
|
|
984
|
+
try {
|
|
985
|
+
await captureHandshakeSecret(
|
|
986
|
+
{ refNamespace: NOTION_HANDSHAKE_REF_NAMESPACE, label: "Notion webhook (verification_token)", value: handshakeToken(rawBody) },
|
|
987
|
+
{ log },
|
|
988
|
+
);
|
|
989
|
+
} catch (err) {
|
|
990
|
+
// The old line could not throw; this one reaches the vault, so it can (no AES key, store
|
|
991
|
+
// down). Never let that 500 the handshake — Notion would mark the endpoint unverified and
|
|
992
|
+
// the operator would be debugging the wrong thing. Report the FAILURE, never the value:
|
|
993
|
+
// an uncaptured secret is a rotation (recreate the subscription), a logged one is an
|
|
994
|
+
// incident. `err` is a store/crypto error and carries no secret material.
|
|
995
|
+
log(`handshake secret could NOT be captured (${err instanceof Error ? err.message : String(err)}) — the value is NOT logged; delete and recreate the Notion subscription once fixed.`);
|
|
996
|
+
}
|
|
997
|
+
} else if (outcome.action === "dispatched") {
|
|
998
|
+
log(`comment.created → dispatched (${outcome.detail})`);
|
|
999
|
+
} else if (outcome.action === "stopped") {
|
|
1000
|
+
// OPE-989 — the audit row for the cancel itself (`turn.cancel_requested`) is already
|
|
1001
|
+
// written by `cancelAnchoredTurn`; this is only the operator-facing line.
|
|
1002
|
+
log(`stop comment → ${outcome.detail}`);
|
|
1003
|
+
} else if (outcome.action === "ambient-disabled") {
|
|
1004
|
+
// Was the generic `else` below: ONE log line, no row (OPE-205). `detail` is the surface
|
|
1005
|
+
// key by construction (notion-webhook.ts's gate). The gate itself is unchanged — this
|
|
1006
|
+
// only makes "the supervisor was never asked about this page" as answerable as the
|
|
1007
|
+
// notion.no_reply row we already write when it IS asked and judges silence.
|
|
1008
|
+
const surfaceKey = outcome.detail ?? "";
|
|
1009
|
+
waitUntil(
|
|
1010
|
+
auditAmbientSkip({
|
|
1011
|
+
platform: "notion",
|
|
1012
|
+
workspace: surfaceKey.split("/")[1] ?? "unknown",
|
|
1013
|
+
surfaceKey,
|
|
1014
|
+
log,
|
|
1015
|
+
}),
|
|
1016
|
+
);
|
|
1017
|
+
} else if (outcome.action === "scope-refused") {
|
|
1018
|
+
// OPE-502. A forged `workspace_id` is INVISIBLE on the surface — the agent simply never
|
|
1019
|
+
// wakes — so the refusal writes a row as well as a line, the same reason notion-scope.ts
|
|
1020
|
+
// gives for auditing a refused read. The reason string already names the page, the claimed
|
|
1021
|
+
// workspace, the owning domain and both credentials, each bounded and control-stripped
|
|
1022
|
+
// (notion-credentials.ts). Nothing of the fetched COMMENT is recorded.
|
|
1023
|
+
log(`delivery scope-refused: ${outcome.detail}`);
|
|
1024
|
+
waitUntil(
|
|
1025
|
+
appendAudit({
|
|
1026
|
+
org: deploymentOrg(),
|
|
1027
|
+
platform: "notion",
|
|
1028
|
+
// The claimed workspace is the field under suspicion, so it must not become an audit
|
|
1029
|
+
// dimension. It rides in `detail` inside the reason instead.
|
|
1030
|
+
workspace: "unknown",
|
|
1031
|
+
action: "notion.scope_refused",
|
|
1032
|
+
detail: { reason: outcome.detail ?? null, signer: outcome.signerCredentialId ?? null },
|
|
1033
|
+
}),
|
|
1034
|
+
);
|
|
1035
|
+
} else if (outcome.action === "rejected") {
|
|
1036
|
+
log(`delivery rejected (${outcome.status}): ${outcome.detail}`);
|
|
1037
|
+
waitUntil(
|
|
1038
|
+
appendAudit({
|
|
1039
|
+
org: deploymentOrg(),
|
|
1040
|
+
platform: "notion",
|
|
1041
|
+
workspace: "unknown",
|
|
1042
|
+
action: "notion.rejected",
|
|
1043
|
+
detail: { status: outcome.status, reason: outcome.detail },
|
|
1044
|
+
}),
|
|
1045
|
+
);
|
|
1046
|
+
} else {
|
|
1047
|
+
log(`delivery ${outcome.action}${outcome.detail ? ` (${outcome.detail})` : ""}`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// TELEMETRY ONLY. The delivery, dedupe mark and wake acceptance above remain synchronous;
|
|
1051
|
+
// this content-free row is the sole deferred addition and cannot lengthen the ACK it measures.
|
|
1052
|
+
// `unknown` dimensions are deliberate: the phase buckets need no workspace/page/comment,
|
|
1053
|
+
// credential or message data, and the action + audit timestamp are enough to find a sample.
|
|
1054
|
+
if (acceptedIngress) {
|
|
1055
|
+
await settleNotionIngressDrain(acceptedIngress, notionIngressDisposition(outcome, eventPlaneRefused), {
|
|
1056
|
+
org: deploymentOrg(),
|
|
1057
|
+
store: getOperationalStore(),
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const response = new Response(outcome.body, { status: outcome.status });
|
|
1061
|
+
finishTiming(outcome.action);
|
|
1062
|
+
return response;
|
|
1063
|
+
},
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
export default defineChannel<NotionState, { state: NotionState }>({
|
|
1067
|
+
kindHint: "notion",
|
|
1068
|
+
// 🔴 QUEUE, NEVER STEER (OPE-412, OPE-1732). eve's default is `steer`, and a steer delivery
|
|
1069
|
+
// carrying a message CANCELS the turn in flight — rolling session history back to the last
|
|
1070
|
+
// completed model call while that call's tool side effects stay real and unrecorded. The policy
|
|
1071
|
+
// resolves per RECEIVING channel, and every Notion comment session lives on THIS channel, so no
|
|
1072
|
+
// other pin covers it. Notion ships no vendor factory, so `lint:turn-policy` cannot see this
|
|
1073
|
+
// line either — `scripts/notion-turn-policy-test.ts` is what holds it. See
|
|
1074
|
+
// packages/providers/slack/agent/channels/slack.ts for the full note.
|
|
1075
|
+
turnPolicy: "queue",
|
|
1076
|
+
state: { discussionId: null, pageId: null, workspaceId: null },
|
|
1077
|
+
context(state) {
|
|
1078
|
+
return { state };
|
|
1079
|
+
},
|
|
1080
|
+
routes: [
|
|
1081
|
+
POST(ROUTE, notionWebhookRouteHandler),
|
|
1082
|
+
POST(NOTION_INGRESS_DRAIN_PATH, notionWebhookRouteHandler),
|
|
1083
|
+
],
|
|
1084
|
+
events: {
|
|
1085
|
+
// Snapshot the PER-TURN reply target from the delivery's auth attributes. The seeded
|
|
1086
|
+
// `state.discussionId` is applied by eve only when the session is CREATED — a resumed
|
|
1087
|
+
// standing supervisor session keeps its original state, so relying on the seed would post
|
|
1088
|
+
// every later reply into the FIRST discussion. But eve rewrites `session.auth.current`
|
|
1089
|
+
// from the DELIVERY's auth on every delivery, and buildNotionAuth stows discussion_id /
|
|
1090
|
+
// page_id / workspace_id there — so at turn.started they are THIS turn's real trigger
|
|
1091
|
+
// (the exact pattern slack.ts's supervisor turn.started snapshot uses, T1.4). A missing
|
|
1092
|
+
// discussion_id CLEARS the target (never carry a stale one forward) and message.completed
|
|
1093
|
+
// fails safe: no target ⇒ it posts NOTHING, never into a guessed thread.
|
|
1094
|
+
"turn.started"(_data, channel, ctx) {
|
|
1095
|
+
const attrs = ctx?.session?.auth?.current?.attributes as Record<string, unknown> | undefined;
|
|
1096
|
+
const s = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
1097
|
+
const discussionId = s(attrs?.discussion_id);
|
|
1098
|
+
channel.state.oeReplyTarget = discussionId
|
|
1099
|
+
? ({
|
|
1100
|
+
discussionId,
|
|
1101
|
+
pageId: s(attrs?.page_id),
|
|
1102
|
+
workspaceId: s(attrs?.workspace_id),
|
|
1103
|
+
// OPE-467: the integration THIS delivery verified under. Snapshotted per turn like the
|
|
1104
|
+
// rest of the target, so a standing supervisor session answers each doc under the
|
|
1105
|
+
// integration that fetched that doc's comment.
|
|
1106
|
+
credentialId: s(attrs?.credential_id),
|
|
1107
|
+
} satisfies NotionReplyTarget)
|
|
1108
|
+
: undefined;
|
|
1109
|
+
// OPE-811: the OPE-508 REPLIER TIER snapshot is GONE from this handler, and nothing replaced
|
|
1110
|
+
// it. It existed because `message.completed` posted the reply and holds no live auth ctx. The
|
|
1111
|
+
// reply now leaves through the `reply` tool, whose own ctx resolves the grain and task label
|
|
1112
|
+
// directly (tools/reply.ts `replierFor`), and reply-post.ts bakes the bracket at the choke
|
|
1113
|
+
// point for every arm that cannot vary a display name — Notion included. One computation, at
|
|
1114
|
+
// the seam that posts.
|
|
1115
|
+
},
|
|
1116
|
+
// SHOW A PARKED QUESTION OR APPROVAL PROMPT (OPE-1275). Before this, eve's `input.requested` —
|
|
1117
|
+
// an `ask_question` mid-turn, or a `user-approval` turned `tool-approval` — reached this channel
|
|
1118
|
+
// and NOTHING answered it: the generic `defineChannel` this module builds on (unlike the
|
|
1119
|
+
// built-in `slackChannel`/`discordChannel`/`teamsChannel`/`githubChannel` every other native
|
|
1120
|
+
// provider wraps) ships no default handler at all, so a parked Notion turn showed nothing in the
|
|
1121
|
+
// discussion thread. The audit half (a request occurred) is a SEPARATE, surface-agnostic hook
|
|
1122
|
+
// (hooks/native-input-audit.ts) — this handler owns only the post.
|
|
1123
|
+
//
|
|
1124
|
+
// Posts through `postHostNotice` — `postReply` with NO audit parameter to pass, same posture as
|
|
1125
|
+
// the unified window's own bridge (channels/question-bridge.ts): a parked prompt is neither the
|
|
1126
|
+
// model's answer nor an answer to anything yet, so it must not write the `reply.post` row the
|
|
1127
|
+
// eval scorers join on.
|
|
1128
|
+
//
|
|
1129
|
+
// 🔴 AND IT REMEMBERS (OPE-1305), ONLY ON A POSTED QUESTION. The row keyed by this session's
|
|
1130
|
+
// token is what turns the discussion's next comment into `respond([{requestId, text}])` in the
|
|
1131
|
+
// `wake` dep above — the return leg a DELEGATED WORKER's question has no other way to reach. eve
|
|
1132
|
+
// proxies the child's request up through the PARENT's channel adapter, so the prompt reaches this
|
|
1133
|
+
// handler and lands in the thread, but the answer only reaches the child through an
|
|
1134
|
+
// `inputResponses` entry whose `requestId` sits in the parent's proxy map; a plain message falls
|
|
1135
|
+
// to `forSelf` and the parent eats it. Before this, OPE-1275 had traded an INVISIBLE unanswerable
|
|
1136
|
+
// question for a VISIBLE one — better, and a new false promise: the human answered in the thread
|
|
1137
|
+
// and believed they had answered. channels/notion-question-return.ts owns the leg and states the
|
|
1138
|
+
// keying rule; the only thing this handler must get right is that a DROPPED question writes
|
|
1139
|
+
// nothing, because nobody was shown anything that may be answered.
|
|
1140
|
+
//
|
|
1141
|
+
// NO WAKE ID RIDES THE ROW on this route, and none is missing: a wake id is minted on the
|
|
1142
|
+
// SINGLE-WINDOW path only (notion-supervisor.ts), and a windowed wake never reaches this handler
|
|
1143
|
+
// — it diverts to the unified channel, which runs its own leg. The field is an audit join, not a
|
|
1144
|
+
// routing input, so its absence costs a join and nothing else.
|
|
1145
|
+
//
|
|
1146
|
+
// 🔴 AWAITED, NOT FLOATED (cold review, 2026-08-29). Every other `input.requested` handler in
|
|
1147
|
+
// the tree awaits its post (channels/unified.ts, and linear.ts's delegation to eve's own
|
|
1148
|
+
// default) — an un-awaited post here could still be in flight when this invocation's own
|
|
1149
|
+
// response is sent and the host freezes it, turning the exact silence this handler exists to
|
|
1150
|
+
// remove into an intermittent one that is harder to diagnose, because the SEPARATE audit hook
|
|
1151
|
+
// (hooks/native-input-audit.ts) would still report the request as having happened.
|
|
1152
|
+
async "input.requested"(data, channel) {
|
|
1153
|
+
const target = channel.state.oeReplyTarget;
|
|
1154
|
+
if (target === undefined) return; // no resolvable discussion this turn — nothing to post into.
|
|
1155
|
+
const question = detectBridgeableInputRequest((data as { requests?: unknown } | undefined)?.requests);
|
|
1156
|
+
if (!question) return; // eve's session-limit park (or an unrecognisable batch) — not ours.
|
|
1157
|
+
try {
|
|
1158
|
+
await postHostNotice({ platform: "notion", ...target }, renderQuestionForSurface(question));
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
log(`input-request could not be posted into discussion ${target.discussionId}: ${String(err)}`);
|
|
1161
|
+
return; // NOT posted ⇒ NOT remembered: nothing was shown, so nothing may be consumed as its answer.
|
|
1162
|
+
}
|
|
1163
|
+
// POSTED ⇒ answerable. `rememberNotionQuestion` never throws; a `false` means this discussion
|
|
1164
|
+
// cannot answer after all (no usable token, or an unwritable store), which is the pre-OPE-1305
|
|
1165
|
+
// behaviour and is said out loud rather than left to look like success.
|
|
1166
|
+
const remembered = await rememberNotionQuestion({
|
|
1167
|
+
org: deploymentOrg(),
|
|
1168
|
+
namespacedToken: channel.continuation?.token,
|
|
1169
|
+
requestId: question.requestId,
|
|
1170
|
+
discussionId: target.discussionId,
|
|
1171
|
+
});
|
|
1172
|
+
log(
|
|
1173
|
+
remembered
|
|
1174
|
+
? `question bridge: posted ${question.toolName} (${question.kind}) into discussion ${target.discussionId} — answerable there (${question.requestId})`
|
|
1175
|
+
: `question bridge: posted ${question.toolName} (${question.kind}) into discussion ${target.discussionId} — NOT answerable (no outstanding-question row); a delegated worker's question will stay parked`,
|
|
1176
|
+
);
|
|
1177
|
+
},
|
|
1178
|
+
// Deliver the supervisor's reply back into the SAME Notion discussion thread — OR audit its
|
|
1179
|
+
// judged SILENCE. finalMessageActionForTurn is the shared decision every surface calls (it
|
|
1180
|
+
// honours BOTH the NO_REPLY sentinel and the audited `no_reply` TOOL). A "silent" turn writes
|
|
1181
|
+
// a notion.no_reply audit row (parity with Slack — every silence is durable and answerable);
|
|
1182
|
+
// a "buffer-tool-status" turn (mid-turn tool call) is not terminal and is left alone.
|
|
1183
|
+
// The reply targets the PER-TURN snapshot (turn.started above), falling back to the seeded
|
|
1184
|
+
// state only for a session's FIRST turn shape where both agree anyway; audit rows fall back
|
|
1185
|
+
// to the seeded page/workspace when the snapshot omits them (context, not a post target).
|
|
1186
|
+
async "message.completed"(data, channel, ctx) {
|
|
1187
|
+
const sessionId = ctx?.session?.id; // stamped into the terminal audit rows → joins to the transcript
|
|
1188
|
+
const action = finalMessageActionForTurn(data.finishReason, data.message);
|
|
1189
|
+
if (action.kind === "buffer-tool-status") return; // not terminal — check BEFORE consuming below
|
|
1190
|
+
const target = channel.state.oeReplyTarget;
|
|
1191
|
+
const discussionId = target?.discussionId;
|
|
1192
|
+
// Read-and-reset the reply-tool signal on every TERMINAL completion (silent or post) — it must
|
|
1193
|
+
// never bleed into the next turn — mirrors slack.ts's consume-at-top. Feeds the shared arm's
|
|
1194
|
+
// stray-prose-leak accounting on the unified path. TURN-ID KEYED (OPE-805): only THIS turn's
|
|
1195
|
+
// own mark answers true — see reply-signal.ts's header.
|
|
1196
|
+
const repliedThisTurn = consumeSupervisorReplyMark(ctx);
|
|
1197
|
+
const org = deploymentOrg();
|
|
1198
|
+
const workspace = target?.workspaceId ?? channel.state.workspaceId ?? "unknown";
|
|
1199
|
+
// The SHARED ticketing completion arm (channels/supervisor-complete.ts). It POSTS NOTHING since
|
|
1200
|
+
// OPE-811: reply-post.ts's notion arm posts into the per-delivery discussion binding on ANY
|
|
1201
|
+
// Notion session, so the `reply` tool is the delivery on a per-doc supervisor turn exactly as
|
|
1202
|
+
// it already was on a windowed one, and the manifest's own midTurnPost is what the gate reads.
|
|
1203
|
+
//
|
|
1204
|
+
// 🔴 THE `oeUnified` CONDITION IS GONE, AND THAT IS THE POINT. It used to pass midTurnPost=false
|
|
1205
|
+
// on a NON-windowed per-doc session, so the gate failed open there and the turn's closing text
|
|
1206
|
+
// posted via createCommentReply. That was the surface's last prose delivery channel, and it made
|
|
1207
|
+
// one seat's delivery contract depend on its domain's topology — the divergence this ticket
|
|
1208
|
+
// removes. (OPE-787's warning still holds for anyone reading `oeUnified`: it is grain.unified,
|
|
1209
|
+
// true for EVERY windowed grain including a solo per-doc window, never "cross-surface".)
|
|
1210
|
+
// The notion.dispatched row above + replied/no_reply/turn.supervisor_prose_dropped = the
|
|
1211
|
+
// "a row closes a row" family — every terminal outcome closes the dispatched row.
|
|
1212
|
+
await completeSupervisorTurn({
|
|
1213
|
+
action,
|
|
1214
|
+
...(sessionId ? { sessionId } : {}),
|
|
1215
|
+
repliedViaTool: repliedThisTurn,
|
|
1216
|
+
midTurnPost: capabilitiesForPlatform("notion").midTurnPost,
|
|
1217
|
+
noReplyAction: "notion.no_reply",
|
|
1218
|
+
repliedAction: "notion.replied",
|
|
1219
|
+
contextRow: { org, platform: "notion", workspace, surface: target?.pageId ?? channel.state.pageId ?? discussionId ?? "unknown" },
|
|
1220
|
+
repliedRow: { org, platform: "notion", workspace, surface: target?.pageId ?? channel.state.pageId ?? discussionId },
|
|
1221
|
+
silentAnchor: { discussionId: discussionId ?? channel.state.discussionId },
|
|
1222
|
+
droppedAnchor: { discussionId: discussionId ?? null },
|
|
1223
|
+
repliedAnchor: { discussionId },
|
|
1224
|
+
log: (m) => log(m),
|
|
1225
|
+
});
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
// Re-parse the handshake token purely to hand it to captureHandshakeSecret above (handleNotion
|
|
1231
|
+
// Delivery already consumed it; this keeps that function's return type clean). Returns "" rather
|
|
1232
|
+
// than a placeholder string on a shape it can't read: capture must never store a sentinel as though
|
|
1233
|
+
// it were a key. Unreachable in practice — `action === "handshake"` implies parseVerification
|
|
1234
|
+
// Handshake already succeeded on this exact body — but a stored lie is worse than a skipped capture.
|
|
1235
|
+
function handshakeToken(rawBody: string): string {
|
|
1236
|
+
try {
|
|
1237
|
+
const t = (JSON.parse(rawBody) as { verification_token?: unknown }).verification_token;
|
|
1238
|
+
return typeof t === "string" ? t : "";
|
|
1239
|
+
} catch {
|
|
1240
|
+
return "";
|
|
1241
|
+
}
|
|
1242
|
+
}
|