@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,188 @@
1
+ import { appendAudit, type BindableContext, type Domain } from "@open-everywhere/core/agent/adapter-api.js";
2
+ /** How a Notion page reached a domain — an EXACT `reach` entry, or a container one. */
3
+ export type NotionVia = "roster" | "container" | "none";
4
+ /** A page's domain, how it got there, and the `reach` entry that did it.
5
+ *
6
+ * 🔴 `entry` IS THE PARTITION'S SOURCE (OPE-254 §2). It is the config text that matched, so
7
+ * {@link notionReachWorkspace} reads the workspace segment off it rather than off the delivery —
8
+ * see that function. Absent only when `via` is `"none"`. */
9
+ export interface NotionPageRouting {
10
+ readonly domain?: Domain;
11
+ readonly via: NotionVia;
12
+ readonly entry?: string;
13
+ }
14
+ /** Why a read was admitted or refused — the audited `reason`, and the only thing the verdict says. */
15
+ export type NotionScopeReason =
16
+ /** the page IS the session's own surface — always readable, it is where the agent was engaged */
17
+ "own-page"
18
+ /** caller and target are the same domain, matched through an EXACT `reach` entry */
19
+ | "roster"
20
+ /** caller and target are the same domain, matched through a container `reach` entry */
21
+ | "container"
22
+ /** the target resolves to a DIFFERENT domain than the caller */
23
+ | "cross-domain"
24
+ /** the target resolves to no domain at all — unresolvable ⇒ refused (fail closed) */
25
+ | "target-unassigned"
26
+ /** the CALLER is in no domain, so it has no siblings — only its own page is readable */
27
+ | "caller-unassigned";
28
+ export interface NotionScopeVerdict {
29
+ readonly allowed: boolean;
30
+ readonly reason: NotionScopeReason;
31
+ readonly callerDomain?: string;
32
+ readonly targetDomain?: string;
33
+ }
34
+ /**
35
+ * Compare-form for a Notion id. Notion serves the same page id both hyphenated
36
+ * (`40000000-0000-4000-8000-000000000101`) and bare (32 hex), and an operator writes whichever form
37
+ * they pasted into a domain's `reach`, so a raw string compare would refuse a listed page on a
38
+ * spelling difference. Hyphens and case are the ONLY things normalised away — nothing else is
39
+ * rewritten, so two genuinely different ids can never collide.
40
+ */
41
+ export declare function canonicalNotionId(id: string): string;
42
+ /**
43
+ * The domain a Notion page belongs to, how it got there, and the `reach` entry responsible — the
44
+ * SAME rule {@link resolveDomainForChannel} applies, re-expressed here because the exact-key leg has
45
+ * to compare page ids in {@link canonicalNotionId} form and the core resolver compares whole keys
46
+ * verbatim.
47
+ *
48
+ * 1. EXACT — a `reach` entry `notion/<any workspace>/<page>` whose page id canonicalises equal.
49
+ * It WINS, because an exact key is the most specific entry there is: a listed page keeps its
50
+ * owner under any container.
51
+ * 2. CONTAINER — a `reach` entry naming THIS workspace (`notion/<workspace>`). Reaches every page
52
+ * in the workspace, including ones nobody enumerated — that is what a container is for. MINUS
53
+ * whatever that domain's `exclude` carves out, because exclude always wins (OPE-417). This leg
54
+ * asks core's `domainReachVerdict` rather than re-testing the list itself: a second
55
+ * implementation of "does this domain reach that page?" is how this fence and the router would
56
+ * come to disagree, and the fence is the one people trust. Entries are canonicalised page-id-
57
+ * wise first (see `canonicalNotionId`) so an exclusion written with hyphens still matches a
58
+ * bare id, exactly like an exact entry.
59
+ *
60
+ * WHY THE EXACT LEG IS WORKSPACE-BLIND AND THE CONTAINER LEG IS NOT. A Notion page id is a globally
61
+ * unique UUID, so an exact `reach` entry identifies THE page whatever workspace segment it was
62
+ * written under; a container, by contrast, is a statement about a workspace and means nothing
63
+ * outside it. If the exact leg were workspace-scoped, a page's owner would depend on which workspace
64
+ * the CALLER's key happens to name — and a domain reaching workspace B would be handed a page domain
65
+ * A had listed under workspace A, which is exactly the annexation most-specific-wins exists to
66
+ * prevent. (The first cut of this function got that wrong and the "a container over one workspace
67
+ * does not reach a page in another workspace" assertion is what caught it.)
68
+ *
69
+ * PURE (injected `domains`, no config read), so the fence's whole decision is unit-testable.
70
+ */
71
+ export declare function notionPageDomain(workspace: string | undefined, pageId: string, domains: readonly Domain[]): NotionPageRouting;
72
+ /**
73
+ * The WORKSPACE SEGMENT of the `reach` entry that matched this page — the SESSION PARTITION's
74
+ * workspace, on both legs (OPE-254 §2, closing OPE-537 §1). Undefined when the page reaches no
75
+ * domain, which is the only case config cannot answer.
76
+ *
77
+ * 🔴 THE PARTITION'S TENANT COMES FROM CONFIG, NEVER FROM THE DELIVERY. A delivery's `workspace_id`
78
+ * is an unauthenticated payload field and Notion returns no workspace id on any REST object, so
79
+ * there is no verified source for one anywhere. The only place an operator can ever have read one is
80
+ * a real delivery for that very page — so the segment recorded in the matched `reach` entry IS the
81
+ * real workspace, and a delivery whose payload disagrees is a stale, mistyped or forged copy, never
82
+ * a second legitimate one. Composing the surface key with the payload's value instead used to open a
83
+ * SECOND partition (`notion/<wrong-workspace>/<page>`) beside the honest one, splitting one page's
84
+ * session and memory history with no domain boundary ever crossed to explain it.
85
+ *
86
+ * WHY IT IS DECIDABLE ON THE CONTAINER LEG TOO. A domain may reach at most ONE Notion workspace BY
87
+ * CONTAINER (memory/domain.ts `partitionTenantProblems`, enforced at config load), so "which
88
+ * workspace does this domain sweep pages from?" has exactly one config answer — and reading it here
89
+ * is what makes that assertion load-bearing rather than decorative. Without it, a domain reaching
90
+ * two workspaces would key the same page under either, chosen by the payload. An EXACT entry needs
91
+ * no such rule: it carries its own workspace, so a domain may still name pages in several.
92
+ *
93
+ * Pure — `domains` is injected, exactly like {@link notionPageDomain}'s.
94
+ */
95
+ export declare function notionReachWorkspace(workspace: string | undefined, pageId: string, domains: readonly Domain[]): string | undefined;
96
+ /**
97
+ * THE DECISION, pure. Given the calling session's surface key (`notion/<workspace>/<page>`), the
98
+ * page it wants to read, and the domain registry: may it? See the file header for the doctrine —
99
+ * own page always, otherwise both sides must land in the same domain, and anything unresolvable is
100
+ * a refusal.
101
+ */
102
+ export declare function notionReadScope(callerSurfaceKey: string, pageId: string, domains: readonly Domain[]): NotionScopeVerdict;
103
+ /**
104
+ * THE WORK-PLANE DECISION, pure (OPE-913). Same doctrine as {@link notionReadScope} — caller and
105
+ * target must land in the SAME domain, unresolvable is a refusal — with ONE difference, and it is
106
+ * the whole point of the work grant: the caller's domain is passed IN rather than derived from a
107
+ * `notion/<workspace>/<page>` surface key.
108
+ *
109
+ * That difference is what makes cross-surface grounding work. `notionReadScope` reads the caller's
110
+ * domain out of its own Notion surface key, so a Slack-woken or GitHub-woken session has no page
111
+ * segment, resolves no caller domain, and is refused `caller-unassigned` — the exact shape OPE-926
112
+ * found on Linear from a Slack caller. Capability derives from the DOMAIN (Tommy, 2026-08-21), so
113
+ * the work plane resolves the caller's domain from whatever surface it was actually woken on and
114
+ * fences the TARGET against that.
115
+ *
116
+ * 🔴 IT IS NOT A LOOSER FENCE. It admits exactly the pages `notionReadScope` would admit for a
117
+ * Notion-woken caller in the same domain — no page that domain does not own, no cross-domain page,
118
+ * nothing unassigned. Only the caller side is resolved differently. There is no own-page arm here:
119
+ * a non-Notion caller has no own page, and a Notion caller reaching this path holds the grant that
120
+ * covers its own page anyway.
121
+ *
122
+ * `workspaceHints` supplies the workspace the CLAIM leg needs (see
123
+ * notion-work.ts `notionWorkspacesOf`): the roster leg is tried first, workspace-blind, and each
124
+ * hint is tried only if that found nothing. Hints come from the caller's OWN domain declarations,
125
+ * so they can never name a workspace the config does not.
126
+ */
127
+ export declare function notionWorkScope(callerDomain: Domain | undefined, pageId: string, domains: readonly Domain[], workspaceHints?: readonly string[]): NotionScopeVerdict;
128
+ /**
129
+ * Assert this WORK-PLANE session may read `pageId` — else AUDIT the refusal and throw
130
+ * {@link ChannelScopeError}, exactly like {@link assertNotionPageAllowed} does for the chat plane.
131
+ * The row lands under the same `read.refused` action with the same detail shape, so an operator
132
+ * reading the audit log does not have to know which plane refused to find the refusal.
133
+ */
134
+ export declare function assertNotionWorkPageAllowed(ctx: BindableContext, pageId: string, callerDomain: Domain | undefined, domains: readonly Domain[], workspaceHints?: readonly string[], deps?: NotionScopeDeps): Promise<void>;
135
+ /**
136
+ * Assert this session may read `pageId` — else AUDIT the refusal and throw {@link ChannelScopeError}
137
+ * (fail closed, the same class every other surface's read fence raises, so a caller that catches
138
+ * one catches all of them).
139
+ *
140
+ * `domains` is passed IN (the tools hand it `getConfig().loadDomains()`, the same way they hand it
141
+ * to `sourceScopedMount`) rather than read here — it keeps this module config-free and the decision
142
+ * injectable end-to-end.
143
+ */
144
+ export declare function assertNotionPageAllowed(ctx: BindableContext, pageId: string, domains: readonly Domain[], deps?: NotionScopeDeps): Promise<void>;
145
+ /** Test seam — the audit sink, defaulting to the real append. Same shape delivery-history.ts uses,
146
+ * so the refusal ROW is assertable without standing up an operational store. */
147
+ export interface NotionScopeDeps {
148
+ readonly audit?: (entry: Parameters<typeof appendAudit>[0]) => Promise<void>;
149
+ }
150
+ /**
151
+ * Assert this session may read one BLOCK's inline discussion — the `block` drill-in on
152
+ * read_notion_comments, which addresses a block DIRECTLY and never touches the `page` argument
153
+ * (notion-api.ts readNotionComments: `if (opts.block) { … return }`). Without this, fencing `page`
154
+ * alone would leave the fence bypassable in one argument.
155
+ *
156
+ * Two tiers, cheapest first:
157
+ * 1. the block id resolves in the caller's domain by itself — the page-level discussion anchor (a
158
+ * page id IS a valid `block` here) and every id under a `notion/<workspace>` reach entry. No I/O.
159
+ * 2. otherwise ask Notion which PAGE owns the block (`resolveOwningPage`) and fence THAT.
160
+ * Anything the walk can't answer — a workspace/database parent, hop limit, a lookup that throws —
161
+ * is a REFUSAL. "We can't tell whose page this is" must never resolve to yes.
162
+ */
163
+ export declare function assertNotionBlockAllowed(ctx: BindableContext, blockId: string, domains: readonly Domain[], resolveOwningPage: (blockId: string) => Promise<string | undefined>, deps?: NotionScopeDeps): Promise<void>;
164
+ /**
165
+ * Like {@link assertNotionPageAllowed}, but for an id that MIGHT be a discussion/block id rather than
166
+ * a page id (OPE-352): the Notion worker intermittently hands read_notion_page a block/discussion id
167
+ * instead of the page it lives on, and the plain page fence has no notion of "resolve it and try
168
+ * again" — every such call was a silent-looking `target-unassigned` refusal even when the block's
169
+ * OWNING PAGE was perfectly in scope.
170
+ *
171
+ * Same resolve-then-fence walk as {@link assertNotionBlockAllowed} ({@link resolveNotionReadTarget}),
172
+ * so the SAME security property holds: resolution never grants more than the fence would grant the
173
+ * resolved page directly, because the resolved page is fenced through the identical
174
+ * {@link notionReadScope} decision. If the resolved page is itself refused (unassigned/cross-domain),
175
+ * or resolution fails outright (not a block, no API access, hop limit), the read stays refused.
176
+ *
177
+ * Returns the id that is actually authorized to read — `requestedId` unchanged when it passed the
178
+ * fence directly, or the RESOLVED page id when only the resolution hop admitted it. The caller MUST
179
+ * use the returned id (not `requestedId`) for the content fetch: reading `requestedId` itself after a
180
+ * successful resolution would fetch the block's own children, not the parent page's body the caller
181
+ * actually wants.
182
+ *
183
+ * Every refusal is audited with BOTH the originally-requested id (`target`) and the resolved page id
184
+ * (`resolvedPage`, when a resolution hop was attempted) — the trail shows the hop, not just the final
185
+ * verdict.
186
+ */
187
+ export declare function assertNotionPageAllowedOrResolveBlock(ctx: BindableContext, requestedId: string, domains: readonly Domain[], resolveOwningPage: (id: string) => Promise<string | undefined>, deps?: NotionScopeDeps): Promise<string>;
188
+ //# sourceMappingURL=notion-scope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notion-scope.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-scope.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,433 @@
1
+ // The DOMAIN READ FENCE for Notion pages — the Notion analogue of slack-read.ts's
2
+ // assertChannelAllowed / channel-read.ts's assertSurfaceAllowed (OPE-236).
3
+ //
4
+ // WHY IT EXISTS AT ALL. read_notion_page / read_notion_comments used to be bounded ONLY by what the
5
+ // Notion integration itself can see, and said so in a comment: "Notion enforces access itself: the
6
+ // integration can only read pages explicitly shared with it, so an out-of-scope `page` 404s here."
7
+ // That is true and it is not the boundary we need. On the DEFAULT setup there is ONE credential (an
8
+ // internal-integration secret bound to a single workspace) and it is IDENTICAL for every domain —
9
+ // the shipped runbook says it "cannot express anything narrower than the workspace, so it is not the
10
+ // boundary and OE does not present it as one". So Notion's own enforcement fences the WORKSPACE;
11
+ // nothing fenced the DOMAIN, and a session woken in domain A could pass any page id the integration
12
+ // can see — including pages the deployer put in domain B — and Notion would happily serve it.
13
+ // Exactly the shape slack-read.ts calls out: "broad credential underneath, narrow allowlist on top,
14
+ // FAIL CLOSED". This module is that allowlist.
15
+ //
16
+ // 🔴 A DOMAIN MAY NOW BIND ITS OWN INTEGRATION (OPE-467, channels/notion-credentials.ts), and that
17
+ // DOES buy a vendor-enforced fence for the pages nobody shared elsewhere. THIS MODULE IS UNCHANGED
18
+ // BY IT, deliberately: a binding is optional, it does not un-share a page, and an allowlist that
19
+ // weakened whenever a credential looked narrow would be a fence you could switch off by config.
20
+ // Both fences apply, always, and this one never consults the credential.
21
+ //
22
+ // WHY IT IS NOT `assertSurfaceAllowed`. The generic gate admits the caller's own surface plus every
23
+ // `platform`-matching EXACT entry in the caller's domain. That is right for chat, where a domain
24
+ // enumerates its rooms, but it would refuse every page in a workspace a domain reaches by CONTAINER
25
+ // — and a container entry is precisely how a Notion domain is expressed (a two-segment
26
+ // `notion/<workspace>` key, whose whole point is that it reaches pages nobody enumerated). So the
27
+ // fence here resolves BOTH SIDES — caller and target — with the same most-specific-wins rule
28
+ // `resolveDomainForChannel` uses, and admits the read only when the two land in the SAME domain.
29
+ // That inherits the ordering for free: a page named outright in domain B's `reach` stays with B
30
+ // under domain A's workspace container, so a container can never quietly annex a page someone else
31
+ // listed. The event plane's read grant already behaves exactly this way
32
+ // (notion-event-normalizer-test.ts §5b); this brings the TOOL plane into line with it.
33
+ //
34
+ // FAIL CLOSED, in both directions. A page that resolves to NO domain is refused, not admitted — an
35
+ // unassigned page is the case where we cannot say whose it is, and "we can't tell" must never mean
36
+ // "yes". A caller that is itself in no domain keeps only its OWN page. An under-identified session
37
+ // (`notion/unknown/unknown`) therefore reads nothing but itself.
38
+ //
39
+ // AND IT IS AUDITED. The gap this closes was invisible: a cross-domain read produced no refusal, no
40
+ // row, and nothing on the surface to notice. A fence that refuses silently would only fix half of
41
+ // that, so every refusal appends a `read.refused` line (best-effort, never throws — appendAudit's
42
+ // contract) carrying the reason, the caller's domain and the target's, and nothing else: ids and a
43
+ // verdict, never page CONTENT.
44
+ import { appendAudit, bindFrom, domainReachVerdict, domainRoster, resolvePartition, ChannelScopeError, } from "@open-everywhere/core/agent/adapter-api.js";
45
+ /**
46
+ * Compare-form for a Notion id. Notion serves the same page id both hyphenated
47
+ * (`40000000-0000-4000-8000-000000000101`) and bare (32 hex), and an operator writes whichever form
48
+ * they pasted into a domain's `reach`, so a raw string compare would refuse a listed page on a
49
+ * spelling difference. Hyphens and case are the ONLY things normalised away — nothing else is
50
+ * rewritten, so two genuinely different ids can never collide.
51
+ */
52
+ export function canonicalNotionId(id) {
53
+ return id.trim().toLowerCase().replace(/-/g, "");
54
+ }
55
+ /** The `<workspace>` and `<page>` segments of a `notion/<workspace>/<page>` surface key. */
56
+ function splitNotionKey(surfaceKey) {
57
+ const parts = surfaceKey.split("/");
58
+ if (parts.length !== 3 || parts[0] !== "notion")
59
+ return {};
60
+ return { workspace: parts[1] || undefined, page: parts[2] || undefined };
61
+ }
62
+ /**
63
+ * The domain a Notion page belongs to, how it got there, and the `reach` entry responsible — the
64
+ * SAME rule {@link resolveDomainForChannel} applies, re-expressed here because the exact-key leg has
65
+ * to compare page ids in {@link canonicalNotionId} form and the core resolver compares whole keys
66
+ * verbatim.
67
+ *
68
+ * 1. EXACT — a `reach` entry `notion/<any workspace>/<page>` whose page id canonicalises equal.
69
+ * It WINS, because an exact key is the most specific entry there is: a listed page keeps its
70
+ * owner under any container.
71
+ * 2. CONTAINER — a `reach` entry naming THIS workspace (`notion/<workspace>`). Reaches every page
72
+ * in the workspace, including ones nobody enumerated — that is what a container is for. MINUS
73
+ * whatever that domain's `exclude` carves out, because exclude always wins (OPE-417). This leg
74
+ * asks core's `domainReachVerdict` rather than re-testing the list itself: a second
75
+ * implementation of "does this domain reach that page?" is how this fence and the router would
76
+ * come to disagree, and the fence is the one people trust. Entries are canonicalised page-id-
77
+ * wise first (see `canonicalNotionId`) so an exclusion written with hyphens still matches a
78
+ * bare id, exactly like an exact entry.
79
+ *
80
+ * WHY THE EXACT LEG IS WORKSPACE-BLIND AND THE CONTAINER LEG IS NOT. A Notion page id is a globally
81
+ * unique UUID, so an exact `reach` entry identifies THE page whatever workspace segment it was
82
+ * written under; a container, by contrast, is a statement about a workspace and means nothing
83
+ * outside it. If the exact leg were workspace-scoped, a page's owner would depend on which workspace
84
+ * the CALLER's key happens to name — and a domain reaching workspace B would be handed a page domain
85
+ * A had listed under workspace A, which is exactly the annexation most-specific-wins exists to
86
+ * prevent. (The first cut of this function got that wrong and the "a container over one workspace
87
+ * does not reach a page in another workspace" assertion is what caught it.)
88
+ *
89
+ * PURE (injected `domains`, no config read), so the fence's whole decision is unit-testable.
90
+ */
91
+ export function notionPageDomain(workspace, pageId, domains) {
92
+ const wanted = canonicalNotionId(pageId);
93
+ if (wanted.length === 0)
94
+ return { via: "none" };
95
+ for (const d of domains) {
96
+ const exact = domainRoster(d).find((key) => {
97
+ const parts = key.split("/");
98
+ return parts.length === 3 && parts[0] === "notion" && canonicalNotionId(parts[2] ?? "") === wanted;
99
+ });
100
+ if (exact === undefined)
101
+ continue;
102
+ // EXCLUDE ALWAYS WINS, over an exact entry too. Config load refuses that pair as a
103
+ // contradiction (memory/domain.ts assertDomainsValid), so this is the second line rather than
104
+ // the first — but the router applies exclude at every entry shape, and a fence that did not
105
+ // would be the second implementation this module exists to avoid.
106
+ const entryWorkspace = exact.split("/")[1] ?? "";
107
+ if (domainReachVerdict(canonicalDomainReach(d), `notion/${entryWorkspace}/${wanted}`)?.kind === "excluded")
108
+ continue;
109
+ return { domain: d, via: "roster", entry: exact };
110
+ }
111
+ if (!workspace)
112
+ return { via: "none" };
113
+ const key = `notion/${workspace}/${wanted}`;
114
+ for (const d of domains) {
115
+ const verdict = domainReachVerdict(canonicalDomainReach(d), key);
116
+ if (verdict?.kind === "admits")
117
+ return { domain: d, via: "container", entry: verdict.entry };
118
+ }
119
+ return { via: "none" };
120
+ }
121
+ /**
122
+ * The WORKSPACE SEGMENT of the `reach` entry that matched this page — the SESSION PARTITION's
123
+ * workspace, on both legs (OPE-254 §2, closing OPE-537 §1). Undefined when the page reaches no
124
+ * domain, which is the only case config cannot answer.
125
+ *
126
+ * 🔴 THE PARTITION'S TENANT COMES FROM CONFIG, NEVER FROM THE DELIVERY. A delivery's `workspace_id`
127
+ * is an unauthenticated payload field and Notion returns no workspace id on any REST object, so
128
+ * there is no verified source for one anywhere. The only place an operator can ever have read one is
129
+ * a real delivery for that very page — so the segment recorded in the matched `reach` entry IS the
130
+ * real workspace, and a delivery whose payload disagrees is a stale, mistyped or forged copy, never
131
+ * a second legitimate one. Composing the surface key with the payload's value instead used to open a
132
+ * SECOND partition (`notion/<wrong-workspace>/<page>`) beside the honest one, splitting one page's
133
+ * session and memory history with no domain boundary ever crossed to explain it.
134
+ *
135
+ * WHY IT IS DECIDABLE ON THE CONTAINER LEG TOO. A domain may reach at most ONE Notion workspace BY
136
+ * CONTAINER (memory/domain.ts `partitionTenantProblems`, enforced at config load), so "which
137
+ * workspace does this domain sweep pages from?" has exactly one config answer — and reading it here
138
+ * is what makes that assertion load-bearing rather than decorative. Without it, a domain reaching
139
+ * two workspaces would key the same page under either, chosen by the payload. An EXACT entry needs
140
+ * no such rule: it carries its own workspace, so a domain may still name pages in several.
141
+ *
142
+ * Pure — `domains` is injected, exactly like {@link notionPageDomain}'s.
143
+ */
144
+ export function notionReachWorkspace(workspace, pageId, domains) {
145
+ const routing = notionPageDomain(workspace, pageId, domains);
146
+ if (routing.entry === undefined)
147
+ return undefined;
148
+ return routing.entry.split("/")[1] || undefined;
149
+ }
150
+ /**
151
+ * The domain with its `reach` and `exclude` entries' PAGE-ID segment canonicalised, so the core
152
+ * predicate compares ids the way this module compares them everywhere else. Only the third segment
153
+ * is touched (and only when there is one): a `notion/<workspace>` entry is unchanged, and no entry
154
+ * ever changes shape — a glob stays a glob, its trailing `*` intact.
155
+ */
156
+ function canonicalDomainReach(d) {
157
+ const fix = (entry) => {
158
+ const parts = entry.split("/");
159
+ if (parts.length !== 3 || !parts[2])
160
+ return entry;
161
+ const glob = parts[2].endsWith("*");
162
+ const body = glob ? parts[2].slice(0, -1) : parts[2];
163
+ return `${parts[0]}/${parts[1]}/${canonicalNotionId(body)}${glob ? "*" : ""}`;
164
+ };
165
+ if (!d.reach && !d.exclude)
166
+ return d;
167
+ return {
168
+ ...d,
169
+ ...(d.reach ? { reach: d.reach.map(fix) } : {}),
170
+ ...(d.exclude ? { exclude: d.exclude.map(fix) } : {}),
171
+ };
172
+ }
173
+ /**
174
+ * THE DECISION, pure. Given the calling session's surface key (`notion/<workspace>/<page>`), the
175
+ * page it wants to read, and the domain registry: may it? See the file header for the doctrine —
176
+ * own page always, otherwise both sides must land in the same domain, and anything unresolvable is
177
+ * a refusal.
178
+ */
179
+ export function notionReadScope(callerSurfaceKey, pageId, domains) {
180
+ const { workspace, page: ownPage } = splitNotionKey(callerSurfaceKey);
181
+ const wanted = canonicalNotionId(pageId);
182
+ // The agent can always read the page it was engaged on (slack-read's "its own channel" arm).
183
+ if (ownPage && wanted.length > 0 && canonicalNotionId(ownPage) === wanted) {
184
+ const own = notionPageDomain(workspace, pageId, domains);
185
+ return { allowed: true, reason: "own-page", ...(own.domain ? { callerDomain: own.domain.name, targetDomain: own.domain.name } : {}) };
186
+ }
187
+ const caller = ownPage ? notionPageDomain(workspace, ownPage, domains) : { via: "none" };
188
+ const target = notionPageDomain(workspace, pageId, domains);
189
+ if (!target.domain) {
190
+ return { allowed: false, reason: "target-unassigned", ...(caller.domain ? { callerDomain: caller.domain.name } : {}) };
191
+ }
192
+ if (!caller.domain) {
193
+ return { allowed: false, reason: "caller-unassigned", targetDomain: target.domain.name };
194
+ }
195
+ if (caller.domain.name !== target.domain.name) {
196
+ return { allowed: false, reason: "cross-domain", callerDomain: caller.domain.name, targetDomain: target.domain.name };
197
+ }
198
+ return {
199
+ allowed: true,
200
+ reason: target.via === "container" ? "container" : "roster",
201
+ callerDomain: caller.domain.name,
202
+ targetDomain: target.domain.name,
203
+ };
204
+ }
205
+ /**
206
+ * THE WORK-PLANE DECISION, pure (OPE-913). Same doctrine as {@link notionReadScope} — caller and
207
+ * target must land in the SAME domain, unresolvable is a refusal — with ONE difference, and it is
208
+ * the whole point of the work grant: the caller's domain is passed IN rather than derived from a
209
+ * `notion/<workspace>/<page>` surface key.
210
+ *
211
+ * That difference is what makes cross-surface grounding work. `notionReadScope` reads the caller's
212
+ * domain out of its own Notion surface key, so a Slack-woken or GitHub-woken session has no page
213
+ * segment, resolves no caller domain, and is refused `caller-unassigned` — the exact shape OPE-926
214
+ * found on Linear from a Slack caller. Capability derives from the DOMAIN (Tommy, 2026-08-21), so
215
+ * the work plane resolves the caller's domain from whatever surface it was actually woken on and
216
+ * fences the TARGET against that.
217
+ *
218
+ * 🔴 IT IS NOT A LOOSER FENCE. It admits exactly the pages `notionReadScope` would admit for a
219
+ * Notion-woken caller in the same domain — no page that domain does not own, no cross-domain page,
220
+ * nothing unassigned. Only the caller side is resolved differently. There is no own-page arm here:
221
+ * a non-Notion caller has no own page, and a Notion caller reaching this path holds the grant that
222
+ * covers its own page anyway.
223
+ *
224
+ * `workspaceHints` supplies the workspace the CLAIM leg needs (see
225
+ * notion-work.ts `notionWorkspacesOf`): the roster leg is tried first, workspace-blind, and each
226
+ * hint is tried only if that found nothing. Hints come from the caller's OWN domain declarations,
227
+ * so they can never name a workspace the config does not.
228
+ */
229
+ export function notionWorkScope(callerDomain, pageId, domains, workspaceHints = []) {
230
+ if (!callerDomain)
231
+ return { allowed: false, reason: "caller-unassigned" };
232
+ let target = notionPageDomain(undefined, pageId, domains); // roster leg (workspace-blind)
233
+ for (const ws of workspaceHints) {
234
+ if (target.domain)
235
+ break;
236
+ target = notionPageDomain(ws, pageId, domains);
237
+ }
238
+ if (!target.domain)
239
+ return { allowed: false, reason: "target-unassigned", callerDomain: callerDomain.name };
240
+ if (target.domain.name !== callerDomain.name) {
241
+ return { allowed: false, reason: "cross-domain", callerDomain: callerDomain.name, targetDomain: target.domain.name };
242
+ }
243
+ return {
244
+ allowed: true,
245
+ reason: target.via === "container" ? "container" : "roster",
246
+ callerDomain: callerDomain.name,
247
+ targetDomain: target.domain.name,
248
+ };
249
+ }
250
+ /**
251
+ * Assert this WORK-PLANE session may read `pageId` — else AUDIT the refusal and throw
252
+ * {@link ChannelScopeError}, exactly like {@link assertNotionPageAllowed} does for the chat plane.
253
+ * The row lands under the same `read.refused` action with the same detail shape, so an operator
254
+ * reading the audit log does not have to know which plane refused to find the refusal.
255
+ */
256
+ export async function assertNotionWorkPageAllowed(ctx, pageId, callerDomain, domains, workspaceHints = [], deps) {
257
+ const verdict = notionWorkScope(callerDomain, pageId, domains, workspaceHints);
258
+ if (verdict.allowed)
259
+ return;
260
+ await auditNotionReadRefusal(ctx, pageId, verdict.reason, verdict, deps);
261
+ throw new ChannelScopeError(notionScopeRefusal(pageId, verdict));
262
+ }
263
+ /** The refusal copy for a BLOCK drill-in whose owning page could not be established. */
264
+ function notionBlockRefusal(blockId, callerDomain) {
265
+ const scope = callerDomain ? `domain "${callerDomain}"` : "this session's";
266
+ return (`Notion block "${blockId}" could not be resolved to a page in ${scope} read scope, so it is refused. ` +
267
+ "A block is only readable through a page the agent's memory domain owns — pass `page` (and let the " +
268
+ "sweep find the discussion), or add the owning page to the domain's `reach`.");
269
+ }
270
+ /** The refusal copy — one place, so the tool and its test can't drift. */
271
+ function notionScopeRefusal(pageId, verdict) {
272
+ const scope = verdict.callerDomain ? `domain "${verdict.callerDomain}"` : "this page's (no domain owns this session's page)";
273
+ return (`Notion page "${pageId}" is not in ${scope} read scope. The agent may only read pages in its ` +
274
+ "memory domain (memory/config/domains/<name>.ts). Add the page to the domain's `reach` as " +
275
+ "`notion/<workspace>/<page>`, or reach the whole workspace with `notion/<workspace>`, to grant it.");
276
+ }
277
+ /**
278
+ * The refusal copy for {@link assertNotionPageAllowedOrResolveBlock}'s residual arm (OPE-352) — after
279
+ * BOTH the direct fence and the block-parent resolution attempt have failed to admit `id`. Notion
280
+ * block ids and page ids share the same UUID shape (there is no way to tell them apart from the
281
+ * string alone — this is wording only, NEVER a security signal), so the two sub-cases are told apart
282
+ * by what the resolution attempt actually observed:
283
+ *
284
+ * - `resolvedPage` set: resolution DID find an owning page, and THAT page is also out of scope —
285
+ * name it, so the operator grants the id that actually matters (granting `id` itself would do
286
+ * nothing; `id` was never the page the fence needs to admit).
287
+ * - `resolvedPage` null: resolution could not place `id` at all (not a block Notion will show us,
288
+ * a lookup error, or the hop limit) — the worker gets the "this might be a block id" nudge, since
289
+ * a manually-supplied parent page id can succeed where automatic resolution could not.
290
+ */
291
+ function notionPageOrBlockRefusal(id, verdict, resolvedPage) {
292
+ if (resolvedPage) {
293
+ const scope = verdict.callerDomain ? `domain "${verdict.callerDomain}"` : "this session's";
294
+ return (`Notion id "${id}" resolved to its parent page "${resolvedPage}", but that page is not in ${scope} read scope either ` +
295
+ `(${verdict.reason}). Add "${resolvedPage}" to the domain's \`reach\` as \`notion/<workspace>/<page>\`, or reach the whole ` +
296
+ "workspace with `notion/<workspace>`, to grant it.");
297
+ }
298
+ return (`${notionScopeRefusal(id, verdict)} If "${id}" is a discussion/block id rather than a page id, this looks like a block id ` +
299
+ "— automatic resolution to its parent page did not succeed (not a block, no API access, or the hop limit), so try passing " +
300
+ "its parent page id directly.");
301
+ }
302
+ /**
303
+ * Assert this session may read `pageId` — else AUDIT the refusal and throw {@link ChannelScopeError}
304
+ * (fail closed, the same class every other surface's read fence raises, so a caller that catches
305
+ * one catches all of them).
306
+ *
307
+ * `domains` is passed IN (the tools hand it `getConfig().loadDomains()`, the same way they hand it
308
+ * to `sourceScopedMount`) rather than read here — it keeps this module config-free and the decision
309
+ * injectable end-to-end.
310
+ */
311
+ export async function assertNotionPageAllowed(ctx, pageId, domains, deps) {
312
+ const partition = resolvePartition(ctx);
313
+ const verdict = notionReadScope(partition.surfaceKey, pageId, domains);
314
+ if (verdict.allowed)
315
+ return;
316
+ await auditNotionReadRefusal(ctx, pageId, verdict.reason, verdict, deps);
317
+ throw new ChannelScopeError(notionScopeRefusal(pageId, verdict));
318
+ }
319
+ /**
320
+ * Append the refusal row. Best-effort by contract (appendAudit never throws): the record is EVIDENCE,
321
+ * not a dependency of the refusal — the throw happens either way. Ids + verdict only; a refused read
322
+ * has no content to leak because nothing was ever fetched.
323
+ *
324
+ * `resolvedPage` (OPE-352): when a block-parent resolution hop was attempted, record what it found —
325
+ * `null` if it found nothing, the resolved page id if it found one (whether or not THAT page was
326
+ * itself in scope). Omitted entirely for a refusal that never attempted resolution (the plain
327
+ * {@link assertNotionPageAllowed} path), so the row shape for that path is byte-identical to before.
328
+ * This is what makes the audit trail show the HOP, not just the final verdict.
329
+ *
330
+ * 🔴 `workspace`/`surface` NAME THE ROW'S SUBJECT, NEVER A FOREIGN CALLER (OPE-935). `platform` is
331
+ * always `"notion"` here — every `read.refused` row this function writes is about a refused NOTION
332
+ * read, chat plane or work plane alike. On the chat plane the caller IS the Notion page, so
333
+ * `resolvePartition(ctx)`'s workspace/surface genuinely describe that subject. On the WORK plane
334
+ * ({@link assertNotionWorkPageAllowed}) the ctx binds to whatever surface woke the session — a Slack
335
+ * channel, a GitHub repo, a Linear team — so `resolvePartition(ctx).workspace`/`.surface` name THAT
336
+ * platform, not Notion. Stamping a Slack team id under a `platform: "notion"` row reads as a bug, not
337
+ * a cross-plane caller. So when the caller's platform differs from `"notion"`, `workspace`/`surface`
338
+ * are stamped `"unknown"` instead of a foreign platform's ids. No identity is lost: `detail.callerSurfaceKey`
339
+ * always carries the caller's real `platform/workspace/surface` string (chat plane: unchanged, still
340
+ * `notion/<workspace>/<page>`).
341
+ */
342
+ async function auditNotionReadRefusal(ctx, target, reason, verdict, deps, resolvedPage) {
343
+ const partition = resolvePartition(ctx);
344
+ const principalId = bindFrom(ctx).principalId;
345
+ const foreignCaller = partition.platform !== "notion";
346
+ await (deps?.audit ?? appendAudit)({
347
+ org: partition.org,
348
+ platform: "notion",
349
+ workspace: foreignCaller ? "unknown" : partition.workspace,
350
+ surface: foreignCaller ? "unknown" : partition.surface,
351
+ ...(principalId ? { principalId } : {}),
352
+ action: "read.refused",
353
+ detail: {
354
+ target,
355
+ reason,
356
+ callerDomain: verdict.callerDomain ?? null,
357
+ targetDomain: verdict.targetDomain ?? null,
358
+ callerSurfaceKey: partition.surfaceKey,
359
+ ...(resolvedPage !== undefined ? { resolvedPage } : {}),
360
+ },
361
+ });
362
+ }
363
+ async function resolveNotionReadTarget(callerSurfaceKey, requestedId, domains, resolveOwningPage) {
364
+ const direct = notionReadScope(callerSurfaceKey, requestedId, domains);
365
+ if (direct.allowed)
366
+ return { allowed: true, verdict: direct };
367
+ const owning = await resolveOwningPage(requestedId);
368
+ if (!owning)
369
+ return { allowed: false, verdict: direct, resolvedPage: null };
370
+ const viaPage = notionReadScope(callerSurfaceKey, owning, domains);
371
+ return { allowed: viaPage.allowed, verdict: viaPage, resolvedPage: owning };
372
+ }
373
+ /**
374
+ * Assert this session may read one BLOCK's inline discussion — the `block` drill-in on
375
+ * read_notion_comments, which addresses a block DIRECTLY and never touches the `page` argument
376
+ * (notion-api.ts readNotionComments: `if (opts.block) { … return }`). Without this, fencing `page`
377
+ * alone would leave the fence bypassable in one argument.
378
+ *
379
+ * Two tiers, cheapest first:
380
+ * 1. the block id resolves in the caller's domain by itself — the page-level discussion anchor (a
381
+ * page id IS a valid `block` here) and every id under a `notion/<workspace>` reach entry. No I/O.
382
+ * 2. otherwise ask Notion which PAGE owns the block (`resolveOwningPage`) and fence THAT.
383
+ * Anything the walk can't answer — a workspace/database parent, hop limit, a lookup that throws —
384
+ * is a REFUSAL. "We can't tell whose page this is" must never resolve to yes.
385
+ */
386
+ export async function assertNotionBlockAllowed(ctx, blockId, domains, resolveOwningPage, deps) {
387
+ const partition = resolvePartition(ctx);
388
+ const res = await resolveNotionReadTarget(partition.surfaceKey, blockId, domains, resolveOwningPage);
389
+ if (res.allowed)
390
+ return;
391
+ if (res.resolvedPage) {
392
+ await auditNotionReadRefusal(ctx, blockId, `block:${res.verdict.reason}`, res.verdict, deps, res.resolvedPage);
393
+ }
394
+ else {
395
+ await auditNotionReadRefusal(ctx, blockId, "block-unresolvable", res.verdict, deps);
396
+ }
397
+ throw new ChannelScopeError(notionBlockRefusal(blockId, res.verdict.callerDomain));
398
+ }
399
+ /**
400
+ * Like {@link assertNotionPageAllowed}, but for an id that MIGHT be a discussion/block id rather than
401
+ * a page id (OPE-352): the Notion worker intermittently hands read_notion_page a block/discussion id
402
+ * instead of the page it lives on, and the plain page fence has no notion of "resolve it and try
403
+ * again" — every such call was a silent-looking `target-unassigned` refusal even when the block's
404
+ * OWNING PAGE was perfectly in scope.
405
+ *
406
+ * Same resolve-then-fence walk as {@link assertNotionBlockAllowed} ({@link resolveNotionReadTarget}),
407
+ * so the SAME security property holds: resolution never grants more than the fence would grant the
408
+ * resolved page directly, because the resolved page is fenced through the identical
409
+ * {@link notionReadScope} decision. If the resolved page is itself refused (unassigned/cross-domain),
410
+ * or resolution fails outright (not a block, no API access, hop limit), the read stays refused.
411
+ *
412
+ * Returns the id that is actually authorized to read — `requestedId` unchanged when it passed the
413
+ * fence directly, or the RESOLVED page id when only the resolution hop admitted it. The caller MUST
414
+ * use the returned id (not `requestedId`) for the content fetch: reading `requestedId` itself after a
415
+ * successful resolution would fetch the block's own children, not the parent page's body the caller
416
+ * actually wants.
417
+ *
418
+ * Every refusal is audited with BOTH the originally-requested id (`target`) and the resolved page id
419
+ * (`resolvedPage`, when a resolution hop was attempted) — the trail shows the hop, not just the final
420
+ * verdict.
421
+ */
422
+ export async function assertNotionPageAllowedOrResolveBlock(ctx, requestedId, domains, resolveOwningPage, deps) {
423
+ const partition = resolvePartition(ctx);
424
+ const res = await resolveNotionReadTarget(partition.surfaceKey, requestedId, domains, resolveOwningPage);
425
+ if (res.allowed)
426
+ return res.resolvedPage ?? requestedId;
427
+ // resolvedPage is ALWAYS explicit here (a string, or `null`) — resolveNotionReadTarget only omits
428
+ // it on the early ALLOWED return above, so a refusal's audit row always shows whether resolution
429
+ // was even attempted and, if so, what it found. That is the "trail shows the hop" property.
430
+ await auditNotionReadRefusal(ctx, requestedId, res.resolvedPage ? `page-via-block:${res.verdict.reason}` : "page-block-unresolvable", res.verdict, deps, res.resolvedPage ?? null);
431
+ throw new ChannelScopeError(notionPageOrBlockRefusal(requestedId, res.verdict, res.resolvedPage ?? null));
432
+ }
433
+ //# sourceMappingURL=notion-scope.js.map