@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,182 @@
1
+ // DURABLE NOTION INGRESS — the authenticated acceptance boundary in front of the expensive
2
+ // comment enrichment and Eve dispatch path. A 200 from this module means the verified, projected
3
+ // event is in the operational store. `waitUntil` is only a low-latency nudge; recovery comes from
4
+ // the persisted row and the minute drain schedule, never from the lifetime of this invocation.
5
+
6
+ import { gateNotionDeliveryAcross, parseVerificationHandshake, type NotionSigningKey } from "./notion-api.js";
7
+
8
+ export const NOTION_INGRESS_SOURCE = "notion";
9
+ const NOTION_INGRESS_SCHEMA_VERSION = 1 as const;
10
+ export const NOTION_INGRESS_MAX_BODY_BYTES = 256 * 1024;
11
+
12
+ export interface ProjectedNotionEvent {
13
+ readonly id: string;
14
+ readonly timestamp?: string;
15
+ readonly type?: string;
16
+ readonly event_type?: string;
17
+ readonly workspace_id?: string;
18
+ readonly entity?: { readonly id?: string; readonly type?: string };
19
+ readonly data?: {
20
+ readonly page_id?: string;
21
+ readonly parent?: { readonly id?: string; readonly type?: string };
22
+ readonly updated_blocks?: readonly { readonly id?: string }[];
23
+ };
24
+ readonly authors?: readonly { readonly id?: string; readonly type?: string }[];
25
+ }
26
+
27
+ export interface NotionIngressPayload {
28
+ readonly schemaVersion: typeof NOTION_INGRESS_SCHEMA_VERSION;
29
+ readonly event: ProjectedNotionEvent;
30
+ }
31
+
32
+ export interface NotionIngressKey {
33
+ readonly source: typeof NOTION_INGRESS_SOURCE;
34
+ readonly authorityId: string;
35
+ readonly deliveryId: string;
36
+ }
37
+
38
+ export interface NotionIngressWrite extends NotionIngressKey {
39
+ readonly payloadJson: string;
40
+ readonly receivedAt: string;
41
+ }
42
+
43
+ export interface AcknowledgeNotionIngressDeps {
44
+ readonly org: string;
45
+ readonly signingKeys: readonly NotionSigningKey[];
46
+ readonly allowUnverified: boolean;
47
+ readonly enqueue: (org: string, write: NotionIngressWrite) => Promise<boolean>;
48
+ /** The internal recovery route has both durable secret custody and a resolvable own URL. */
49
+ readonly drainAvailable: boolean;
50
+ readonly drain: (key: NotionIngressKey) => Promise<void>;
51
+ readonly waitUntil: (promise: Promise<unknown>) => void;
52
+ readonly now?: () => Date;
53
+ }
54
+
55
+ function text(value: unknown): string | undefined {
56
+ return typeof value === "string" && value.length > 0 ? value : undefined;
57
+ }
58
+
59
+ function record(value: unknown): Readonly<Record<string, unknown>> | undefined {
60
+ return value !== null && typeof value === "object" && !Array.isArray(value)
61
+ ? (value as Readonly<Record<string, unknown>>)
62
+ : undefined;
63
+ }
64
+
65
+ /** Project exactly the fields consumed by the conversational and structural processors. */
66
+ function projectNotionIngressEvent(value: unknown): ProjectedNotionEvent | undefined {
67
+ const input = record(value);
68
+ const id = text(input?.id);
69
+ if (!input || !id) return undefined;
70
+
71
+ const entityIn = record(input.entity);
72
+ const entityId = text(entityIn?.id);
73
+ const entityType = text(entityIn?.type);
74
+ const entity = entityIn ? { ...(entityId ? { id: entityId } : {}), ...(entityType ? { type: entityType } : {}) } : undefined;
75
+
76
+ const dataIn = record(input.data);
77
+ const parentIn = record(dataIn?.parent);
78
+ const parentId = text(parentIn?.id);
79
+ const parentType = text(parentIn?.type);
80
+ const parent = parentIn ? { ...(parentId ? { id: parentId } : {}), ...(parentType ? { type: parentType } : {}) } : undefined;
81
+ const updatedBlocks = Array.isArray(dataIn?.updated_blocks)
82
+ ? dataIn.updated_blocks.map((item) => {
83
+ const id = text(record(item)?.id);
84
+ return id ? { id } : {};
85
+ })
86
+ : undefined;
87
+ const pageId = text(dataIn?.page_id);
88
+ const data = dataIn
89
+ ? {
90
+ ...(pageId ? { page_id: pageId } : {}),
91
+ ...(parent ? { parent } : {}),
92
+ ...(updatedBlocks ? { updated_blocks: updatedBlocks } : {}),
93
+ }
94
+ : undefined;
95
+
96
+ const authors = Array.isArray(input.authors)
97
+ ? input.authors.map((item) => {
98
+ const author = record(item);
99
+ const authorId = text(author?.id);
100
+ const authorType = text(author?.type);
101
+ return { ...(authorId ? { id: authorId } : {}), ...(authorType ? { type: authorType } : {}) };
102
+ })
103
+ : undefined;
104
+
105
+ return {
106
+ id,
107
+ ...(text(input.timestamp) ? { timestamp: text(input.timestamp) } : {}),
108
+ ...(text(input.type) ? { type: text(input.type) } : {}),
109
+ ...(text(input.event_type) ? { event_type: text(input.event_type) } : {}),
110
+ ...(text(input.workspace_id) ? { workspace_id: text(input.workspace_id) } : {}),
111
+ ...(entity ? { entity } : {}),
112
+ ...(data ? { data } : {}),
113
+ ...(authors ? { authors } : {}),
114
+ };
115
+ }
116
+
117
+ export function decodeNotionIngressPayload(payloadJson: string): NotionIngressPayload | undefined {
118
+ try {
119
+ const parsed = record(JSON.parse(payloadJson));
120
+ if (parsed?.schemaVersion !== NOTION_INGRESS_SCHEMA_VERSION) return undefined;
121
+ const event = projectNotionIngressEvent(parsed.event);
122
+ return event ? { schemaVersion: NOTION_INGRESS_SCHEMA_VERSION, event } : undefined;
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ function synchronousResponse(): Response {
129
+ return new Response("synchronous", { status: 409, headers: { "x-oe-notion-ingress": "synchronous" } });
130
+ }
131
+
132
+ /**
133
+ * Authenticate, project and durably accept one public Notion delivery.
134
+ *
135
+ * `409 + x-oe-notion-ingress:synchronous` is an internal hand-off to the existing route for the
136
+ * subscription handshake and the explicit unsigned development hatch. The live route consumes it;
137
+ * it is never returned to Notion.
138
+ */
139
+ export async function acknowledgeNotionIngress(
140
+ rawBody: string,
141
+ signatureHeader: string | null,
142
+ deps: AcknowledgeNotionIngressDeps,
143
+ ): Promise<Response> {
144
+ if (Buffer.byteLength(rawBody, "utf8") > NOTION_INGRESS_MAX_BODY_BYTES) {
145
+ return new Response("payload too large", { status: 413 });
146
+ }
147
+ if (parseVerificationHandshake(rawBody)) return synchronousResponse();
148
+
149
+ const gate = gateNotionDeliveryAcross(deps.signingKeys, rawBody, signatureHeader, deps.allowUnverified);
150
+ if (gate.rejection) return new Response(gate.rejection, { status: 401 });
151
+ if (!gate.verified || !gate.credentialId) return synchronousResponse();
152
+ if (!deps.drainAvailable) return new Response("durable ingress drain unavailable", { status: 503 });
153
+
154
+ let parsed: unknown;
155
+ try {
156
+ parsed = JSON.parse(rawBody);
157
+ } catch {
158
+ return new Response("bad json", { status: 400 });
159
+ }
160
+ const event = projectNotionIngressEvent(parsed);
161
+ if (!event) return new Response("missing event id", { status: 400 });
162
+
163
+ const key: NotionIngressKey = {
164
+ source: NOTION_INGRESS_SOURCE,
165
+ authorityId: gate.credentialId,
166
+ deliveryId: event.id,
167
+ };
168
+ const write: NotionIngressWrite = {
169
+ ...key,
170
+ payloadJson: JSON.stringify({ schemaVersion: NOTION_INGRESS_SCHEMA_VERSION, event } satisfies NotionIngressPayload),
171
+ receivedAt: (deps.now ?? (() => new Date()))().toISOString(),
172
+ };
173
+ try {
174
+ await deps.enqueue(deps.org, write);
175
+ } catch {
176
+ return new Response("durable ingress unavailable", { status: 503 });
177
+ }
178
+
179
+ const drain = Promise.resolve().then(() => deps.drain(key));
180
+ deps.waitUntil(drain);
181
+ return new Response("ok", { status: 200 });
182
+ }
@@ -0,0 +1,140 @@
1
+ // notion-message-ops — Notion's SurfaceMessageOps (OPE-1043, the remaining slice of OPE-899's
2
+ // class): the ancillary message ops core features dispatch through channels/surface-message-ops.ts.
3
+ // Before this file, Notion registered none, so the stop acknowledgement (hooks/turn-anchor.ts's
4
+ // `postSurfaceText`) resolved nothing on a Notion turn — OPE-989 shipped the stop CONTROL itself
5
+ // (notion-stop.ts, a bare `stop` comment), it was just invisible: a person who used it saw the agent
6
+ // go quiet and read nothing telling them it had stood down.
7
+ //
8
+ // - postText → a FRESH top-level comment on the page (`createPageComment`, notion-api.ts) when
9
+ // `opts?.replyToMessageId` is absent — the shape every call on this ticket's own
10
+ // path takes (turn-anchor.ts never passes a reply anchor for a stop note). When
11
+ // present, this arm resolves the NAMED comment's own `discussion_id` (one extra
12
+ // fetch, `retrieveComment`) and replies into that same discussion instead
13
+ // (`createCommentReply`) — falling back to a fresh page comment only when that
14
+ // comment carries no discussion (never silently drops the anchor request).
15
+ // - editMessage → `updateComment` (`PATCH /v1/comments/{id}`), confirmed live 2026-08-23
16
+ // (developers.notion.com/reference/update-a-comment). `edit_reply`'s own seam
17
+ // (`PostArm.editMessage`, a model-facing tool capability with its own ownership/
18
+ // locus rules) now wires to the same endpoint too — notion-post-arm.ts's
19
+ // `liveNotionEditComment` (OPE-1046) — a separate, reviewable change from this one.
20
+ // - postFiles → uploads each file through Notion's two-step flow (`uploadNotionFile`,
21
+ // notion-api.ts: create-file-upload, then send the bytes) and attaches the
22
+ // resulting `file_upload` ids to a FRESH top-level page comment
23
+ // (`createPageComment`'s `attachments`) — OPE-1047. Notion caps a single comment
24
+ // at 3 attachments; a larger batch is refused up front, before any byte uploads,
25
+ // so a caller never pays for uploads it then has to discard.
26
+ // - NO `setReaction`: confirmed (2026-08-23, developers.notion.com) — Notion's public API has no
27
+ // reaction resource for comments at all, the same finding OPE-989 made for the stop trigger
28
+ // itself. Matches the manifest's existing `reactions: false` (register.ts).
29
+ //
30
+ // `channelId` IS THE STOP REGISTRY'S CONVERSATION KEY — `<workspace>/<page>`, not a
31
+ // notion-message-ops-specific pair invented here. `turn-anchor.ts`'s `turnAnchorAddress` reads it off
32
+ // `binding.anchorChannelId`, which `bindNotion` (notion-identity.ts) mints through
33
+ // `notionConversationKey` — the SAME key `notion-stop.ts`'s `routeNotionStopComment` parses to
34
+ // resolve the cancel. This file reuses `parseNotionConversationKey`, the composer's own documented
35
+ // inverse, rather than writing a second parser: two derivations of the same address is exactly the
36
+ // "silently miss" trap notion-identity.ts's own header warns about.
37
+ //
38
+ // CREDENTIAL RESOLUTION mirrors notion-post-arm.ts's `notionReplyCredential` (a page's domain binding
39
+ // picks its own Notion integration; no binding falls through to the org default) — copied rather than
40
+ // imported, for the same reason notion-identity.ts's `notionAttr`/`notionAuthOf` are local copies: a
41
+ // three-line structural read a caller outside the post-arm's own module is expected to make itself.
42
+
43
+ import type { SurfaceMessageOps, OutboundFile } from "@open-everywhere/core/agent/adapter-api.js";
44
+ import { getConfig, type Domain } from "@open-everywhere/core/agent/adapter-api.js";
45
+ import {
46
+ createPageComment,
47
+ createCommentReply,
48
+ updateComment,
49
+ resolveNotionCredential,
50
+ retrieveComment,
51
+ uploadNotionFile,
52
+ NOTION_MAX_ATTACHMENTS,
53
+ type ResolvedNotionCredential,
54
+ } from "./notion-api.js";
55
+ import { notionChannelCredentialFor } from "./notion-credentials.js";
56
+ import { parseNotionConversationKey } from "./notion-identity.js";
57
+
58
+ function notionDomains(): readonly Domain[] {
59
+ try {
60
+ return getConfig().loadDomains();
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ /** Parse the `<workspace>/<page>` conversation id, or throw the same honest, naming refusal every
67
+ * other surface-message-ops call gives on a malformed address — never a guessed target. */
68
+ function requireConversation(channelId: string): { readonly workspaceId: string; readonly pageId: string } {
69
+ const parsed = parseNotionConversationKey(channelId);
70
+ if (!parsed) throw new Error(`Notion surface message ops: "${channelId}" is not a conversation id (expected "<workspace>/<page>").`);
71
+ return parsed;
72
+ }
73
+
74
+ async function requireCredential(workspaceId: string, pageId: string): Promise<ResolvedNotionCredential> {
75
+ const credentialId = notionChannelCredentialFor(workspaceId, pageId, notionDomains());
76
+ const cred = await resolveNotionCredential(credentialId);
77
+ if (!cred) throw new Error(`Notion surface message ops: no value stored for notion credential "${credentialId ?? "channel-key"}".`);
78
+ return cred;
79
+ }
80
+
81
+ export const notionMessageOps: SurfaceMessageOps = {
82
+ platform: "notion",
83
+
84
+ /**
85
+ * A fresh top-level comment on the page, or — when `opts?.replyToMessageId` names a comment that
86
+ * carries a `discussion_id` — a reply into that SAME discussion instead. Never guesses a
87
+ * discussion: a named comment this call cannot resolve (fetch failure, or a comment with no
88
+ * discussion of its own) falls back to the fresh-comment shape rather than dropping the request.
89
+ */
90
+ async postText(channelId, text, opts) {
91
+ const { workspaceId, pageId } = requireConversation(channelId);
92
+ const cred = await requireCredential(workspaceId, pageId);
93
+ if (opts?.replyToMessageId) {
94
+ let discussionId: string | undefined;
95
+ try {
96
+ discussionId = (await retrieveComment(opts.replyToMessageId, cred)).discussion_id;
97
+ } catch {
98
+ discussionId = undefined;
99
+ }
100
+ if (discussionId) return createCommentReply({ discussionId, text, ...cred });
101
+ }
102
+ return createPageComment({ pageId, text, ...cred });
103
+ },
104
+
105
+ /** `PATCH /v1/comments/{id}` — rewrite the comment's body in place (`updateComment`). */
106
+ async editMessage(channelId, messageId, text) {
107
+ const { workspaceId, pageId } = requireConversation(channelId);
108
+ const cred = await requireCredential(workspaceId, pageId);
109
+ await updateComment({ commentId: messageId, text, ...cred });
110
+ },
111
+
112
+ /**
113
+ * Upload each file through Notion's two-step flow (`uploadNotionFile`), then land them all on
114
+ * ONE fresh top-level page comment via `createPageComment`'s `attachments` (OPE-1047). Notion
115
+ * caps a single comment at {@link NOTION_MAX_ATTACHMENTS} attachments — refused up front, before
116
+ * any upload runs, so a too-large batch never pays for bytes it then has to discard.
117
+ *
118
+ * Always a PAGE comment, never a reply into a named discussion: `SurfaceMessageOps.postFiles`
119
+ * carries no `replyToMessageId` (unlike `postText`), the same "current channel" shape Discord's
120
+ * `postFiles` posts into.
121
+ *
122
+ * Returns the created comment's id (Notion echoes it, same as `postText`) — undefined only if
123
+ * the response carries none, never a guessed id.
124
+ */
125
+ async postFiles(channelId, files: readonly OutboundFile[], comment) {
126
+ if (files.length > NOTION_MAX_ATTACHMENTS) {
127
+ throw new Error(`Notion surface message ops: at most ${NOTION_MAX_ATTACHMENTS} files per post; got ${files.length}.`);
128
+ }
129
+ const { workspaceId, pageId } = requireConversation(channelId);
130
+ const cred = await requireCredential(workspaceId, pageId);
131
+ const fileUploadIds: string[] = [];
132
+ for (const file of files) {
133
+ fileUploadIds.push(
134
+ await uploadNotionFile({ filename: file.filename, contentType: file.contentType, bytes: file.bytes, ...cred }),
135
+ );
136
+ }
137
+ const id = await createPageComment({ pageId, text: comment ?? "", attachments: fileUploadIds, ...cred });
138
+ return id.length > 0 ? id : undefined;
139
+ },
140
+ };
@@ -0,0 +1,38 @@
1
+ // The Notion UnifiedWakeOrigin codec — MOVED VERBATIM out of core's
2
+ // channels/wake-origin-codecs.ts (OPE-114 stage 3, the first extracted arm). STORED-DATA
3
+ // COMPATIBILITY IS THE CONTRACT: a Notion origin is JSON-serialised into the durable
4
+ // operational store and fail-closed re-validated on read, so every behaviour here is frozen
5
+ // by core's scripts/wake-origin-fixtures-test.ts (run from the app root with this bundle's
6
+ // boot wiring — scripts/wake-origin-fixtures-boot.ts). Validator tolerances (typeof-only
7
+ // optional checks, extra unknown fields) are LOAD-BEARING for rows already on disk — never
8
+ // tighten them here.
9
+ //
10
+ // The `UnifiedWakeOrigin` TYPE union (including its notion arm) stays in core — it is the
11
+ // wire schema for stored rows; only the codec BEHAVIOUR lives in the bundle, registered by
12
+ // registerNotionProvider() (../register.ts).
13
+
14
+ import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
15
+
16
+ type NotionOrigin = Extract<UnifiedWakeOrigin, { platform: "notion" }>;
17
+
18
+ /** COLON-FREE by construction (a task windowId is `[^:]+` — core channels/window-key.ts): any colon
19
+ * in a raw surface id is folded to `_`, deterministically, so spawn + ingress agree on identity. */
20
+ const noColon = (s: string): string => s.replace(/:/g, "_");
21
+
22
+ export const notionWakeOriginCodec: WakeOriginCodec<NotionOrigin> = {
23
+ validate: (o) =>
24
+ typeof o.discussionId === "string" &&
25
+ o.discussionId.length > 0 &&
26
+ (o.pageId === undefined || typeof o.pageId === "string") &&
27
+ (o.workspaceId === undefined || typeof o.workspaceId === "string"),
28
+ threadRef: (origin) => ({ channelKey: noColon(`notion/${origin.pageId ?? origin.discussionId}`), threadKey: noColon(origin.discussionId) }),
29
+ isThreadScoped: () => true,
30
+ containerId: (origin) => origin.discussionId,
31
+ // NO `placementRef` AND NO `carryPlacement`, AND THAT ABSENCE IS THE DECLARATION (OPE-1826). Notion
32
+ // keeps its placement INSIDE the conversation identity: a reply targets `discussionId`, which is
33
+ // exactly what `threadRef` keys on, so two origins that pass `sameUnifiedConversation` name the same
34
+ // discussion and cannot disagree about where the answer lands. `credentialId` is not a
35
+ // counter-example: it selects the integration the post is made under, never where the post lands.
36
+ // The position is recorded, and held to account against the live registry, by the
37
+ // `origin-placement-carry` row in core's identity/surface-parity.ts.
38
+ };
@@ -0,0 +1,189 @@
1
+ // notion-post-arm — Notion's POST arm for the post-arms registry (OPE-114 stage 1c). Absorbs,
2
+ // VERBATIM, Notion's arms out of the three reply/delivery spines: reply-post.ts (resolveReplyTarget's
3
+ // notion case + dispatchReply's createCommentReply branch), unified-reply.ts (liveNotionPost), and
4
+ // unified-redrive.ts (the notion describe/container cases). Every Notion reply funnels through
5
+ // notion-api.ts createCommentReply — a NEW comment in the triggering discussion thread — under the
6
+ // integration key resolved by resolveNotionCredential, which carries that credential's endpoint
7
+ // rules alongside the key so the guarded fetch can refuse a call outside them.
8
+ //
9
+ // EDIT (OPE-1046). `PATCH /v1/comments/{id}` (`updateComment`, notion-api.ts) exists — confirmed live
10
+ // 2026-08-23, correcting this tree's earlier "no comment-update endpoint" claim. `editMessage` below
11
+ // resolves the credential the SAME way `notion-message-ops.ts`'s `SurfaceMessageOps.editMessage`
12
+ // already does (OPE-1043): parse the `<workspace>/<page>` conversation key back into its two ids
13
+ // (`parseNotionConversationKey`) and look up that page's own credential (`notionReplyCredential`,
14
+ // already defined below for the reply arms). `messageId` is the comment id itself — the one field
15
+ // `edit_reply`'s own-post ledger checked before this arm is ever called (channels/reply-edit.ts).
16
+
17
+ import { createCommentReply, updateComment, resolveNotionCredential } from "./notion-api.js";
18
+ import { notionChannelCredentialFor, NOTION_CHANNEL_CREDENTIAL, NOTION_PROVIDER } from "./notion-credentials.js";
19
+ import { parseNotionConversationKey } from "./notion-identity.js";
20
+ import { getConfig, resolvePartition, markNotApplied, carryFailureSignals, findConnectorCredential, type Domain } from "@open-everywhere/core/agent/adapter-api.js";
21
+ import type { PostArm, TargetResolveBag, ReplyTarget, UnifiedWakeOrigin, UnifiedReplyDeps, EditLocus } from "@open-everywhere/core/agent/adapter-api.js";
22
+
23
+ // OPE-467 — THE CREDENTIAL A REPLY POSTS UNDER, in one sentence: the credential the DELIVERY ran
24
+ // under, which the wake stowed on the binding target / wake origin (notion-identity.ts's
25
+ // `credential_id` attribute). The credential that fetched the comment is the credential that
26
+ // answers it — a page shared with an integration its domain does not bind would otherwise fetch
27
+ // under one key and reply under another whose integration was never shared the page, and
28
+ // `createCommentReply` would throw with nobody watching.
29
+ //
30
+ // The page's domain binding is the FALLBACK, for a target minted before the stamp existed and for
31
+ // proactive posts that no delivery woke. Fail-soft on a config-load error: no domains ⇒ no binding
32
+ // ⇒ the org default, which is the pre-OPE-467 behaviour rather than a refusal.
33
+ function notionDomains(): readonly Domain[] {
34
+ try {
35
+ return getConfig().loadDomains();
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ function notionReplyCredential(workspaceId: string | undefined, pageId: string | undefined): string | undefined {
42
+ return notionChannelCredentialFor(workspaceId, pageId, notionDomains());
43
+ }
44
+
45
+ type NotionTarget = Extract<ReplyTarget, { platform: "notion" }>;
46
+ type NotionOrigin = Extract<UnifiedWakeOrigin, { platform: "notion" }>;
47
+
48
+ /** MID-TURN: post back into the SAME discussion thread as a new comment (createCommentReply). */
49
+ async function dispatchNotionReply(target: NotionTarget, text: string): Promise<string> {
50
+ const credentialId = target.credentialId ?? notionReplyCredential(target.workspaceId, target.pageId);
51
+ // OPE-505: resolve the key WITH its endpoint rules and spread both, so the credential's fence
52
+ // travels with it into createCommentReply's guarded fetch.
53
+ const cred = await resolveNotionCredential(credentialId);
54
+ if (!cred) {
55
+ // DEFINITIVE (OPE-825): the call never leaves this host — no credential, no request — so
56
+ // nothing reached Notion and an identical retry is legitimate.
57
+ throw markNotApplied(new Error(`reply couldn't post to Notion — no value stored for notion credential "${credentialId ?? "channel-key"}".`));
58
+ }
59
+ return createCommentReply({ discussionId: target.discussionId, text, ...cred });
60
+ }
61
+
62
+ /** UNIFIED: POST /v1/comments { discussion_id } — the exact helper (and Notion-Version header) every
63
+ * per-doc Notion reply already goes through; a supervisor session on the synthetic unified channel
64
+ * has no notion channel state, so the REST call under the integration token (db:notion:channel-key)
65
+ * is the reply route. The target discussionId is the frozen wake origin's — host-side, never
66
+ * model-authored. */
67
+ async function liveNotionPost(origin: NotionOrigin, text: string): Promise<string> {
68
+ const credentialId = origin.credentialId ?? notionReplyCredential(origin.workspaceId, origin.pageId);
69
+ // OPE-505: key + endpoint rules together (see dispatchNotionReply).
70
+ const cred = await resolveNotionCredential(credentialId);
71
+ if (!cred) {
72
+ // DEFINITIVE (OPE-825): no credential ⇒ no request ever left this host.
73
+ throw markNotApplied(new Error(`unified reply couldn't post to Notion — no value stored for notion credential "${credentialId ?? "channel-key"}" (discussion ${origin.discussionId})`));
74
+ }
75
+ let id: string;
76
+ try {
77
+ id = await createCommentReply({ discussionId: origin.discussionId, text, ...cred });
78
+ } catch (err) {
79
+ // Token-free error: never echo the transport error body (defensive parity with the Linear arm).
80
+ // carryFailureSignals (OPE-825) carries the numeric status createCommentReply attached, so this
81
+ // re-wrap doesn't silently downgrade a DEFINITIVE 4xx refusal to AMBIGUOUS.
82
+ throw carryFailureSignals(err, new Error(`unified reply couldn't post to Notion — createCommentReply failed on discussion ${origin.discussionId}`));
83
+ }
84
+ return id || "unknown";
85
+ }
86
+
87
+ /**
88
+ * `PATCH /v1/comments/{id}` — rewrite a comment THIS deployment already posted, in place (OPE-1046).
89
+ * `locus.channelId` is the `<workspace>/<page>` conversation key `replyEditLocus`/`unifiedEditLocus`
90
+ * (channels/reply-post.ts / unified-reply.ts) compose from the target/origin's `workspaceId`/`pageId`
91
+ * — parsed back apart here rather than carried as two separate `EditLocus` fields, matching the
92
+ * SAME composite `notion-message-ops.ts` already parses for its own `editMessage` (OPE-1043).
93
+ *
94
+ * Notion 404s a comment the calling connection did not create and 403s a connection lacking
95
+ * insert-comment capability — both DEFINITIVE (a 4xx `updateComment` already attaches to the thrown
96
+ * error's `.status`, OPE-825's classifier reads it with no help from this function); a timeout or a
97
+ * 5xx is AMBIGUOUS. Re-wrapped token-free, matching `liveNotionPost`'s own re-wrap discipline above.
98
+ */
99
+ async function liveNotionEditComment(locus: EditLocus, messageId: string, text: string): Promise<void> {
100
+ const parsed = parseNotionConversationKey(locus.channelId);
101
+ if (!parsed) {
102
+ // DEFINITIVE (OPE-825): a malformed locus is a host refusal before any byte leaves.
103
+ throw markNotApplied(new Error(`edit_reply couldn't edit the Notion comment ${messageId} — "${locus.channelId}" is not a conversation id.`));
104
+ }
105
+ const credentialId = notionReplyCredential(parsed.workspaceId, parsed.pageId);
106
+ const cred = await resolveNotionCredential(credentialId);
107
+ if (!cred) {
108
+ // DEFINITIVE (OPE-825): no credential ⇒ no request ever left this host.
109
+ throw markNotApplied(new Error(`edit_reply couldn't edit the Notion comment ${messageId} — no value stored for notion credential "${credentialId ?? "channel-key"}".`));
110
+ }
111
+ try {
112
+ await updateComment({ commentId: messageId, text, ...cred });
113
+ } catch (err) {
114
+ // Token-free re-wrap; carryFailureSignals keeps the numeric status (and any `applied:false`)
115
+ // from downgrading to AMBIGUOUS across the wrap — the same discipline dispatchNotionReply/
116
+ // liveNotionPost above already apply.
117
+ throw carryFailureSignals(err, new Error(`edit_reply couldn't edit the Notion comment ${messageId} — updateComment failed`));
118
+ }
119
+ }
120
+
121
+ export const notionPostArm: PostArm = {
122
+ platform: "notion",
123
+ resolveTarget: (bag: TargetResolveBag): ReplyTarget | null => {
124
+ // Notion posts into the triggering comment's discussion (bindNotion.target.discussionId).
125
+ const discussionId = bag.str("discussionId");
126
+ if (!discussionId) return null;
127
+ // `pageId` rides the binding target (notion-identity.ts bindNotion); the workspace comes off the
128
+ // partition. Both are CONTEXT for credential selection only — the post target stays the
129
+ // discussion. Fail-soft: an unresolvable partition leaves the workspace absent, and the page
130
+ // alone still resolves a domain through the roster tier.
131
+ let workspaceId: string | undefined;
132
+ try {
133
+ workspaceId = resolvePartition(bag.ctx).workspace;
134
+ } catch {
135
+ workspaceId = undefined;
136
+ }
137
+ const pageId = bag.str("pageId");
138
+ // The delivery's own integration (bindNotion carries it off the `credential_id` auth attribute)
139
+ // — the reply posts under the credential that fetched, never a re-derivation that agrees by luck.
140
+ const credentialId = bag.str("credentialId");
141
+ return {
142
+ platform: "notion",
143
+ discussionId,
144
+ ...(pageId ? { pageId } : {}),
145
+ ...(workspaceId ? { workspaceId } : {}),
146
+ ...(credentialId ? { credentialId } : {}),
147
+ };
148
+ },
149
+ dispatchReply: (target, text) => dispatchNotionReply(target as NotionTarget, text),
150
+ dispatchUnified: (origin, text, deps: UnifiedReplyDeps) => (deps.postNotion ?? liveNotionPost)(origin as NotionOrigin, text),
151
+ // EDIT (OPE-1046): `updateComment` on the address `replyEditLocus`/`unifiedEditLocus` compose from
152
+ // the target/origin's `workspaceId`/`pageId` — see `liveNotionEditComment` above.
153
+ editMessage: (locus, messageId, text) => liveNotionEditComment(locus, messageId, text),
154
+ // 🔴 CAN THAT EDIT ACTUALLY BE ISSUED HERE? (OPE-1753.) `editMessage` above PATCHes
155
+ // `/v1/comments/{id}`, and this deployment's Notion channel key declares `GET,POST` on purpose —
156
+ // "a DELETE or PATCH can never be issued under the channel key" is that rule's own stated point.
157
+ // So the endpoint fence refuses every edit before a byte leaves, and the should-be-edit policy,
158
+ // which could only see that `editMessage` EXISTS, went on steering the seat at a verb that cannot
159
+ // work: on soak `soak-2026-09-12-a` that cost a refusal loop and the duplicate post behind it.
160
+ // This answers the narrower question the policy actually needs — is PATCH within the resolved
161
+ // credential's declared methods?
162
+ // 🔴 IT READS THE RULES, IT DOES NOT WIDEN THEM. The endpoint declaration is security posture and
163
+ // stays exactly as it is; this only stops the host OFFERING an edit the fence will refuse.
164
+ // FAIL-OPEN in both directions that are not a positive refusal: an unresolvable locus or credential
165
+ // answers true and leaves the pre-OPE-1753 behaviour, and a credential with NO declared methods is
166
+ // unnarrowed, so every method is allowed.
167
+ // 🔴 IT READS THE DECLARATION, NEVER THE SECRET. `resolveNotionCredential` would answer the same
168
+ // question, but it calls `getSecretVia` — a broker read that fetches the live integration key,
169
+ // holds it in memory, and on an unstamped broker appends a `credential unresolved` audit row. This
170
+ // predicate runs on every fold evaluation in a Notion conversation, and the question it asks is
171
+ // answerable from the static catalogue alone: which methods did the operator declare? The member's
172
+ // own contract in `channels/post-arms.ts` says READ-ONLY and cheap, and fetching a key to read a
173
+ // config field is neither.
174
+ editReachable: async (locus) => {
175
+ const parsed = parseNotionConversationKey(locus.channelId);
176
+ if (!parsed) return true;
177
+ // 🔴 `undefined` MEANS THE DEFAULT KEY, NOT "NO KEY". `notionChannelCredentialFor` answers
178
+ // undefined whenever the page's domain declares no per-domain override, which is the ORDINARY
179
+ // case — and `resolveNotionCredential` reads that as `NOTION_CHANNEL_CREDENTIAL`, the very key
180
+ // whose GET,POST rules this predicate exists to read. Treating undefined as "cannot tell" and
181
+ // failing open here disabled the whole fix on every default-credential page; `notion-post-arm-
182
+ // test.ts` §5 caught exactly that.
183
+ const credentialId = notionReplyCredential(parsed.workspaceId, parsed.pageId) ?? NOTION_CHANNEL_CREDENTIAL;
184
+ const methods = findConnectorCredential(NOTION_PROVIDER, credentialId)?.endpoints?.methods;
185
+ return methods === undefined || methods.includes("PATCH");
186
+ },
187
+ describeRedriveTarget: (origin) => `notion discussion ${(origin as NotionOrigin).discussionId}`,
188
+ redriveContainerId: (origin) => (origin as NotionOrigin).discussionId,
189
+ };