@open-everywhere/provider-notion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +48 -0
  4. package/agent/channels/notion-acting-identity.ts +88 -0
  5. package/agent/channels/notion-ancestry-trust.ts +458 -0
  6. package/agent/channels/notion-api.ts +1290 -0
  7. package/agent/channels/notion-bot-user.ts +250 -0
  8. package/agent/channels/notion-catch-up.ts +339 -0
  9. package/agent/channels/notion-credentials.ts +417 -0
  10. package/agent/channels/notion-engagement.ts +372 -0
  11. package/agent/channels/notion-event-normalizer.ts +587 -0
  12. package/agent/channels/notion-identity.ts +271 -0
  13. package/agent/channels/notion-ingress-drain.ts +289 -0
  14. package/agent/channels/notion-ingress.ts +182 -0
  15. package/agent/channels/notion-message-ops.ts +140 -0
  16. package/agent/channels/notion-origin-codec.ts +38 -0
  17. package/agent/channels/notion-post-arm.ts +189 -0
  18. package/agent/channels/notion-question-return.ts +223 -0
  19. package/agent/channels/notion-read.ts +283 -0
  20. package/agent/channels/notion-scope.ts +560 -0
  21. package/agent/channels/notion-stop.ts +169 -0
  22. package/agent/channels/notion-supervisor.ts +856 -0
  23. package/agent/channels/notion-webhook-timing.ts +131 -0
  24. package/agent/channels/notion-webhook-verifier.ts +55 -0
  25. package/agent/channels/notion-webhook.ts +963 -0
  26. package/agent/channels/notion-work-search.ts +74 -0
  27. package/agent/channels/notion-work.ts +144 -0
  28. package/agent/channels/notion.ts +1242 -0
  29. package/agent/contributions.ts +39 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/memory/blocks.ts +295 -0
  32. package/agent/memory/memory.ts +622 -0
  33. package/agent/provider-events.ts +198 -0
  34. package/agent/register.ts +144 -0
  35. package/agent/schedules/notion-ingress.ts +20 -0
  36. package/agent/surface-parity.ts +105 -0
  37. package/agent/tools/notion_search.ts +110 -0
  38. package/agent/tools/read_notion_comments.ts +250 -0
  39. package/agent/tools/read_notion_page.ts +351 -0
  40. package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
  41. package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
  42. package/dist/agent/channels/notion-acting-identity.js +85 -0
  43. package/dist/agent/channels/notion-acting-identity.js.map +1 -0
  44. package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
  45. package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
  46. package/dist/agent/channels/notion-ancestry-trust.js +331 -0
  47. package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
  48. package/dist/agent/channels/notion-api.d.ts +655 -0
  49. package/dist/agent/channels/notion-api.d.ts.map +1 -0
  50. package/dist/agent/channels/notion-api.js +938 -0
  51. package/dist/agent/channels/notion-api.js.map +1 -0
  52. package/dist/agent/channels/notion-bot-user.d.ts +107 -0
  53. package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
  54. package/dist/agent/channels/notion-bot-user.js +192 -0
  55. package/dist/agent/channels/notion-bot-user.js.map +1 -0
  56. package/dist/agent/channels/notion-catch-up.d.ts +64 -0
  57. package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
  58. package/dist/agent/channels/notion-catch-up.js +289 -0
  59. package/dist/agent/channels/notion-catch-up.js.map +1 -0
  60. package/dist/agent/channels/notion-credentials.d.ts +210 -0
  61. package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
  62. package/dist/agent/channels/notion-credentials.js +360 -0
  63. package/dist/agent/channels/notion-credentials.js.map +1 -0
  64. package/dist/agent/channels/notion-engagement.d.ts +219 -0
  65. package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
  66. package/dist/agent/channels/notion-engagement.js +283 -0
  67. package/dist/agent/channels/notion-engagement.js.map +1 -0
  68. package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
  69. package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
  70. package/dist/agent/channels/notion-event-normalizer.js +542 -0
  71. package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
  72. package/dist/agent/channels/notion-identity.d.ts +119 -0
  73. package/dist/agent/channels/notion-identity.d.ts.map +1 -0
  74. package/dist/agent/channels/notion-identity.js +205 -0
  75. package/dist/agent/channels/notion-identity.js.map +1 -0
  76. package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
  77. package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
  78. package/dist/agent/channels/notion-ingress-drain.js +185 -0
  79. package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
  80. package/dist/agent/channels/notion-ingress.d.ts +64 -0
  81. package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
  82. package/dist/agent/channels/notion-ingress.js +130 -0
  83. package/dist/agent/channels/notion-ingress.js.map +1 -0
  84. package/dist/agent/channels/notion-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/notion-message-ops.js +126 -0
  87. package/dist/agent/channels/notion-message-ops.js.map +1 -0
  88. package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
  89. package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/notion-origin-codec.js +32 -0
  91. package/dist/agent/channels/notion-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/notion-post-arm.d.ts +3 -0
  93. package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/notion-post-arm.js +185 -0
  95. package/dist/agent/channels/notion-post-arm.js.map +1 -0
  96. package/dist/agent/channels/notion-question-return.d.ts +77 -0
  97. package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
  98. package/dist/agent/channels/notion-question-return.js +183 -0
  99. package/dist/agent/channels/notion-question-return.js.map +1 -0
  100. package/dist/agent/channels/notion-read.d.ts +62 -0
  101. package/dist/agent/channels/notion-read.d.ts.map +1 -0
  102. package/dist/agent/channels/notion-read.js +257 -0
  103. package/dist/agent/channels/notion-read.js.map +1 -0
  104. package/dist/agent/channels/notion-scope.d.ts +188 -0
  105. package/dist/agent/channels/notion-scope.d.ts.map +1 -0
  106. package/dist/agent/channels/notion-scope.js +433 -0
  107. package/dist/agent/channels/notion-scope.js.map +1 -0
  108. package/dist/agent/channels/notion-stop.d.ts +48 -0
  109. package/dist/agent/channels/notion-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/notion-stop.js +141 -0
  111. package/dist/agent/channels/notion-stop.js.map +1 -0
  112. package/dist/agent/channels/notion-supervisor.d.ts +462 -0
  113. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/notion-supervisor.js +482 -0
  115. package/dist/agent/channels/notion-supervisor.js.map +1 -0
  116. package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
  117. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
  118. package/dist/agent/channels/notion-webhook-timing.js +99 -0
  119. package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
  120. package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
  121. package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
  122. package/dist/agent/channels/notion-webhook-verifier.js +53 -0
  123. package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
  124. package/dist/agent/channels/notion-webhook.d.ts +242 -0
  125. package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
  126. package/dist/agent/channels/notion-webhook.js +675 -0
  127. package/dist/agent/channels/notion-webhook.js.map +1 -0
  128. package/dist/agent/channels/notion-work-search.d.ts +9 -0
  129. package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
  130. package/dist/agent/channels/notion-work-search.js +69 -0
  131. package/dist/agent/channels/notion-work-search.js.map +1 -0
  132. package/dist/agent/channels/notion-work.d.ts +68 -0
  133. package/dist/agent/channels/notion-work.d.ts.map +1 -0
  134. package/dist/agent/channels/notion-work.js +126 -0
  135. package/dist/agent/channels/notion-work.js.map +1 -0
  136. package/dist/agent/channels/notion.d.ts +95 -0
  137. package/dist/agent/channels/notion.d.ts.map +1 -0
  138. package/dist/agent/channels/notion.js +1081 -0
  139. package/dist/agent/channels/notion.js.map +1 -0
  140. package/dist/agent/contributions.d.ts +32 -0
  141. package/dist/agent/contributions.d.ts.map +1 -0
  142. package/dist/agent/contributions.js +37 -0
  143. package/dist/agent/contributions.js.map +1 -0
  144. package/dist/agent/docs-generated.d.ts +3 -0
  145. package/dist/agent/docs-generated.d.ts.map +1 -0
  146. package/dist/agent/docs-generated.js +13 -0
  147. package/dist/agent/docs-generated.js.map +1 -0
  148. package/dist/agent/memory/blocks.d.ts +58 -0
  149. package/dist/agent/memory/blocks.d.ts.map +1 -0
  150. package/dist/agent/memory/blocks.js +264 -0
  151. package/dist/agent/memory/blocks.js.map +1 -0
  152. package/dist/agent/memory/memory.d.ts +157 -0
  153. package/dist/agent/memory/memory.d.ts.map +1 -0
  154. package/dist/agent/memory/memory.js +471 -0
  155. package/dist/agent/memory/memory.js.map +1 -0
  156. package/dist/agent/provider-events.d.ts +3 -0
  157. package/dist/agent/provider-events.d.ts.map +1 -0
  158. package/dist/agent/provider-events.js +196 -0
  159. package/dist/agent/provider-events.js.map +1 -0
  160. package/dist/agent/register.d.ts +5 -0
  161. package/dist/agent/register.d.ts.map +1 -0
  162. package/dist/agent/register.js +128 -0
  163. package/dist/agent/register.js.map +1 -0
  164. package/dist/agent/schedules/notion-ingress.d.ts +6 -0
  165. package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
  166. package/dist/agent/schedules/notion-ingress.js +16 -0
  167. package/dist/agent/schedules/notion-ingress.js.map +1 -0
  168. package/dist/agent/surface-parity.d.ts +3 -0
  169. package/dist/agent/surface-parity.d.ts.map +1 -0
  170. package/dist/agent/surface-parity.js +99 -0
  171. package/dist/agent/surface-parity.js.map +1 -0
  172. package/dist/agent/tools/notion_search.d.ts +56 -0
  173. package/dist/agent/tools/notion_search.d.ts.map +1 -0
  174. package/dist/agent/tools/notion_search.js +100 -0
  175. package/dist/agent/tools/notion_search.js.map +1 -0
  176. package/dist/agent/tools/read_notion_comments.d.ts +57 -0
  177. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
  178. package/dist/agent/tools/read_notion_comments.js +236 -0
  179. package/dist/agent/tools/read_notion_comments.js.map +1 -0
  180. package/dist/agent/tools/read_notion_page.d.ts +53 -0
  181. package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
  182. package/dist/agent/tools/read_notion_page.js +327 -0
  183. package/dist/agent/tools/read_notion_page.js.map +1 -0
  184. package/package.json +231 -0
  185. package/tsconfig.build.json +22 -0
  186. package/tsconfig.json +15 -0
@@ -0,0 +1,74 @@
1
+ // Notion's WORK-SEARCH adapter (OPE-961) — wraps notion_search.ts's own `searchNotion` call behind
2
+ // the federating `search` tool's registry (@open-everywhere/core `channels/work-search.ts`).
3
+ //
4
+ // 🔴 NO RESULT FILTER AS A BOUNDARY, unconditionally — the ratified rule this bundle's
5
+ // `notion_search.ts` already states: Notion's own integration-share IS the reach, and OE never
6
+ // narrows it as enforcement. That rule governs the UNSCOPED call.
7
+ //
8
+ // `insideDomain: true` IS a real narrowing here, and it is legitimate for the reason OPE-961 exists
9
+ // at all: it is OPT-IN, documented as a convenience, and it can only ever DROP hits, never add one
10
+ // the credential could not already reach. It keeps only the pages this domain names outright —
11
+ // the EXACT `domain.reach` entries of the form `notion/<workspace>/<pageId>` (the same set
12
+ // `read_notion_page`'s fence reads, via `notionPageDomain` — see notion-scope.ts). A shared Notion
13
+ // integration used by several domains therefore lets each ask "just MY declared pages" without
14
+ // touching what the credential itself can see.
15
+ //
16
+ // Registered GATED (`registerWorkSearchAdapter`), mirroring `registerReadAdapter` — Notion is a
17
+ // provider bundle and participates in provider selection like every other bundle seam.
18
+
19
+ import {
20
+ registerWorkSearchAdapter,
21
+ type WorkSearchAdapter,
22
+ type WorkSearchHit,
23
+ type BindableContext,
24
+ type Domain,
25
+ } from "@open-everywhere/core/agent/adapter-api.js";
26
+ import { resolveNotionCredential, searchNotion } from "./notion-api.js";
27
+ import { notionWorkGrant } from "./notion-work.js";
28
+ import { assertNotionActingIdentity } from "./notion-acting-identity.js";
29
+ import { canonicalNotionId } from "./notion-scope.js";
30
+ import { domainRoster } from "@open-everywhere/core/agent/adapter-api.js";
31
+
32
+ /** The canonical page ids this domain names OUTRIGHT — the EXACT `reach` entries shaped
33
+ * `notion/<workspace>/<pageId>`. Any workspace segment counts: an exact entry belongs to exactly
34
+ * one domain (config-load exclusivity), so no workspace cross-check is needed here the way
35
+ * `notionPageDomain`'s CALLER-vs-TARGET comparison needs one. */
36
+ export function domainOwnPageIds(domain: Domain): ReadonlySet<string> {
37
+ const ids = new Set<string>();
38
+ for (const key of domainRoster(domain)) {
39
+ const parts = key.split("/");
40
+ if (parts.length === 3 && parts[0] === "notion" && parts[2].length > 0) ids.add(canonicalNotionId(parts[2]));
41
+ }
42
+ return ids;
43
+ }
44
+
45
+ export const notionWorkSearchAdapter: WorkSearchAdapter = {
46
+ connector: "notion",
47
+ async search(ctx: BindableContext, domain: Domain | undefined, query: string, opts: { limit: number; insideDomain: boolean }): Promise<readonly WorkSearchHit[]> {
48
+ // Notion's grant is a `Domain.credentials` declaration (never domain-free like GitHub's repo
49
+ // grant), so `domain` is undefined here only if the federator's grant-set derivation and this
50
+ // adapter ever disagreed about what grants Notion — a defensive backstop, not a reachable path.
51
+ if (!domain) throw new Error("this domain holds no Notion work grant.");
52
+ const grant = notionWorkGrant(ctx, [domain]);
53
+ if (!grant) throw new Error("this domain holds no Notion work grant.");
54
+ // 🔴 OPE-1525 — THE OWN-ACCOUNT GATE, on the line below's behalf. This is the federated `search`
55
+ // tool's Notion arm and it spends the same `install`-family work key `notion_search` does, so it
56
+ // takes the same gate. See channels/notion-acting-identity.ts.
57
+ await assertNotionActingIdentity(ctx, "notion_work_search");
58
+ const cred = await resolveNotionCredential(grant.credential);
59
+ if (!cred) throw new Error(`no value stored for this domain's Notion work credential "${grant.credential}".`);
60
+ const hits = await searchNotion(query, { ...cred, pageSize: Math.min(Math.max(opts.limit, 1), 100) });
61
+ const ownIds = opts.insideDomain ? domainOwnPageIds(domain) : undefined;
62
+ const scoped = ownIds ? hits.filter((h) => ownIds.has(canonicalNotionId(h.id))) : hits;
63
+ return scoped.slice(0, opts.limit).map((h) => ({
64
+ connector: "notion",
65
+ id: h.id,
66
+ title: h.title || "(untitled)",
67
+ ...(h.url ? { url: h.url } : {}),
68
+ }));
69
+ },
70
+ };
71
+
72
+ export function registerNotionWorkSearch(): void {
73
+ registerWorkSearchAdapter(notionWorkSearchAdapter);
74
+ }
@@ -0,0 +1,144 @@
1
+ // NOTION AS A WORK SURFACE (OPE-913) — the domain-granted half of the provider chat/work split.
2
+ //
3
+ // THE FINDING THIS EXISTS FOR. A soak run asked the agent to open a PR whose content came from a
4
+ // spec page on Notion. The seat delegated; the delegated worker reported that it had no authorized
5
+ // Notion connection and — correctly — refused to invent the spec. The seat had read that same page
6
+ // an hour earlier, from a NOTION wake. Nothing about the page had changed; the WAKE SURFACE had.
7
+ // Every Notion tool in this bundle is fenced to a Notion-origin turn (`assertTurnSurface`) and
8
+ // mounted on `notion ∈ sources`, so a Slack-woken worker in the same domain, holding the same
9
+ // credential, could not read it.
10
+ //
11
+ // THE RULE, Tommy 2026-08-21 (OPE-926 comment, generalized here): **capability derives from the
12
+ // DOMAIN's connectors, not from the wake surface.** A domain that binds a Notion work credential
13
+ // may read Notion from any of its surfaces; a domain that binds none may not, from any of them —
14
+ // including from Notion itself, where the CHAT plane still serves the wake container.
15
+ //
16
+ // THE TWO PLANES, and which credential each runs on:
17
+ //
18
+ // • CHAT plane — `read_notion_page` / `read_notion_comments` on a NOTION-origin turn, plus the
19
+ // reply arm. Credential: `channel-key` (notion-credentials.ts). Bounded to the wake container
20
+ // and its domain; unchanged by this module.
21
+ // • WORK plane — the same two reads from ANY surface in a work-granted domain, plus
22
+ // `notion_search`, the cross-container read. Credential: the domain's declared WORK credential
23
+ // ({@link notionWorkCredential}). Mounted only where {@link notionWorkGrant} resolves.
24
+ //
25
+ // 🔴 THE GRANT IS THE DECLARATION, AND ABSENCE IS A REFUSAL. `hasWorkGrant` (core
26
+ // access/work-grant.ts) reads `Domain.credentials` by the OPPOSITE rule to `domainCredentialId`:
27
+ // no entry for the work role ⇒ no grant ⇒ the work tools do not mount. Do not "fall back to the
28
+ // org default" anywhere in this file; that is the hole the split closes.
29
+
30
+ import {
31
+ callerWorkDomain,
32
+ hasWorkGrant,
33
+ resolveDomainForChannel,
34
+ workGrantCredential,
35
+ type BindableContext,
36
+ type Domain,
37
+ } from "@open-everywhere/core/agent/adapter-api.js";
38
+ import { NOTION_PROVIDER } from "./notion-credentials.js";
39
+ import { notionPageDomain } from "./notion-scope.js";
40
+
41
+ /**
42
+ * The ORG-DEFAULT credential id that plays Notion's WORK role — the role a domain's
43
+ * `Domain.credentials` entry names to hold the work grant.
44
+ *
45
+ * It is `team-key` because that is what the credential catalogue already calls it, in those words:
46
+ * "This is the WORK-SURFACE credential ONLY … The COMMUNICATION-CHANNEL plumbing … is a DISTINCT
47
+ * credential — `channel-key`". The role split predates this module; what was missing was any path
48
+ * that read it as a GRANT.
49
+ */
50
+ export const NOTION_WORK_CREDENTIAL = "team-key";
51
+
52
+ /** A resolved Notion work grant: the domain that holds it and the credential id it runs under. */
53
+ export interface NotionWorkGrant {
54
+ readonly domain: Domain;
55
+ /** The `ConnectorCredential.id` this domain's Notion work calls run under. */
56
+ readonly credential: string;
57
+ }
58
+
59
+ /**
60
+ * The domain a CALLING SESSION belongs to, whatever surface it was woken on.
61
+ *
62
+ * Two resolvers, because a Notion page id is not compared the way every other surface key is:
63
+ * `notionPageDomain` canonicalises the UUID (dashes are cosmetic in Notion ids) and is
64
+ * workspace-blind on its roster leg, while `resolveDomainForChannel` compares whole keys verbatim.
65
+ * Sending a `notion/…` key through the verbatim comparison would miss a roster entry written with
66
+ * the other dash convention — a silent "no domain", i.e. a silent loss of the grant.
67
+ *
68
+ * Pure: `domains` is injected, like every other decision in this bundle.
69
+ */
70
+ export function notionCallerDomain(surfaceKey: string, domains: readonly Domain[]): Domain | undefined {
71
+ const parts = surfaceKey.split("/");
72
+ if (parts[0] === "notion" && parts.length === 3 && parts[2]) {
73
+ return notionPageDomain(parts[1] || undefined, parts[2], domains).domain;
74
+ }
75
+ return resolveDomainForChannel(surfaceKey, domains).domain;
76
+ }
77
+
78
+ /**
79
+ * This session's Notion WORK grant, or `undefined` when its domain holds none.
80
+ *
81
+ * FAIL-SOFT ON AN UNRESOLVABLE CONTEXT, and that is the fail-CLOSED direction here: a synthetic or
82
+ * identity-less ctx has no partition, so it resolves no domain, so it holds no grant, so no work
83
+ * tool mounts. There is no arm in which an unreadable context widens the surface. Core's
84
+ * {@link callerWorkDomain} owns that chain and that fail direction for every provider (OPE-951);
85
+ * Notion passes {@link notionCallerDomain} as the resolver because of the UUID rule above. The guard
86
+ * now covers the RESOLVER too, not only the partition read: a throw out of the roster comparison
87
+ * also resolves to no grant, which is the same direction.
88
+ */
89
+ export function notionWorkGrant(ctx: BindableContext, domains: readonly Domain[]): NotionWorkGrant | undefined {
90
+ const domain = callerWorkDomain(ctx, () => domains, notionCallerDomain);
91
+ return domain ? notionWorkGrantForDomain(domain) : undefined;
92
+ }
93
+
94
+ /**
95
+ * The MOUNT predicate for a Notion work tool — {@link notionWorkGrant} as a boolean, with `domains`
96
+ * as a LAZY thunk and every failure absorbed.
97
+ *
98
+ * Both properties matter at a `session.started` resolver. The thunk is the same dependency-injection
99
+ * shape `sourceScopedMount` uses, so a resolver that runs before the app registers its config does
100
+ * not throw on `getConfig()`. And an absorbed failure resolves FALSE: an unreadable config hides the
101
+ * work tools rather than mounting them, which is the fail-closed direction.
102
+ */
103
+ export function notionWorkMount(ctx: BindableContext, domains: () => readonly Domain[]): boolean {
104
+ try {
105
+ return notionWorkGrant(ctx, domains()) !== undefined;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /** {@link notionWorkGrant} for a domain already in hand — the form {@link notionWorkGrant} and
112
+ * {@link notionWorkCredential} share, and the one place the (connector, role) pair is named. */
113
+ function notionWorkGrantForDomain(domain: Domain): NotionWorkGrant | undefined {
114
+ if (!hasWorkGrant(domain, NOTION_PROVIDER, NOTION_WORK_CREDENTIAL)) return undefined;
115
+ const credential = workGrantCredential(domain, NOTION_PROVIDER, NOTION_WORK_CREDENTIAL);
116
+ return credential ? { domain, credential } : undefined;
117
+ }
118
+
119
+ /** The credential id a work-granted domain's Notion calls run under, or undefined when it holds no
120
+ * grant. Never falls back to the org default — see the header. */
121
+ export function notionWorkCredential(domain: Domain | undefined): string | undefined {
122
+ return domain ? notionWorkGrantForDomain(domain)?.credential : undefined;
123
+ }
124
+
125
+ /**
126
+ * The Notion WORKSPACE ids a domain names for itself — every `notion/<workspace>/…` entry in its
127
+ * roster and its claims, de-duplicated.
128
+ *
129
+ * WHY A WORK READ NEEDS THIS. `notionPageDomain`'s CLAIM leg needs a workspace to build the key it
130
+ * matches; its roster leg does not. A Notion-woken caller supplies its own workspace from its
131
+ * surface key, but a Slack-woken worker has none — so without this, a work-granted domain that
132
+ * expresses its Notion membership as a CONTAINER would resolve nothing cross-surface, and the
133
+ * grant would silently work for pages the config names outright ONLY. Reading the candidates off the CALLER's
134
+ * OWN domain keeps that strictly inside what the domain already declares: it can never introduce a
135
+ * workspace nobody wrote down.
136
+ */
137
+ export function notionWorkspacesOf(domain: Domain): readonly string[] {
138
+ const out = new Set<string>();
139
+ for (const key of domain.reach ?? []) {
140
+ const parts = key.split("/");
141
+ if (parts[0] === "notion" && parts.length >= 2 && parts[1]) out.add(parts[1]);
142
+ }
143
+ return [...out];
144
+ }