@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.
Files changed (186) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +48 -0
  4. package/agent/channels/notion-acting-identity.ts +88 -0
  5. package/agent/channels/notion-ancestry-trust.ts +458 -0
  6. package/agent/channels/notion-api.ts +1290 -0
  7. package/agent/channels/notion-bot-user.ts +250 -0
  8. package/agent/channels/notion-catch-up.ts +339 -0
  9. package/agent/channels/notion-credentials.ts +417 -0
  10. package/agent/channels/notion-engagement.ts +372 -0
  11. package/agent/channels/notion-event-normalizer.ts +587 -0
  12. package/agent/channels/notion-identity.ts +271 -0
  13. package/agent/channels/notion-ingress-drain.ts +289 -0
  14. package/agent/channels/notion-ingress.ts +182 -0
  15. package/agent/channels/notion-message-ops.ts +140 -0
  16. package/agent/channels/notion-origin-codec.ts +38 -0
  17. package/agent/channels/notion-post-arm.ts +189 -0
  18. package/agent/channels/notion-question-return.ts +223 -0
  19. package/agent/channels/notion-read.ts +283 -0
  20. package/agent/channels/notion-scope.ts +560 -0
  21. package/agent/channels/notion-stop.ts +169 -0
  22. package/agent/channels/notion-supervisor.ts +856 -0
  23. package/agent/channels/notion-webhook-timing.ts +131 -0
  24. package/agent/channels/notion-webhook-verifier.ts +55 -0
  25. package/agent/channels/notion-webhook.ts +963 -0
  26. package/agent/channels/notion-work-search.ts +74 -0
  27. package/agent/channels/notion-work.ts +144 -0
  28. package/agent/channels/notion.ts +1242 -0
  29. package/agent/contributions.ts +39 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/memory/blocks.ts +295 -0
  32. package/agent/memory/memory.ts +622 -0
  33. package/agent/provider-events.ts +198 -0
  34. package/agent/register.ts +144 -0
  35. package/agent/schedules/notion-ingress.ts +20 -0
  36. package/agent/surface-parity.ts +105 -0
  37. package/agent/tools/notion_search.ts +110 -0
  38. package/agent/tools/read_notion_comments.ts +250 -0
  39. package/agent/tools/read_notion_page.ts +351 -0
  40. package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
  41. package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
  42. package/dist/agent/channels/notion-acting-identity.js +85 -0
  43. package/dist/agent/channels/notion-acting-identity.js.map +1 -0
  44. package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
  45. package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
  46. package/dist/agent/channels/notion-ancestry-trust.js +331 -0
  47. package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
  48. package/dist/agent/channels/notion-api.d.ts +655 -0
  49. package/dist/agent/channels/notion-api.d.ts.map +1 -0
  50. package/dist/agent/channels/notion-api.js +938 -0
  51. package/dist/agent/channels/notion-api.js.map +1 -0
  52. package/dist/agent/channels/notion-bot-user.d.ts +107 -0
  53. package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
  54. package/dist/agent/channels/notion-bot-user.js +192 -0
  55. package/dist/agent/channels/notion-bot-user.js.map +1 -0
  56. package/dist/agent/channels/notion-catch-up.d.ts +64 -0
  57. package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
  58. package/dist/agent/channels/notion-catch-up.js +289 -0
  59. package/dist/agent/channels/notion-catch-up.js.map +1 -0
  60. package/dist/agent/channels/notion-credentials.d.ts +210 -0
  61. package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
  62. package/dist/agent/channels/notion-credentials.js +360 -0
  63. package/dist/agent/channels/notion-credentials.js.map +1 -0
  64. package/dist/agent/channels/notion-engagement.d.ts +219 -0
  65. package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
  66. package/dist/agent/channels/notion-engagement.js +283 -0
  67. package/dist/agent/channels/notion-engagement.js.map +1 -0
  68. package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
  69. package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
  70. package/dist/agent/channels/notion-event-normalizer.js +542 -0
  71. package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
  72. package/dist/agent/channels/notion-identity.d.ts +119 -0
  73. package/dist/agent/channels/notion-identity.d.ts.map +1 -0
  74. package/dist/agent/channels/notion-identity.js +205 -0
  75. package/dist/agent/channels/notion-identity.js.map +1 -0
  76. package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
  77. package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
  78. package/dist/agent/channels/notion-ingress-drain.js +185 -0
  79. package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
  80. package/dist/agent/channels/notion-ingress.d.ts +64 -0
  81. package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
  82. package/dist/agent/channels/notion-ingress.js +130 -0
  83. package/dist/agent/channels/notion-ingress.js.map +1 -0
  84. package/dist/agent/channels/notion-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/notion-message-ops.js +126 -0
  87. package/dist/agent/channels/notion-message-ops.js.map +1 -0
  88. package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
  89. package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/notion-origin-codec.js +32 -0
  91. package/dist/agent/channels/notion-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/notion-post-arm.d.ts +3 -0
  93. package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/notion-post-arm.js +185 -0
  95. package/dist/agent/channels/notion-post-arm.js.map +1 -0
  96. package/dist/agent/channels/notion-question-return.d.ts +77 -0
  97. package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
  98. package/dist/agent/channels/notion-question-return.js +183 -0
  99. package/dist/agent/channels/notion-question-return.js.map +1 -0
  100. package/dist/agent/channels/notion-read.d.ts +62 -0
  101. package/dist/agent/channels/notion-read.d.ts.map +1 -0
  102. package/dist/agent/channels/notion-read.js +257 -0
  103. package/dist/agent/channels/notion-read.js.map +1 -0
  104. package/dist/agent/channels/notion-scope.d.ts +188 -0
  105. package/dist/agent/channels/notion-scope.d.ts.map +1 -0
  106. package/dist/agent/channels/notion-scope.js +433 -0
  107. package/dist/agent/channels/notion-scope.js.map +1 -0
  108. package/dist/agent/channels/notion-stop.d.ts +48 -0
  109. package/dist/agent/channels/notion-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/notion-stop.js +141 -0
  111. package/dist/agent/channels/notion-stop.js.map +1 -0
  112. package/dist/agent/channels/notion-supervisor.d.ts +462 -0
  113. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/notion-supervisor.js +482 -0
  115. package/dist/agent/channels/notion-supervisor.js.map +1 -0
  116. package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
  117. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
  118. package/dist/agent/channels/notion-webhook-timing.js +99 -0
  119. package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
  120. package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
  121. package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
  122. package/dist/agent/channels/notion-webhook-verifier.js +53 -0
  123. package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
  124. package/dist/agent/channels/notion-webhook.d.ts +242 -0
  125. package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
  126. package/dist/agent/channels/notion-webhook.js +675 -0
  127. package/dist/agent/channels/notion-webhook.js.map +1 -0
  128. package/dist/agent/channels/notion-work-search.d.ts +9 -0
  129. package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
  130. package/dist/agent/channels/notion-work-search.js +69 -0
  131. package/dist/agent/channels/notion-work-search.js.map +1 -0
  132. package/dist/agent/channels/notion-work.d.ts +68 -0
  133. package/dist/agent/channels/notion-work.d.ts.map +1 -0
  134. package/dist/agent/channels/notion-work.js +126 -0
  135. package/dist/agent/channels/notion-work.js.map +1 -0
  136. package/dist/agent/channels/notion.d.ts +95 -0
  137. package/dist/agent/channels/notion.d.ts.map +1 -0
  138. package/dist/agent/channels/notion.js +1081 -0
  139. package/dist/agent/channels/notion.js.map +1 -0
  140. package/dist/agent/contributions.d.ts +32 -0
  141. package/dist/agent/contributions.d.ts.map +1 -0
  142. package/dist/agent/contributions.js +37 -0
  143. package/dist/agent/contributions.js.map +1 -0
  144. package/dist/agent/docs-generated.d.ts +3 -0
  145. package/dist/agent/docs-generated.d.ts.map +1 -0
  146. package/dist/agent/docs-generated.js +13 -0
  147. package/dist/agent/docs-generated.js.map +1 -0
  148. package/dist/agent/memory/blocks.d.ts +58 -0
  149. package/dist/agent/memory/blocks.d.ts.map +1 -0
  150. package/dist/agent/memory/blocks.js +264 -0
  151. package/dist/agent/memory/blocks.js.map +1 -0
  152. package/dist/agent/memory/memory.d.ts +157 -0
  153. package/dist/agent/memory/memory.d.ts.map +1 -0
  154. package/dist/agent/memory/memory.js +471 -0
  155. package/dist/agent/memory/memory.js.map +1 -0
  156. package/dist/agent/provider-events.d.ts +3 -0
  157. package/dist/agent/provider-events.d.ts.map +1 -0
  158. package/dist/agent/provider-events.js +196 -0
  159. package/dist/agent/provider-events.js.map +1 -0
  160. package/dist/agent/register.d.ts +5 -0
  161. package/dist/agent/register.d.ts.map +1 -0
  162. package/dist/agent/register.js +128 -0
  163. package/dist/agent/register.js.map +1 -0
  164. package/dist/agent/schedules/notion-ingress.d.ts +6 -0
  165. package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
  166. package/dist/agent/schedules/notion-ingress.js +16 -0
  167. package/dist/agent/schedules/notion-ingress.js.map +1 -0
  168. package/dist/agent/surface-parity.d.ts +3 -0
  169. package/dist/agent/surface-parity.d.ts.map +1 -0
  170. package/dist/agent/surface-parity.js +99 -0
  171. package/dist/agent/surface-parity.js.map +1 -0
  172. package/dist/agent/tools/notion_search.d.ts +56 -0
  173. package/dist/agent/tools/notion_search.d.ts.map +1 -0
  174. package/dist/agent/tools/notion_search.js +100 -0
  175. package/dist/agent/tools/notion_search.js.map +1 -0
  176. package/dist/agent/tools/read_notion_comments.d.ts +57 -0
  177. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
  178. package/dist/agent/tools/read_notion_comments.js +236 -0
  179. package/dist/agent/tools/read_notion_comments.js.map +1 -0
  180. package/dist/agent/tools/read_notion_page.d.ts +53 -0
  181. package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
  182. package/dist/agent/tools/read_notion_page.js +327 -0
  183. package/dist/agent/tools/read_notion_page.js.map +1 -0
  184. package/package.json +231 -0
  185. package/tsconfig.build.json +22 -0
  186. package/tsconfig.json +15 -0
@@ -0,0 +1,271 @@
1
+ // Pure Notion→OpenEverywhere identity capture. The Notion channel (notion.ts) calls
2
+ // these to stow RAW Notion facts (workspace / page / discussion / comment ids + the
3
+ // commenting actor) into the session auth context's `attributes`. The INTERPRETATION of
4
+ // those facts into mode/visibility lives here too — notionMode/notionVisibility — so the
5
+ // test can call them and the security-critical "is this private?" decision sits in one
6
+ // tested place. This file imports no eve types (only a type-only alias, erased at
7
+ // compile), so it's trivially unit-testable.
8
+ //
9
+ // SILO CHOICE (why surface = the PAGE, not the workspace and not the comment thread):
10
+ // the surface-fit criterion is "the persistent multi-instance container, NOT the
11
+ // invocation point". Notion's tiers map onto ours exactly like Linear's do:
12
+ // Notion workspace ≈ Linear organization ≈ our `workspace` (the tenant tier)
13
+ // Notion PAGE ≈ Linear TEAM ≈ our `surface` (the silo — one page
14
+ // hosts many discussion threads over time,
15
+ // just as a team hosts many issues)
16
+ // Notion discussion ≈ Linear issue ≈ the conversation ANCHOR (post-back target
17
+ // + continuation key), NOT the partition key
18
+ // So a page's accumulated memory is reused across every comment thread on it, while a
19
+ // bare comment thread (which comes and goes) is only the reply anchor. This mirrors Slack
20
+ // too: surface = channel, the thread is the anchor. (A finer teamspace→page two-level silo
21
+ // is a future refinement; page is the launch grain, matching the Linear team grain.)
22
+
23
+ import type { SessionMode, Visibility, BindableContext, IdentityMapper, RawIdentity } from "@open-everywhere/core/agent/adapter-api.js";
24
+
25
+ /** Structural match for eve's SessionAuthContext (attributes + principal). */
26
+ export interface OpenEverywhereNotionAuth {
27
+ readonly authenticator: string;
28
+ readonly principalId: string;
29
+ readonly principalType: string;
30
+ readonly attributes: Record<string, string>;
31
+ }
32
+
33
+ export interface NotionIdentityInput {
34
+ /** Notion workspace id — becomes the OpenEverywhere `workspace` (tenant tier). */
35
+ readonly workspaceId?: string;
36
+ /** The page the comment lives on — becomes the OpenEverywhere `surface` (the silo grain). */
37
+ readonly pageId?: string;
38
+ /** The discussion thread the comment belongs to — the post-back anchor (create-comment
39
+ * targets `discussion_id`), NOT the partition key. */
40
+ readonly discussionId?: string;
41
+ /** The specific comment that triggered this turn — for audit/provenance. */
42
+ readonly commentId?: string;
43
+ /** The acting Notion user (comment author), if known — becomes the principal. */
44
+ readonly principalId?: string;
45
+ /**
46
+ * The Notion channel credential this DELIVERY was selected to run under (OPE-467) — the
47
+ * integration whose secret verified it. Stowed so every reply for this turn posts under the SAME
48
+ * integration that fetched the comment. Without it a reply re-derives the credential from the
49
+ * page's domain binding alone, and a page shared with an integration its domain does not bind
50
+ * would fetch under one key and answer under another whose integration was never shared the page
51
+ * — `createCommentReply` throws and the agent silently never answers.
52
+ *
53
+ * HOST-SET (§AUTH-ATTRIBUTE PROVENANCE): built from the HMAC gate's verdict, never from the
54
+ * comment body, so it cannot be forged into naming another integration's credential.
55
+ */
56
+ readonly credentialId?: string;
57
+ /**
58
+ * The declared TRUST ROOT this page sits under, canonical form, when the ancestor walk matched one
59
+ * (OPE-492; channels/notion-ancestry-trust.ts resolveNotionDeliveryTrust). Present ⇒ the operator
60
+ * declared a page above this one internal, so {@link bindNotion} binds `internal` instead of the
61
+ * fail-closed `external`. Absent on every untrusted verdict, which is every verdict on a
62
+ * deployment that declares no roots.
63
+ *
64
+ * HOST-SET (§AUTH-ATTRIBUTE PROVENANCE): the webhook core resolves it from Notion's own parent
65
+ * chain under the VERIFIED credential, never from the comment body, so it cannot be forged into
66
+ * claiming a root.
67
+ */
68
+ readonly trustRoot?: string;
69
+ }
70
+
71
+ /** Build the auth context OpenEverywhere dispatches with — raw facts as string attributes. */
72
+ export function buildNotionAuth(input: NotionIdentityInput): OpenEverywhereNotionAuth {
73
+ const attributes: Record<string, string> = {};
74
+ if (input.workspaceId) attributes.workspace_id = input.workspaceId;
75
+ if (input.pageId) attributes.page_id = input.pageId;
76
+ if (input.discussionId) attributes.discussion_id = input.discussionId;
77
+ if (input.commentId) attributes.comment_id = input.commentId;
78
+ if (input.credentialId) attributes.credential_id = input.credentialId;
79
+ if (input.trustRoot) attributes.trust_root = input.trustRoot;
80
+ return {
81
+ authenticator: "notion",
82
+ principalId: input.principalId ?? "",
83
+ principalType: "notion-user",
84
+ attributes,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The workspaceKey a Notion surface resolves to (`notion/<workspace id>`) — the value an
90
+ * operator lists in agent/identity/surfaces.ts to vouch a whole Notion workspace
91
+ * as internal (which lets its pages' learnings flow to shared memory despite the
92
+ * fail-closed default below). Takes the raw workspace id as Notion reports it on the
93
+ * webhook, so the operator pastes what they see. Notion ids are UUIDs, already within the
94
+ * binding's `seg` charset (hyphens allowed), so no encoding is needed. notion-test.ts pins
95
+ * this key against the one bindFrom actually produces.
96
+ */
97
+ export function notionWorkspaceKey(workspaceId: string): string {
98
+ return `notion/${workspaceId}`;
99
+ }
100
+
101
+ /**
102
+ * The fully-qualified surfaceKey a Notion page resolves to — `notion/<workspace>/<page>` — the
103
+ * SAME shape resolvePartition(ctx) produces for a bound Notion session (memory/partition.ts:
104
+ * `platform/workspace/surface`, surface = the page). The ambient-supervisor gate + the
105
+ * channel_session registry are keyed by this, so the webhook core (which runs BEFORE any eve
106
+ * binding) must compute the identical key. Falls back through discussion → "unknown" the same way
107
+ * bindNotion picks its surface, so an under-identified event still keys to a stable string.
108
+ */
109
+ export function notionSurfaceKey(workspaceId: string | undefined, surface: string | undefined): string {
110
+ return `notion/${workspaceId ?? "unknown"}/${surface ?? "unknown"}`;
111
+ }
112
+
113
+ /**
114
+ * The CONVERSATION key the stop registry keys a Notion turn by (OPE-989) — `<workspace>/<page>`.
115
+ * ONE derivation, used by BOTH sides of the affordance: {@link bindNotion} writes it onto the
116
+ * anchor row at turn start (as `anchorChannelId`), and the stop leg (channels/notion-stop.ts)
117
+ * rebuilds it from the fetched comment. Two spellings of this key would make the arm silently miss
118
+ * — the same discipline githubConversationKey documents.
119
+ *
120
+ * THE PAGE, NOT THE DISCUSSION. The Notion doc-session is page-scoped (notion-supervisor.ts's
121
+ * `notionSupervisorToken` keys the standing session by page, not by discussion — "a page-scoped
122
+ * standing session aware of all the doc's discussions"), so the conversation a stop trigger can
123
+ * name is the page, mirroring how a GitHub stop names the issue/PR and a Teams stop names the
124
+ * reply chain.
125
+ *
126
+ * Returns undefined on any missing part — never "unknown" (unlike {@link notionSurfaceKey}, which
127
+ * exists for a different purpose: a STABLE partition key even under-identified). A partial anchor
128
+ * key would let two different under-identified deliveries collide and cancel each other's turns.
129
+ */
130
+ export function notionConversationKey(workspaceId: string | undefined, pageId: string | undefined): string | undefined {
131
+ return workspaceId && pageId ? `${workspaceId}/${pageId}` : undefined;
132
+ }
133
+
134
+ /**
135
+ * The inverse of {@link notionConversationKey} — split the `<workspace>/<page>` string back into its
136
+ * two Notion ids. Undefined on anything that is not exactly two non-empty segments: a malformed or
137
+ * partial address must never resolve to a guessed page. Notion ids are UUIDs (no `/`), so splitting
138
+ * on the first slash is exact — there is no ambiguity to resolve.
139
+ *
140
+ * The one caller today is `notion-message-ops.ts` (OPE-1043): `SurfaceMessageOps.postText`/
141
+ * `editMessage` are handed only the composed `channelId` (this key), never the two ids apart, so a
142
+ * transport that needs the page or the workspace on its own has to parse it back out. Kept beside
143
+ * the composer rather than in that file, for the same reason the composer's own doc states: one
144
+ * derivation, read by both sides, so the write and the read can never drift into two spellings.
145
+ */
146
+ export function parseNotionConversationKey(channelId: string): { readonly workspaceId: string; readonly pageId: string } | undefined {
147
+ const i = channelId.indexOf("/");
148
+ if (i <= 0 || i === channelId.length - 1) return undefined;
149
+ const workspaceId = channelId.slice(0, i);
150
+ const pageId = channelId.slice(i + 1);
151
+ return workspaceId.length > 0 && pageId.length > 0 ? { workspaceId, pageId } : undefined;
152
+ }
153
+
154
+ /**
155
+ * Notion has NO 1:1 DM primitive — every comment is on a shared page/discussion — so a
156
+ * Notion surface is always `channel` mode. Total function (no input) kept for symmetry
157
+ * with the other platforms' mode helpers and so the mapper reads the same shape.
158
+ */
159
+ export function notionMode(): SessionMode {
160
+ return "channel";
161
+ }
162
+
163
+ /**
164
+ * Notion facts → visibility. FAIL CLOSED — Notion is ALWAYS private.
165
+ * An internal integration only sees pages EXPLICITLY shared with it, and the webhook
166
+ * carries no cheap, reliable "this page is workspace-public / world-visible" bit — the
167
+ * `accessible_by` array is a coarse who-can-see list, not a public/private classifier.
168
+ * With no positive signal that a page is a broadly-shared internal space, a surface's
169
+ * learnings must never flow up to a shared (workspace) tier: every Notion page stays
170
+ * surface-tier private. A future maintainer who finds a positive workspace-visible signal
171
+ * (or enriches page permissions from the API) flips this — until then it fails closed.
172
+ */
173
+ export function notionVisibility(): Visibility {
174
+ return "private";
175
+ }
176
+
177
+ // --- the identity MAPPER (moved out of core identity/binding.ts in OPE-114 stage 3) ---------
178
+ // A Notion comment lives on a PAGE inside a Notion workspace, in a discussion thread. The
179
+ // silo grain is the PAGE (the persistent container that hosts many discussion threads over
180
+ // time — the Linear TEAM / Slack channel analog), NOT the discussion (which is the reply
181
+ // anchor, like a Linear issue / Slack thread ts). So: workspace = workspace_id, surface =
182
+ // page_id (discussion is the post-back target, not the partition key), mode = channel
183
+ // (Notion has no 1:1 DM primitive). Raw facts are stowed by buildNotionAuth above;
184
+ // mode/visibility interpretation lives in this file (the one tested place) — visibility FAILS
185
+ // CLOSED to private (Notion exposes no reliable world-/workspace-public signal).
186
+ //
187
+ // The two attribute helpers are LOCAL COPIES of core binding.ts's module-private probes (attr /
188
+ // authPrincipalId) — deliberately not barrel ports: they are three-line structural reads over
189
+ // the public BindableContext shape, and a mapper author outside core is expected to read its own
190
+ // platform's attributes itself (the barrel exposes the CONTRACT — BindableContext / RawIdentity /
191
+ // registerIdentityMapper — not core's private conveniences).
192
+
193
+ type AuthAttrs = Readonly<Record<string, string | readonly string[]>> | undefined;
194
+
195
+ function notionAuthOf(ctx: BindableContext): { attributes?: AuthAttrs; principalId?: string } | null {
196
+ return ctx.session?.auth?.current ?? ctx.session?.auth?.initiator ?? null;
197
+ }
198
+
199
+ function notionAttr(a: AuthAttrs, keys: readonly string[]): string | undefined {
200
+ if (!a) return undefined;
201
+ for (const k of keys) {
202
+ const v = a[k];
203
+ if (typeof v === "string" && v.length > 0) return v;
204
+ if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ export const bindNotion: IdentityMapper = (ctx): RawIdentity => {
210
+ const auth = notionAuthOf(ctx);
211
+ const a = auth?.attributes;
212
+ const workspaceId = notionAttr(a, ["workspace_id", "workspaceId"]);
213
+ // Surface = the PAGE (silo grain); fall back to the discussion, then the comment, so a
214
+ // session always keys to a stable surface even on an under-identified event.
215
+ const surface =
216
+ notionAttr(a, ["page_id", "pageId"]) ??
217
+ notionAttr(a, ["discussion_id", "discussionId"]) ??
218
+ notionAttr(a, ["comment_id", "commentId"]);
219
+ const discussionId = notionAttr(a, ["discussion_id", "discussionId"]);
220
+ const pageId = notionAttr(a, ["page_id", "pageId"]);
221
+ // The delivery's selected integration (OPE-467), so `reply` posts under the credential that
222
+ // fetched. Absent on a session that predates the stamp ⇒ the reply arm falls back to the page's
223
+ // domain binding, which is the pre-OPE-467 behaviour.
224
+ const credentialId = notionAttr(a, ["credential_id", "credentialId"]);
225
+ // The ANCESTOR-WALK verdict the webhook core resolved for THIS delivery (OPE-492). Present only
226
+ // when the page sits under a declared trust root; the stamp names the root that matched.
227
+ const trustRoot = notionAttr(a, ["trust_root", "trustRoot"]);
228
+ const principal = auth?.principalId;
229
+ return {
230
+ workspace: workspaceId,
231
+ surface,
232
+ mode: notionMode(),
233
+ visibility: notionVisibility(),
234
+ // Notion refutes no author and reports no room-grain external signal, so trust here is ALWAYS an
235
+ // operator declaration — never a platform verdict. Two declarations reach it, and this is the
236
+ // ONE the platform mapper carries: the page sits under a declared TRUST ROOT, walked and matched
237
+ // at ingestion (channels/notion-ancestry-trust.ts). The other is the flat per-surface vouch
238
+ // (identity/surfaces.ts), which bindFrom applies one rung lower.
239
+ //
240
+ // POSITIVE-ONLY. No root matched ⇒ undefined, NEVER "external": bindFrom must still fall through
241
+ // to the per-surface vouch and then to the fail-closed default. Returning "external" here would
242
+ // out-rank the operator's own `defineSurface` line and silently switch off shared memory on every
243
+ // Notion page of a deployment that declares no roots.
244
+ trust: trustRoot ? ("internal" as const) : undefined,
245
+ principalId: (principal && principal.length > 0 ? principal : undefined) ?? notionAttr(a, ["user_id", "userId"]),
246
+ // Post-back targets the DISCUSSION thread (create-comment's discussion_id), with the page
247
+ // riding along. Not a thread ts — Notion replies key off discussion_id, carried on the
248
+ // session state the channel stashes, so this target is for routine/proactive use.
249
+ target: discussionId ? { discussionId, ...(pageId ? { pageId } : {}), ...(credentialId ? { credentialId } : {}) } : undefined,
250
+ // THE STOP REGISTRY'S ADDRESS FOR THIS TURN (OPE-989). A Notion doc-session is PAGE-scoped
251
+ // (notionSupervisorToken keys the standing session by page), so that is what a stop directive
252
+ // posted on the page can name. It cannot ride `target`: that object composes the REPLY target
253
+ // (the discussion), a different grain that changes on every comment.
254
+ //
255
+ // Notion pushes no reaction webhook (comment.created/updated/deleted is the whole comment event
256
+ // catalogue — developers.notion.com/reference/webhooks-events-delivery, read 2026-08-23), so the
257
+ // trigger on this surface is a bare `stop` comment read at the ingress — see
258
+ // channels/notion-stop.ts. The key it resolves by is this one.
259
+ ...(notionConversationKey(workspaceId, pageId) ? { anchorChannelId: notionConversationKey(workspaceId, pageId) } : {}),
260
+ // THE ENGAGEMENT REGISTRY'S ADDRESS FOR THIS TURN (OPE-1501) — the SAME page key, and
261
+ // deliberately the same derivation rather than a second spelling of it. `stand_down` marks
262
+ // `binding.engagementKey ?? binding.threadTs`, and Notion carries no `threadTs`, so without this
263
+ // field the verb found no anchor and refused on every Notion turn.
264
+ //
265
+ // THE PAGE, NOT THE DISCUSSION, for the reason {@link notionConversationKey} states: the doc
266
+ // session is page-scoped and aware of all the page's discussions, so a person asking the agent
267
+ // to stop is asking it to stop on the page. A per-discussion mark would leave the same standing
268
+ // session answering the discussion next door.
269
+ ...(notionConversationKey(workspaceId, pageId) ? { engagementKey: notionConversationKey(workspaceId, pageId) } : {}),
270
+ };
271
+ };
@@ -0,0 +1,289 @@
1
+ // DURABLE NOTION INGRESS DRAIN — the authenticated, fenced consumer of rows admitted by
2
+ // notion-ingress.ts. The public webhook stores a projected provider envelope and returns; both its
3
+ // immediate nudge and the minute recovery schedule send only the row key through this internal
4
+ // HMAC route. The payload never leaves the operational store.
5
+
6
+ import { createHmac, randomUUID } from "node:crypto";
7
+
8
+ import {
9
+ getOrMintSelfSecret,
10
+ readCappedBody,
11
+ selfDispatchBaseUrl,
12
+ selfSecretsArmed,
13
+ verifyWebhookSignature,
14
+ type ClaimedIngressDelivery,
15
+ type IngressDeliveryKey,
16
+ type OperationalStore,
17
+ } from "@open-everywhere/core/agent/adapter-api.js";
18
+
19
+ import { decodeNotionIngressPayload, NOTION_INGRESS_SOURCE, type NotionIngressKey } from "./notion-ingress.js";
20
+
21
+ export const NOTION_INGRESS_DRAIN_PATH = "/eve/v1/notion/drain";
22
+ export const NOTION_INGRESS_DRAIN_SIGNATURE_HEADER = "x-oe-notion-drain-signature";
23
+ const NOTION_INGRESS_DRAIN_SECRET_ENV = "OE_NOTION_INGRESS_DRAIN_SECRET";
24
+ export const NOTION_COMMENT_DISPATCH_SOURCE = "notion-comment-dispatch";
25
+ // Comment ids are globally unique inside Notion. Keep this authority constant so the same comment
26
+ // arriving from two subscriptions shares ONE dispatch lease, matching the legacy OPE-1906 guard.
27
+ export const NOTION_COMMENT_DISPATCH_AUTHORITY = "notion";
28
+ const NOTION_INGRESS_LEASE_MS = 5 * 60_000;
29
+ const NOTION_INGRESS_RETRY_MS = NOTION_INGRESS_LEASE_MS + 1_000;
30
+ const NOTION_INGRESS_TOMBSTONE_MS = 24 * 60 * 60_000;
31
+ const NOTION_COMMENT_DISPATCH_TOMBSTONE_MS = 10 * 60_000;
32
+ const NOTION_INGRESS_DRAIN_MAX_BODY_BYTES = 16 * 1024;
33
+ const NOTION_INGRESS_DRAIN_MAX_SKEW_MS = 5 * 60_000;
34
+ const NOTION_INGRESS_DRAIN_BATCH = 25;
35
+
36
+ interface DrainEnvelope {
37
+ readonly authorityId: string;
38
+ readonly deliveryId: string;
39
+ readonly issuedAt: number;
40
+ }
41
+
42
+ export interface NotionIngressDrainKey extends NotionIngressKey {}
43
+
44
+ export interface AcceptedNotionIngressDrain {
45
+ readonly key: NotionIngressDrainKey;
46
+ readonly claim: ClaimedIngressDelivery;
47
+ readonly rawBody: string;
48
+ readonly signerCredentialId: string;
49
+ }
50
+
51
+ export type NotionIngressDisposition = "complete" | "discard" | "retry";
52
+
53
+ export interface NotionCommentDispatchGuard {
54
+ readonly claim: (commentId: string) => Promise<boolean>;
55
+ readonly complete: (commentId: string) => Promise<void>;
56
+ }
57
+
58
+ export interface AcceptNotionIngressDrainDeps {
59
+ readonly org: string;
60
+ readonly store: OperationalStore;
61
+ readonly secret?: string;
62
+ readonly now?: () => Date;
63
+ readonly claimId?: () => string;
64
+ }
65
+
66
+ function nonEmpty(value: unknown, max = 512): string | undefined {
67
+ return typeof value === "string" && value.length > 0 && value.length <= max ? value : undefined;
68
+ }
69
+
70
+ function parseEnvelope(value: unknown): DrainEnvelope | undefined {
71
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
72
+ const row = value as Record<string, unknown>;
73
+ const authorityId = nonEmpty(row.authorityId);
74
+ const deliveryId = nonEmpty(row.deliveryId);
75
+ const issuedAt = row.issuedAt;
76
+ if (!authorityId || !deliveryId || typeof issuedAt !== "number" || !Number.isFinite(issuedAt)) return undefined;
77
+ return { authorityId, deliveryId, issuedAt };
78
+ }
79
+
80
+ export function signNotionIngressDrainBody(secret: string, body: string): string {
81
+ return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`;
82
+ }
83
+
84
+ async function drainSecret(injected?: string): Promise<string | undefined> {
85
+ if (injected !== undefined) return injected;
86
+ if (!selfSecretsArmed()) return undefined;
87
+ return getOrMintSelfSecret(NOTION_INGRESS_DRAIN_SECRET_ENV);
88
+ }
89
+
90
+ /** Cheap public-ACK precondition: recovery can authenticate and reach this deployment. */
91
+ export function notionIngressDrainAvailable(): boolean {
92
+ return selfSecretsArmed() && selfDispatchBaseUrl() !== undefined;
93
+ }
94
+
95
+ /** Authenticate an internal row-key request and atomically claim its exact org-scoped row. */
96
+ export async function acceptNotionIngressDrainRequest(
97
+ req: Request,
98
+ deps: AcceptNotionIngressDrainDeps,
99
+ ): Promise<{ readonly accepted: AcceptedNotionIngressDrain } | { readonly response: Response }> {
100
+ const read = await readCappedBody(req, NOTION_INGRESS_DRAIN_MAX_BODY_BYTES);
101
+ if ("tooLarge" in read) return { response: new Response("payload too large", { status: 413 }) };
102
+ const secret = await drainSecret(deps.secret);
103
+ if (!secret) return { response: new Response("not found", { status: 404 }) };
104
+ if (!verifyWebhookSignature(secret, read.body, req.headers.get(NOTION_INGRESS_DRAIN_SIGNATURE_HEADER))) {
105
+ return { response: new Response("unauthorized", { status: 401 }) };
106
+ }
107
+ let envelope: DrainEnvelope | undefined;
108
+ try {
109
+ envelope = parseEnvelope(JSON.parse(read.body));
110
+ } catch {
111
+ // handled below
112
+ }
113
+ if (!envelope) return { response: new Response("bad request", { status: 400 }) };
114
+ const now = (deps.now ?? (() => new Date()))();
115
+ if (Math.abs(now.getTime() - envelope.issuedAt) > NOTION_INGRESS_DRAIN_MAX_SKEW_MS) {
116
+ return { response: new Response("unauthorized", { status: 401 }) };
117
+ }
118
+ const key: NotionIngressDrainKey = {
119
+ source: NOTION_INGRESS_SOURCE,
120
+ authorityId: envelope.authorityId,
121
+ deliveryId: envelope.deliveryId,
122
+ };
123
+ const staleBefore = new Date(now.getTime() - NOTION_INGRESS_LEASE_MS).toISOString();
124
+ const claim = await deps.store.claimIngressDelivery(
125
+ deps.org,
126
+ key,
127
+ (deps.claimId ?? randomUUID)(),
128
+ now.toISOString(),
129
+ staleBefore,
130
+ );
131
+ // A duplicate nudge, active lease or completed tombstone is already safely represented in the
132
+ // store. Return 200 so the self-client does not amplify it; the minute scan handles stale work.
133
+ if (!claim) return { response: new Response("ok", { status: 200 }) };
134
+ const payload = decodeNotionIngressPayload(claim.payloadJson);
135
+ if (!payload) {
136
+ await deps.store.releaseIngressDelivery(
137
+ deps.org,
138
+ key,
139
+ claim.claimId,
140
+ new Date(now.getTime() + NOTION_INGRESS_RETRY_MS).toISOString(),
141
+ );
142
+ return { response: new Response("stored payload invalid", { status: 500 }) };
143
+ }
144
+ return {
145
+ accepted: {
146
+ key,
147
+ claim,
148
+ rawBody: JSON.stringify(payload.event),
149
+ signerCredentialId: key.authorityId,
150
+ },
151
+ };
152
+ }
153
+
154
+ /** Finish one primary claim according to the processor's explicit semantic result. */
155
+ export async function settleNotionIngressDrain(
156
+ accepted: AcceptedNotionIngressDrain,
157
+ disposition: NotionIngressDisposition,
158
+ deps: Pick<AcceptNotionIngressDrainDeps, "org" | "store" | "now">,
159
+ ): Promise<void> {
160
+ const now = (deps.now ?? (() => new Date()))();
161
+ if (disposition === "discard") {
162
+ await deps.store.discardIngressDelivery(deps.org, accepted.key, accepted.claim.claimId);
163
+ return;
164
+ }
165
+ if (disposition === "retry") {
166
+ await deps.store.releaseIngressDelivery(
167
+ deps.org,
168
+ accepted.key,
169
+ accepted.claim.claimId,
170
+ new Date(now.getTime() + NOTION_INGRESS_RETRY_MS).toISOString(),
171
+ );
172
+ return;
173
+ }
174
+ await deps.store.completeIngressDelivery(
175
+ deps.org,
176
+ accepted.key,
177
+ accepted.claim.claimId,
178
+ now.toISOString(),
179
+ new Date(now.getTime() + NOTION_INGRESS_TOMBSTONE_MS).toISOString(),
180
+ );
181
+ }
182
+
183
+ /**
184
+ * A comment-level guard for the crash seam between "accepted by Eve" and primary-row completion.
185
+ * Its lease is deliberately the same length as the primary retry delay: a retry never mistakes an
186
+ * active pre-dispatch lease for a completed duplicate. A stale lease is stolen and may duplicate
187
+ * after an ambiguous post-dispatch crash, which is the honest at-least-once side of the choice.
188
+ */
189
+ export function createNotionCommentDispatchGuard(input: {
190
+ readonly org: string;
191
+ readonly store: OperationalStore;
192
+ readonly now?: () => Date;
193
+ readonly claimId?: () => string;
194
+ }): NotionCommentDispatchGuard {
195
+ let held: { key: IngressDeliveryKey; claimId: string } | undefined;
196
+ return {
197
+ async claim(commentId) {
198
+ const now = (input.now ?? (() => new Date()))();
199
+ const key: IngressDeliveryKey = {
200
+ source: NOTION_COMMENT_DISPATCH_SOURCE,
201
+ authorityId: NOTION_COMMENT_DISPATCH_AUTHORITY,
202
+ deliveryId: commentId,
203
+ };
204
+ await input.store.enqueueIngressDelivery(input.org, {
205
+ ...key,
206
+ payloadJson: JSON.stringify({ schemaVersion: 1 }),
207
+ receivedAt: now.toISOString(),
208
+ });
209
+ const claim = await input.store.claimIngressDelivery(
210
+ input.org,
211
+ key,
212
+ (input.claimId ?? randomUUID)(),
213
+ now.toISOString(),
214
+ new Date(now.getTime() - NOTION_INGRESS_LEASE_MS).toISOString(),
215
+ );
216
+ if (!claim) return false;
217
+ held = { key, claimId: claim.claimId };
218
+ return true;
219
+ },
220
+ async complete(commentId) {
221
+ if (!held || held.key.deliveryId !== commentId) throw new Error("Notion dispatch guard was not claimed");
222
+ const now = (input.now ?? (() => new Date()))();
223
+ const completed = await input.store.completeIngressDelivery(
224
+ input.org,
225
+ held.key,
226
+ held.claimId,
227
+ now.toISOString(),
228
+ new Date(now.getTime() + NOTION_COMMENT_DISPATCH_TOMBSTONE_MS).toISOString(),
229
+ );
230
+ if (!completed) throw new Error("Notion dispatch guard completion lost its lease");
231
+ held = undefined;
232
+ },
233
+ };
234
+ }
235
+
236
+ export interface PostNotionIngressDrainDeps {
237
+ readonly secret?: string;
238
+ readonly baseUrl?: string;
239
+ readonly fetchImpl?: typeof fetch;
240
+ readonly now?: () => Date;
241
+ }
242
+
243
+ /** Post one content-free row key to this deployment's private drain route. */
244
+ export async function postNotionIngressDrain(
245
+ key: NotionIngressDrainKey,
246
+ deps: PostNotionIngressDrainDeps = {},
247
+ ): Promise<void> {
248
+ const base = deps.baseUrl ?? selfDispatchBaseUrl();
249
+ const secret = await drainSecret(deps.secret);
250
+ if (!base || !secret) throw new Error("Notion ingress drain is unavailable");
251
+ const body = JSON.stringify({
252
+ authorityId: key.authorityId,
253
+ deliveryId: key.deliveryId,
254
+ issuedAt: (deps.now ?? (() => new Date()))().getTime(),
255
+ } satisfies DrainEnvelope);
256
+ const response = await (deps.fetchImpl ?? fetch)(`${base}${NOTION_INGRESS_DRAIN_PATH}`, {
257
+ method: "POST",
258
+ headers: {
259
+ "content-type": "application/json",
260
+ [NOTION_INGRESS_DRAIN_SIGNATURE_HEADER]: signNotionIngressDrainBody(secret, body),
261
+ },
262
+ body,
263
+ signal: AbortSignal.timeout(20_000),
264
+ });
265
+ if (!response.ok) throw new Error(`Notion ingress drain returned ${response.status}`);
266
+ }
267
+
268
+ /** One recovery tick. A failed self-hop leaves the row pending for the next minute. */
269
+ export async function recoverNotionIngress(
270
+ org: string,
271
+ store: OperationalStore,
272
+ deps: PostNotionIngressDrainDeps & { readonly now?: () => Date } = {},
273
+ ): Promise<{ readonly due: number; readonly posted: number; readonly failed: number }> {
274
+ const now = (deps.now ?? (() => new Date()))();
275
+ const due = await store.listDueIngressDeliveries(
276
+ org,
277
+ NOTION_INGRESS_SOURCE,
278
+ now.toISOString(),
279
+ new Date(now.getTime() - NOTION_INGRESS_LEASE_MS).toISOString(),
280
+ NOTION_INGRESS_DRAIN_BATCH,
281
+ );
282
+ const results = await Promise.allSettled(due.map((key) => postNotionIngressDrain({
283
+ source: NOTION_INGRESS_SOURCE,
284
+ authorityId: key.authorityId,
285
+ deliveryId: key.deliveryId,
286
+ }, deps)));
287
+ const posted = results.filter((result) => result.status === "fulfilled").length;
288
+ return { due: due.length, posted, failed: due.length - posted };
289
+ }