@open-everywhere/provider-notion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +48 -0
- package/agent/channels/notion-acting-identity.ts +88 -0
- package/agent/channels/notion-ancestry-trust.ts +458 -0
- package/agent/channels/notion-api.ts +1290 -0
- package/agent/channels/notion-bot-user.ts +250 -0
- package/agent/channels/notion-catch-up.ts +339 -0
- package/agent/channels/notion-credentials.ts +417 -0
- package/agent/channels/notion-engagement.ts +372 -0
- package/agent/channels/notion-event-normalizer.ts +587 -0
- package/agent/channels/notion-identity.ts +271 -0
- package/agent/channels/notion-ingress-drain.ts +289 -0
- package/agent/channels/notion-ingress.ts +182 -0
- package/agent/channels/notion-message-ops.ts +140 -0
- package/agent/channels/notion-origin-codec.ts +38 -0
- package/agent/channels/notion-post-arm.ts +189 -0
- package/agent/channels/notion-question-return.ts +223 -0
- package/agent/channels/notion-read.ts +283 -0
- package/agent/channels/notion-scope.ts +560 -0
- package/agent/channels/notion-stop.ts +169 -0
- package/agent/channels/notion-supervisor.ts +856 -0
- package/agent/channels/notion-webhook-timing.ts +131 -0
- package/agent/channels/notion-webhook-verifier.ts +55 -0
- package/agent/channels/notion-webhook.ts +963 -0
- package/agent/channels/notion-work-search.ts +74 -0
- package/agent/channels/notion-work.ts +144 -0
- package/agent/channels/notion.ts +1242 -0
- package/agent/contributions.ts +39 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/memory/blocks.ts +295 -0
- package/agent/memory/memory.ts +622 -0
- package/agent/provider-events.ts +198 -0
- package/agent/register.ts +144 -0
- package/agent/schedules/notion-ingress.ts +20 -0
- package/agent/surface-parity.ts +105 -0
- package/agent/tools/notion_search.ts +110 -0
- package/agent/tools/read_notion_comments.ts +250 -0
- package/agent/tools/read_notion_page.ts +351 -0
- package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
- package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-acting-identity.js +85 -0
- package/dist/agent/channels/notion-acting-identity.js.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.js +331 -0
- package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
- package/dist/agent/channels/notion-api.d.ts +655 -0
- package/dist/agent/channels/notion-api.d.ts.map +1 -0
- package/dist/agent/channels/notion-api.js +938 -0
- package/dist/agent/channels/notion-api.js.map +1 -0
- package/dist/agent/channels/notion-bot-user.d.ts +107 -0
- package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
- package/dist/agent/channels/notion-bot-user.js +192 -0
- package/dist/agent/channels/notion-bot-user.js.map +1 -0
- package/dist/agent/channels/notion-catch-up.d.ts +64 -0
- package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/notion-catch-up.js +289 -0
- package/dist/agent/channels/notion-catch-up.js.map +1 -0
- package/dist/agent/channels/notion-credentials.d.ts +210 -0
- package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
- package/dist/agent/channels/notion-credentials.js +360 -0
- package/dist/agent/channels/notion-credentials.js.map +1 -0
- package/dist/agent/channels/notion-engagement.d.ts +219 -0
- package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
- package/dist/agent/channels/notion-engagement.js +283 -0
- package/dist/agent/channels/notion-engagement.js.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.js +542 -0
- package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
- package/dist/agent/channels/notion-identity.d.ts +119 -0
- package/dist/agent/channels/notion-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-identity.js +205 -0
- package/dist/agent/channels/notion-identity.js.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.js +185 -0
- package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
- package/dist/agent/channels/notion-ingress.d.ts +64 -0
- package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress.js +130 -0
- package/dist/agent/channels/notion-ingress.js.map +1 -0
- package/dist/agent/channels/notion-message-ops.d.ts +3 -0
- package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/notion-message-ops.js +126 -0
- package/dist/agent/channels/notion-message-ops.js.map +1 -0
- package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
- package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/notion-origin-codec.js +32 -0
- package/dist/agent/channels/notion-origin-codec.js.map +1 -0
- package/dist/agent/channels/notion-post-arm.d.ts +3 -0
- package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/notion-post-arm.js +185 -0
- package/dist/agent/channels/notion-post-arm.js.map +1 -0
- package/dist/agent/channels/notion-question-return.d.ts +77 -0
- package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
- package/dist/agent/channels/notion-question-return.js +183 -0
- package/dist/agent/channels/notion-question-return.js.map +1 -0
- package/dist/agent/channels/notion-read.d.ts +62 -0
- package/dist/agent/channels/notion-read.d.ts.map +1 -0
- package/dist/agent/channels/notion-read.js +257 -0
- package/dist/agent/channels/notion-read.js.map +1 -0
- package/dist/agent/channels/notion-scope.d.ts +188 -0
- package/dist/agent/channels/notion-scope.d.ts.map +1 -0
- package/dist/agent/channels/notion-scope.js +433 -0
- package/dist/agent/channels/notion-scope.js.map +1 -0
- package/dist/agent/channels/notion-stop.d.ts +48 -0
- package/dist/agent/channels/notion-stop.d.ts.map +1 -0
- package/dist/agent/channels/notion-stop.js +141 -0
- package/dist/agent/channels/notion-stop.js.map +1 -0
- package/dist/agent/channels/notion-supervisor.d.ts +462 -0
- package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/notion-supervisor.js +482 -0
- package/dist/agent/channels/notion-supervisor.js.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.js +99 -0
- package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.js +53 -0
- package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/notion-webhook.d.ts +242 -0
- package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook.js +675 -0
- package/dist/agent/channels/notion-webhook.js.map +1 -0
- package/dist/agent/channels/notion-work-search.d.ts +9 -0
- package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
- package/dist/agent/channels/notion-work-search.js +69 -0
- package/dist/agent/channels/notion-work-search.js.map +1 -0
- package/dist/agent/channels/notion-work.d.ts +68 -0
- package/dist/agent/channels/notion-work.d.ts.map +1 -0
- package/dist/agent/channels/notion-work.js +126 -0
- package/dist/agent/channels/notion-work.js.map +1 -0
- package/dist/agent/channels/notion.d.ts +95 -0
- package/dist/agent/channels/notion.d.ts.map +1 -0
- package/dist/agent/channels/notion.js +1081 -0
- package/dist/agent/channels/notion.js.map +1 -0
- package/dist/agent/contributions.d.ts +32 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +37 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/memory/blocks.d.ts +58 -0
- package/dist/agent/memory/blocks.d.ts.map +1 -0
- package/dist/agent/memory/blocks.js +264 -0
- package/dist/agent/memory/blocks.js.map +1 -0
- package/dist/agent/memory/memory.d.ts +157 -0
- package/dist/agent/memory/memory.d.ts.map +1 -0
- package/dist/agent/memory/memory.js +471 -0
- package/dist/agent/memory/memory.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +196 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +128 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/schedules/notion-ingress.d.ts +6 -0
- package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
- package/dist/agent/schedules/notion-ingress.js +16 -0
- package/dist/agent/schedules/notion-ingress.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +99 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/dist/agent/tools/notion_search.d.ts +56 -0
- package/dist/agent/tools/notion_search.d.ts.map +1 -0
- package/dist/agent/tools/notion_search.js +100 -0
- package/dist/agent/tools/notion_search.js.map +1 -0
- package/dist/agent/tools/read_notion_comments.d.ts +57 -0
- package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_comments.js +236 -0
- package/dist/agent/tools/read_notion_comments.js.map +1 -0
- package/dist/agent/tools/read_notion_page.d.ts +53 -0
- package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_page.js +327 -0
- package/dist/agent/tools/read_notion_page.js.map +1 -0
- package/package.json +231 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,560 @@
|
|
|
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
|
+
|
|
45
|
+
import {
|
|
46
|
+
appendAudit,
|
|
47
|
+
bindFrom,
|
|
48
|
+
domainReachVerdict,
|
|
49
|
+
domainRoster,
|
|
50
|
+
resolvePartition,
|
|
51
|
+
ChannelScopeError,
|
|
52
|
+
type BindableContext,
|
|
53
|
+
type Domain,
|
|
54
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
55
|
+
|
|
56
|
+
/** How a Notion page reached a domain — an EXACT `reach` entry, or a container one. */
|
|
57
|
+
export type NotionVia = "roster" | "container" | "none";
|
|
58
|
+
|
|
59
|
+
/** A page's domain, how it got there, and the `reach` entry that did it.
|
|
60
|
+
*
|
|
61
|
+
* 🔴 `entry` IS THE PARTITION'S SOURCE (OPE-254 §2). It is the config text that matched, so
|
|
62
|
+
* {@link notionReachWorkspace} reads the workspace segment off it rather than off the delivery —
|
|
63
|
+
* see that function. Absent only when `via` is `"none"`. */
|
|
64
|
+
export interface NotionPageRouting {
|
|
65
|
+
readonly domain?: Domain;
|
|
66
|
+
readonly via: NotionVia;
|
|
67
|
+
readonly entry?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Why a read was admitted or refused — the audited `reason`, and the only thing the verdict says. */
|
|
71
|
+
export type NotionScopeReason =
|
|
72
|
+
/** the page IS the session's own surface — always readable, it is where the agent was engaged */
|
|
73
|
+
| "own-page"
|
|
74
|
+
/** caller and target are the same domain, matched through an EXACT `reach` entry */
|
|
75
|
+
| "roster"
|
|
76
|
+
/** caller and target are the same domain, matched through a container `reach` entry */
|
|
77
|
+
| "container"
|
|
78
|
+
/** the target resolves to a DIFFERENT domain than the caller */
|
|
79
|
+
| "cross-domain"
|
|
80
|
+
/** the target resolves to no domain at all — unresolvable ⇒ refused (fail closed) */
|
|
81
|
+
| "target-unassigned"
|
|
82
|
+
/** the CALLER is in no domain, so it has no siblings — only its own page is readable */
|
|
83
|
+
| "caller-unassigned";
|
|
84
|
+
|
|
85
|
+
export interface NotionScopeVerdict {
|
|
86
|
+
readonly allowed: boolean;
|
|
87
|
+
readonly reason: NotionScopeReason;
|
|
88
|
+
readonly callerDomain?: string;
|
|
89
|
+
readonly targetDomain?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compare-form for a Notion id. Notion serves the same page id both hyphenated
|
|
94
|
+
* (`40000000-0000-4000-8000-000000000101`) and bare (32 hex), and an operator writes whichever form
|
|
95
|
+
* they pasted into a domain's `reach`, so a raw string compare would refuse a listed page on a
|
|
96
|
+
* spelling difference. Hyphens and case are the ONLY things normalised away — nothing else is
|
|
97
|
+
* rewritten, so two genuinely different ids can never collide.
|
|
98
|
+
*/
|
|
99
|
+
export function canonicalNotionId(id: string): string {
|
|
100
|
+
return id.trim().toLowerCase().replace(/-/g, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The `<workspace>` and `<page>` segments of a `notion/<workspace>/<page>` surface key. */
|
|
104
|
+
function splitNotionKey(surfaceKey: string): { workspace?: string; page?: string } {
|
|
105
|
+
const parts = surfaceKey.split("/");
|
|
106
|
+
if (parts.length !== 3 || parts[0] !== "notion") return {};
|
|
107
|
+
return { workspace: parts[1] || undefined, page: parts[2] || undefined };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The domain a Notion page belongs to, how it got there, and the `reach` entry responsible — the
|
|
112
|
+
* SAME rule {@link resolveDomainForChannel} applies, re-expressed here because the exact-key leg has
|
|
113
|
+
* to compare page ids in {@link canonicalNotionId} form and the core resolver compares whole keys
|
|
114
|
+
* verbatim.
|
|
115
|
+
*
|
|
116
|
+
* 1. EXACT — a `reach` entry `notion/<any workspace>/<page>` whose page id canonicalises equal.
|
|
117
|
+
* It WINS, because an exact key is the most specific entry there is: a listed page keeps its
|
|
118
|
+
* owner under any container.
|
|
119
|
+
* 2. CONTAINER — a `reach` entry naming THIS workspace (`notion/<workspace>`). Reaches every page
|
|
120
|
+
* in the workspace, including ones nobody enumerated — that is what a container is for. MINUS
|
|
121
|
+
* whatever that domain's `exclude` carves out, because exclude always wins (OPE-417). This leg
|
|
122
|
+
* asks core's `domainReachVerdict` rather than re-testing the list itself: a second
|
|
123
|
+
* implementation of "does this domain reach that page?" is how this fence and the router would
|
|
124
|
+
* come to disagree, and the fence is the one people trust. Entries are canonicalised page-id-
|
|
125
|
+
* wise first (see `canonicalNotionId`) so an exclusion written with hyphens still matches a
|
|
126
|
+
* bare id, exactly like an exact entry.
|
|
127
|
+
*
|
|
128
|
+
* WHY THE EXACT LEG IS WORKSPACE-BLIND AND THE CONTAINER LEG IS NOT. A Notion page id is a globally
|
|
129
|
+
* unique UUID, so an exact `reach` entry identifies THE page whatever workspace segment it was
|
|
130
|
+
* written under; a container, by contrast, is a statement about a workspace and means nothing
|
|
131
|
+
* outside it. If the exact leg were workspace-scoped, a page's owner would depend on which workspace
|
|
132
|
+
* the CALLER's key happens to name — and a domain reaching workspace B would be handed a page domain
|
|
133
|
+
* A had listed under workspace A, which is exactly the annexation most-specific-wins exists to
|
|
134
|
+
* prevent. (The first cut of this function got that wrong and the "a container over one workspace
|
|
135
|
+
* does not reach a page in another workspace" assertion is what caught it.)
|
|
136
|
+
*
|
|
137
|
+
* PURE (injected `domains`, no config read), so the fence's whole decision is unit-testable.
|
|
138
|
+
*/
|
|
139
|
+
export function notionPageDomain(workspace: string | undefined, pageId: string, domains: readonly Domain[]): NotionPageRouting {
|
|
140
|
+
const wanted = canonicalNotionId(pageId);
|
|
141
|
+
if (wanted.length === 0) return { via: "none" };
|
|
142
|
+
for (const d of domains) {
|
|
143
|
+
const exact = domainRoster(d).find((key) => {
|
|
144
|
+
const parts = key.split("/");
|
|
145
|
+
return parts.length === 3 && parts[0] === "notion" && canonicalNotionId(parts[2] ?? "") === wanted;
|
|
146
|
+
});
|
|
147
|
+
if (exact === undefined) continue;
|
|
148
|
+
// EXCLUDE ALWAYS WINS, over an exact entry too. Config load refuses that pair as a
|
|
149
|
+
// contradiction (memory/domain.ts assertDomainsValid), so this is the second line rather than
|
|
150
|
+
// the first — but the router applies exclude at every entry shape, and a fence that did not
|
|
151
|
+
// would be the second implementation this module exists to avoid.
|
|
152
|
+
const entryWorkspace = exact.split("/")[1] ?? "";
|
|
153
|
+
if (domainReachVerdict(canonicalDomainReach(d), `notion/${entryWorkspace}/${wanted}`)?.kind === "excluded") continue;
|
|
154
|
+
return { domain: d, via: "roster", entry: exact };
|
|
155
|
+
}
|
|
156
|
+
if (!workspace) return { via: "none" };
|
|
157
|
+
const key = `notion/${workspace}/${wanted}`;
|
|
158
|
+
for (const d of domains) {
|
|
159
|
+
const verdict = domainReachVerdict(canonicalDomainReach(d), key);
|
|
160
|
+
if (verdict?.kind === "admits") return { domain: d, via: "container", entry: verdict.entry };
|
|
161
|
+
}
|
|
162
|
+
return { via: "none" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The WORKSPACE SEGMENT of the `reach` entry that matched this page — the SESSION PARTITION's
|
|
167
|
+
* workspace, on both legs (OPE-254 §2, closing OPE-537 §1). Undefined when the page reaches no
|
|
168
|
+
* domain, which is the only case config cannot answer.
|
|
169
|
+
*
|
|
170
|
+
* 🔴 THE PARTITION'S TENANT COMES FROM CONFIG, NEVER FROM THE DELIVERY. A delivery's `workspace_id`
|
|
171
|
+
* is an unauthenticated payload field and Notion returns no workspace id on any REST object, so
|
|
172
|
+
* there is no verified source for one anywhere. The only place an operator can ever have read one is
|
|
173
|
+
* a real delivery for that very page — so the segment recorded in the matched `reach` entry IS the
|
|
174
|
+
* real workspace, and a delivery whose payload disagrees is a stale, mistyped or forged copy, never
|
|
175
|
+
* a second legitimate one. Composing the surface key with the payload's value instead used to open a
|
|
176
|
+
* SECOND partition (`notion/<wrong-workspace>/<page>`) beside the honest one, splitting one page's
|
|
177
|
+
* session and memory history with no domain boundary ever crossed to explain it.
|
|
178
|
+
*
|
|
179
|
+
* WHY IT IS DECIDABLE ON THE CONTAINER LEG TOO. A domain may reach at most ONE Notion workspace BY
|
|
180
|
+
* CONTAINER (memory/domain.ts `partitionTenantProblems`, enforced at config load), so "which
|
|
181
|
+
* workspace does this domain sweep pages from?" has exactly one config answer — and reading it here
|
|
182
|
+
* is what makes that assertion load-bearing rather than decorative. Without it, a domain reaching
|
|
183
|
+
* two workspaces would key the same page under either, chosen by the payload. An EXACT entry needs
|
|
184
|
+
* no such rule: it carries its own workspace, so a domain may still name pages in several.
|
|
185
|
+
*
|
|
186
|
+
* Pure — `domains` is injected, exactly like {@link notionPageDomain}'s.
|
|
187
|
+
*/
|
|
188
|
+
export function notionReachWorkspace(workspace: string | undefined, pageId: string, domains: readonly Domain[]): string | undefined {
|
|
189
|
+
const routing = notionPageDomain(workspace, pageId, domains);
|
|
190
|
+
if (routing.entry === undefined) return undefined;
|
|
191
|
+
return routing.entry.split("/")[1] || undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The domain with its `reach` and `exclude` entries' PAGE-ID segment canonicalised, so the core
|
|
196
|
+
* predicate compares ids the way this module compares them everywhere else. Only the third segment
|
|
197
|
+
* is touched (and only when there is one): a `notion/<workspace>` entry is unchanged, and no entry
|
|
198
|
+
* ever changes shape — a glob stays a glob, its trailing `*` intact.
|
|
199
|
+
*/
|
|
200
|
+
function canonicalDomainReach(d: Domain): Domain {
|
|
201
|
+
const fix = (entry: string): string => {
|
|
202
|
+
const parts = entry.split("/");
|
|
203
|
+
if (parts.length !== 3 || !parts[2]) return entry;
|
|
204
|
+
const glob = parts[2].endsWith("*");
|
|
205
|
+
const body = glob ? parts[2].slice(0, -1) : parts[2];
|
|
206
|
+
return `${parts[0]}/${parts[1]}/${canonicalNotionId(body)}${glob ? "*" : ""}`;
|
|
207
|
+
};
|
|
208
|
+
if (!d.reach && !d.exclude) return d;
|
|
209
|
+
return {
|
|
210
|
+
...d,
|
|
211
|
+
...(d.reach ? { reach: d.reach.map(fix) } : {}),
|
|
212
|
+
...(d.exclude ? { exclude: d.exclude.map(fix) } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* THE DECISION, pure. Given the calling session's surface key (`notion/<workspace>/<page>`), the
|
|
218
|
+
* page it wants to read, and the domain registry: may it? See the file header for the doctrine —
|
|
219
|
+
* own page always, otherwise both sides must land in the same domain, and anything unresolvable is
|
|
220
|
+
* a refusal.
|
|
221
|
+
*/
|
|
222
|
+
export function notionReadScope(callerSurfaceKey: string, pageId: string, domains: readonly Domain[]): NotionScopeVerdict {
|
|
223
|
+
const { workspace, page: ownPage } = splitNotionKey(callerSurfaceKey);
|
|
224
|
+
const wanted = canonicalNotionId(pageId);
|
|
225
|
+
// The agent can always read the page it was engaged on (slack-read's "its own channel" arm).
|
|
226
|
+
if (ownPage && wanted.length > 0 && canonicalNotionId(ownPage) === wanted) {
|
|
227
|
+
const own = notionPageDomain(workspace, pageId, domains);
|
|
228
|
+
return { allowed: true, reason: "own-page", ...(own.domain ? { callerDomain: own.domain.name, targetDomain: own.domain.name } : {}) };
|
|
229
|
+
}
|
|
230
|
+
const caller = ownPage ? notionPageDomain(workspace, ownPage, domains) : { via: "none" as const };
|
|
231
|
+
const target = notionPageDomain(workspace, pageId, domains);
|
|
232
|
+
if (!target.domain) {
|
|
233
|
+
return { allowed: false, reason: "target-unassigned", ...(caller.domain ? { callerDomain: caller.domain.name } : {}) };
|
|
234
|
+
}
|
|
235
|
+
if (!caller.domain) {
|
|
236
|
+
return { allowed: false, reason: "caller-unassigned", targetDomain: target.domain.name };
|
|
237
|
+
}
|
|
238
|
+
if (caller.domain.name !== target.domain.name) {
|
|
239
|
+
return { allowed: false, reason: "cross-domain", callerDomain: caller.domain.name, targetDomain: target.domain.name };
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
allowed: true,
|
|
243
|
+
reason: target.via === "container" ? "container" : "roster",
|
|
244
|
+
callerDomain: caller.domain.name,
|
|
245
|
+
targetDomain: target.domain.name,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* THE WORK-PLANE DECISION, pure (OPE-913). Same doctrine as {@link notionReadScope} — caller and
|
|
251
|
+
* target must land in the SAME domain, unresolvable is a refusal — with ONE difference, and it is
|
|
252
|
+
* the whole point of the work grant: the caller's domain is passed IN rather than derived from a
|
|
253
|
+
* `notion/<workspace>/<page>` surface key.
|
|
254
|
+
*
|
|
255
|
+
* That difference is what makes cross-surface grounding work. `notionReadScope` reads the caller's
|
|
256
|
+
* domain out of its own Notion surface key, so a Slack-woken or GitHub-woken session has no page
|
|
257
|
+
* segment, resolves no caller domain, and is refused `caller-unassigned` — the exact shape OPE-926
|
|
258
|
+
* found on Linear from a Slack caller. Capability derives from the DOMAIN (Tommy, 2026-08-21), so
|
|
259
|
+
* the work plane resolves the caller's domain from whatever surface it was actually woken on and
|
|
260
|
+
* fences the TARGET against that.
|
|
261
|
+
*
|
|
262
|
+
* 🔴 IT IS NOT A LOOSER FENCE. It admits exactly the pages `notionReadScope` would admit for a
|
|
263
|
+
* Notion-woken caller in the same domain — no page that domain does not own, no cross-domain page,
|
|
264
|
+
* nothing unassigned. Only the caller side is resolved differently. There is no own-page arm here:
|
|
265
|
+
* a non-Notion caller has no own page, and a Notion caller reaching this path holds the grant that
|
|
266
|
+
* covers its own page anyway.
|
|
267
|
+
*
|
|
268
|
+
* `workspaceHints` supplies the workspace the CLAIM leg needs (see
|
|
269
|
+
* notion-work.ts `notionWorkspacesOf`): the roster leg is tried first, workspace-blind, and each
|
|
270
|
+
* hint is tried only if that found nothing. Hints come from the caller's OWN domain declarations,
|
|
271
|
+
* so they can never name a workspace the config does not.
|
|
272
|
+
*/
|
|
273
|
+
export function notionWorkScope(
|
|
274
|
+
callerDomain: Domain | undefined,
|
|
275
|
+
pageId: string,
|
|
276
|
+
domains: readonly Domain[],
|
|
277
|
+
workspaceHints: readonly string[] = [],
|
|
278
|
+
): NotionScopeVerdict {
|
|
279
|
+
if (!callerDomain) return { allowed: false, reason: "caller-unassigned" };
|
|
280
|
+
let target = notionPageDomain(undefined, pageId, domains); // roster leg (workspace-blind)
|
|
281
|
+
for (const ws of workspaceHints) {
|
|
282
|
+
if (target.domain) break;
|
|
283
|
+
target = notionPageDomain(ws, pageId, domains);
|
|
284
|
+
}
|
|
285
|
+
if (!target.domain) return { allowed: false, reason: "target-unassigned", callerDomain: callerDomain.name };
|
|
286
|
+
if (target.domain.name !== callerDomain.name) {
|
|
287
|
+
return { allowed: false, reason: "cross-domain", callerDomain: callerDomain.name, targetDomain: target.domain.name };
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
allowed: true,
|
|
291
|
+
reason: target.via === "container" ? "container" : "roster",
|
|
292
|
+
callerDomain: callerDomain.name,
|
|
293
|
+
targetDomain: target.domain.name,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Assert this WORK-PLANE session may read `pageId` — else AUDIT the refusal and throw
|
|
299
|
+
* {@link ChannelScopeError}, exactly like {@link assertNotionPageAllowed} does for the chat plane.
|
|
300
|
+
* The row lands under the same `read.refused` action with the same detail shape, so an operator
|
|
301
|
+
* reading the audit log does not have to know which plane refused to find the refusal.
|
|
302
|
+
*/
|
|
303
|
+
export async function assertNotionWorkPageAllowed(
|
|
304
|
+
ctx: BindableContext,
|
|
305
|
+
pageId: string,
|
|
306
|
+
callerDomain: Domain | undefined,
|
|
307
|
+
domains: readonly Domain[],
|
|
308
|
+
workspaceHints: readonly string[] = [],
|
|
309
|
+
deps?: NotionScopeDeps,
|
|
310
|
+
): Promise<void> {
|
|
311
|
+
const verdict = notionWorkScope(callerDomain, pageId, domains, workspaceHints);
|
|
312
|
+
if (verdict.allowed) return;
|
|
313
|
+
await auditNotionReadRefusal(ctx, pageId, verdict.reason, verdict, deps);
|
|
314
|
+
throw new ChannelScopeError(notionScopeRefusal(pageId, verdict));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The refusal copy for a BLOCK drill-in whose owning page could not be established. */
|
|
318
|
+
function notionBlockRefusal(blockId: string, callerDomain: string | undefined): string {
|
|
319
|
+
const scope = callerDomain ? `domain "${callerDomain}"` : "this session's";
|
|
320
|
+
return (
|
|
321
|
+
`Notion block "${blockId}" could not be resolved to a page in ${scope} read scope, so it is refused. ` +
|
|
322
|
+
"A block is only readable through a page the agent's memory domain owns — pass `page` (and let the " +
|
|
323
|
+
"sweep find the discussion), or add the owning page to the domain's `reach`."
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The refusal copy — one place, so the tool and its test can't drift. */
|
|
328
|
+
function notionScopeRefusal(pageId: string, verdict: NotionScopeVerdict): string {
|
|
329
|
+
const scope = verdict.callerDomain ? `domain "${verdict.callerDomain}"` : "this page's (no domain owns this session's page)";
|
|
330
|
+
return (
|
|
331
|
+
`Notion page "${pageId}" is not in ${scope} read scope. The agent may only read pages in its ` +
|
|
332
|
+
"memory domain (memory/config/domains/<name>.ts). Add the page to the domain's `reach` as " +
|
|
333
|
+
"`notion/<workspace>/<page>`, or reach the whole workspace with `notion/<workspace>`, to grant it."
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The refusal copy for {@link assertNotionPageAllowedOrResolveBlock}'s residual arm (OPE-352) — after
|
|
339
|
+
* BOTH the direct fence and the block-parent resolution attempt have failed to admit `id`. Notion
|
|
340
|
+
* block ids and page ids share the same UUID shape (there is no way to tell them apart from the
|
|
341
|
+
* string alone — this is wording only, NEVER a security signal), so the two sub-cases are told apart
|
|
342
|
+
* by what the resolution attempt actually observed:
|
|
343
|
+
*
|
|
344
|
+
* - `resolvedPage` set: resolution DID find an owning page, and THAT page is also out of scope —
|
|
345
|
+
* name it, so the operator grants the id that actually matters (granting `id` itself would do
|
|
346
|
+
* nothing; `id` was never the page the fence needs to admit).
|
|
347
|
+
* - `resolvedPage` null: resolution could not place `id` at all (not a block Notion will show us,
|
|
348
|
+
* a lookup error, or the hop limit) — the worker gets the "this might be a block id" nudge, since
|
|
349
|
+
* a manually-supplied parent page id can succeed where automatic resolution could not.
|
|
350
|
+
*/
|
|
351
|
+
function notionPageOrBlockRefusal(id: string, verdict: NotionScopeVerdict, resolvedPage: string | null): string {
|
|
352
|
+
if (resolvedPage) {
|
|
353
|
+
const scope = verdict.callerDomain ? `domain "${verdict.callerDomain}"` : "this session's";
|
|
354
|
+
return (
|
|
355
|
+
`Notion id "${id}" resolved to its parent page "${resolvedPage}", but that page is not in ${scope} read scope either ` +
|
|
356
|
+
`(${verdict.reason}). Add "${resolvedPage}" to the domain's \`reach\` as \`notion/<workspace>/<page>\`, or reach the whole ` +
|
|
357
|
+
"workspace with `notion/<workspace>`, to grant it."
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return (
|
|
361
|
+
`${notionScopeRefusal(id, verdict)} If "${id}" is a discussion/block id rather than a page id, this looks like a block id ` +
|
|
362
|
+
"— automatic resolution to its parent page did not succeed (not a block, no API access, or the hop limit), so try passing " +
|
|
363
|
+
"its parent page id directly."
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Assert this session may read `pageId` — else AUDIT the refusal and throw {@link ChannelScopeError}
|
|
369
|
+
* (fail closed, the same class every other surface's read fence raises, so a caller that catches
|
|
370
|
+
* one catches all of them).
|
|
371
|
+
*
|
|
372
|
+
* `domains` is passed IN (the tools hand it `getConfig().loadDomains()`, the same way they hand it
|
|
373
|
+
* to `sourceScopedMount`) rather than read here — it keeps this module config-free and the decision
|
|
374
|
+
* injectable end-to-end.
|
|
375
|
+
*/
|
|
376
|
+
export async function assertNotionPageAllowed(
|
|
377
|
+
ctx: BindableContext,
|
|
378
|
+
pageId: string,
|
|
379
|
+
domains: readonly Domain[],
|
|
380
|
+
deps?: NotionScopeDeps,
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
const partition = resolvePartition(ctx);
|
|
383
|
+
const verdict = notionReadScope(partition.surfaceKey, pageId, domains);
|
|
384
|
+
if (verdict.allowed) return;
|
|
385
|
+
await auditNotionReadRefusal(ctx, pageId, verdict.reason, verdict, deps);
|
|
386
|
+
throw new ChannelScopeError(notionScopeRefusal(pageId, verdict));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Test seam — the audit sink, defaulting to the real append. Same shape delivery-history.ts uses,
|
|
390
|
+
* so the refusal ROW is assertable without standing up an operational store. */
|
|
391
|
+
export interface NotionScopeDeps {
|
|
392
|
+
readonly audit?: (entry: Parameters<typeof appendAudit>[0]) => Promise<void>;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Append the refusal row. Best-effort by contract (appendAudit never throws): the record is EVIDENCE,
|
|
397
|
+
* not a dependency of the refusal — the throw happens either way. Ids + verdict only; a refused read
|
|
398
|
+
* has no content to leak because nothing was ever fetched.
|
|
399
|
+
*
|
|
400
|
+
* `resolvedPage` (OPE-352): when a block-parent resolution hop was attempted, record what it found —
|
|
401
|
+
* `null` if it found nothing, the resolved page id if it found one (whether or not THAT page was
|
|
402
|
+
* itself in scope). Omitted entirely for a refusal that never attempted resolution (the plain
|
|
403
|
+
* {@link assertNotionPageAllowed} path), so the row shape for that path is byte-identical to before.
|
|
404
|
+
* This is what makes the audit trail show the HOP, not just the final verdict.
|
|
405
|
+
*
|
|
406
|
+
* 🔴 `workspace`/`surface` NAME THE ROW'S SUBJECT, NEVER A FOREIGN CALLER (OPE-935). `platform` is
|
|
407
|
+
* always `"notion"` here — every `read.refused` row this function writes is about a refused NOTION
|
|
408
|
+
* read, chat plane or work plane alike. On the chat plane the caller IS the Notion page, so
|
|
409
|
+
* `resolvePartition(ctx)`'s workspace/surface genuinely describe that subject. On the WORK plane
|
|
410
|
+
* ({@link assertNotionWorkPageAllowed}) the ctx binds to whatever surface woke the session — a Slack
|
|
411
|
+
* channel, a GitHub repo, a Linear team — so `resolvePartition(ctx).workspace`/`.surface` name THAT
|
|
412
|
+
* platform, not Notion. Stamping a Slack team id under a `platform: "notion"` row reads as a bug, not
|
|
413
|
+
* a cross-plane caller. So when the caller's platform differs from `"notion"`, `workspace`/`surface`
|
|
414
|
+
* are stamped `"unknown"` instead of a foreign platform's ids. No identity is lost: `detail.callerSurfaceKey`
|
|
415
|
+
* always carries the caller's real `platform/workspace/surface` string (chat plane: unchanged, still
|
|
416
|
+
* `notion/<workspace>/<page>`).
|
|
417
|
+
*/
|
|
418
|
+
async function auditNotionReadRefusal(
|
|
419
|
+
ctx: BindableContext,
|
|
420
|
+
target: string,
|
|
421
|
+
reason: string,
|
|
422
|
+
verdict: { readonly callerDomain?: string; readonly targetDomain?: string },
|
|
423
|
+
deps?: NotionScopeDeps,
|
|
424
|
+
resolvedPage?: string | null,
|
|
425
|
+
): Promise<void> {
|
|
426
|
+
const partition = resolvePartition(ctx);
|
|
427
|
+
const principalId = bindFrom(ctx).principalId;
|
|
428
|
+
const foreignCaller = partition.platform !== "notion";
|
|
429
|
+
await (deps?.audit ?? appendAudit)({
|
|
430
|
+
org: partition.org,
|
|
431
|
+
platform: "notion",
|
|
432
|
+
workspace: foreignCaller ? "unknown" : partition.workspace,
|
|
433
|
+
surface: foreignCaller ? "unknown" : partition.surface,
|
|
434
|
+
...(principalId ? { principalId } : {}),
|
|
435
|
+
action: "read.refused",
|
|
436
|
+
detail: {
|
|
437
|
+
target,
|
|
438
|
+
reason,
|
|
439
|
+
callerDomain: verdict.callerDomain ?? null,
|
|
440
|
+
targetDomain: verdict.targetDomain ?? null,
|
|
441
|
+
callerSurfaceKey: partition.surfaceKey,
|
|
442
|
+
...(resolvedPage !== undefined ? { resolvedPage } : {}),
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The shared resolve-then-fence walk behind both {@link assertNotionBlockAllowed} and
|
|
449
|
+
* {@link assertNotionPageAllowedOrResolveBlock} (OPE-352): try `requestedId` directly against the
|
|
450
|
+
* fence first (cheap, no I/O — own-page / roster / claim), and ONLY on a direct refusal ask Notion
|
|
451
|
+
* which page owns it and fence THAT page, through the exact same {@link notionReadScope} decision a
|
|
452
|
+
* directly-passed page id takes.
|
|
453
|
+
*
|
|
454
|
+
* 🔴 THE SECURITY PROPERTY: this function is the ONLY place resolution and fencing meet, and the
|
|
455
|
+
* fence call for the resolved page is the identical pure function used for the direct check — there
|
|
456
|
+
* is no separate, looser "reached via resolution" grant. Resolution can only ever produce an id that
|
|
457
|
+
* then has to clear the SAME allowlist a caller-supplied page id would.
|
|
458
|
+
*
|
|
459
|
+
* Never audits, never throws — both callers own their own audit target and refusal wording (a block
|
|
460
|
+
* id and a possibly-block-shaped `page` argument warrant different copy), so this only returns the
|
|
461
|
+
* outcome: whether it's allowed, the verdict that decided it, and (only when a resolution hop was
|
|
462
|
+
* attempted) the page id it resolved to, or `null` if resolution found nothing.
|
|
463
|
+
*/
|
|
464
|
+
interface NotionResolveOutcome {
|
|
465
|
+
readonly allowed: boolean;
|
|
466
|
+
readonly verdict: NotionScopeVerdict;
|
|
467
|
+
readonly resolvedPage?: string | null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function resolveNotionReadTarget(
|
|
471
|
+
callerSurfaceKey: string,
|
|
472
|
+
requestedId: string,
|
|
473
|
+
domains: readonly Domain[],
|
|
474
|
+
resolveOwningPage: (id: string) => Promise<string | undefined>,
|
|
475
|
+
): Promise<NotionResolveOutcome> {
|
|
476
|
+
const direct = notionReadScope(callerSurfaceKey, requestedId, domains);
|
|
477
|
+
if (direct.allowed) return { allowed: true, verdict: direct };
|
|
478
|
+
const owning = await resolveOwningPage(requestedId);
|
|
479
|
+
if (!owning) return { allowed: false, verdict: direct, resolvedPage: null };
|
|
480
|
+
const viaPage = notionReadScope(callerSurfaceKey, owning, domains);
|
|
481
|
+
return { allowed: viaPage.allowed, verdict: viaPage, resolvedPage: owning };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Assert this session may read one BLOCK's inline discussion — the `block` drill-in on
|
|
486
|
+
* read_notion_comments, which addresses a block DIRECTLY and never touches the `page` argument
|
|
487
|
+
* (notion-api.ts readNotionComments: `if (opts.block) { … return }`). Without this, fencing `page`
|
|
488
|
+
* alone would leave the fence bypassable in one argument.
|
|
489
|
+
*
|
|
490
|
+
* Two tiers, cheapest first:
|
|
491
|
+
* 1. the block id resolves in the caller's domain by itself — the page-level discussion anchor (a
|
|
492
|
+
* page id IS a valid `block` here) and every id under a `notion/<workspace>` reach entry. No I/O.
|
|
493
|
+
* 2. otherwise ask Notion which PAGE owns the block (`resolveOwningPage`) and fence THAT.
|
|
494
|
+
* Anything the walk can't answer — a workspace/database parent, hop limit, a lookup that throws —
|
|
495
|
+
* is a REFUSAL. "We can't tell whose page this is" must never resolve to yes.
|
|
496
|
+
*/
|
|
497
|
+
export async function assertNotionBlockAllowed(
|
|
498
|
+
ctx: BindableContext,
|
|
499
|
+
blockId: string,
|
|
500
|
+
domains: readonly Domain[],
|
|
501
|
+
resolveOwningPage: (blockId: string) => Promise<string | undefined>,
|
|
502
|
+
deps?: NotionScopeDeps,
|
|
503
|
+
): Promise<void> {
|
|
504
|
+
const partition = resolvePartition(ctx);
|
|
505
|
+
const res = await resolveNotionReadTarget(partition.surfaceKey, blockId, domains, resolveOwningPage);
|
|
506
|
+
if (res.allowed) return;
|
|
507
|
+
if (res.resolvedPage) {
|
|
508
|
+
await auditNotionReadRefusal(ctx, blockId, `block:${res.verdict.reason}`, res.verdict, deps, res.resolvedPage);
|
|
509
|
+
} else {
|
|
510
|
+
await auditNotionReadRefusal(ctx, blockId, "block-unresolvable", res.verdict, deps);
|
|
511
|
+
}
|
|
512
|
+
throw new ChannelScopeError(notionBlockRefusal(blockId, res.verdict.callerDomain));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Like {@link assertNotionPageAllowed}, but for an id that MIGHT be a discussion/block id rather than
|
|
517
|
+
* a page id (OPE-352): the Notion worker intermittently hands read_notion_page a block/discussion id
|
|
518
|
+
* instead of the page it lives on, and the plain page fence has no notion of "resolve it and try
|
|
519
|
+
* again" — every such call was a silent-looking `target-unassigned` refusal even when the block's
|
|
520
|
+
* OWNING PAGE was perfectly in scope.
|
|
521
|
+
*
|
|
522
|
+
* Same resolve-then-fence walk as {@link assertNotionBlockAllowed} ({@link resolveNotionReadTarget}),
|
|
523
|
+
* so the SAME security property holds: resolution never grants more than the fence would grant the
|
|
524
|
+
* resolved page directly, because the resolved page is fenced through the identical
|
|
525
|
+
* {@link notionReadScope} decision. If the resolved page is itself refused (unassigned/cross-domain),
|
|
526
|
+
* or resolution fails outright (not a block, no API access, hop limit), the read stays refused.
|
|
527
|
+
*
|
|
528
|
+
* Returns the id that is actually authorized to read — `requestedId` unchanged when it passed the
|
|
529
|
+
* fence directly, or the RESOLVED page id when only the resolution hop admitted it. The caller MUST
|
|
530
|
+
* use the returned id (not `requestedId`) for the content fetch: reading `requestedId` itself after a
|
|
531
|
+
* successful resolution would fetch the block's own children, not the parent page's body the caller
|
|
532
|
+
* actually wants.
|
|
533
|
+
*
|
|
534
|
+
* Every refusal is audited with BOTH the originally-requested id (`target`) and the resolved page id
|
|
535
|
+
* (`resolvedPage`, when a resolution hop was attempted) — the trail shows the hop, not just the final
|
|
536
|
+
* verdict.
|
|
537
|
+
*/
|
|
538
|
+
export async function assertNotionPageAllowedOrResolveBlock(
|
|
539
|
+
ctx: BindableContext,
|
|
540
|
+
requestedId: string,
|
|
541
|
+
domains: readonly Domain[],
|
|
542
|
+
resolveOwningPage: (id: string) => Promise<string | undefined>,
|
|
543
|
+
deps?: NotionScopeDeps,
|
|
544
|
+
): Promise<string> {
|
|
545
|
+
const partition = resolvePartition(ctx);
|
|
546
|
+
const res = await resolveNotionReadTarget(partition.surfaceKey, requestedId, domains, resolveOwningPage);
|
|
547
|
+
if (res.allowed) return res.resolvedPage ?? requestedId;
|
|
548
|
+
// resolvedPage is ALWAYS explicit here (a string, or `null`) — resolveNotionReadTarget only omits
|
|
549
|
+
// it on the early ALLOWED return above, so a refusal's audit row always shows whether resolution
|
|
550
|
+
// was even attempted and, if so, what it found. That is the "trail shows the hop" property.
|
|
551
|
+
await auditNotionReadRefusal(
|
|
552
|
+
ctx,
|
|
553
|
+
requestedId,
|
|
554
|
+
res.resolvedPage ? `page-via-block:${res.verdict.reason}` : "page-block-unresolvable",
|
|
555
|
+
res.verdict,
|
|
556
|
+
deps,
|
|
557
|
+
res.resolvedPage ?? null,
|
|
558
|
+
);
|
|
559
|
+
throw new ChannelScopeError(notionPageOrBlockRefusal(requestedId, res.verdict, res.resolvedPage ?? null));
|
|
560
|
+
}
|