@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,271 @@
|
|
|
1
|
+
// Pure Notion→OpenEverywhere identity capture. The Notion channel (notion.ts) calls
|
|
2
|
+
// these to stow RAW Notion facts (workspace / page / discussion / comment ids + the
|
|
3
|
+
// commenting actor) into the session auth context's `attributes`. The INTERPRETATION of
|
|
4
|
+
// those facts into mode/visibility lives here too — notionMode/notionVisibility — so the
|
|
5
|
+
// test can call them and the security-critical "is this private?" decision sits in one
|
|
6
|
+
// tested place. This file imports no eve types (only a type-only alias, erased at
|
|
7
|
+
// compile), so it's trivially unit-testable.
|
|
8
|
+
//
|
|
9
|
+
// SILO CHOICE (why surface = the PAGE, not the workspace and not the comment thread):
|
|
10
|
+
// the surface-fit criterion is "the persistent multi-instance container, NOT the
|
|
11
|
+
// invocation point". Notion's tiers map onto ours exactly like Linear's do:
|
|
12
|
+
// Notion workspace ≈ Linear organization ≈ our `workspace` (the tenant tier)
|
|
13
|
+
// Notion PAGE ≈ Linear TEAM ≈ our `surface` (the silo — one page
|
|
14
|
+
// hosts many discussion threads over time,
|
|
15
|
+
// just as a team hosts many issues)
|
|
16
|
+
// Notion discussion ≈ Linear issue ≈ the conversation ANCHOR (post-back target
|
|
17
|
+
// + continuation key), NOT the partition key
|
|
18
|
+
// So a page's accumulated memory is reused across every comment thread on it, while a
|
|
19
|
+
// bare comment thread (which comes and goes) is only the reply anchor. This mirrors Slack
|
|
20
|
+
// too: surface = channel, the thread is the anchor. (A finer teamspace→page two-level silo
|
|
21
|
+
// is a future refinement; page is the launch grain, matching the Linear team grain.)
|
|
22
|
+
|
|
23
|
+
import type { SessionMode, Visibility, BindableContext, IdentityMapper, RawIdentity } from "@open-everywhere/core/agent/adapter-api.js";
|
|
24
|
+
|
|
25
|
+
/** Structural match for eve's SessionAuthContext (attributes + principal). */
|
|
26
|
+
export interface OpenEverywhereNotionAuth {
|
|
27
|
+
readonly authenticator: string;
|
|
28
|
+
readonly principalId: string;
|
|
29
|
+
readonly principalType: string;
|
|
30
|
+
readonly attributes: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface NotionIdentityInput {
|
|
34
|
+
/** Notion workspace id — becomes the OpenEverywhere `workspace` (tenant tier). */
|
|
35
|
+
readonly workspaceId?: string;
|
|
36
|
+
/** The page the comment lives on — becomes the OpenEverywhere `surface` (the silo grain). */
|
|
37
|
+
readonly pageId?: string;
|
|
38
|
+
/** The discussion thread the comment belongs to — the post-back anchor (create-comment
|
|
39
|
+
* targets `discussion_id`), NOT the partition key. */
|
|
40
|
+
readonly discussionId?: string;
|
|
41
|
+
/** The specific comment that triggered this turn — for audit/provenance. */
|
|
42
|
+
readonly commentId?: string;
|
|
43
|
+
/** The acting Notion user (comment author), if known — becomes the principal. */
|
|
44
|
+
readonly principalId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The Notion channel credential this DELIVERY was selected to run under (OPE-467) — the
|
|
47
|
+
* integration whose secret verified it. Stowed so every reply for this turn posts under the SAME
|
|
48
|
+
* integration that fetched the comment. Without it a reply re-derives the credential from the
|
|
49
|
+
* page's domain binding alone, and a page shared with an integration its domain does not bind
|
|
50
|
+
* would fetch under one key and answer under another whose integration was never shared the page
|
|
51
|
+
* — `createCommentReply` throws and the agent silently never answers.
|
|
52
|
+
*
|
|
53
|
+
* HOST-SET (§AUTH-ATTRIBUTE PROVENANCE): built from the HMAC gate's verdict, never from the
|
|
54
|
+
* comment body, so it cannot be forged into naming another integration's credential.
|
|
55
|
+
*/
|
|
56
|
+
readonly credentialId?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The declared TRUST ROOT this page sits under, canonical form, when the ancestor walk matched one
|
|
59
|
+
* (OPE-492; channels/notion-ancestry-trust.ts resolveNotionDeliveryTrust). Present ⇒ the operator
|
|
60
|
+
* declared a page above this one internal, so {@link bindNotion} binds `internal` instead of the
|
|
61
|
+
* fail-closed `external`. Absent on every untrusted verdict, which is every verdict on a
|
|
62
|
+
* deployment that declares no roots.
|
|
63
|
+
*
|
|
64
|
+
* HOST-SET (§AUTH-ATTRIBUTE PROVENANCE): the webhook core resolves it from Notion's own parent
|
|
65
|
+
* chain under the VERIFIED credential, never from the comment body, so it cannot be forged into
|
|
66
|
+
* claiming a root.
|
|
67
|
+
*/
|
|
68
|
+
readonly trustRoot?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the auth context OpenEverywhere dispatches with — raw facts as string attributes. */
|
|
72
|
+
export function buildNotionAuth(input: NotionIdentityInput): OpenEverywhereNotionAuth {
|
|
73
|
+
const attributes: Record<string, string> = {};
|
|
74
|
+
if (input.workspaceId) attributes.workspace_id = input.workspaceId;
|
|
75
|
+
if (input.pageId) attributes.page_id = input.pageId;
|
|
76
|
+
if (input.discussionId) attributes.discussion_id = input.discussionId;
|
|
77
|
+
if (input.commentId) attributes.comment_id = input.commentId;
|
|
78
|
+
if (input.credentialId) attributes.credential_id = input.credentialId;
|
|
79
|
+
if (input.trustRoot) attributes.trust_root = input.trustRoot;
|
|
80
|
+
return {
|
|
81
|
+
authenticator: "notion",
|
|
82
|
+
principalId: input.principalId ?? "",
|
|
83
|
+
principalType: "notion-user",
|
|
84
|
+
attributes,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The workspaceKey a Notion surface resolves to (`notion/<workspace id>`) — the value an
|
|
90
|
+
* operator lists in agent/identity/surfaces.ts to vouch a whole Notion workspace
|
|
91
|
+
* as internal (which lets its pages' learnings flow to shared memory despite the
|
|
92
|
+
* fail-closed default below). Takes the raw workspace id as Notion reports it on the
|
|
93
|
+
* webhook, so the operator pastes what they see. Notion ids are UUIDs, already within the
|
|
94
|
+
* binding's `seg` charset (hyphens allowed), so no encoding is needed. notion-test.ts pins
|
|
95
|
+
* this key against the one bindFrom actually produces.
|
|
96
|
+
*/
|
|
97
|
+
export function notionWorkspaceKey(workspaceId: string): string {
|
|
98
|
+
return `notion/${workspaceId}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The fully-qualified surfaceKey a Notion page resolves to — `notion/<workspace>/<page>` — the
|
|
103
|
+
* SAME shape resolvePartition(ctx) produces for a bound Notion session (memory/partition.ts:
|
|
104
|
+
* `platform/workspace/surface`, surface = the page). The ambient-supervisor gate + the
|
|
105
|
+
* channel_session registry are keyed by this, so the webhook core (which runs BEFORE any eve
|
|
106
|
+
* binding) must compute the identical key. Falls back through discussion → "unknown" the same way
|
|
107
|
+
* bindNotion picks its surface, so an under-identified event still keys to a stable string.
|
|
108
|
+
*/
|
|
109
|
+
export function notionSurfaceKey(workspaceId: string | undefined, surface: string | undefined): string {
|
|
110
|
+
return `notion/${workspaceId ?? "unknown"}/${surface ?? "unknown"}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The CONVERSATION key the stop registry keys a Notion turn by (OPE-989) — `<workspace>/<page>`.
|
|
115
|
+
* ONE derivation, used by BOTH sides of the affordance: {@link bindNotion} writes it onto the
|
|
116
|
+
* anchor row at turn start (as `anchorChannelId`), and the stop leg (channels/notion-stop.ts)
|
|
117
|
+
* rebuilds it from the fetched comment. Two spellings of this key would make the arm silently miss
|
|
118
|
+
* — the same discipline githubConversationKey documents.
|
|
119
|
+
*
|
|
120
|
+
* THE PAGE, NOT THE DISCUSSION. The Notion doc-session is page-scoped (notion-supervisor.ts's
|
|
121
|
+
* `notionSupervisorToken` keys the standing session by page, not by discussion — "a page-scoped
|
|
122
|
+
* standing session aware of all the doc's discussions"), so the conversation a stop trigger can
|
|
123
|
+
* name is the page, mirroring how a GitHub stop names the issue/PR and a Teams stop names the
|
|
124
|
+
* reply chain.
|
|
125
|
+
*
|
|
126
|
+
* Returns undefined on any missing part — never "unknown" (unlike {@link notionSurfaceKey}, which
|
|
127
|
+
* exists for a different purpose: a STABLE partition key even under-identified). A partial anchor
|
|
128
|
+
* key would let two different under-identified deliveries collide and cancel each other's turns.
|
|
129
|
+
*/
|
|
130
|
+
export function notionConversationKey(workspaceId: string | undefined, pageId: string | undefined): string | undefined {
|
|
131
|
+
return workspaceId && pageId ? `${workspaceId}/${pageId}` : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The inverse of {@link notionConversationKey} — split the `<workspace>/<page>` string back into its
|
|
136
|
+
* two Notion ids. Undefined on anything that is not exactly two non-empty segments: a malformed or
|
|
137
|
+
* partial address must never resolve to a guessed page. Notion ids are UUIDs (no `/`), so splitting
|
|
138
|
+
* on the first slash is exact — there is no ambiguity to resolve.
|
|
139
|
+
*
|
|
140
|
+
* The one caller today is `notion-message-ops.ts` (OPE-1043): `SurfaceMessageOps.postText`/
|
|
141
|
+
* `editMessage` are handed only the composed `channelId` (this key), never the two ids apart, so a
|
|
142
|
+
* transport that needs the page or the workspace on its own has to parse it back out. Kept beside
|
|
143
|
+
* the composer rather than in that file, for the same reason the composer's own doc states: one
|
|
144
|
+
* derivation, read by both sides, so the write and the read can never drift into two spellings.
|
|
145
|
+
*/
|
|
146
|
+
export function parseNotionConversationKey(channelId: string): { readonly workspaceId: string; readonly pageId: string } | undefined {
|
|
147
|
+
const i = channelId.indexOf("/");
|
|
148
|
+
if (i <= 0 || i === channelId.length - 1) return undefined;
|
|
149
|
+
const workspaceId = channelId.slice(0, i);
|
|
150
|
+
const pageId = channelId.slice(i + 1);
|
|
151
|
+
return workspaceId.length > 0 && pageId.length > 0 ? { workspaceId, pageId } : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Notion has NO 1:1 DM primitive — every comment is on a shared page/discussion — so a
|
|
156
|
+
* Notion surface is always `channel` mode. Total function (no input) kept for symmetry
|
|
157
|
+
* with the other platforms' mode helpers and so the mapper reads the same shape.
|
|
158
|
+
*/
|
|
159
|
+
export function notionMode(): SessionMode {
|
|
160
|
+
return "channel";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Notion facts → visibility. FAIL CLOSED — Notion is ALWAYS private.
|
|
165
|
+
* An internal integration only sees pages EXPLICITLY shared with it, and the webhook
|
|
166
|
+
* carries no cheap, reliable "this page is workspace-public / world-visible" bit — the
|
|
167
|
+
* `accessible_by` array is a coarse who-can-see list, not a public/private classifier.
|
|
168
|
+
* With no positive signal that a page is a broadly-shared internal space, a surface's
|
|
169
|
+
* learnings must never flow up to a shared (workspace) tier: every Notion page stays
|
|
170
|
+
* surface-tier private. A future maintainer who finds a positive workspace-visible signal
|
|
171
|
+
* (or enriches page permissions from the API) flips this — until then it fails closed.
|
|
172
|
+
*/
|
|
173
|
+
export function notionVisibility(): Visibility {
|
|
174
|
+
return "private";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- the identity MAPPER (moved out of core identity/binding.ts in OPE-114 stage 3) ---------
|
|
178
|
+
// A Notion comment lives on a PAGE inside a Notion workspace, in a discussion thread. The
|
|
179
|
+
// silo grain is the PAGE (the persistent container that hosts many discussion threads over
|
|
180
|
+
// time — the Linear TEAM / Slack channel analog), NOT the discussion (which is the reply
|
|
181
|
+
// anchor, like a Linear issue / Slack thread ts). So: workspace = workspace_id, surface =
|
|
182
|
+
// page_id (discussion is the post-back target, not the partition key), mode = channel
|
|
183
|
+
// (Notion has no 1:1 DM primitive). Raw facts are stowed by buildNotionAuth above;
|
|
184
|
+
// mode/visibility interpretation lives in this file (the one tested place) — visibility FAILS
|
|
185
|
+
// CLOSED to private (Notion exposes no reliable world-/workspace-public signal).
|
|
186
|
+
//
|
|
187
|
+
// The two attribute helpers are LOCAL COPIES of core binding.ts's module-private probes (attr /
|
|
188
|
+
// authPrincipalId) — deliberately not barrel ports: they are three-line structural reads over
|
|
189
|
+
// the public BindableContext shape, and a mapper author outside core is expected to read its own
|
|
190
|
+
// platform's attributes itself (the barrel exposes the CONTRACT — BindableContext / RawIdentity /
|
|
191
|
+
// registerIdentityMapper — not core's private conveniences).
|
|
192
|
+
|
|
193
|
+
type AuthAttrs = Readonly<Record<string, string | readonly string[]>> | undefined;
|
|
194
|
+
|
|
195
|
+
function notionAuthOf(ctx: BindableContext): { attributes?: AuthAttrs; principalId?: string } | null {
|
|
196
|
+
return ctx.session?.auth?.current ?? ctx.session?.auth?.initiator ?? null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function notionAttr(a: AuthAttrs, keys: readonly string[]): string | undefined {
|
|
200
|
+
if (!a) return undefined;
|
|
201
|
+
for (const k of keys) {
|
|
202
|
+
const v = a[k];
|
|
203
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
204
|
+
if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const bindNotion: IdentityMapper = (ctx): RawIdentity => {
|
|
210
|
+
const auth = notionAuthOf(ctx);
|
|
211
|
+
const a = auth?.attributes;
|
|
212
|
+
const workspaceId = notionAttr(a, ["workspace_id", "workspaceId"]);
|
|
213
|
+
// Surface = the PAGE (silo grain); fall back to the discussion, then the comment, so a
|
|
214
|
+
// session always keys to a stable surface even on an under-identified event.
|
|
215
|
+
const surface =
|
|
216
|
+
notionAttr(a, ["page_id", "pageId"]) ??
|
|
217
|
+
notionAttr(a, ["discussion_id", "discussionId"]) ??
|
|
218
|
+
notionAttr(a, ["comment_id", "commentId"]);
|
|
219
|
+
const discussionId = notionAttr(a, ["discussion_id", "discussionId"]);
|
|
220
|
+
const pageId = notionAttr(a, ["page_id", "pageId"]);
|
|
221
|
+
// The delivery's selected integration (OPE-467), so `reply` posts under the credential that
|
|
222
|
+
// fetched. Absent on a session that predates the stamp ⇒ the reply arm falls back to the page's
|
|
223
|
+
// domain binding, which is the pre-OPE-467 behaviour.
|
|
224
|
+
const credentialId = notionAttr(a, ["credential_id", "credentialId"]);
|
|
225
|
+
// The ANCESTOR-WALK verdict the webhook core resolved for THIS delivery (OPE-492). Present only
|
|
226
|
+
// when the page sits under a declared trust root; the stamp names the root that matched.
|
|
227
|
+
const trustRoot = notionAttr(a, ["trust_root", "trustRoot"]);
|
|
228
|
+
const principal = auth?.principalId;
|
|
229
|
+
return {
|
|
230
|
+
workspace: workspaceId,
|
|
231
|
+
surface,
|
|
232
|
+
mode: notionMode(),
|
|
233
|
+
visibility: notionVisibility(),
|
|
234
|
+
// Notion refutes no author and reports no room-grain external signal, so trust here is ALWAYS an
|
|
235
|
+
// operator declaration — never a platform verdict. Two declarations reach it, and this is the
|
|
236
|
+
// ONE the platform mapper carries: the page sits under a declared TRUST ROOT, walked and matched
|
|
237
|
+
// at ingestion (channels/notion-ancestry-trust.ts). The other is the flat per-surface vouch
|
|
238
|
+
// (identity/surfaces.ts), which bindFrom applies one rung lower.
|
|
239
|
+
//
|
|
240
|
+
// POSITIVE-ONLY. No root matched ⇒ undefined, NEVER "external": bindFrom must still fall through
|
|
241
|
+
// to the per-surface vouch and then to the fail-closed default. Returning "external" here would
|
|
242
|
+
// out-rank the operator's own `defineSurface` line and silently switch off shared memory on every
|
|
243
|
+
// Notion page of a deployment that declares no roots.
|
|
244
|
+
trust: trustRoot ? ("internal" as const) : undefined,
|
|
245
|
+
principalId: (principal && principal.length > 0 ? principal : undefined) ?? notionAttr(a, ["user_id", "userId"]),
|
|
246
|
+
// Post-back targets the DISCUSSION thread (create-comment's discussion_id), with the page
|
|
247
|
+
// riding along. Not a thread ts — Notion replies key off discussion_id, carried on the
|
|
248
|
+
// session state the channel stashes, so this target is for routine/proactive use.
|
|
249
|
+
target: discussionId ? { discussionId, ...(pageId ? { pageId } : {}), ...(credentialId ? { credentialId } : {}) } : undefined,
|
|
250
|
+
// THE STOP REGISTRY'S ADDRESS FOR THIS TURN (OPE-989). A Notion doc-session is PAGE-scoped
|
|
251
|
+
// (notionSupervisorToken keys the standing session by page), so that is what a stop directive
|
|
252
|
+
// posted on the page can name. It cannot ride `target`: that object composes the REPLY target
|
|
253
|
+
// (the discussion), a different grain that changes on every comment.
|
|
254
|
+
//
|
|
255
|
+
// Notion pushes no reaction webhook (comment.created/updated/deleted is the whole comment event
|
|
256
|
+
// catalogue — developers.notion.com/reference/webhooks-events-delivery, read 2026-08-23), so the
|
|
257
|
+
// trigger on this surface is a bare `stop` comment read at the ingress — see
|
|
258
|
+
// channels/notion-stop.ts. The key it resolves by is this one.
|
|
259
|
+
...(notionConversationKey(workspaceId, pageId) ? { anchorChannelId: notionConversationKey(workspaceId, pageId) } : {}),
|
|
260
|
+
// THE ENGAGEMENT REGISTRY'S ADDRESS FOR THIS TURN (OPE-1501) — the SAME page key, and
|
|
261
|
+
// deliberately the same derivation rather than a second spelling of it. `stand_down` marks
|
|
262
|
+
// `binding.engagementKey ?? binding.threadTs`, and Notion carries no `threadTs`, so without this
|
|
263
|
+
// field the verb found no anchor and refused on every Notion turn.
|
|
264
|
+
//
|
|
265
|
+
// THE PAGE, NOT THE DISCUSSION, for the reason {@link notionConversationKey} states: the doc
|
|
266
|
+
// session is page-scoped and aware of all the page's discussions, so a person asking the agent
|
|
267
|
+
// to stop is asking it to stop on the page. A per-discussion mark would leave the same standing
|
|
268
|
+
// session answering the discussion next door.
|
|
269
|
+
...(notionConversationKey(workspaceId, pageId) ? { engagementKey: notionConversationKey(workspaceId, pageId) } : {}),
|
|
270
|
+
};
|
|
271
|
+
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// DURABLE NOTION INGRESS DRAIN — the authenticated, fenced consumer of rows admitted by
|
|
2
|
+
// notion-ingress.ts. The public webhook stores a projected provider envelope and returns; both its
|
|
3
|
+
// immediate nudge and the minute recovery schedule send only the row key through this internal
|
|
4
|
+
// HMAC route. The payload never leaves the operational store.
|
|
5
|
+
|
|
6
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getOrMintSelfSecret,
|
|
10
|
+
readCappedBody,
|
|
11
|
+
selfDispatchBaseUrl,
|
|
12
|
+
selfSecretsArmed,
|
|
13
|
+
verifyWebhookSignature,
|
|
14
|
+
type ClaimedIngressDelivery,
|
|
15
|
+
type IngressDeliveryKey,
|
|
16
|
+
type OperationalStore,
|
|
17
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
18
|
+
|
|
19
|
+
import { decodeNotionIngressPayload, NOTION_INGRESS_SOURCE, type NotionIngressKey } from "./notion-ingress.js";
|
|
20
|
+
|
|
21
|
+
export const NOTION_INGRESS_DRAIN_PATH = "/eve/v1/notion/drain";
|
|
22
|
+
export const NOTION_INGRESS_DRAIN_SIGNATURE_HEADER = "x-oe-notion-drain-signature";
|
|
23
|
+
const NOTION_INGRESS_DRAIN_SECRET_ENV = "OE_NOTION_INGRESS_DRAIN_SECRET";
|
|
24
|
+
export const NOTION_COMMENT_DISPATCH_SOURCE = "notion-comment-dispatch";
|
|
25
|
+
// Comment ids are globally unique inside Notion. Keep this authority constant so the same comment
|
|
26
|
+
// arriving from two subscriptions shares ONE dispatch lease, matching the legacy OPE-1906 guard.
|
|
27
|
+
export const NOTION_COMMENT_DISPATCH_AUTHORITY = "notion";
|
|
28
|
+
const NOTION_INGRESS_LEASE_MS = 5 * 60_000;
|
|
29
|
+
const NOTION_INGRESS_RETRY_MS = NOTION_INGRESS_LEASE_MS + 1_000;
|
|
30
|
+
const NOTION_INGRESS_TOMBSTONE_MS = 24 * 60 * 60_000;
|
|
31
|
+
const NOTION_COMMENT_DISPATCH_TOMBSTONE_MS = 10 * 60_000;
|
|
32
|
+
const NOTION_INGRESS_DRAIN_MAX_BODY_BYTES = 16 * 1024;
|
|
33
|
+
const NOTION_INGRESS_DRAIN_MAX_SKEW_MS = 5 * 60_000;
|
|
34
|
+
const NOTION_INGRESS_DRAIN_BATCH = 25;
|
|
35
|
+
|
|
36
|
+
interface DrainEnvelope {
|
|
37
|
+
readonly authorityId: string;
|
|
38
|
+
readonly deliveryId: string;
|
|
39
|
+
readonly issuedAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NotionIngressDrainKey extends NotionIngressKey {}
|
|
43
|
+
|
|
44
|
+
export interface AcceptedNotionIngressDrain {
|
|
45
|
+
readonly key: NotionIngressDrainKey;
|
|
46
|
+
readonly claim: ClaimedIngressDelivery;
|
|
47
|
+
readonly rawBody: string;
|
|
48
|
+
readonly signerCredentialId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type NotionIngressDisposition = "complete" | "discard" | "retry";
|
|
52
|
+
|
|
53
|
+
export interface NotionCommentDispatchGuard {
|
|
54
|
+
readonly claim: (commentId: string) => Promise<boolean>;
|
|
55
|
+
readonly complete: (commentId: string) => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AcceptNotionIngressDrainDeps {
|
|
59
|
+
readonly org: string;
|
|
60
|
+
readonly store: OperationalStore;
|
|
61
|
+
readonly secret?: string;
|
|
62
|
+
readonly now?: () => Date;
|
|
63
|
+
readonly claimId?: () => string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function nonEmpty(value: unknown, max = 512): string | undefined {
|
|
67
|
+
return typeof value === "string" && value.length > 0 && value.length <= max ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseEnvelope(value: unknown): DrainEnvelope | undefined {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
72
|
+
const row = value as Record<string, unknown>;
|
|
73
|
+
const authorityId = nonEmpty(row.authorityId);
|
|
74
|
+
const deliveryId = nonEmpty(row.deliveryId);
|
|
75
|
+
const issuedAt = row.issuedAt;
|
|
76
|
+
if (!authorityId || !deliveryId || typeof issuedAt !== "number" || !Number.isFinite(issuedAt)) return undefined;
|
|
77
|
+
return { authorityId, deliveryId, issuedAt };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function signNotionIngressDrainBody(secret: string, body: string): string {
|
|
81
|
+
return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function drainSecret(injected?: string): Promise<string | undefined> {
|
|
85
|
+
if (injected !== undefined) return injected;
|
|
86
|
+
if (!selfSecretsArmed()) return undefined;
|
|
87
|
+
return getOrMintSelfSecret(NOTION_INGRESS_DRAIN_SECRET_ENV);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Cheap public-ACK precondition: recovery can authenticate and reach this deployment. */
|
|
91
|
+
export function notionIngressDrainAvailable(): boolean {
|
|
92
|
+
return selfSecretsArmed() && selfDispatchBaseUrl() !== undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Authenticate an internal row-key request and atomically claim its exact org-scoped row. */
|
|
96
|
+
export async function acceptNotionIngressDrainRequest(
|
|
97
|
+
req: Request,
|
|
98
|
+
deps: AcceptNotionIngressDrainDeps,
|
|
99
|
+
): Promise<{ readonly accepted: AcceptedNotionIngressDrain } | { readonly response: Response }> {
|
|
100
|
+
const read = await readCappedBody(req, NOTION_INGRESS_DRAIN_MAX_BODY_BYTES);
|
|
101
|
+
if ("tooLarge" in read) return { response: new Response("payload too large", { status: 413 }) };
|
|
102
|
+
const secret = await drainSecret(deps.secret);
|
|
103
|
+
if (!secret) return { response: new Response("not found", { status: 404 }) };
|
|
104
|
+
if (!verifyWebhookSignature(secret, read.body, req.headers.get(NOTION_INGRESS_DRAIN_SIGNATURE_HEADER))) {
|
|
105
|
+
return { response: new Response("unauthorized", { status: 401 }) };
|
|
106
|
+
}
|
|
107
|
+
let envelope: DrainEnvelope | undefined;
|
|
108
|
+
try {
|
|
109
|
+
envelope = parseEnvelope(JSON.parse(read.body));
|
|
110
|
+
} catch {
|
|
111
|
+
// handled below
|
|
112
|
+
}
|
|
113
|
+
if (!envelope) return { response: new Response("bad request", { status: 400 }) };
|
|
114
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
115
|
+
if (Math.abs(now.getTime() - envelope.issuedAt) > NOTION_INGRESS_DRAIN_MAX_SKEW_MS) {
|
|
116
|
+
return { response: new Response("unauthorized", { status: 401 }) };
|
|
117
|
+
}
|
|
118
|
+
const key: NotionIngressDrainKey = {
|
|
119
|
+
source: NOTION_INGRESS_SOURCE,
|
|
120
|
+
authorityId: envelope.authorityId,
|
|
121
|
+
deliveryId: envelope.deliveryId,
|
|
122
|
+
};
|
|
123
|
+
const staleBefore = new Date(now.getTime() - NOTION_INGRESS_LEASE_MS).toISOString();
|
|
124
|
+
const claim = await deps.store.claimIngressDelivery(
|
|
125
|
+
deps.org,
|
|
126
|
+
key,
|
|
127
|
+
(deps.claimId ?? randomUUID)(),
|
|
128
|
+
now.toISOString(),
|
|
129
|
+
staleBefore,
|
|
130
|
+
);
|
|
131
|
+
// A duplicate nudge, active lease or completed tombstone is already safely represented in the
|
|
132
|
+
// store. Return 200 so the self-client does not amplify it; the minute scan handles stale work.
|
|
133
|
+
if (!claim) return { response: new Response("ok", { status: 200 }) };
|
|
134
|
+
const payload = decodeNotionIngressPayload(claim.payloadJson);
|
|
135
|
+
if (!payload) {
|
|
136
|
+
await deps.store.releaseIngressDelivery(
|
|
137
|
+
deps.org,
|
|
138
|
+
key,
|
|
139
|
+
claim.claimId,
|
|
140
|
+
new Date(now.getTime() + NOTION_INGRESS_RETRY_MS).toISOString(),
|
|
141
|
+
);
|
|
142
|
+
return { response: new Response("stored payload invalid", { status: 500 }) };
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
accepted: {
|
|
146
|
+
key,
|
|
147
|
+
claim,
|
|
148
|
+
rawBody: JSON.stringify(payload.event),
|
|
149
|
+
signerCredentialId: key.authorityId,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Finish one primary claim according to the processor's explicit semantic result. */
|
|
155
|
+
export async function settleNotionIngressDrain(
|
|
156
|
+
accepted: AcceptedNotionIngressDrain,
|
|
157
|
+
disposition: NotionIngressDisposition,
|
|
158
|
+
deps: Pick<AcceptNotionIngressDrainDeps, "org" | "store" | "now">,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
161
|
+
if (disposition === "discard") {
|
|
162
|
+
await deps.store.discardIngressDelivery(deps.org, accepted.key, accepted.claim.claimId);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (disposition === "retry") {
|
|
166
|
+
await deps.store.releaseIngressDelivery(
|
|
167
|
+
deps.org,
|
|
168
|
+
accepted.key,
|
|
169
|
+
accepted.claim.claimId,
|
|
170
|
+
new Date(now.getTime() + NOTION_INGRESS_RETRY_MS).toISOString(),
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await deps.store.completeIngressDelivery(
|
|
175
|
+
deps.org,
|
|
176
|
+
accepted.key,
|
|
177
|
+
accepted.claim.claimId,
|
|
178
|
+
now.toISOString(),
|
|
179
|
+
new Date(now.getTime() + NOTION_INGRESS_TOMBSTONE_MS).toISOString(),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A comment-level guard for the crash seam between "accepted by Eve" and primary-row completion.
|
|
185
|
+
* Its lease is deliberately the same length as the primary retry delay: a retry never mistakes an
|
|
186
|
+
* active pre-dispatch lease for a completed duplicate. A stale lease is stolen and may duplicate
|
|
187
|
+
* after an ambiguous post-dispatch crash, which is the honest at-least-once side of the choice.
|
|
188
|
+
*/
|
|
189
|
+
export function createNotionCommentDispatchGuard(input: {
|
|
190
|
+
readonly org: string;
|
|
191
|
+
readonly store: OperationalStore;
|
|
192
|
+
readonly now?: () => Date;
|
|
193
|
+
readonly claimId?: () => string;
|
|
194
|
+
}): NotionCommentDispatchGuard {
|
|
195
|
+
let held: { key: IngressDeliveryKey; claimId: string } | undefined;
|
|
196
|
+
return {
|
|
197
|
+
async claim(commentId) {
|
|
198
|
+
const now = (input.now ?? (() => new Date()))();
|
|
199
|
+
const key: IngressDeliveryKey = {
|
|
200
|
+
source: NOTION_COMMENT_DISPATCH_SOURCE,
|
|
201
|
+
authorityId: NOTION_COMMENT_DISPATCH_AUTHORITY,
|
|
202
|
+
deliveryId: commentId,
|
|
203
|
+
};
|
|
204
|
+
await input.store.enqueueIngressDelivery(input.org, {
|
|
205
|
+
...key,
|
|
206
|
+
payloadJson: JSON.stringify({ schemaVersion: 1 }),
|
|
207
|
+
receivedAt: now.toISOString(),
|
|
208
|
+
});
|
|
209
|
+
const claim = await input.store.claimIngressDelivery(
|
|
210
|
+
input.org,
|
|
211
|
+
key,
|
|
212
|
+
(input.claimId ?? randomUUID)(),
|
|
213
|
+
now.toISOString(),
|
|
214
|
+
new Date(now.getTime() - NOTION_INGRESS_LEASE_MS).toISOString(),
|
|
215
|
+
);
|
|
216
|
+
if (!claim) return false;
|
|
217
|
+
held = { key, claimId: claim.claimId };
|
|
218
|
+
return true;
|
|
219
|
+
},
|
|
220
|
+
async complete(commentId) {
|
|
221
|
+
if (!held || held.key.deliveryId !== commentId) throw new Error("Notion dispatch guard was not claimed");
|
|
222
|
+
const now = (input.now ?? (() => new Date()))();
|
|
223
|
+
const completed = await input.store.completeIngressDelivery(
|
|
224
|
+
input.org,
|
|
225
|
+
held.key,
|
|
226
|
+
held.claimId,
|
|
227
|
+
now.toISOString(),
|
|
228
|
+
new Date(now.getTime() + NOTION_COMMENT_DISPATCH_TOMBSTONE_MS).toISOString(),
|
|
229
|
+
);
|
|
230
|
+
if (!completed) throw new Error("Notion dispatch guard completion lost its lease");
|
|
231
|
+
held = undefined;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface PostNotionIngressDrainDeps {
|
|
237
|
+
readonly secret?: string;
|
|
238
|
+
readonly baseUrl?: string;
|
|
239
|
+
readonly fetchImpl?: typeof fetch;
|
|
240
|
+
readonly now?: () => Date;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Post one content-free row key to this deployment's private drain route. */
|
|
244
|
+
export async function postNotionIngressDrain(
|
|
245
|
+
key: NotionIngressDrainKey,
|
|
246
|
+
deps: PostNotionIngressDrainDeps = {},
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const base = deps.baseUrl ?? selfDispatchBaseUrl();
|
|
249
|
+
const secret = await drainSecret(deps.secret);
|
|
250
|
+
if (!base || !secret) throw new Error("Notion ingress drain is unavailable");
|
|
251
|
+
const body = JSON.stringify({
|
|
252
|
+
authorityId: key.authorityId,
|
|
253
|
+
deliveryId: key.deliveryId,
|
|
254
|
+
issuedAt: (deps.now ?? (() => new Date()))().getTime(),
|
|
255
|
+
} satisfies DrainEnvelope);
|
|
256
|
+
const response = await (deps.fetchImpl ?? fetch)(`${base}${NOTION_INGRESS_DRAIN_PATH}`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: {
|
|
259
|
+
"content-type": "application/json",
|
|
260
|
+
[NOTION_INGRESS_DRAIN_SIGNATURE_HEADER]: signNotionIngressDrainBody(secret, body),
|
|
261
|
+
},
|
|
262
|
+
body,
|
|
263
|
+
signal: AbortSignal.timeout(20_000),
|
|
264
|
+
});
|
|
265
|
+
if (!response.ok) throw new Error(`Notion ingress drain returned ${response.status}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One recovery tick. A failed self-hop leaves the row pending for the next minute. */
|
|
269
|
+
export async function recoverNotionIngress(
|
|
270
|
+
org: string,
|
|
271
|
+
store: OperationalStore,
|
|
272
|
+
deps: PostNotionIngressDrainDeps & { readonly now?: () => Date } = {},
|
|
273
|
+
): Promise<{ readonly due: number; readonly posted: number; readonly failed: number }> {
|
|
274
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
275
|
+
const due = await store.listDueIngressDeliveries(
|
|
276
|
+
org,
|
|
277
|
+
NOTION_INGRESS_SOURCE,
|
|
278
|
+
now.toISOString(),
|
|
279
|
+
new Date(now.getTime() - NOTION_INGRESS_LEASE_MS).toISOString(),
|
|
280
|
+
NOTION_INGRESS_DRAIN_BATCH,
|
|
281
|
+
);
|
|
282
|
+
const results = await Promise.allSettled(due.map((key) => postNotionIngressDrain({
|
|
283
|
+
source: NOTION_INGRESS_SOURCE,
|
|
284
|
+
authorityId: key.authorityId,
|
|
285
|
+
deliveryId: key.deliveryId,
|
|
286
|
+
}, deps)));
|
|
287
|
+
const posted = results.filter((result) => result.status === "fulfilled").length;
|
|
288
|
+
return { due: due.length, posted, failed: due.length - posted };
|
|
289
|
+
}
|