@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,458 @@
1
+ // The NOTION ANCESTOR WALK for trust (OPE-491) — "is this page underneath a TRUST ROOT the operator
2
+ // declared?", answered in the VENDOR ADAPTER.
3
+ //
4
+ // WHERE IT IS CALLED (OPE-492). ONE call site: `handleNotionDelivery` (channels/notion-webhook.ts)
5
+ // resolves {@link resolveNotionDeliveryTrust} once per INGESTED EVENT, after the classifier drops a
6
+ // self/bot comment and before it builds the auth. The verdict rides the auth as the `trust_root`
7
+ // attribute, and `bindNotion` (channels/notion-identity.ts) turns that attribute into the mapper's
8
+ // `internal` verdict. So the walk runs once per page per delivery, never per tool call — the layer
9
+ // above only ever sees an already-resolved answer, which is the whole reason the walk is allowed to
10
+ // live here (see below).
11
+ //
12
+ // 🔴 WHERE A ROOT IS DECLARED (OPE-539). A root is ONE LINE of the shared trust registry that carries
13
+ // the word `subtree`:
14
+ //
15
+ // defineSurface({ key: "notion/<workspaceId>/<pageId>", trust: "internal", subtree: true })
16
+ //
17
+ // An entry WITHOUT `subtree` vouches THAT ONE PAGE and nothing under it — the widening is opt-in on
18
+ // the line, so no Notion entry an operator already wrote changed meaning when the walk shipped. This
19
+ // bundle declares that Notion honours the word (`registerSubtreePlatform` in agent/register.ts) and
20
+ // receives the declared keys through {@link registerNotionTrustRoots}. There is no second list and no
21
+ // app file that imports this package: core carries the claim, this module resolves it.
22
+ //
23
+ // Format: `notion/<workspaceId>/<pageId>` — the same key shape the trust register and the `reach`
24
+ // rosters use. Hyphenated or bare ids both work; they are canonicalised.
25
+ //
26
+ // WHY IT LIVES HERE AND NOT IN CORE. A Notion surface key is `notion/<workspace>/<page>` and it
27
+ // carries NO hierarchy: a page's parent is on the page OBJECT, so a subtree question costs an API
28
+ // call per level. Core's `resolveDomainForChannel` is PURE and SYNCHRONOUS on purpose — a domain is
29
+ // an authorization input, and "making it depend on a fetch would make EffectiveAccess time-varying —
30
+ // that is a change of authorization semantics, not a refactor". The same doctrine names the way out:
31
+ // "any hierarchy that needs a lookup belongs in the vendor adapter, which hands this layer keys it
32
+ // has already resolved" (memory/domain.ts). This module is that adapter-side lookup. It hands back a
33
+ // verdict about ONE page, so the layer above still only ever sees an already-resolved answer.
34
+ //
35
+ // THE SHIPPED PRECEDENT for the async resolve-then-fence hop is `assertNotionPageAllowedOrResolveBlock`
36
+ // in notion-scope.ts: try the cheap pure decision first, and only on a miss pay for the walk. The
37
+ // same shape is used here — a page that IS a root needs no I/O at all.
38
+ //
39
+ // 🔴 FAIL CLOSED, EVERY EXIT. Unresolvable ancestry is NOT trusted. A lookup that throws, a chain
40
+ // that runs out of hops, a chain that loops, a parent Notion will not name — all of them return
41
+ // `trusted: false`. "We could not walk it" must never read as "we walked it and it was internal".
42
+ //
43
+ // 🔴 THE WALK STOPS AT A DATABASE. A database ROW is a separate page whose parent is the database,
44
+ // and a row commonly holds customer-submitted content. So a root declared above a database does NOT
45
+ // reach that database's rows: the walk returns `database-boundary` and the row is untrusted. The
46
+ // matching CONFIG-TIME refusal is in {@link prepareNotionTrustRoots}, by name, so an operator who
47
+ // tries to declare row-grain trust reads why instead of assuming it worked.
48
+ //
49
+ // 🔴 THE CACHE IS THE STALENESS. Ancestry is cached per page id for {@link NOTION_ANCESTRY_TTL_MS},
50
+ // so a page MOVED between trees keeps its old ancestry until the entry expires — in whichever
51
+ // direction the move went. That is a known and accepted cost of this route, and it is why the TTL is
52
+ // short. Only a RESOLVED edge is ever cached; an unresolvable one is never written, so a transient
53
+ // API failure can never stick as a lasting answer.
54
+
55
+ import { canonicalNotionId } from "./notion-scope.js";
56
+
57
+ /**
58
+ * What Notion says owns an id. A closed union so every branch of the walk is decided explicitly —
59
+ * `unresolvable` is a real member rather than an `undefined` that a caller might read as "no parent,
60
+ * so it must be the root".
61
+ */
62
+ export type NotionParentLink =
63
+ | { readonly kind: "page"; readonly pageId: string }
64
+ /** A page nested inside a block (a column, a toggle) — keep walking through the block. */
65
+ | { readonly kind: "block"; readonly blockId: string }
66
+ /** A database row, or a database's own container. The walk STOPS here. */
67
+ | { readonly kind: "database"; readonly databaseId: string }
68
+ /** The workspace itself — the chain ended, and no trust root sat on it. */
69
+ | { readonly kind: "workspace" }
70
+ /** Notion would not say: a 404, a throw, a parent shape we do not recognise. */
71
+ | { readonly kind: "unresolvable" };
72
+
73
+ /** Why the verdict came out the way it did — the audited reason, and the whole statement. */
74
+ export type NotionAncestryReason =
75
+ /** the page IS a declared trust root — no walk, no I/O */
76
+ | "self"
77
+ /** a page ABOVE it is a declared trust root */
78
+ | "ancestor"
79
+ /** the chain reached the workspace root with no root on it */
80
+ | "workspace-root"
81
+ /** the chain reached a database — rows are never reached by an ancestor declaration */
82
+ | "database-boundary"
83
+ /** the walk ran out of hops before deciding */
84
+ | "hop-limit"
85
+ /** the chain revisited an id it had already seen */
86
+ | "cycle"
87
+ /** Notion would not name a parent (404, throw, unknown shape, an empty id) */
88
+ | "unresolvable"
89
+ /** no trust root is declared for this workspace at all — nothing to walk towards */
90
+ | "no-roots";
91
+
92
+ export interface NotionAncestryVerdict {
93
+ readonly trusted: boolean;
94
+ readonly reason: NotionAncestryReason;
95
+ /** The trust root that matched, in canonical form. Present only when `trusted`. */
96
+ readonly matchedRoot?: string;
97
+ /** How many parent lookups the walk consumed. `0` for the `self` and `no-roots` exits. */
98
+ readonly hops: number;
99
+ }
100
+
101
+ /** How far up the walk will climb before giving up. A real page tree is a handful of levels deep;
102
+ * an unbounded walk over attacker-chosen ids is its own problem. Exhausting it is a REFUSAL. */
103
+ const NOTION_ANCESTRY_MAX_HOPS = 8;
104
+
105
+ /** How long ONE resolved parent edge stays cached. Short on purpose: a moved page keeps its old
106
+ * ancestry for at most this long. */
107
+ export const NOTION_ANCESTRY_TTL_MS = 60_000;
108
+
109
+ // --- config: the trust roots ---------------------------------------------
110
+ //
111
+ // A trust root is an EXPLICIT operator declaration, exactly like a vouched surface: it exists
112
+ // only to raise a subtree above the fail-closed default, and core never infers one. Preparing the
113
+ // list is FAIL-LOUD (config time) so a grain we do not support is a boot error an operator reads,
114
+ // not a silently-ignored line they assume is covered.
115
+
116
+ /** The prepared, validated roots — canonical page ids grouped by workspace. Opaque by intent: the
117
+ * matching rules (canonicalisation, workspace scoping) belong to this module, not to its callers. */
118
+ export interface NotionTrustRoots {
119
+ readonly byWorkspace: ReadonlyMap<string, ReadonlySet<string>>;
120
+ }
121
+
122
+ /** A Notion id: 32 hex digits, hyphenated or bare. The `<page>` segment must look like one — this
123
+ * is what stops a human-readable label from being accepted as a page and silently never matching. */
124
+ const NOTION_ID = /^[0-9a-f]{32}$/;
125
+
126
+ /** Thrown by {@link prepareNotionTrustRoots}. Its own class so a boot handler can tell a trust-config
127
+ * mistake from any other startup error. */
128
+ export class NotionTrustConfigError extends Error {
129
+ constructor(message: string) {
130
+ super(message);
131
+ this.name = "NotionTrustConfigError";
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Validate and prepare the declared trust roots, FAIL-LOUD.
137
+ *
138
+ * An entry is a THREE-SEGMENT Notion surface key, `notion/<workspaceId>/<pageId>` — the same key
139
+ * shape the trust register and a domain's exact `reach` entries already use. It means "this page and every page
140
+ * underneath it, except across a database boundary".
141
+ *
142
+ * TWO GRAINS ARE REFUSED BY NAME, each with the reason in the message. A named refusal an operator
143
+ * reads beats a silent gap they assume is covered — the same discipline `memory/domain.ts` applies to
144
+ * an intermediate container `reach` entry. `defineSurface` refuses both grains first, in vendor-free
145
+ * words; these messages stay because they name the NOTION reason (a database row, a walk that would
146
+ * buy nothing), and because this function is also the direct entry point a test drives.
147
+ *
148
+ * 1. DATABASE ROWS (`.../<dbId>/rows`, a fourth segment, or a trailing glob). A database row is a
149
+ * SEPARATE page whose relation to the database is exactly the ancestry a key does not carry, and
150
+ * rows commonly hold customer-submitted content. A declaration on a database page reaches the
151
+ * DATABASE PAGE ONLY.
152
+ * 2. A WORKSPACE key (`notion/<ws>`). A whole workspace needs no walk — vouch it as a workspace
153
+ * surface instead. Accepting it here would run an API-call-per-level walk to reach an answer the
154
+ * registry already holds for free.
155
+ *
156
+ * 🔴 A TEAMSPACE ID CANNOT BE REFUSED HERE, and pretending otherwise was worse than not trying. A
157
+ * Notion teamspace id is a 32-hex uuid — the SAME shape as a page id — so no test on the string can
158
+ * tell the two apart. A teamspace never appears as a `parent` on any Notion object either, so a
159
+ * teamspace root matches nothing the walk ever sees: it is accepted, and then silently grants
160
+ * nothing. Config time cannot close that; {@link probeUnreachableNotionTrustRoots} closes it at RUN
161
+ * time by asking Notion what the root is, and {@link resolveNotionDeliveryTrust} runs that probe on
162
+ * the first walk that fails to match.
163
+ */
164
+ export function prepareNotionTrustRoots(entries: Iterable<string>): NotionTrustRoots {
165
+ const byWorkspace = new Map<string, Set<string>>();
166
+ for (const raw of entries) {
167
+ const entry = typeof raw === "string" ? raw.trim() : "";
168
+ const segs = entry.split("/");
169
+ if (segs[0] !== "notion") {
170
+ throw new NotionTrustConfigError(
171
+ `notion trust root ${JSON.stringify(entry)} is not a Notion surface key — expected "notion/<workspaceId>/<pageId>".`,
172
+ );
173
+ }
174
+ if (segs.length === 2) {
175
+ throw new NotionTrustConfigError(
176
+ `notion trust root ${JSON.stringify(entry)} names a WHOLE WORKSPACE, which is not a trust root — a workspace needs no ancestor walk, so vouch the workspace key directly in the surface register instead of paying an API call per level to reach the same answer.`,
177
+ );
178
+ }
179
+ if (segs.length < 3) {
180
+ throw new NotionTrustConfigError(
181
+ `notion trust root ${JSON.stringify(entry)} is not a Notion surface key — expected "notion/<workspaceId>/<pageId>".`,
182
+ );
183
+ }
184
+ if (segs.length > 3) {
185
+ throw new NotionTrustConfigError(
186
+ `notion trust root ${JSON.stringify(entry)} is DEEPER than a page, which is refused BY NAME: the only thing under a Notion page that a deeper path could name is a DATABASE ROW, and a row is a separate page holding commonly customer-submitted content. A declaration on a database page reaches the DATABASE PAGE ITSELF and never its rows. Declare "notion/<workspaceId>/<pageId>" for the page, and list any row you genuinely trust as its own entry.`,
187
+ );
188
+ }
189
+ const page = segs[2] ?? "";
190
+ if (page.includes("*")) {
191
+ throw new NotionTrustConfigError(
192
+ `notion trust root ${JSON.stringify(entry)} uses a GLOB, which cannot match anything: a Notion page id is an opaque 32-hex uuid, so a prefix carries no meaning. Name the top page; the ancestor walk is what reaches the pages under it.`,
193
+ );
194
+ }
195
+ const workspace = segs[1] ?? "";
196
+ if (!workspace) {
197
+ throw new NotionTrustConfigError(
198
+ `notion trust root ${JSON.stringify(entry)} has an EMPTY workspace segment — expected "notion/<workspaceId>/<pageId>".`,
199
+ );
200
+ }
201
+ const canonical = canonicalNotionId(page);
202
+ if (!NOTION_ID.test(canonical)) {
203
+ throw new NotionTrustConfigError(
204
+ `notion trust root ${JSON.stringify(entry)} does not name a page — the last segment must be a Notion page id (32 hex digits, hyphenated or bare), not a title or a label.`,
205
+ );
206
+ }
207
+ const set = byWorkspace.get(workspace) ?? new Set<string>();
208
+ set.add(canonical);
209
+ byWorkspace.set(workspace, set);
210
+ }
211
+ return { byWorkspace };
212
+ }
213
+
214
+ /** No workspace, no root — the value every read gets before the app registers, and the value a
215
+ * deployment that declares nothing keeps for ever. Fail-closed: nothing is a trust root. */
216
+ const NO_TRUST_ROOTS: NotionTrustRoots = { byWorkspace: new Map() };
217
+
218
+ let registeredRoots: NotionTrustRoots = NO_TRUST_ROOTS;
219
+
220
+ /**
221
+ * THE SUBTREE HANDLER (OPE-539) — core hands this every `subtree: true` surface key an operator
222
+ * declared for Notion, at boot and again on any later `registerSurfaces`. A call REPLACES the
223
+ * previous register, the same convention `registerSurfaces` itself uses.
224
+ *
225
+ * It is wired ONCE, by `registerSubtreePlatform("notion", registerNotionTrustRoots)` in
226
+ * agent/register.ts. Nothing else calls it in production; a test may call it directly to stand up a
227
+ * register without composing core.
228
+ *
229
+ * Validation is {@link prepareNotionTrustRoots}, so a key core accepted but Notion cannot use — a
230
+ * last segment that is not a page id — is a BOOT error the operator reads. Core refuses the grain
231
+ * (a tenant key, a glob, a group rung, a `public` subtree); this refuses the vendor's own rules.
232
+ *
233
+ * Fail-CLOSED before the call and after an empty one: {@link notionTrustRoots} returns an empty
234
+ * register, and an empty register makes every page's verdict `no-roots` with no I/O at all.
235
+ */
236
+ export function registerNotionTrustRoots(entries: Iterable<string>): void {
237
+ registeredRoots = prepareNotionTrustRoots(entries);
238
+ }
239
+
240
+ /** The registered roots — the value the Notion route hands the webhook core. Empty until the app
241
+ * registers, which is the fail-closed default. */
242
+ export function notionTrustRoots(): NotionTrustRoots {
243
+ return registeredRoots;
244
+ }
245
+
246
+ /** Test seam: drop the register (a fresh test must not inherit another's declarations). */
247
+ export function _resetNotionTrustRootsForTest(): void {
248
+ registeredRoots = NO_TRUST_ROOTS;
249
+ }
250
+
251
+ // --- the ancestry cache -----------------------------------------------------
252
+
253
+ interface CacheEntry {
254
+ readonly link: NotionParentLink;
255
+ readonly expiresAt: number;
256
+ }
257
+
258
+ /**
259
+ * The per-page ancestry cache. A separate object (not a module-level singleton only) so a test can
260
+ * hold its own and so two workspaces never share an entry by accident; {@link notionAncestryCache}
261
+ * is the shared process-wide one the real ingestion path uses.
262
+ */
263
+ export interface NotionAncestryCache {
264
+ readonly entries: Map<string, CacheEntry>;
265
+ }
266
+
267
+ /** The process-wide cache the ingestion path uses when the caller passes none. */
268
+ const notionAncestryCache: NotionAncestryCache = { entries: new Map() };
269
+
270
+ /** Test seam, and the operator escape hatch after a big page move: drop every cached edge. */
271
+ export function clearNotionAncestryCache(cache: NotionAncestryCache = notionAncestryCache): void {
272
+ cache.entries.clear();
273
+ }
274
+
275
+ export interface NotionAncestryDeps {
276
+ /** Ask Notion what owns an id. MUST NOT throw for a "no answer" — but if it does, that is a
277
+ * refusal, not an exception the caller has to handle. */
278
+ readonly parentOf: (id: string) => Promise<NotionParentLink>;
279
+ readonly cache?: NotionAncestryCache;
280
+ readonly now?: () => number;
281
+ readonly maxHops?: number;
282
+ readonly ttlMs?: number;
283
+ }
284
+
285
+ /**
286
+ * One cached parent lookup.
287
+ *
288
+ * 🔴 ONLY A RESOLVED EDGE IS CACHED. An `unresolvable` answer — including a thrown lookup — is
289
+ * returned but never written, for two reasons that both matter: a transient Notion failure must not
290
+ * pin a page as untrusted for the whole TTL, and an entry that says "unknown" is exactly the entry a
291
+ * later reader could mistake for a decided one. The verdict for an unresolvable edge is untrusted
292
+ * either way, so nothing is gained by remembering it.
293
+ */
294
+ async function cachedParentOf(id: string, deps: NotionAncestryDeps): Promise<NotionParentLink> {
295
+ const cache = deps.cache ?? notionAncestryCache;
296
+ const now = deps.now ?? Date.now;
297
+ const ttl = deps.ttlMs ?? NOTION_ANCESTRY_TTL_MS;
298
+ const key = canonicalNotionId(id);
299
+ const hit = cache.entries.get(key);
300
+ if (hit) {
301
+ if (hit.expiresAt > now()) return hit.link;
302
+ cache.entries.delete(key);
303
+ }
304
+ let link: NotionParentLink;
305
+ try {
306
+ link = await deps.parentOf(id);
307
+ } catch {
308
+ return { kind: "unresolvable" };
309
+ }
310
+ if (!link || link.kind === "unresolvable") return { kind: "unresolvable" };
311
+ cache.entries.set(key, { link, expiresAt: now() + ttl });
312
+ return link;
313
+ }
314
+
315
+ /**
316
+ * Is `pageId` a declared trust root, or underneath one, in `workspaceId`?
317
+ *
318
+ * The cheap decision first: if the page IS a root, the verdict is `self` and NO I/O happens at all —
319
+ * the same "try the pure fence before paying for the hop" order `resolveNotionReadTarget` uses. Only
320
+ * a miss walks, one `parentOf` call per level, bounded by `maxHops`.
321
+ *
322
+ * Every non-trusted exit is explicit, and each is a REFUSAL rather than a fallthrough: the workspace
323
+ * root, a database boundary, the hop limit, a cycle, and an unresolvable link. There is no branch
324
+ * that returns `trusted: true` without a canonical id equal to a declared trust root.
325
+ *
326
+ * A BLOCK parent is walked THROUGH, not matched: a root is a page, and a page nested in a column or
327
+ * a toggle reports the enclosing block as its parent. Walking through it is what makes the subtree
328
+ * answer correct for a nested page; matching a block id against the roots would be matching a
329
+ * different kind of id.
330
+ */
331
+ export async function resolveNotionAncestorTrust(
332
+ pageId: string,
333
+ workspaceId: string,
334
+ roots: NotionTrustRoots,
335
+ deps: NotionAncestryDeps,
336
+ ): Promise<NotionAncestryVerdict> {
337
+ const declared = roots.byWorkspace.get(workspaceId);
338
+ if (!declared || declared.size === 0) return { trusted: false, reason: "no-roots", hops: 0 };
339
+
340
+ const start = canonicalNotionId(pageId);
341
+ if (declared.has(start)) return { trusted: true, reason: "self", matchedRoot: start, hops: 0 };
342
+
343
+ const maxHops = deps.maxHops ?? NOTION_ANCESTRY_MAX_HOPS;
344
+ const seen = new Set<string>([start]);
345
+ let current = pageId;
346
+ for (let hop = 1; hop <= maxHops; hop++) {
347
+ const link = await cachedParentOf(current, deps);
348
+ if (link.kind === "unresolvable") return { trusted: false, reason: "unresolvable", hops: hop };
349
+ if (link.kind === "workspace") return { trusted: false, reason: "workspace-root", hops: hop };
350
+ if (link.kind === "database") return { trusted: false, reason: "database-boundary", hops: hop };
351
+ const next = link.kind === "page" ? link.pageId : link.blockId;
352
+ const canonical = canonicalNotionId(next);
353
+ // An EMPTY parent id is not a loop, it is Notion declining to say — keep the two reasons apart
354
+ // so the audited reason stays true. Both are refusals; only the wording differs.
355
+ if (!canonical) return { trusted: false, reason: "unresolvable", hops: hop };
356
+ if (seen.has(canonical)) return { trusted: false, reason: "cycle", hops: hop };
357
+ seen.add(canonical);
358
+ if (link.kind === "page" && declared.has(canonical)) {
359
+ return { trusted: true, reason: "ancestor", matchedRoot: canonical, hops: hop };
360
+ }
361
+ current = next;
362
+ }
363
+ return { trusted: false, reason: "hop-limit", hops: maxHops };
364
+ }
365
+
366
+ // --- the ingestion seam (OPE-492) -------------------------------------------
367
+ //
368
+ // One function the webhook core calls per ingested event: resolve the page's verdict, and — only
369
+ // when a declared root failed to match — spend one probe telling the operator that a root of theirs
370
+ // is not a page Notion will serve.
371
+
372
+ /** Ids already probed in this process, keyed `<workspaceId>/<canonical root>`. A root is probed AT
373
+ * MOST ONCE per process, whatever the probe found: the finding is a WARNING, so re-asking on every
374
+ * delivery would buy nothing and cost an API call each time. A process restart re-probes. */
375
+ const probedRoots = new Set<string>();
376
+
377
+ /** Test seam: forget which roots were probed. */
378
+ export function _resetNotionTrustRootProbesForTest(): void {
379
+ probedRoots.clear();
380
+ }
381
+
382
+ /**
383
+ * Which declared roots for this workspace does Notion NOT serve as a page or a block?
384
+ *
385
+ * 🔴 THIS IS THE TEAMSPACE DETECTOR, and it is the only one that can work. A teamspace id is a
386
+ * 32-hex uuid, so {@link prepareNotionTrustRoots} cannot refuse it on its shape — but Notion answers
387
+ * neither `GET /v1/pages/<teamspaceId>` nor `GET /v1/blocks/<teamspaceId>`, so `parentOf` returns
388
+ * `unresolvable` for it while a real page ALWAYS resolves a parent (a top-level page resolves
389
+ * `workspace`). A root that comes back unresolvable is a root that can never grant anything, and the
390
+ * operator has to hear about it.
391
+ *
392
+ * 🔴 IT REPORTS, IT NEVER WITHDRAWS A ROOT — and that is not timidity. An integration is shared on a
393
+ * page and inherits DOWNWARD, so a root declared one level ABOVE the shared page is unreadable to
394
+ * this credential and STILL matches: the walk reads the child's own `parent.page_id` and compares
395
+ * IDS, with no retrieval of the root. Withdrawing such a root would break a working config over a
396
+ * permission the walk does not need.
397
+ *
398
+ * FAIL-SOFT BY CONSTRUCTION, not by a `catch`: every lookup goes through {@link cachedParentOf},
399
+ * which turns a throw into `unresolvable`. So a probe can never throw at its caller.
400
+ *
401
+ * ⚠️ A TRANSIENT NOTION FAILURE THEREFORE READS AS UNREACHABLE, once. The memo is written whatever
402
+ * the probe found, so an outage during the first probe costs ONE wrong warning for the life of the
403
+ * process and never touches a verdict. That is the deliberate trade: re-asking on every delivery
404
+ * would spend an API call per delivery to sharpen a log line.
405
+ */
406
+ export async function probeUnreachableNotionTrustRoots(
407
+ workspaceId: string,
408
+ roots: NotionTrustRoots,
409
+ deps: NotionAncestryDeps,
410
+ ): Promise<readonly string[]> {
411
+ const declared = roots.byWorkspace.get(workspaceId);
412
+ if (!declared || declared.size === 0) return [];
413
+ const unreachable: string[] = [];
414
+ for (const root of declared) {
415
+ const memo = `${workspaceId}/${root}`;
416
+ if (probedRoots.has(memo)) continue;
417
+ probedRoots.add(memo);
418
+ const link = await cachedParentOf(root, deps);
419
+ if (link.kind === "unresolvable") unreachable.push(root);
420
+ }
421
+ return unreachable;
422
+ }
423
+
424
+ /** The per-delivery answer: the ancestry verdict, plus any declared root this credential's probe
425
+ * could not resolve. Both fields are AUDIT input — the route writes them onto the dispatch row so
426
+ * a refusal says WHICH refusal happened, and a dead root says which root is dead. */
427
+ export interface NotionDeliveryTrust extends NotionAncestryVerdict {
428
+ /** Declared roots for this workspace that Notion serves as neither a page nor a block. Present
429
+ * only when the probe ran and found some — see {@link probeUnreachableNotionTrustRoots}. */
430
+ readonly unreachableRoots?: readonly string[];
431
+ }
432
+
433
+ /**
434
+ * THE INGESTION SEAM. Resolve one ingested page's trust verdict, and diagnose a root that never
435
+ * matches.
436
+ *
437
+ * 🔴 A DEPLOYMENT THAT DECLARES NO ROOT PAYS NOTHING. No roots for this workspace ⇒ `no-roots`,
438
+ * zero API calls, zero probes — byte-identical to the behaviour before this seam existed. That is
439
+ * what makes the feature opt-in rather than a cost every Notion deployment carries.
440
+ *
441
+ * The probe runs ONLY on a walk that both ran and failed to match (`no-roots` and `self` never reach
442
+ * it), so the diagnosis costs nothing on the trusted path and nothing on the un-configured path. It
443
+ * cannot move the verdict either — it only adds `unreachableRoots`.
444
+ */
445
+ export async function resolveNotionDeliveryTrust(
446
+ pageId: string,
447
+ workspaceId: string,
448
+ roots: NotionTrustRoots | undefined,
449
+ deps: NotionAncestryDeps,
450
+ ): Promise<NotionDeliveryTrust> {
451
+ const declared = roots ?? NO_TRUST_ROOTS;
452
+ const verdict = await resolveNotionAncestorTrust(pageId, workspaceId, declared, deps);
453
+ if (verdict.trusted || verdict.reason === "no-roots") return verdict;
454
+ // No `try` here on purpose: probeUnreachableNotionTrustRoots cannot throw (see its contract), and a
455
+ // catch that can never run is a guard nobody can test.
456
+ const unreachable = await probeUnreachableNotionTrustRoots(workspaceId, declared, deps);
457
+ return unreachable.length > 0 ? { ...verdict, unreachableRoots: unreachable } : verdict;
458
+ }