@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,85 @@
|
|
|
1
|
+
// THE OWN-ACCOUNT SEAM FOR NOTION (OPE-1525) — the one call every Notion read that runs on the
|
|
2
|
+
// deployment's own integration makes before it resolves that credential.
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT, measured live 2026-09-07. A member asked the seat to read a Notion page "using my own
|
|
5
|
+
// Notion account". The seat delegated, and the worker read the page under the deployment's Notion
|
|
6
|
+
// integration. No consent card, no personal mint, and nothing in the room saying which account had
|
|
7
|
+
// been spent. The two acting identities do not see the same pages, so the member was handed the
|
|
8
|
+
// org's view of their own workspace and could not tell.
|
|
9
|
+
//
|
|
10
|
+
// 🔴 WHY IT SITS HERE AND NOT IN A PROMPT. This module is called at the exact line where the
|
|
11
|
+
// credential id is chosen, and EVERY such line in this bundle resolves an `install`-family key
|
|
12
|
+
// (`team-key` or `channel-key`; connector-credentials.ts). There is no personal branch, so "act as
|
|
13
|
+
// the requester" has no answer any of these tools can give — which is precisely why the refusal has
|
|
14
|
+
// to be code: a model told to be careful still has only team keys to reach for.
|
|
15
|
+
//
|
|
16
|
+
// 🔴 THE FIVE CALL SITES, AND THE LIST IS THE CLAIM. Gating two tools and leaving the rest is worse
|
|
17
|
+
// than gating none, because the doc then says "covered" while a worker reaches the same pages
|
|
18
|
+
// through a neighbouring verb (OPE-1525 review, finding 1). Every site in this bundle that resolves
|
|
19
|
+
// a Notion credential and returns Notion content calls this:
|
|
20
|
+
// • `tools/read_notion_page.ts` — both planes;
|
|
21
|
+
// • `tools/read_notion_comments.ts` — the chat plane, the delegated worker's own arm;
|
|
22
|
+
// • `tools/notion_search.ts` — the work plane's cross-container read;
|
|
23
|
+
// • `channels/notion-read.ts` admit()— the generic `read_channel`/`read_thread` pair's Notion arm,
|
|
24
|
+
// which `read_notion_page`'s own header calls its superset;
|
|
25
|
+
// • `channels/notion-work-search.ts` — the federated `search` tool's Notion arm.
|
|
26
|
+
// TWO READS ARE DELIBERATELY NOT HERE, both INGRESS, and the reason is the same one:
|
|
27
|
+
// • `memory/memory.ts` — an org-default subtree pull with no requester turn behind it;
|
|
28
|
+
// • `channels/notion-catch-up.ts` — the standing seat's since-last-wake catch-up, which fires from
|
|
29
|
+
// the webhook route with no turn and no `BindableContext` at all.
|
|
30
|
+
// Neither has an ask to honour, and the container each reads is the one the wake itself already
|
|
31
|
+
// admitted, so there is no requester-named page a personal credential could answer differently.
|
|
32
|
+
// Gating either would be decoration: the seam needs a turn context that does not exist at ingress,
|
|
33
|
+
// so the gate could only ever allow. The same ruling covers GitHub's catch-up — see
|
|
34
|
+
// `UNGATED_BY_DECISION` in `packages/core/scripts/github-acting-identity-test.ts`.
|
|
35
|
+
//
|
|
36
|
+
// 🔴 NO NOTION TOOL CAN SPEND THE PERSONAL IDENTITY, AND THE GATE IS TOLD SO. Notion's
|
|
37
|
+
// human-acting credential (`user-oauth`) is spent by BORROWING one of the owner's own MCP tools,
|
|
38
|
+
// which mounts on the seat under a `grant__…` name (core access/credential-grant-broker.ts). So a
|
|
39
|
+
// held grant does not let these tools run; it routes the model to that tool instead.
|
|
40
|
+
import { connectorActingIdentityForTurn, ownAccountVendorFor } from "@open-everywhere/core/agent/adapter-api.js";
|
|
41
|
+
import { NOTION_PROVIDER } from "./notion-credentials.js";
|
|
42
|
+
/** Notion, as the own-account grammar recognises it. The connector id is already the word a person
|
|
43
|
+
* writes; the product's other spelling comes from the shared registry.
|
|
44
|
+
*
|
|
45
|
+
* 🔴 THE ALIASES ARE THE REGISTRY'S, NOT A LITERAL HERE (OPE-1641). The STAMP PRODUCER reads the
|
|
46
|
+
* same registry, so a word this gate recognises on a seat turn is recognised on a delegated one
|
|
47
|
+
* too. Two literals agreed the day they were written and had nothing keeping them in step: a call
|
|
48
|
+
* site that learned a new spelling on its own would refuse a seat read and answer the same
|
|
49
|
+
* sentence as the org inside a worker. */
|
|
50
|
+
const NOTION_OWN_ACCOUNT_VENDOR = ownAccountVendorFor(NOTION_PROVIDER);
|
|
51
|
+
/**
|
|
52
|
+
* Refuse this Notion read when the requester asked to act with their OWN Notion account.
|
|
53
|
+
*
|
|
54
|
+
* Throws the gate's own sentence — which names the consent route when one exists here, says plainly
|
|
55
|
+
* that there is none when it does not, names the granted tool when the account is already lent, and
|
|
56
|
+
* asks which account was meant when one sentence named both. Returns normally on every other turn,
|
|
57
|
+
* which is the ordinary path and is unchanged.
|
|
58
|
+
*
|
|
59
|
+
* 🔴 IT ALWAYS WRITES THE `connector.acting_identity` ROW, INCLUDING ON THE ORDINARY PATH. A trail
|
|
60
|
+
* that records only refusals cannot answer "which account read this page", which is the question
|
|
61
|
+
* OPE-1525 was filed for.
|
|
62
|
+
*
|
|
63
|
+
* 🔴 THE `install` CHECK IS STRUCTURAL, NOT DECORATION. The gate's contract is "refuse on `refusal`,
|
|
64
|
+
* otherwise run under `identity`" — and every caller of this helper goes on to resolve the
|
|
65
|
+
* DEPLOYMENT's credential, which is the `install` identity and the only one it can spend. Reading
|
|
66
|
+
* only the refusal would keep that contract by convention: the day a caller passes
|
|
67
|
+
* `callerCanSpendPersonal`, the gate would return `identity: "human-acting"`, this helper would
|
|
68
|
+
* return normally, and the read would run on the install key — the exact OPE-1525 defect, re-entered
|
|
69
|
+
* through the fix for it. So the precondition is asserted here rather than remembered.
|
|
70
|
+
*/
|
|
71
|
+
export async function assertNotionActingIdentity(ctx, tool) {
|
|
72
|
+
// own-account-gate: notion — the marker `npm run lint:own-account-gate` reads (OPE-1598). The
|
|
73
|
+
// connector reaches the gate as a VALUE, through an imported constant, so no checker can read it
|
|
74
|
+
// from this line; the lint joins CREDENTIAL_GRANT_ROLES to the gated read sites through this
|
|
75
|
+
// marker, and reds when a connector joins that list with no site declaring it.
|
|
76
|
+
const verdict = await connectorActingIdentityForTurn(ctx, NOTION_OWN_ACCOUNT_VENDOR, tool);
|
|
77
|
+
if (verdict.refusal !== undefined)
|
|
78
|
+
throw new Error(verdict.refusal);
|
|
79
|
+
if (verdict.identity !== "install") {
|
|
80
|
+
throw new Error(`Notion read "${tool}": the acting-identity gate allowed ${verdict.identity ?? "no"} identity, ` +
|
|
81
|
+
`which this call site cannot spend — it resolves the deployment's own Notion credential. ` +
|
|
82
|
+
`A caller that can act as the member must resolve that credential itself.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=notion-acting-identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notion-acting-identity.js","sourceRoot":"","sources":["../../../agent/channels/notion-acting-identity.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,yEAAyE;AACzE,EAAE;AACF,oGAAoG;AACpG,kGAAkG;AAClG,mGAAmG;AACnG,gGAAgG;AAChG,wDAAwD;AACxD,EAAE;AACF,6FAA6F;AAC7F,+FAA+F;AAC/F,mGAAmG;AACnG,oGAAoG;AACpG,gFAAgF;AAChF,EAAE;AACF,oGAAoG;AACpG,8FAA8F;AAC9F,oGAAoG;AACpG,6DAA6D;AAC7D,sDAAsD;AACtD,yFAAyF;AACzF,gFAAgF;AAChF,qGAAqG;AACrG,mGAAmG;AACnG,mFAAmF;AACnF,qFAAqF;AACrF,kGAAkG;AAClG,qGAAqG;AACrG,sEAAsE;AACtE,gGAAgG;AAChG,gGAAgG;AAChG,mGAAmG;AACnG,oFAAoF;AACpF,mFAAmF;AACnF,EAAE;AACF,uFAAuF;AACvF,iGAAiG;AACjG,kGAAkG;AAClG,qFAAqF;AAErF,OAAO,EAAE,8BAA8B,EAAE,mBAAmB,EAA+C,MAAM,4CAA4C,CAAC;AAC9J,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE1D;;;;;;;2CAO2C;AAC3C,MAAM,yBAAyB,GAAqB,mBAAmB,CAAC,eAAe,CAAC,CAAC;AAEzF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAAC,GAAoB,EAAE,IAAY;IACjF,8FAA8F;IAC9F,iGAAiG;IACjG,6FAA6F;IAC7F,+EAA+E;IAC/E,MAAM,OAAO,GAAG,MAAM,8BAA8B,CAAC,GAAG,EAAE,yBAAyB,EAAE,IAAI,CAAC,CAAC;IAC3F,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACpE,IAAI,OAAO,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CACb,gBAAgB,IAAI,uCAAuC,OAAO,CAAC,QAAQ,IAAI,IAAI,aAAa;YAC9F,0FAA0F;YAC1F,0EAA0E,CAC7E,CAAC;IACJ,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Notion says owns an id. A closed union so every branch of the walk is decided explicitly —
|
|
3
|
+
* `unresolvable` is a real member rather than an `undefined` that a caller might read as "no parent,
|
|
4
|
+
* so it must be the root".
|
|
5
|
+
*/
|
|
6
|
+
export type NotionParentLink = {
|
|
7
|
+
readonly kind: "page";
|
|
8
|
+
readonly pageId: string;
|
|
9
|
+
}
|
|
10
|
+
/** A page nested inside a block (a column, a toggle) — keep walking through the block. */
|
|
11
|
+
| {
|
|
12
|
+
readonly kind: "block";
|
|
13
|
+
readonly blockId: string;
|
|
14
|
+
}
|
|
15
|
+
/** A database row, or a database's own container. The walk STOPS here. */
|
|
16
|
+
| {
|
|
17
|
+
readonly kind: "database";
|
|
18
|
+
readonly databaseId: string;
|
|
19
|
+
}
|
|
20
|
+
/** The workspace itself — the chain ended, and no trust root sat on it. */
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: "workspace";
|
|
23
|
+
}
|
|
24
|
+
/** Notion would not say: a 404, a throw, a parent shape we do not recognise. */
|
|
25
|
+
| {
|
|
26
|
+
readonly kind: "unresolvable";
|
|
27
|
+
};
|
|
28
|
+
/** Why the verdict came out the way it did — the audited reason, and the whole statement. */
|
|
29
|
+
export type NotionAncestryReason =
|
|
30
|
+
/** the page IS a declared trust root — no walk, no I/O */
|
|
31
|
+
"self"
|
|
32
|
+
/** a page ABOVE it is a declared trust root */
|
|
33
|
+
| "ancestor"
|
|
34
|
+
/** the chain reached the workspace root with no root on it */
|
|
35
|
+
| "workspace-root"
|
|
36
|
+
/** the chain reached a database — rows are never reached by an ancestor declaration */
|
|
37
|
+
| "database-boundary"
|
|
38
|
+
/** the walk ran out of hops before deciding */
|
|
39
|
+
| "hop-limit"
|
|
40
|
+
/** the chain revisited an id it had already seen */
|
|
41
|
+
| "cycle"
|
|
42
|
+
/** Notion would not name a parent (404, throw, unknown shape, an empty id) */
|
|
43
|
+
| "unresolvable"
|
|
44
|
+
/** no trust root is declared for this workspace at all — nothing to walk towards */
|
|
45
|
+
| "no-roots";
|
|
46
|
+
export interface NotionAncestryVerdict {
|
|
47
|
+
readonly trusted: boolean;
|
|
48
|
+
readonly reason: NotionAncestryReason;
|
|
49
|
+
/** The trust root that matched, in canonical form. Present only when `trusted`. */
|
|
50
|
+
readonly matchedRoot?: string;
|
|
51
|
+
/** How many parent lookups the walk consumed. `0` for the `self` and `no-roots` exits. */
|
|
52
|
+
readonly hops: number;
|
|
53
|
+
}
|
|
54
|
+
/** How long ONE resolved parent edge stays cached. Short on purpose: a moved page keeps its old
|
|
55
|
+
* ancestry for at most this long. */
|
|
56
|
+
export declare const NOTION_ANCESTRY_TTL_MS = 60000;
|
|
57
|
+
/** The prepared, validated roots — canonical page ids grouped by workspace. Opaque by intent: the
|
|
58
|
+
* matching rules (canonicalisation, workspace scoping) belong to this module, not to its callers. */
|
|
59
|
+
export interface NotionTrustRoots {
|
|
60
|
+
readonly byWorkspace: ReadonlyMap<string, ReadonlySet<string>>;
|
|
61
|
+
}
|
|
62
|
+
/** Thrown by {@link prepareNotionTrustRoots}. Its own class so a boot handler can tell a trust-config
|
|
63
|
+
* mistake from any other startup error. */
|
|
64
|
+
export declare class NotionTrustConfigError extends Error {
|
|
65
|
+
constructor(message: string);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validate and prepare the declared trust roots, FAIL-LOUD.
|
|
69
|
+
*
|
|
70
|
+
* An entry is a THREE-SEGMENT Notion surface key, `notion/<workspaceId>/<pageId>` — the same key
|
|
71
|
+
* shape the trust register and a domain's exact `reach` entries already use. It means "this page and every page
|
|
72
|
+
* underneath it, except across a database boundary".
|
|
73
|
+
*
|
|
74
|
+
* TWO GRAINS ARE REFUSED BY NAME, each with the reason in the message. A named refusal an operator
|
|
75
|
+
* reads beats a silent gap they assume is covered — the same discipline `memory/domain.ts` applies to
|
|
76
|
+
* an intermediate container `reach` entry. `defineSurface` refuses both grains first, in vendor-free
|
|
77
|
+
* words; these messages stay because they name the NOTION reason (a database row, a walk that would
|
|
78
|
+
* buy nothing), and because this function is also the direct entry point a test drives.
|
|
79
|
+
*
|
|
80
|
+
* 1. DATABASE ROWS (`.../<dbId>/rows`, a fourth segment, or a trailing glob). A database row is a
|
|
81
|
+
* SEPARATE page whose relation to the database is exactly the ancestry a key does not carry, and
|
|
82
|
+
* rows commonly hold customer-submitted content. A declaration on a database page reaches the
|
|
83
|
+
* DATABASE PAGE ONLY.
|
|
84
|
+
* 2. A WORKSPACE key (`notion/<ws>`). A whole workspace needs no walk — vouch it as a workspace
|
|
85
|
+
* surface instead. Accepting it here would run an API-call-per-level walk to reach an answer the
|
|
86
|
+
* registry already holds for free.
|
|
87
|
+
*
|
|
88
|
+
* 🔴 A TEAMSPACE ID CANNOT BE REFUSED HERE, and pretending otherwise was worse than not trying. A
|
|
89
|
+
* Notion teamspace id is a 32-hex uuid — the SAME shape as a page id — so no test on the string can
|
|
90
|
+
* tell the two apart. A teamspace never appears as a `parent` on any Notion object either, so a
|
|
91
|
+
* teamspace root matches nothing the walk ever sees: it is accepted, and then silently grants
|
|
92
|
+
* nothing. Config time cannot close that; {@link probeUnreachableNotionTrustRoots} closes it at RUN
|
|
93
|
+
* time by asking Notion what the root is, and {@link resolveNotionDeliveryTrust} runs that probe on
|
|
94
|
+
* the first walk that fails to match.
|
|
95
|
+
*/
|
|
96
|
+
export declare function prepareNotionTrustRoots(entries: Iterable<string>): NotionTrustRoots;
|
|
97
|
+
/**
|
|
98
|
+
* THE SUBTREE HANDLER (OPE-539) — core hands this every `subtree: true` surface key an operator
|
|
99
|
+
* declared for Notion, at boot and again on any later `registerSurfaces`. A call REPLACES the
|
|
100
|
+
* previous register, the same convention `registerSurfaces` itself uses.
|
|
101
|
+
*
|
|
102
|
+
* It is wired ONCE, by `registerSubtreePlatform("notion", registerNotionTrustRoots)` in
|
|
103
|
+
* agent/register.ts. Nothing else calls it in production; a test may call it directly to stand up a
|
|
104
|
+
* register without composing core.
|
|
105
|
+
*
|
|
106
|
+
* Validation is {@link prepareNotionTrustRoots}, so a key core accepted but Notion cannot use — a
|
|
107
|
+
* last segment that is not a page id — is a BOOT error the operator reads. Core refuses the grain
|
|
108
|
+
* (a tenant key, a glob, a group rung, a `public` subtree); this refuses the vendor's own rules.
|
|
109
|
+
*
|
|
110
|
+
* Fail-CLOSED before the call and after an empty one: {@link notionTrustRoots} returns an empty
|
|
111
|
+
* register, and an empty register makes every page's verdict `no-roots` with no I/O at all.
|
|
112
|
+
*/
|
|
113
|
+
export declare function registerNotionTrustRoots(entries: Iterable<string>): void;
|
|
114
|
+
/** The registered roots — the value the Notion route hands the webhook core. Empty until the app
|
|
115
|
+
* registers, which is the fail-closed default. */
|
|
116
|
+
export declare function notionTrustRoots(): NotionTrustRoots;
|
|
117
|
+
/** Test seam: drop the register (a fresh test must not inherit another's declarations). */
|
|
118
|
+
export declare function _resetNotionTrustRootsForTest(): void;
|
|
119
|
+
interface CacheEntry {
|
|
120
|
+
readonly link: NotionParentLink;
|
|
121
|
+
readonly expiresAt: number;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The per-page ancestry cache. A separate object (not a module-level singleton only) so a test can
|
|
125
|
+
* hold its own and so two workspaces never share an entry by accident; {@link notionAncestryCache}
|
|
126
|
+
* is the shared process-wide one the real ingestion path uses.
|
|
127
|
+
*/
|
|
128
|
+
export interface NotionAncestryCache {
|
|
129
|
+
readonly entries: Map<string, CacheEntry>;
|
|
130
|
+
}
|
|
131
|
+
/** Test seam, and the operator escape hatch after a big page move: drop every cached edge. */
|
|
132
|
+
export declare function clearNotionAncestryCache(cache?: NotionAncestryCache): void;
|
|
133
|
+
export interface NotionAncestryDeps {
|
|
134
|
+
/** Ask Notion what owns an id. MUST NOT throw for a "no answer" — but if it does, that is a
|
|
135
|
+
* refusal, not an exception the caller has to handle. */
|
|
136
|
+
readonly parentOf: (id: string) => Promise<NotionParentLink>;
|
|
137
|
+
readonly cache?: NotionAncestryCache;
|
|
138
|
+
readonly now?: () => number;
|
|
139
|
+
readonly maxHops?: number;
|
|
140
|
+
readonly ttlMs?: number;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Is `pageId` a declared trust root, or underneath one, in `workspaceId`?
|
|
144
|
+
*
|
|
145
|
+
* The cheap decision first: if the page IS a root, the verdict is `self` and NO I/O happens at all —
|
|
146
|
+
* the same "try the pure fence before paying for the hop" order `resolveNotionReadTarget` uses. Only
|
|
147
|
+
* a miss walks, one `parentOf` call per level, bounded by `maxHops`.
|
|
148
|
+
*
|
|
149
|
+
* Every non-trusted exit is explicit, and each is a REFUSAL rather than a fallthrough: the workspace
|
|
150
|
+
* root, a database boundary, the hop limit, a cycle, and an unresolvable link. There is no branch
|
|
151
|
+
* that returns `trusted: true` without a canonical id equal to a declared trust root.
|
|
152
|
+
*
|
|
153
|
+
* A BLOCK parent is walked THROUGH, not matched: a root is a page, and a page nested in a column or
|
|
154
|
+
* a toggle reports the enclosing block as its parent. Walking through it is what makes the subtree
|
|
155
|
+
* answer correct for a nested page; matching a block id against the roots would be matching a
|
|
156
|
+
* different kind of id.
|
|
157
|
+
*/
|
|
158
|
+
export declare function resolveNotionAncestorTrust(pageId: string, workspaceId: string, roots: NotionTrustRoots, deps: NotionAncestryDeps): Promise<NotionAncestryVerdict>;
|
|
159
|
+
/** Test seam: forget which roots were probed. */
|
|
160
|
+
export declare function _resetNotionTrustRootProbesForTest(): void;
|
|
161
|
+
/**
|
|
162
|
+
* Which declared roots for this workspace does Notion NOT serve as a page or a block?
|
|
163
|
+
*
|
|
164
|
+
* 🔴 THIS IS THE TEAMSPACE DETECTOR, and it is the only one that can work. A teamspace id is a
|
|
165
|
+
* 32-hex uuid, so {@link prepareNotionTrustRoots} cannot refuse it on its shape — but Notion answers
|
|
166
|
+
* neither `GET /v1/pages/<teamspaceId>` nor `GET /v1/blocks/<teamspaceId>`, so `parentOf` returns
|
|
167
|
+
* `unresolvable` for it while a real page ALWAYS resolves a parent (a top-level page resolves
|
|
168
|
+
* `workspace`). A root that comes back unresolvable is a root that can never grant anything, and the
|
|
169
|
+
* operator has to hear about it.
|
|
170
|
+
*
|
|
171
|
+
* 🔴 IT REPORTS, IT NEVER WITHDRAWS A ROOT — and that is not timidity. An integration is shared on a
|
|
172
|
+
* page and inherits DOWNWARD, so a root declared one level ABOVE the shared page is unreadable to
|
|
173
|
+
* this credential and STILL matches: the walk reads the child's own `parent.page_id` and compares
|
|
174
|
+
* IDS, with no retrieval of the root. Withdrawing such a root would break a working config over a
|
|
175
|
+
* permission the walk does not need.
|
|
176
|
+
*
|
|
177
|
+
* FAIL-SOFT BY CONSTRUCTION, not by a `catch`: every lookup goes through {@link cachedParentOf},
|
|
178
|
+
* which turns a throw into `unresolvable`. So a probe can never throw at its caller.
|
|
179
|
+
*
|
|
180
|
+
* ⚠️ A TRANSIENT NOTION FAILURE THEREFORE READS AS UNREACHABLE, once. The memo is written whatever
|
|
181
|
+
* the probe found, so an outage during the first probe costs ONE wrong warning for the life of the
|
|
182
|
+
* process and never touches a verdict. That is the deliberate trade: re-asking on every delivery
|
|
183
|
+
* would spend an API call per delivery to sharpen a log line.
|
|
184
|
+
*/
|
|
185
|
+
export declare function probeUnreachableNotionTrustRoots(workspaceId: string, roots: NotionTrustRoots, deps: NotionAncestryDeps): Promise<readonly string[]>;
|
|
186
|
+
/** The per-delivery answer: the ancestry verdict, plus any declared root this credential's probe
|
|
187
|
+
* could not resolve. Both fields are AUDIT input — the route writes them onto the dispatch row so
|
|
188
|
+
* a refusal says WHICH refusal happened, and a dead root says which root is dead. */
|
|
189
|
+
export interface NotionDeliveryTrust extends NotionAncestryVerdict {
|
|
190
|
+
/** Declared roots for this workspace that Notion serves as neither a page nor a block. Present
|
|
191
|
+
* only when the probe ran and found some — see {@link probeUnreachableNotionTrustRoots}. */
|
|
192
|
+
readonly unreachableRoots?: readonly string[];
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* THE INGESTION SEAM. Resolve one ingested page's trust verdict, and diagnose a root that never
|
|
196
|
+
* matches.
|
|
197
|
+
*
|
|
198
|
+
* 🔴 A DEPLOYMENT THAT DECLARES NO ROOT PAYS NOTHING. No roots for this workspace ⇒ `no-roots`,
|
|
199
|
+
* zero API calls, zero probes — byte-identical to the behaviour before this seam existed. That is
|
|
200
|
+
* what makes the feature opt-in rather than a cost every Notion deployment carries.
|
|
201
|
+
*
|
|
202
|
+
* The probe runs ONLY on a walk that both ran and failed to match (`no-roots` and `self` never reach
|
|
203
|
+
* it), so the diagnosis costs nothing on the trusted path and nothing on the un-configured path. It
|
|
204
|
+
* cannot move the verdict either — it only adds `unreachableRoots`.
|
|
205
|
+
*/
|
|
206
|
+
export declare function resolveNotionDeliveryTrust(pageId: string, workspaceId: string, roots: NotionTrustRoots | undefined, deps: NotionAncestryDeps): Promise<NotionDeliveryTrust>;
|
|
207
|
+
export {};
|
|
208
|
+
//# sourceMappingURL=notion-ancestry-trust.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notion-ancestry-trust.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-ancestry-trust.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// The NOTION ANCESTOR WALK for trust (OPE-491) — "is this page underneath a TRUST ROOT the operator
|
|
2
|
+
// declared?", answered in the VENDOR ADAPTER.
|
|
3
|
+
//
|
|
4
|
+
// WHERE IT IS CALLED (OPE-492). ONE call site: `handleNotionDelivery` (channels/notion-webhook.ts)
|
|
5
|
+
// resolves {@link resolveNotionDeliveryTrust} once per INGESTED EVENT, after the classifier drops a
|
|
6
|
+
// self/bot comment and before it builds the auth. The verdict rides the auth as the `trust_root`
|
|
7
|
+
// attribute, and `bindNotion` (channels/notion-identity.ts) turns that attribute into the mapper's
|
|
8
|
+
// `internal` verdict. So the walk runs once per page per delivery, never per tool call — the layer
|
|
9
|
+
// above only ever sees an already-resolved answer, which is the whole reason the walk is allowed to
|
|
10
|
+
// live here (see below).
|
|
11
|
+
//
|
|
12
|
+
// 🔴 WHERE A ROOT IS DECLARED (OPE-539). A root is ONE LINE of the shared trust registry that carries
|
|
13
|
+
// the word `subtree`:
|
|
14
|
+
//
|
|
15
|
+
// defineSurface({ key: "notion/<workspaceId>/<pageId>", trust: "internal", subtree: true })
|
|
16
|
+
//
|
|
17
|
+
// An entry WITHOUT `subtree` vouches THAT ONE PAGE and nothing under it — the widening is opt-in on
|
|
18
|
+
// the line, so no Notion entry an operator already wrote changed meaning when the walk shipped. This
|
|
19
|
+
// bundle declares that Notion honours the word (`registerSubtreePlatform` in agent/register.ts) and
|
|
20
|
+
// receives the declared keys through {@link registerNotionTrustRoots}. There is no second list and no
|
|
21
|
+
// app file that imports this package: core carries the claim, this module resolves it.
|
|
22
|
+
//
|
|
23
|
+
// Format: `notion/<workspaceId>/<pageId>` — the same key shape the trust register and the `reach`
|
|
24
|
+
// rosters use. Hyphenated or bare ids both work; they are canonicalised.
|
|
25
|
+
//
|
|
26
|
+
// WHY IT LIVES HERE AND NOT IN CORE. A Notion surface key is `notion/<workspace>/<page>` and it
|
|
27
|
+
// carries NO hierarchy: a page's parent is on the page OBJECT, so a subtree question costs an API
|
|
28
|
+
// call per level. Core's `resolveDomainForChannel` is PURE and SYNCHRONOUS on purpose — a domain is
|
|
29
|
+
// an authorization input, and "making it depend on a fetch would make EffectiveAccess time-varying —
|
|
30
|
+
// that is a change of authorization semantics, not a refactor". The same doctrine names the way out:
|
|
31
|
+
// "any hierarchy that needs a lookup belongs in the vendor adapter, which hands this layer keys it
|
|
32
|
+
// has already resolved" (memory/domain.ts). This module is that adapter-side lookup. It hands back a
|
|
33
|
+
// verdict about ONE page, so the layer above still only ever sees an already-resolved answer.
|
|
34
|
+
//
|
|
35
|
+
// THE SHIPPED PRECEDENT for the async resolve-then-fence hop is `assertNotionPageAllowedOrResolveBlock`
|
|
36
|
+
// in notion-scope.ts: try the cheap pure decision first, and only on a miss pay for the walk. The
|
|
37
|
+
// same shape is used here — a page that IS a root needs no I/O at all.
|
|
38
|
+
//
|
|
39
|
+
// 🔴 FAIL CLOSED, EVERY EXIT. Unresolvable ancestry is NOT trusted. A lookup that throws, a chain
|
|
40
|
+
// that runs out of hops, a chain that loops, a parent Notion will not name — all of them return
|
|
41
|
+
// `trusted: false`. "We could not walk it" must never read as "we walked it and it was internal".
|
|
42
|
+
//
|
|
43
|
+
// 🔴 THE WALK STOPS AT A DATABASE. A database ROW is a separate page whose parent is the database,
|
|
44
|
+
// and a row commonly holds customer-submitted content. So a root declared above a database does NOT
|
|
45
|
+
// reach that database's rows: the walk returns `database-boundary` and the row is untrusted. The
|
|
46
|
+
// matching CONFIG-TIME refusal is in {@link prepareNotionTrustRoots}, by name, so an operator who
|
|
47
|
+
// tries to declare row-grain trust reads why instead of assuming it worked.
|
|
48
|
+
//
|
|
49
|
+
// 🔴 THE CACHE IS THE STALENESS. Ancestry is cached per page id for {@link NOTION_ANCESTRY_TTL_MS},
|
|
50
|
+
// so a page MOVED between trees keeps its old ancestry until the entry expires — in whichever
|
|
51
|
+
// direction the move went. That is a known and accepted cost of this route, and it is why the TTL is
|
|
52
|
+
// short. Only a RESOLVED edge is ever cached; an unresolvable one is never written, so a transient
|
|
53
|
+
// API failure can never stick as a lasting answer.
|
|
54
|
+
import { canonicalNotionId } from "./notion-scope.js";
|
|
55
|
+
/** How far up the walk will climb before giving up. A real page tree is a handful of levels deep;
|
|
56
|
+
* an unbounded walk over attacker-chosen ids is its own problem. Exhausting it is a REFUSAL. */
|
|
57
|
+
const NOTION_ANCESTRY_MAX_HOPS = 8;
|
|
58
|
+
/** How long ONE resolved parent edge stays cached. Short on purpose: a moved page keeps its old
|
|
59
|
+
* ancestry for at most this long. */
|
|
60
|
+
export const NOTION_ANCESTRY_TTL_MS = 60_000;
|
|
61
|
+
/** A Notion id: 32 hex digits, hyphenated or bare. The `<page>` segment must look like one — this
|
|
62
|
+
* is what stops a human-readable label from being accepted as a page and silently never matching. */
|
|
63
|
+
const NOTION_ID = /^[0-9a-f]{32}$/;
|
|
64
|
+
/** Thrown by {@link prepareNotionTrustRoots}. Its own class so a boot handler can tell a trust-config
|
|
65
|
+
* mistake from any other startup error. */
|
|
66
|
+
export class NotionTrustConfigError extends Error {
|
|
67
|
+
constructor(message) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "NotionTrustConfigError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Validate and prepare the declared trust roots, FAIL-LOUD.
|
|
74
|
+
*
|
|
75
|
+
* An entry is a THREE-SEGMENT Notion surface key, `notion/<workspaceId>/<pageId>` — the same key
|
|
76
|
+
* shape the trust register and a domain's exact `reach` entries already use. It means "this page and every page
|
|
77
|
+
* underneath it, except across a database boundary".
|
|
78
|
+
*
|
|
79
|
+
* TWO GRAINS ARE REFUSED BY NAME, each with the reason in the message. A named refusal an operator
|
|
80
|
+
* reads beats a silent gap they assume is covered — the same discipline `memory/domain.ts` applies to
|
|
81
|
+
* an intermediate container `reach` entry. `defineSurface` refuses both grains first, in vendor-free
|
|
82
|
+
* words; these messages stay because they name the NOTION reason (a database row, a walk that would
|
|
83
|
+
* buy nothing), and because this function is also the direct entry point a test drives.
|
|
84
|
+
*
|
|
85
|
+
* 1. DATABASE ROWS (`.../<dbId>/rows`, a fourth segment, or a trailing glob). A database row is a
|
|
86
|
+
* SEPARATE page whose relation to the database is exactly the ancestry a key does not carry, and
|
|
87
|
+
* rows commonly hold customer-submitted content. A declaration on a database page reaches the
|
|
88
|
+
* DATABASE PAGE ONLY.
|
|
89
|
+
* 2. A WORKSPACE key (`notion/<ws>`). A whole workspace needs no walk — vouch it as a workspace
|
|
90
|
+
* surface instead. Accepting it here would run an API-call-per-level walk to reach an answer the
|
|
91
|
+
* registry already holds for free.
|
|
92
|
+
*
|
|
93
|
+
* 🔴 A TEAMSPACE ID CANNOT BE REFUSED HERE, and pretending otherwise was worse than not trying. A
|
|
94
|
+
* Notion teamspace id is a 32-hex uuid — the SAME shape as a page id — so no test on the string can
|
|
95
|
+
* tell the two apart. A teamspace never appears as a `parent` on any Notion object either, so a
|
|
96
|
+
* teamspace root matches nothing the walk ever sees: it is accepted, and then silently grants
|
|
97
|
+
* nothing. Config time cannot close that; {@link probeUnreachableNotionTrustRoots} closes it at RUN
|
|
98
|
+
* time by asking Notion what the root is, and {@link resolveNotionDeliveryTrust} runs that probe on
|
|
99
|
+
* the first walk that fails to match.
|
|
100
|
+
*/
|
|
101
|
+
export function prepareNotionTrustRoots(entries) {
|
|
102
|
+
const byWorkspace = new Map();
|
|
103
|
+
for (const raw of entries) {
|
|
104
|
+
const entry = typeof raw === "string" ? raw.trim() : "";
|
|
105
|
+
const segs = entry.split("/");
|
|
106
|
+
if (segs[0] !== "notion") {
|
|
107
|
+
throw new NotionTrustConfigError(`notion trust root ${JSON.stringify(entry)} is not a Notion surface key — expected "notion/<workspaceId>/<pageId>".`);
|
|
108
|
+
}
|
|
109
|
+
if (segs.length === 2) {
|
|
110
|
+
throw new NotionTrustConfigError(`notion trust root ${JSON.stringify(entry)} names a WHOLE WORKSPACE, which is not a trust root — a workspace needs no ancestor walk, so vouch the workspace key directly in the surface register instead of paying an API call per level to reach the same answer.`);
|
|
111
|
+
}
|
|
112
|
+
if (segs.length < 3) {
|
|
113
|
+
throw new NotionTrustConfigError(`notion trust root ${JSON.stringify(entry)} is not a Notion surface key — expected "notion/<workspaceId>/<pageId>".`);
|
|
114
|
+
}
|
|
115
|
+
if (segs.length > 3) {
|
|
116
|
+
throw new NotionTrustConfigError(`notion trust root ${JSON.stringify(entry)} is DEEPER than a page, which is refused BY NAME: the only thing under a Notion page that a deeper path could name is a DATABASE ROW, and a row is a separate page holding commonly customer-submitted content. A declaration on a database page reaches the DATABASE PAGE ITSELF and never its rows. Declare "notion/<workspaceId>/<pageId>" for the page, and list any row you genuinely trust as its own entry.`);
|
|
117
|
+
}
|
|
118
|
+
const page = segs[2] ?? "";
|
|
119
|
+
if (page.includes("*")) {
|
|
120
|
+
throw new NotionTrustConfigError(`notion trust root ${JSON.stringify(entry)} uses a GLOB, which cannot match anything: a Notion page id is an opaque 32-hex uuid, so a prefix carries no meaning. Name the top page; the ancestor walk is what reaches the pages under it.`);
|
|
121
|
+
}
|
|
122
|
+
const workspace = segs[1] ?? "";
|
|
123
|
+
if (!workspace) {
|
|
124
|
+
throw new NotionTrustConfigError(`notion trust root ${JSON.stringify(entry)} has an EMPTY workspace segment — expected "notion/<workspaceId>/<pageId>".`);
|
|
125
|
+
}
|
|
126
|
+
const canonical = canonicalNotionId(page);
|
|
127
|
+
if (!NOTION_ID.test(canonical)) {
|
|
128
|
+
throw new NotionTrustConfigError(`notion trust root ${JSON.stringify(entry)} does not name a page — the last segment must be a Notion page id (32 hex digits, hyphenated or bare), not a title or a label.`);
|
|
129
|
+
}
|
|
130
|
+
const set = byWorkspace.get(workspace) ?? new Set();
|
|
131
|
+
set.add(canonical);
|
|
132
|
+
byWorkspace.set(workspace, set);
|
|
133
|
+
}
|
|
134
|
+
return { byWorkspace };
|
|
135
|
+
}
|
|
136
|
+
/** No workspace, no root — the value every read gets before the app registers, and the value a
|
|
137
|
+
* deployment that declares nothing keeps for ever. Fail-closed: nothing is a trust root. */
|
|
138
|
+
const NO_TRUST_ROOTS = { byWorkspace: new Map() };
|
|
139
|
+
let registeredRoots = NO_TRUST_ROOTS;
|
|
140
|
+
/**
|
|
141
|
+
* THE SUBTREE HANDLER (OPE-539) — core hands this every `subtree: true` surface key an operator
|
|
142
|
+
* declared for Notion, at boot and again on any later `registerSurfaces`. A call REPLACES the
|
|
143
|
+
* previous register, the same convention `registerSurfaces` itself uses.
|
|
144
|
+
*
|
|
145
|
+
* It is wired ONCE, by `registerSubtreePlatform("notion", registerNotionTrustRoots)` in
|
|
146
|
+
* agent/register.ts. Nothing else calls it in production; a test may call it directly to stand up a
|
|
147
|
+
* register without composing core.
|
|
148
|
+
*
|
|
149
|
+
* Validation is {@link prepareNotionTrustRoots}, so a key core accepted but Notion cannot use — a
|
|
150
|
+
* last segment that is not a page id — is a BOOT error the operator reads. Core refuses the grain
|
|
151
|
+
* (a tenant key, a glob, a group rung, a `public` subtree); this refuses the vendor's own rules.
|
|
152
|
+
*
|
|
153
|
+
* Fail-CLOSED before the call and after an empty one: {@link notionTrustRoots} returns an empty
|
|
154
|
+
* register, and an empty register makes every page's verdict `no-roots` with no I/O at all.
|
|
155
|
+
*/
|
|
156
|
+
export function registerNotionTrustRoots(entries) {
|
|
157
|
+
registeredRoots = prepareNotionTrustRoots(entries);
|
|
158
|
+
}
|
|
159
|
+
/** The registered roots — the value the Notion route hands the webhook core. Empty until the app
|
|
160
|
+
* registers, which is the fail-closed default. */
|
|
161
|
+
export function notionTrustRoots() {
|
|
162
|
+
return registeredRoots;
|
|
163
|
+
}
|
|
164
|
+
/** Test seam: drop the register (a fresh test must not inherit another's declarations). */
|
|
165
|
+
export function _resetNotionTrustRootsForTest() {
|
|
166
|
+
registeredRoots = NO_TRUST_ROOTS;
|
|
167
|
+
}
|
|
168
|
+
/** The process-wide cache the ingestion path uses when the caller passes none. */
|
|
169
|
+
const notionAncestryCache = { entries: new Map() };
|
|
170
|
+
/** Test seam, and the operator escape hatch after a big page move: drop every cached edge. */
|
|
171
|
+
export function clearNotionAncestryCache(cache = notionAncestryCache) {
|
|
172
|
+
cache.entries.clear();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* One cached parent lookup.
|
|
176
|
+
*
|
|
177
|
+
* 🔴 ONLY A RESOLVED EDGE IS CACHED. An `unresolvable` answer — including a thrown lookup — is
|
|
178
|
+
* returned but never written, for two reasons that both matter: a transient Notion failure must not
|
|
179
|
+
* pin a page as untrusted for the whole TTL, and an entry that says "unknown" is exactly the entry a
|
|
180
|
+
* later reader could mistake for a decided one. The verdict for an unresolvable edge is untrusted
|
|
181
|
+
* either way, so nothing is gained by remembering it.
|
|
182
|
+
*/
|
|
183
|
+
async function cachedParentOf(id, deps) {
|
|
184
|
+
const cache = deps.cache ?? notionAncestryCache;
|
|
185
|
+
const now = deps.now ?? Date.now;
|
|
186
|
+
const ttl = deps.ttlMs ?? NOTION_ANCESTRY_TTL_MS;
|
|
187
|
+
const key = canonicalNotionId(id);
|
|
188
|
+
const hit = cache.entries.get(key);
|
|
189
|
+
if (hit) {
|
|
190
|
+
if (hit.expiresAt > now())
|
|
191
|
+
return hit.link;
|
|
192
|
+
cache.entries.delete(key);
|
|
193
|
+
}
|
|
194
|
+
let link;
|
|
195
|
+
try {
|
|
196
|
+
link = await deps.parentOf(id);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return { kind: "unresolvable" };
|
|
200
|
+
}
|
|
201
|
+
if (!link || link.kind === "unresolvable")
|
|
202
|
+
return { kind: "unresolvable" };
|
|
203
|
+
cache.entries.set(key, { link, expiresAt: now() + ttl });
|
|
204
|
+
return link;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Is `pageId` a declared trust root, or underneath one, in `workspaceId`?
|
|
208
|
+
*
|
|
209
|
+
* The cheap decision first: if the page IS a root, the verdict is `self` and NO I/O happens at all —
|
|
210
|
+
* the same "try the pure fence before paying for the hop" order `resolveNotionReadTarget` uses. Only
|
|
211
|
+
* a miss walks, one `parentOf` call per level, bounded by `maxHops`.
|
|
212
|
+
*
|
|
213
|
+
* Every non-trusted exit is explicit, and each is a REFUSAL rather than a fallthrough: the workspace
|
|
214
|
+
* root, a database boundary, the hop limit, a cycle, and an unresolvable link. There is no branch
|
|
215
|
+
* that returns `trusted: true` without a canonical id equal to a declared trust root.
|
|
216
|
+
*
|
|
217
|
+
* A BLOCK parent is walked THROUGH, not matched: a root is a page, and a page nested in a column or
|
|
218
|
+
* a toggle reports the enclosing block as its parent. Walking through it is what makes the subtree
|
|
219
|
+
* answer correct for a nested page; matching a block id against the roots would be matching a
|
|
220
|
+
* different kind of id.
|
|
221
|
+
*/
|
|
222
|
+
export async function resolveNotionAncestorTrust(pageId, workspaceId, roots, deps) {
|
|
223
|
+
const declared = roots.byWorkspace.get(workspaceId);
|
|
224
|
+
if (!declared || declared.size === 0)
|
|
225
|
+
return { trusted: false, reason: "no-roots", hops: 0 };
|
|
226
|
+
const start = canonicalNotionId(pageId);
|
|
227
|
+
if (declared.has(start))
|
|
228
|
+
return { trusted: true, reason: "self", matchedRoot: start, hops: 0 };
|
|
229
|
+
const maxHops = deps.maxHops ?? NOTION_ANCESTRY_MAX_HOPS;
|
|
230
|
+
const seen = new Set([start]);
|
|
231
|
+
let current = pageId;
|
|
232
|
+
for (let hop = 1; hop <= maxHops; hop++) {
|
|
233
|
+
const link = await cachedParentOf(current, deps);
|
|
234
|
+
if (link.kind === "unresolvable")
|
|
235
|
+
return { trusted: false, reason: "unresolvable", hops: hop };
|
|
236
|
+
if (link.kind === "workspace")
|
|
237
|
+
return { trusted: false, reason: "workspace-root", hops: hop };
|
|
238
|
+
if (link.kind === "database")
|
|
239
|
+
return { trusted: false, reason: "database-boundary", hops: hop };
|
|
240
|
+
const next = link.kind === "page" ? link.pageId : link.blockId;
|
|
241
|
+
const canonical = canonicalNotionId(next);
|
|
242
|
+
// An EMPTY parent id is not a loop, it is Notion declining to say — keep the two reasons apart
|
|
243
|
+
// so the audited reason stays true. Both are refusals; only the wording differs.
|
|
244
|
+
if (!canonical)
|
|
245
|
+
return { trusted: false, reason: "unresolvable", hops: hop };
|
|
246
|
+
if (seen.has(canonical))
|
|
247
|
+
return { trusted: false, reason: "cycle", hops: hop };
|
|
248
|
+
seen.add(canonical);
|
|
249
|
+
if (link.kind === "page" && declared.has(canonical)) {
|
|
250
|
+
return { trusted: true, reason: "ancestor", matchedRoot: canonical, hops: hop };
|
|
251
|
+
}
|
|
252
|
+
current = next;
|
|
253
|
+
}
|
|
254
|
+
return { trusted: false, reason: "hop-limit", hops: maxHops };
|
|
255
|
+
}
|
|
256
|
+
// --- the ingestion seam (OPE-492) -------------------------------------------
|
|
257
|
+
//
|
|
258
|
+
// One function the webhook core calls per ingested event: resolve the page's verdict, and — only
|
|
259
|
+
// when a declared root failed to match — spend one probe telling the operator that a root of theirs
|
|
260
|
+
// is not a page Notion will serve.
|
|
261
|
+
/** Ids already probed in this process, keyed `<workspaceId>/<canonical root>`. A root is probed AT
|
|
262
|
+
* MOST ONCE per process, whatever the probe found: the finding is a WARNING, so re-asking on every
|
|
263
|
+
* delivery would buy nothing and cost an API call each time. A process restart re-probes. */
|
|
264
|
+
const probedRoots = new Set();
|
|
265
|
+
/** Test seam: forget which roots were probed. */
|
|
266
|
+
export function _resetNotionTrustRootProbesForTest() {
|
|
267
|
+
probedRoots.clear();
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Which declared roots for this workspace does Notion NOT serve as a page or a block?
|
|
271
|
+
*
|
|
272
|
+
* 🔴 THIS IS THE TEAMSPACE DETECTOR, and it is the only one that can work. A teamspace id is a
|
|
273
|
+
* 32-hex uuid, so {@link prepareNotionTrustRoots} cannot refuse it on its shape — but Notion answers
|
|
274
|
+
* neither `GET /v1/pages/<teamspaceId>` nor `GET /v1/blocks/<teamspaceId>`, so `parentOf` returns
|
|
275
|
+
* `unresolvable` for it while a real page ALWAYS resolves a parent (a top-level page resolves
|
|
276
|
+
* `workspace`). A root that comes back unresolvable is a root that can never grant anything, and the
|
|
277
|
+
* operator has to hear about it.
|
|
278
|
+
*
|
|
279
|
+
* 🔴 IT REPORTS, IT NEVER WITHDRAWS A ROOT — and that is not timidity. An integration is shared on a
|
|
280
|
+
* page and inherits DOWNWARD, so a root declared one level ABOVE the shared page is unreadable to
|
|
281
|
+
* this credential and STILL matches: the walk reads the child's own `parent.page_id` and compares
|
|
282
|
+
* IDS, with no retrieval of the root. Withdrawing such a root would break a working config over a
|
|
283
|
+
* permission the walk does not need.
|
|
284
|
+
*
|
|
285
|
+
* FAIL-SOFT BY CONSTRUCTION, not by a `catch`: every lookup goes through {@link cachedParentOf},
|
|
286
|
+
* which turns a throw into `unresolvable`. So a probe can never throw at its caller.
|
|
287
|
+
*
|
|
288
|
+
* ⚠️ A TRANSIENT NOTION FAILURE THEREFORE READS AS UNREACHABLE, once. The memo is written whatever
|
|
289
|
+
* the probe found, so an outage during the first probe costs ONE wrong warning for the life of the
|
|
290
|
+
* process and never touches a verdict. That is the deliberate trade: re-asking on every delivery
|
|
291
|
+
* would spend an API call per delivery to sharpen a log line.
|
|
292
|
+
*/
|
|
293
|
+
export async function probeUnreachableNotionTrustRoots(workspaceId, roots, deps) {
|
|
294
|
+
const declared = roots.byWorkspace.get(workspaceId);
|
|
295
|
+
if (!declared || declared.size === 0)
|
|
296
|
+
return [];
|
|
297
|
+
const unreachable = [];
|
|
298
|
+
for (const root of declared) {
|
|
299
|
+
const memo = `${workspaceId}/${root}`;
|
|
300
|
+
if (probedRoots.has(memo))
|
|
301
|
+
continue;
|
|
302
|
+
probedRoots.add(memo);
|
|
303
|
+
const link = await cachedParentOf(root, deps);
|
|
304
|
+
if (link.kind === "unresolvable")
|
|
305
|
+
unreachable.push(root);
|
|
306
|
+
}
|
|
307
|
+
return unreachable;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* THE INGESTION SEAM. Resolve one ingested page's trust verdict, and diagnose a root that never
|
|
311
|
+
* matches.
|
|
312
|
+
*
|
|
313
|
+
* 🔴 A DEPLOYMENT THAT DECLARES NO ROOT PAYS NOTHING. No roots for this workspace ⇒ `no-roots`,
|
|
314
|
+
* zero API calls, zero probes — byte-identical to the behaviour before this seam existed. That is
|
|
315
|
+
* what makes the feature opt-in rather than a cost every Notion deployment carries.
|
|
316
|
+
*
|
|
317
|
+
* The probe runs ONLY on a walk that both ran and failed to match (`no-roots` and `self` never reach
|
|
318
|
+
* it), so the diagnosis costs nothing on the trusted path and nothing on the un-configured path. It
|
|
319
|
+
* cannot move the verdict either — it only adds `unreachableRoots`.
|
|
320
|
+
*/
|
|
321
|
+
export async function resolveNotionDeliveryTrust(pageId, workspaceId, roots, deps) {
|
|
322
|
+
const declared = roots ?? NO_TRUST_ROOTS;
|
|
323
|
+
const verdict = await resolveNotionAncestorTrust(pageId, workspaceId, declared, deps);
|
|
324
|
+
if (verdict.trusted || verdict.reason === "no-roots")
|
|
325
|
+
return verdict;
|
|
326
|
+
// No `try` here on purpose: probeUnreachableNotionTrustRoots cannot throw (see its contract), and a
|
|
327
|
+
// catch that can never run is a guard nobody can test.
|
|
328
|
+
const unreachable = await probeUnreachableNotionTrustRoots(workspaceId, declared, deps);
|
|
329
|
+
return unreachable.length > 0 ? { ...verdict, unreachableRoots: unreachable } : verdict;
|
|
330
|
+
}
|
|
331
|
+
//# sourceMappingURL=notion-ancestry-trust.js.map
|