@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,289 @@
1
+ // notion-catch-up โ€” Notion's registered SEAT CATCH-UP FETCH (OPE-1726): the seat's own page
2
+ // discussion since its last wake, normalized for core's shared catch-up path
3
+ // (channels/seat-catch-up.ts).
4
+ //
5
+ // WHAT "THE SEAT'S CONTAINER" IS HERE. A Notion seat is bound to a PAGE and every wake arrives scoped
6
+ // to one DISCUSSION on it. The read is the PAGE's comment sweep, narrowed to that discussion here.
7
+ // ๐Ÿ”ด THERE IS NO PER-DISCUSSION ENDPOINT. Notion lists comments by their PAGE/BLOCK anchor, while a
8
+ // discussion id is only the reply/filter identity. For an INLINE trigger, ingress carries the
9
+ // fetched comment's authenticated parent block as `threadAnchor`; this fetch drills into that exact
10
+ // block, then filters by `threadRoot`. A PAGE-level trigger has no block anchor and preserves the
11
+ // full page sweep. Guessing the discussion id as a block id would 404 on every wake.
12
+ //
13
+ // WHY A STANDALONE CALLER, not `notionReadAdapter.readChannel`: that method takes a live
14
+ // {@link BindableContext} and runs the turn-time domain-scope fence over it. A catch-up fires from a
15
+ // webhook route with no turn, and its container is the one the wake itself already admitted. The
16
+ // CREDENTIAL is resolved the SAME way the read adapter resolves it โ€” from the DOMAIN that binds this
17
+ // surface (`notionChannelCredentialForSurface`), never the org default alone โ€” so a deployment whose
18
+ // Notion domain binds its own integration reads the page over that integration, and this opens no
19
+ // second transport: the same endpoint fence and the same 429 backoff.
20
+ //
21
+ // ๐Ÿ”ด THE VENDOR CANNOT FILTER BY TIME: Notion's comments endpoint takes no `since`, so the bound is
22
+ // applied on this side and again by the shared path.
23
+ //
24
+ // ๐Ÿ”ด โ€ฆAND IT SERVES OLDEST-FIRST, WHICH IS WHAT RED `J-ENGAGED-notion` ON SHIP #208 (OPE-1831).
25
+ // THE CAUSE IS THE COMMENT CAP, CHARGED FROM THE OLD END. `readNotionComments` sweeps the PAGE's own
26
+ // comments first and only THEN walks the page's blocks. `maxComments` was charged over the whole
27
+ // history, so on the pinned journey page โ€” which had collected every prior ship's page-level comments
28
+ // โ€” the oldest hundred filled the cap and `take` returned STOP. The sweep ended with `blocksScanned`
29
+ // at ZERO: the block sweep never ran at all. The journey's own comment lives in a BLOCK-anchored
30
+ // discussion on a block the run appends itself (`scripts/e2e/scenarios.ts`: `appendParagraphBlock`
31
+ // then `createBlockDiscussion`), and a page-id comment query never returns a child block's comments
32
+ // (`scripts/e2e/notion-driver.ts` records that, live-confirmed). So the read returned `[]` with no
33
+ // error at all, on a discussion that HAD carried the message.
34
+ // THE FIX IS `sinceMs`, handed to the sweep so the cap counts comments IN the seat's window. The
35
+ // ancient page-level comments are then skipped without charging it, `take` does not stop, and the
36
+ // block sweep runs โ€” which is where the seat's discussion actually is.
37
+ // ๐Ÿ”ด `maxCommentPages` IS A SECOND, SEPARATE BOUND, and it is NOT what fixed ship #208. One call
38
+ // returns an anchor's oldest hundred comments and nothing follows `next_cursor`, so a PAGE-ANCHORED
39
+ // discussion on a busy page is unreachable for the same oldest-first reason. Real, and worth closing
40
+ // while the file is open โ€” but a page follow could never have reached a BLOCK-anchored comment, so do
41
+ // not read it as the ship #208 cause.
42
+ // ๐Ÿ”ด THE 80-BLOCK PAGE-SWEEP BOUND STILL EXISTS FOR PAGE-LEVEL CATCH-UP, AND IT IS NOW SPENT ON THE
43
+ // PAGE'S NEWEST BLOCKS (OPE-1843). It never hid the triggering INLINE discussion, because that path
44
+ // uses the authenticated block drill-in. A PAGE-level seat has no narrower anchor, so it keeps the
45
+ // bounded sweep โ€” but in document order the whole budget went to the oldest material of a page that
46
+ // had outgrown the cap, and Notion pages grow by APPENDING. A discussion on a recently appended
47
+ // block was therefore never scanned, and the read returned zero comments with no error: byte for
48
+ // byte the answer a quiet page gives. `blockScanOrder: "newest-first"` spends the same 80 scans on
49
+ // the last blocks instead, which is where a live conversation on an append-only page is.
50
+ // ๐Ÿ”ด IT MOVES THE BLIND SPOT RATHER THAN CLOSING IT: a comment posted today on an ANCIENT block of
51
+ // an over-cap page is now the unread case. Notion can neither query "blocks with recent comments"
52
+ // nor stamp a block when one is commented on, so no scan order is complete at any cap below the
53
+ // page's own length. `notion-api.ts` `blockScanOrder` carries the full reasoning.
54
+ //
55
+ // An unresolvable credential yields `[]` because no transport was attempted. An attempted read
56
+ // failure propagates to core's shared fail-soft boundary and records `outcome=failed` (OPE-1842).
57
+ // ๐Ÿ”ด THE ROW CANNOT CARRY THE RESIDUAL COVERAGE MISS, AND THAT IS A CONTRACT LIMIT, NOT A CHOICE.
58
+ // `readNotionComments` returns a `truncated` bit, but {@link SeatCatchUpFetch} answers with
59
+ // `NormalizedMessage[]` and nothing else, so a surface has no slot to report partial coverage in
60
+ // and `seat.catch_up` writes `outcome=empty raw=0 kept=0 reason=fetch-returned-none` for an
61
+ // over-cap miss exactly as for a quiet page. Closing that needs the shared fetch contract to carry
62
+ // a coverage bit โ€” core's decision, not this surface's.
63
+ import { readNotionComments, resolveNotionCredential } from "./notion-api.js";
64
+ import { notionChannelCredentialForSurface, notionIntegrations, NOTION_CHANNEL_CREDENTIAL } from "./notion-credentials.js";
65
+ import { resolveNotionBotUser } from "./notion-bot-user.js";
66
+ import { catchUpSender, getConfig } from "@open-everywhere/core/agent/adapter-api.js";
67
+ /** The loaded domain roster, or none. FAIL-SOFT: a config that will not load must not throw out of a
68
+ * read contracted never to throw โ€” it degrades to the org-default credential, which is what a
69
+ * surface bound to no domain resolves anyway. */
70
+ function safeDomains() {
71
+ try {
72
+ return getConfig().loadDomains();
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
78
+ /** One sweep. The catch-up window is "since the seat last woke", and a page that collected more than
79
+ * this in that window is one core's token cap would trim regardless. */
80
+ const CATCH_UP_MAX_COMMENTS = 100;
81
+ /** Blocks the page sweep scans. Matches the read adapter's own bound, so a catch-up never costs more
82
+ * API calls than a `read_channel` on the same page. */
83
+ const CATCH_UP_MAX_BLOCK_SCANS = 80;
84
+ /**
85
+ * Pages of comments the sweep follows PER ANCHOR (`notion-api.ts listBlockComments`).
86
+ * ๐Ÿ”ด ONE PAGE IS THE OLDEST HUNDRED, WHICH IS WHY THIS IS NOT 1 (OPE-1831). Notion serves comments
87
+ * oldest-first, so an anchor that has collected more than a page puts today's comment on the LAST
88
+ * page and the default single call can never reach it. A PAGE-ANCHORED discussion on any long-lived
89
+ * doc then reads as permanently quiet to its seat, with no error to show for it.
90
+ * IT COSTS NOTHING ON A NORMAL ANCHOR: the follow stops the moment Notion reports no more, so an
91
+ * anchor under a hundred comments is still exactly one call. Only an overflowing anchor pays, and
92
+ * five pages is five hundred comments โ€” far past any window a seat's last wake can span.
93
+ *
94
+ * ๐Ÿ”ด THE PER-WAKE CALL BUDGET, STATED BECAUSE THIS CHANGE GREW IT (OPE-1831). One catch-up now costs
95
+ * up to `1 + CATCH_UP_MAX_BLOCK_SCANS` list calls (the page anchor, then one per block), each up to
96
+ * `CATCH_UP_MAX_COMMENT_PAGES` pages โ€” so 81 calls typically and 405 at the theoretical ceiling,
97
+ * against the shared 60 s-per-call 429 budget (`channels/rate-limit-retry.ts`, OPE-1254).
98
+ * ๐Ÿ”ด AND THE OLD CHEAP PATH WAS THE BUG. Before `sinceMs`, a page whose own comments filled
99
+ * `maxComments` returned after ONE call โ€” cheap, and WRONG: that early return is exactly what stopped
100
+ * the block sweep and shipped #208. Paying the sweep is the correction, not a regression to undo.
101
+ * The 405 ceiling needs every one of 81 anchors to hold over 100 comments, which no real page does;
102
+ * the realistic cost is one extra call or two on the page anchor alone.
103
+ */
104
+ const CATCH_UP_MAX_COMMENT_PAGES = 5;
105
+ /**
106
+ * Normalize ONE Notion comment, applying the VISIBILITY GATE every fetch adapter owes core: a comment
107
+ * with no author, no text or no readable time is dropped, because an unattributable body must never
108
+ * reach a seat. The deployment's OWN integration id resolves `self`.
109
+ *
110
+ * ๐Ÿ”ด `authorKind` IS ABSENT ON ALMOST EVERY COMMENT, AND READING THAT AS "NOT A PERSON" IS OPE-1857.
111
+ * It is Notion's own actor type, and Notion serves a comment's `created_by` as a PARTIAL user โ€”
112
+ * `id` and `object`, no `type` (notion-api.ts, whose interface says so). This line used to read
113
+ * `authorKind === "person" ? "human" : "bot"`, so EVERY comment this surface read arrived tagged
114
+ * `bot` โ€” and the default `wide` admission policy refuses every bot, on every wake, for ever. Ship
115
+ * #212 measured it: `seat.catch_up outcome=empty raw=4 kept=0 reason=admission-dropped-all` on a
116
+ * read the bounds had kept in full. A real page member's comment was refused by that arithmetic
117
+ * exactly as the e2e driver's was, so the seat read a page it was being spoken on as silent.
118
+ * ๐Ÿ”ด THE RULE IS CORE'S NOW ({@link catchUpSender}), and this surface contributes only the SIGNAL:
119
+ * a kind Notion actually stated, or `undefined` when it stated none. The repository's own trigger
120
+ * road already read it this way โ€” `classifyNotionComment` (notion-supervisor.ts) drops on
121
+ * `author?.type && author.type !== "person"`, so an absent type is a person there too. The two roads
122
+ * disagreeing about one comment is the whole defect.
123
+ */
124
+ function normalizeComment(c, selfActorIds) {
125
+ const text = c.text.trim();
126
+ if (text.length === 0 || !c.authorId || !c.discussionId)
127
+ return undefined;
128
+ const ms = Date.parse(c.createdTime ?? "");
129
+ if (!Number.isFinite(ms))
130
+ return undefined;
131
+ // OUR OWN VOICE IS THE DEPLOYMENT'S, NOT ONE INTEGRATION'S (OPE-1921) โ€” see collectCatchUpSelfIds.
132
+ const isSelf = selfActorIds.has(c.authorId);
133
+ return {
134
+ // ๐Ÿ”ด THE COMMENT'S OWN NOTION ID, AND IT HAS TO BE (OPE-1847). The wake route passes the trigger's
135
+ // comment id as `excludeId`, and core removes the wake's own message BY ID alone โ€” time cannot do
136
+ // it here, because `created_time` floors to the MINUTE and the trigger therefore shares its
137
+ // instant with every comment the seat is reading for. This slot used to hold a render label
138
+ // (`<discussion>:<created_time>`) that could never equal that id, so the trigger had to be bounded
139
+ // out by `untilMs` โ€” which took the seat's whole catch-up with it and shipped #211 red.
140
+ // The label survives only as the fallback for a comment Notion served with no id, where nothing
141
+ // better exists; it is NOT unique (a coarse stamp lets two comments in one discussion collide).
142
+ id: c.id ?? `${c.discussionId}:${c.createdTime ?? ms}`,
143
+ ms,
144
+ // The bot's OWN posts carry no author id, matching every other surface's normalized shape.
145
+ ...(isSelf ? {} : { authorId: c.authorId }),
146
+ sender: catchUpSender({ self: isSelf, ...(c.authorKind === undefined ? {} : { bot: c.authorKind !== "person" }) }),
147
+ // A Notion wake is thread-first: every comment belongs to a discussion, which IS the thread.
148
+ type: "thread",
149
+ threadId: c.discussionId,
150
+ text,
151
+ };
152
+ }
153
+ const liveDeps = {
154
+ readComments: readNotionComments,
155
+ resolveCredential: resolveNotionCredential,
156
+ selfActorIdFor: async (credentialId, credential) => (await resolveNotionBotUser(credentialId, credential)()).id,
157
+ };
158
+ /**
159
+ * EVERY configured integration's bot user id, for the same reason the ingress road takes a SET
160
+ * (OPE-1921): this deployment posts under one bot user id PER INTEGRATION, and a reply a SIBLING
161
+ * integration posted lands in the very discussion this read sweeps. Against the reading
162
+ * integration's id alone it matched nothing, so the seat was handed its own words as another
163
+ * person's โ€” at the `unauth` floor, in the context it then answers from.
164
+ *
165
+ * ๐Ÿ”ด THE BLOCKER THIS FILE USED TO NAME IS GONE. It said closing this needed the DELIVERY's
166
+ * credential id on the shared `SeatCatchUpRequest`, which carries none. That was true only while the
167
+ * answer had to be ONE id: the set does not care which integration posted, so no new request field
168
+ * is needed. What remains is the ordinary residual โ€” an integration whose id will not resolve.
169
+ *
170
+ * FAIL-SOFT PER INTEGRATION, and DELIBERATELY NOT fail-closed like the ingress road. An id this
171
+ * cannot resolve just leaves that integration's comments reading as a person's, which is where this
172
+ * read already stood; blinding the catch-up on a transient `GET /v1/users/me` failure would
173
+ * re-create the OPE-1857 symptom โ€” a seat that keeps nothing.
174
+ * ๐Ÿ”ด AND THE INGRESS ARM DOES NOT COVER THIS ONE, SO DO NOT READ IT AS A BACKSTOP. An unresolved
175
+ * PRIMARY id blocks every wake, so that case really does take no catch-up. An unresolved SIBLING
176
+ * does not: `classifyNotionComment` step 5 drops only authors Notion does not mark `person`, so a
177
+ * person-marked comment still wakes a turn, still takes a catch-up, and that integration's own
178
+ * prior replies then enter the seat's context as a person's words at the `unauth` floor. That
179
+ * residual is the price of the fail-soft posture here, and it is stated rather than covered.
180
+ *
181
+ * Cost: one `GET /v1/users/me` per SIBLING integration, memoised for the process lifetime by
182
+ * `resolveNotionBotUser`'s per-credential cache โ€” against the up-to-81 list calls the sweep itself
183
+ * makes. The sibling's CREDENTIAL is passed as a thunk, so on a cache hit it is never resolved at
184
+ * all; `resolveNotionCredential` has no memo of its own, and an eager resolve here would charge
185
+ * every wake a store read and a decrypt. A single-integration deployment resolves exactly what it
186
+ * did before.
187
+ */
188
+ async function collectCatchUpSelfIds(readingCredentialId, readingCredential, domains, deps) {
189
+ const ids = new Set();
190
+ const add = async (credentialId, credential) => {
191
+ try {
192
+ const id = await deps.selfActorIdFor?.(credentialId, credential);
193
+ if (id)
194
+ ids.add(id);
195
+ }
196
+ catch {
197
+ /* an id we cannot resolve leaves that integration's comments reading as a person's */
198
+ }
199
+ };
200
+ await add(readingCredentialId, async () => readingCredential);
201
+ for (const integration of notionIntegrations(domains)) {
202
+ if (integration.credentialId === readingCredentialId)
203
+ continue;
204
+ // ๐Ÿ”ด A THUNK, NOT A RESOLVED CREDENTIAL. `resolveNotionBotUser` memoises the bot user per
205
+ // credential and NEVER CALLS the thunk on a cache hit, while `resolveNotionCredential` has no
206
+ // memo of its own โ€” so resolving eagerly here would charge every wake an operational-store read
207
+ // and a decrypt for an answer the cache already holds, and would write one
208
+ // credential-unresolved audit row per wake, for ever, on a sibling with no stored secret.
209
+ await add(integration.credentialId, () => deps.resolveCredential(integration.credentialId));
210
+ }
211
+ return ids;
212
+ }
213
+ /** The fetch, with its seams exposed so the contract test drives it with no network. */
214
+ export async function fetchNotionCatchUpWindow(req, deps = liveDeps) {
215
+ // The DOMAIN's own integration where this surface binds one, else the org default โ€” the same
216
+ // resolution `notionReadAdapter` makes, so the catch-up reads the page over the credential the
217
+ // operator selected for it rather than a broader one.
218
+ const domains = deps.domains ?? safeDomains();
219
+ const credentialId = req.channelKey ? notionChannelCredentialForSurface(req.channelKey, domains) : undefined;
220
+ const cred = await deps.resolveCredential(credentialId);
221
+ if (!cred)
222
+ return []; // unconfigured โ‡’ nothing to read, never a guessed key
223
+ // OUR OWN IDENTITY, under the SAME key the ingress road caches it by: an absent `credentialId`
224
+ // means the ORG DEFAULT, which is stored as `NOTION_CHANNEL_CREDENTIAL` โ€” so both roads memoise
225
+ // one integration under one key rather than resolving it twice. EVERY configured integration's id,
226
+ // for the reason the ingress road takes the set (OPE-1921) โ€” see `selfActorIds` below.
227
+ const readingCredentialId = credentialId ?? NOTION_CHANNEL_CREDENTIAL;
228
+ const selfActorIds = deps.selfActorId !== undefined
229
+ ? new Set([deps.selfActorId])
230
+ : await collectCatchUpSelfIds(readingCredentialId, cred, domains, deps);
231
+ const res = await deps.readComments(req.container, {
232
+ // ๐Ÿ”ด THE ENDPOINT FENCE TRAVELS WITH THE KEY, unconditionally and ON ONE LINE: a bare `apiKey`
233
+ // silently disables it, and `notion-credential-fence-test.ts` ยง2 reads the pair line by line โ€”
234
+ // so splitting them reads as a bare key to the rule that exists to stop one. An absent
235
+ // `endpoints` is already the unnarrowed reading, so the pair is always passed together.
236
+ apiKey: cred.apiKey, endpoints: cred.endpoints,
237
+ maxComments: CATCH_UP_MAX_COMMENTS,
238
+ maxBlockScans: CATCH_UP_MAX_BLOCK_SCANS,
239
+ // ๐Ÿ”ด THE WINDOW GOES TO THE SWEEP, not only to the loop below (OPE-1831). `maxComments` is
240
+ // charged in the order Notion serves โ€” OLDEST FIRST โ€” so a cap applied over the whole history
241
+ // fills with a page's most ancient comments and truncates before it reaches the seat's own
242
+ // window. Handing the bound down makes the cap count comments IN the window instead.
243
+ sinceMs: req.sinceMs,
244
+ maxCommentPages: CATCH_UP_MAX_COMMENT_PAGES,
245
+ // ๐Ÿ”ด THE BLOCK BUDGET IS SPENT ON THE PAGE'S NEWEST BLOCKS (OPE-1843). Document order spends
246
+ // all 80 scans on the oldest material of a page that has outgrown the cap, so a discussion on a
247
+ // recently appended block is never scanned and the seat reads a quiet room. A catch-up asks
248
+ // "what happened since I last woke", and the growth shape that crosses the cap is append-only.
249
+ // No effect on a page under the cap, and none on the `block` drill-in above, which scans one
250
+ // named block. The residual โ€” a new comment on an ANCIENT block of an over-cap page โ€” is
251
+ // `readNotionComments`'s own to state, and it does: see `blockScanOrder`.
252
+ blockScanOrder: "newest-first",
253
+ // An inline comment's authenticated parent block addresses the exact discussion anchor. The
254
+ // discussion id is not a block id and remains only the filter below.
255
+ ...(req.threadAnchor ? { block: req.threadAnchor } : {}),
256
+ });
257
+ const out = [];
258
+ for (const c of res.comments) {
259
+ // The seat's own discussion, filtered HERE โ€” see the header for why there is no endpoint that
260
+ // could do it. A seat bound to no discussion reads the whole page.
261
+ if (req.threadRoot !== undefined && c.discussionId !== req.threadRoot)
262
+ continue;
263
+ const n = normalizeComment(c, selfActorIds);
264
+ if (n && n.ms >= req.sinceMs)
265
+ out.push(n);
266
+ }
267
+ return out;
268
+ }
269
+ /**
270
+ * ๐Ÿ”ด NOTION FLOORS `created_time` TO THE MINUTE, and this is the number the shared catch-up road
271
+ * widens both of its bounds by (OPE-1847, `core/agent/channels/seat-catch-up.ts`
272
+ * {@link SeatCatchUpDeclaration.timestampPrecisionMs}).
273
+ * It is the VENDOR'S UNIT, not a margin: every comment posted inside one wall-clock minute comes back
274
+ * carrying that minute's opening instant. Declare it too small and a comment in the trigger's own
275
+ * minute is bounded out of the window it belongs to โ€” the ship #211 red, where the seat read two
276
+ * comments and kept none. Declare it too large and the seat is handed comments from before its window.
277
+ * Measured on the live workspace (OPE-1711) and the repository's own e2e driver floors to the same
278
+ * unit (`core/scripts/e2e/notion-driver.ts` `notionMinuteFloor`).
279
+ */
280
+ export const NOTION_TIMESTAMP_PRECISION_MS = 60_000;
281
+ /** Notion's registered {@link SeatCatchUpFetch} โ€” the one thing this surface contributes to the shared path. */
282
+ export const notionSeatCatchUpFetch = (req) => fetchNotionCatchUpWindow({
283
+ container: req.container,
284
+ ...(req.threadRoot ? { threadRoot: req.threadRoot } : {}),
285
+ ...(req.threadAnchor ? { threadAnchor: req.threadAnchor } : {}),
286
+ sinceMs: req.sinceMs,
287
+ ...(req.channelKey ? { channelKey: req.channelKey } : {}),
288
+ });
289
+ //# sourceMappingURL=notion-catch-up.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notion-catch-up.js","sourceRoot":"","sources":["../../../agent/channels/notion-catch-up.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,210 @@
1
+ import { type BindableContext, type Domain, type ConnectorIntegration } from "@open-everywhere/core/agent/adapter-api.js";
2
+ /** The provider name in `agent/access/providers.ts`. */
3
+ export declare const NOTION_PROVIDER = "notion";
4
+ /** The ORG-DEFAULT channel-plumbing credential id โ€” the role a domain override replaces. */
5
+ export declare const NOTION_CHANNEL_CREDENTIAL = "channel-key";
6
+ /**
7
+ * The notion `channel-key` credential id a page's domain binds, or **undefined when it binds none**.
8
+ *
9
+ * Undefined is a load-bearing answer, not a failure: it means "this page's domain asked for no
10
+ * override", which every caller reads as "use the org default". Keeping it distinct from a resolved
11
+ * id is what lets the webhook route fall through to its own arms (the verifying integration, then
12
+ * the org default) instead of treating "no binding" as a refusal.
13
+ *
14
+ * Pure โ€” `domains` is injected, exactly like `notionPageDomain`'s, so the whole decision is
15
+ * unit-testable with no config and no store.
16
+ */
17
+ export declare function notionChannelCredentialFor(workspace: string | undefined, pageId: string | undefined, domains: readonly Domain[]): string | undefined;
18
+ /**
19
+ * {@link notionChannelCredentialFor} for a session's own surface key (`notion/<workspace>/<page>`) โ€”
20
+ * the form `resolvePartition(ctx).surfaceKey` produces.
21
+ *
22
+ * The read tools resolve the CALLER'S surface, not the page argument. That is deliberate and it is
23
+ * not a weaker check: `notion-scope.ts` has already established that an admitted read is either the
24
+ * caller's own page or a page in the caller's own domain, so the two resolve to the same domain by
25
+ * construction โ€” and the caller's surface is known BEFORE the fence runs, which the block-resolution
26
+ * walk needs (it must make a Notion call to find a block's owning page).
27
+ */
28
+ export declare function notionChannelCredentialForSurface(surfaceKey: string, domains: readonly Domain[]): string | undefined;
29
+ /**
30
+ * The credential a READ TOOL runs under (OPE-499). **The delivery's own carried credential wins**
31
+ * when this session was woken by one โ€” the same `credential_id` auth attribute the reply arm already
32
+ * reads (notion-post-arm.ts) โ€” so a session only ever reads what the integration that verified its
33
+ * delivery was actually shared into, never a wider domain binding a double-shared page could reach.
34
+ *
35
+ * Falls back to the CALLING SESSION's own domain binding when no delivery credential rides the
36
+ * context โ€” an agent-initiated session (a routine, a proactive post) never carried one, and that is
37
+ * the pre-OPE-499 rule, unchanged. Fail-soft throughout: a synthetic or identity-less ctx has no
38
+ * partition to read, so it resolves undefined and the caller falls back to the org default, exactly
39
+ * like an unbound domain. That is the correct direction here: the DOMAIN FENCE (notion-scope.ts) is
40
+ * what refuses an out-of-scope read, and it runs whether or not a credential was selected.
41
+ */
42
+ export declare function notionCallerCredential(ctx: BindableContext, domains: readonly Domain[]): string | undefined;
43
+ /**
44
+ * Every Notion integration this deployment is reachable by โ€” the ORG DEFAULT FIRST, then one per
45
+ * domain that binds its own. The ingress tries each entry's inbound secret in this order.
46
+ */
47
+ export declare function notionIntegrations(domains: readonly Domain[]): readonly ConnectorIntegration[];
48
+ /**
49
+ * Does the integration that SIGNED a delivery serve `owner`? The one comparison behind both signer
50
+ * fences โ€” the trigger plane's {@link notionTriggerScopeVerdict} and the comment plane's
51
+ * {@link notionDeliveryScopeVerdict} โ€” extracted so the two can never answer it differently.
52
+ *
53
+ * Three outcomes, because the caller's log line needs to tell them apart:
54
+ * โ€ข `"credential"` โ€” the signer IS the credential this domain binds. The ordinary yes.
55
+ * โ€ข `"shared-secret"` โ€” a different credential, signed with the SAME inbound secret. Accepted, and
56
+ * named, because `connectorIntegrations` defaults a domain's `inboundSecret` to the deployment
57
+ * default: a domain that binds a credential without declaring its own secret signs exactly like
58
+ * the org default, and refusing would break a supported config in which the domain runs no
59
+ * subscription of its own.
60
+ * โ€ข `served: false` โ€” neither, so the signer cannot speak for this domain.
61
+ *
62
+ * ๐Ÿ”ด THE PROPERTY THIS BUYS, STATED HONESTLY: **a domain is fenced from another domain's forged
63
+ * deliveries IFF it runs its own inbound secret.** That is not a hole either fence opens. It is the
64
+ * limit of what an HMAC can tell one sender from another when two senders hold the same key.
65
+ *
66
+ * `expected` rides on every outcome so a refusal can name the credential that WOULD have served.
67
+ */
68
+ export type NotionSignerVerdict = {
69
+ readonly served: true;
70
+ readonly via: "credential" | "shared-secret";
71
+ readonly expected: string;
72
+ } | {
73
+ readonly served: false;
74
+ readonly expected: string;
75
+ };
76
+ export declare function notionSignerServesDomain(owner: Domain, signerCredentialId: string, integrations: readonly ConnectorIntegration[]): NotionSignerVerdict;
77
+ /**
78
+ * Every domain a COMMENT delivery reaches THROUGH ITS WORKSPACE CLAIM โ€” the domains the payload's
79
+ * `workspace_id` can steer, and therefore the only ones {@link notionDeliveryScopeVerdict} fences.
80
+ *
81
+ * ๐Ÿ”ด TWO RESOLVERS ANSWER "WHICH DOMAIN?" DOWNSTREAM, AND THEY DO NOT ALWAYS AGREE. Both are
82
+ * consulted on the session the delivery opens, so a check on one alone would leave the other open:
83
+ *
84
+ * โ€ข {@link notionPageDomain} โ€” the credential binding (agent-initiated work) and the read fence
85
+ * (notion-scope.ts). Its roster leg compares the PAGE-ID segment alone.
86
+ * โ€ข `resolveDomainForChannel` โ€” the ambient gate, the engagement tier, the supervisor grain and
87
+ * the external-author policy, all keyed on the `notion/<workspace>/<page>` surface key. Its
88
+ * roster leg compares the WHOLE key, so a workspace that does not match the `reach` entry
89
+ * falls THROUGH to the claim leg.
90
+ *
91
+ * ๐Ÿ”ด WHY THE ROSTER LEGS ARE EXEMPT, AND THIS IS THE WHOLE DESIGN. A roster hit is not steerable by
92
+ * the payload's workspace:
93
+ * โ€ข {@link notionPageDomain}'s roster leg ignores the workspace outright, and OPE-496 established
94
+ * the page id from the comment Notion returned under the verified credential. Authenticated fact
95
+ * in, config match out.
96
+ * โ€ข `resolveDomainForChannel`'s roster leg needs the WHOLE key to appear in `reach`, so the
97
+ * workspace it accepts is one the deployer typed themselves.
98
+ * A claim leg is the opposite: a container entry names a workspace, the payload names a workspace, and a
99
+ * match hands over every page under it.
100
+ *
101
+ * KEEPING THE ROSTER LEGS OUT IS LOAD-BEARING, not a shortcut. A page shared with two integrations
102
+ * produces a delivery on EACH subscription, and each one must run under its own verifying integration
103
+ * (the header's "prefer, don't refuse" rule, and the escalation OPE-499 closes by carrying the
104
+ * delivery's credential into the reads). Fencing a rostered page on the signer would refuse the
105
+ * second subscription's delivery outright and delete that behaviour.
106
+ *
107
+ * Pure โ€” `domains` is injected, exactly like {@link notionPageDomain}'s.
108
+ */
109
+ export declare function notionDeliveryClaimedDomains(workspaceId: string | undefined, pageId: string, domains: readonly Domain[]): readonly Domain[];
110
+ /** {@link notionDeliveryScopeVerdict}'s answer. `ok` is the ONLY field a caller may branch on; the
111
+ * `reason` and the `detail` are operator-facing prose for the log line and carry no control flow. */
112
+ export type NotionDeliveryScopeVerdict = {
113
+ readonly ok: true;
114
+ readonly detail: string;
115
+ } | {
116
+ readonly ok: false;
117
+ readonly reason: string;
118
+ };
119
+ /**
120
+ * โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
121
+ * ๐Ÿ”ด THE COMMENT-PLANE INGRESS FENCE (OPE-502) โ€” MAY THIS SIGNER OPEN A SESSION ON THIS PAGE?
122
+ * โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
123
+ *
124
+ * `notion-webhook.ts` calls this after the page is established (OPE-496) and BEFORE the surface key
125
+ * decides anything. The rule is the trigger plane's rule, on the plane next door:
126
+ *
127
+ * A delivery may open a session on a domain it reaches THROUGH ITS WORKSPACE CLAIM only when that
128
+ * domain is one the SIGNING integration serves.
129
+ *
130
+ * WHY THE WORKSPACE NEEDS THIS AND THE PAGE DOES NOT. OPE-496 established the PAGE from the comment
131
+ * Notion returned under the verified credential, so the page is an authenticated fact. It left the
132
+ * WORKSPACE where it found it โ€” `ev.workspace_id`, a payload field nothing authenticates โ€” and the
133
+ * two are composed into `notion/<workspace>/<page>`. There is no verified source for the second
134
+ * half: Notion's REST surface returns no workspace id on any object (`/v1/users/me` gives
135
+ * `bot.workspace_name`, not an id), so no fetch under any credential can confirm the claim. Pinning
136
+ * the workspace to a config constant is wrong for a different reason โ€” one deployment may hold
137
+ * several workspaces. So the claim is fenced instead of proved, exactly as OPE-500 fenced the
138
+ * trigger plane's scope path.
139
+ *
140
+ * WORKED THROUGH, because the point is what it REFUSES. Domain `security` claims workspace
141
+ * `notion/W-sec` in a container entry and binds integration `notion-security` with its own inbound
142
+ * secret. A holder of domain `payments`'s `verification_token` comments on a page their OWN
143
+ * integration can see, then signs a delivery whose `workspace_id` reads `W-sec`. The page reaches
144
+ * `security` through that claim, so without this fence the session opens on `security` โ€” with
145
+ * `security`'s ambient flag, `security`'s read scope over every page under the claim, and (for an
146
+ * agent-initiated call) `security`'s credential. The fence reads that domain's credential, finds
147
+ * `notion-security` against a `notion-payments` signature, and refuses.
148
+ *
149
+ * FAIL CLOSED, AND THE REFUSALS ARE REAL:
150
+ * โ€ข NO SIGNER โ‡’ refuse. `webhooks.allowUnverifiedNotion` accepts a delivery with no secret
151
+ * configured anywhere, so nothing identifies a sender and the fence has no fact to read. A fence
152
+ * that says yes when it cannot tell is not a fence. The consequence is stated in the runbook: with
153
+ * that dev hatch on, a CLAIMED page opens no session. A rostered page is unaffected, so the local
154
+ * exercise the hatch exists for still runs.
155
+ * โ€ข NO CLAIMED DOMAIN โ‡’ allow. Either the page is rostered โ€” see
156
+ * {@link notionDeliveryClaimedDomains} for why a roster hit is not steerable โ€” or it is in no
157
+ * domain at all, where the `org` pseudo-domain answers and no payload field can steer that
158
+ * either.
159
+ *
160
+ * THE PARTITION IS DECIDED SEPARATELY (OPE-254 ยง2, folding OPE-537). This fence decides the domain
161
+ * BOUNDARY; which partition a delivery lands in is notion-webhook.ts's step 8c, which composes the
162
+ * key from the workspace segment of the `reach` entry that MATCHED โ€” `notionReachWorkspace`, on both
163
+ * legs. A page named outright therefore keys on config's workspace whatever a delivery claims. A
164
+ * page reached by a CONTAINER only matches when the payload's workspace equals the container's own
165
+ * text, so there the derived value equals the payload's by construction; a delivery naming a
166
+ * workspace no domain reaches matches nothing, and its key belongs to no domain โ€” no memory, no
167
+ * bundle, and the ambient gate refuses it โ€” rather than opening a second partition inside one.
168
+ *
169
+ * ๐Ÿ”ด WHY THE CONTAINER LEG NEEDED A CONFIG RULE, NOT JUST THE SAME READ. The exact-key match is
170
+ * workspace-BLIND โ€” it finds a page by id alone, so the workspace read back off the matched entry
171
+ * is a signal INDEPENDENT of whatever the payload said. The container leg is the opposite shape:
172
+ * `notionPageDomain`'s container match composes `notion/<payload-workspace>/<page>` and only admits
173
+ * it when a container entry's tenant segment equals THAT SAME payload workspace verbatim
174
+ * (surface-key-grammar.ts refuses a glob on the tenant segment, so the compare is always exact). So
175
+ * whenever a container admits a delivery, the matched entry's workspace text and the payload's are,
176
+ * by construction, the identical string โ€” reading the entry back is a no-op IN VALUE.
177
+ *
178
+ * What it is not is a no-op in PROPERTY, and that is the point. The remaining way one page could
179
+ * still key two ways was a domain reaching MORE THAN ONE Notion workspace: two honestly-signed
180
+ * deliveries for the same page, one naming each, opened two partitions. memory/domain.ts's
181
+ * `partitionTenantProblems` now refuses that config at load, so the matched entry names exactly one
182
+ * workspace per domain and the partition is a fact about config rather than about a delivery.
183
+ * No live re-keying follows: on every configuration that still loads, the derived value equals the
184
+ * value the payload carried, so no existing `channel_session` or memory row changes key.
185
+ *
186
+ * Pure โ€” `domains` is injected, so the whole decision is unit-testable with no config and no store.
187
+ */
188
+ export declare function notionDeliveryScopeVerdict(workspaceId: string | undefined, pageId: string, signerCredentialId: string | undefined, domains: readonly Domain[]): NotionDeliveryScopeVerdict;
189
+ /**
190
+ * One integration's INBOUND signing key by operator-secret name, or undefined โ€” and the ONE place
191
+ * that decides whether a failure is REPORTED.
192
+ *
193
+ * ๐Ÿ”ด THE FAILURE THAT MUST BE LOUD. "Nobody stored this name" is the ordinary state for an
194
+ * integration the operator never configured: it contributes no key, a delivery it would have signed
195
+ * simply fails the gate, and saying so on every POST would be noise. Every OTHER failure โ€” an
196
+ * unarmed `OE_CREDENTIAL_ENCRYPTION_KEY`, a store that will not open โ€” makes EVERY delivery on that
197
+ * integration 401 with nothing in the log to explain it. It is reported.
198
+ *
199
+ * The two are told apart by the ERROR CLASS ({@link OperatorSecretNotSetError}), never by the
200
+ * message: the unarmed-key message ALSO contains the words "is not set", so the substring test this
201
+ * replaces reported neither and a broken credential store went silent.
202
+ *
203
+ * `get` and `log` are injected so the decision is testable with no store and no eve boot; the route
204
+ * passes `getOperatorSecret` and its own channel logger. The report names the SECRET NAME and the
205
+ * reason, never a value. Both arms return undefined โ€” this function never throws into a webhook.
206
+ */
207
+ export declare function resolveNotionInboundSecret(name: string, get: (secretName: string) => Promise<{
208
+ readonly value: string;
209
+ }>, log: (message: string) => void): Promise<string | undefined>;
210
+ //# sourceMappingURL=notion-credentials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notion-credentials.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-credentials.ts"],"names":[],"mappings":"AAyDA,OAAO,EAML,KAAK,eAAe,EACpB,KAAK,MAAM,EACX,KAAK,oBAAoB,EAC1B,MAAM,4CAA4C,CAAC;AAIpD,wDAAwD;AACxD,eAAO,MAAM,eAAe,WAAW,CAAC;AAExC,4FAA4F;AAC5F,eAAO,MAAM,yBAAyB,gBAAgB,CAAC;AAQvD;;;;;;;;;;GAUG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,MAAM,GAAG,SAAS,CAMpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,iCAAiC,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,GAAG,SAAS,CAIpH;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,GAAG,SAAS,CAQ3G;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,oBAAoB,EAAE,CAE9F;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,YAAY,GAAG,eAAe,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAClG;IAAE,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,MAAM,EACb,kBAAkB,EAAE,MAAM,EAC1B,YAAY,EAAE,SAAS,oBAAoB,EAAE,GAC5C,mBAAmB,CAOrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,SAAS,MAAM,EAAE,CAUnB;AAED;sGACsG;AACtG,MAAM,MAAM,0BAA0B,GAClC;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC9C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,MAAM,EAAE,MAAM,EACd,kBAAkB,EAAE,MAAM,GAAG,SAAS,EACtC,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,0BAA0B,CA+B5B;AAmBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,EAChE,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAU7B"}