@open-everywhere/provider-notion 0.6.0 → 0.8.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 (52) hide show
  1. package/agent/channels/notion-api.ts +27 -5
  2. package/agent/channels/notion-engagement.ts +3 -2
  3. package/agent/channels/notion-event-reach.ts +36 -0
  4. package/agent/channels/notion-scope.ts +3 -0
  5. package/agent/channels/notion-supervisor.ts +3 -3
  6. package/agent/channels/notion-webhook-timing.ts +4 -2
  7. package/agent/channels/notion.ts +5 -1
  8. package/agent/docs-generated.ts +422 -3
  9. package/agent/register.ts +5 -0
  10. package/agent/surface-parity.ts +1 -0
  11. package/agent/tools/read_notion_comments.ts +20 -1
  12. package/agent/tools/read_notion_page.ts +2 -0
  13. package/dist/agent/channels/notion-api.d.ts +6 -23
  14. package/dist/agent/channels/notion-api.d.ts.map +1 -1
  15. package/dist/agent/channels/notion-api.js +30 -6
  16. package/dist/agent/channels/notion-api.js.map +1 -1
  17. package/dist/agent/channels/notion-engagement.d.ts +2 -1
  18. package/dist/agent/channels/notion-engagement.d.ts.map +1 -1
  19. package/dist/agent/channels/notion-engagement.js.map +1 -1
  20. package/dist/agent/channels/notion-event-reach.d.ts +8 -0
  21. package/dist/agent/channels/notion-event-reach.d.ts.map +1 -0
  22. package/dist/agent/channels/notion-event-reach.js +39 -0
  23. package/dist/agent/channels/notion-event-reach.js.map +1 -0
  24. package/dist/agent/channels/notion-scope.d.ts.map +1 -1
  25. package/dist/agent/channels/notion-scope.js +3 -1
  26. package/dist/agent/channels/notion-scope.js.map +1 -1
  27. package/dist/agent/channels/notion-supervisor.d.ts +0 -28
  28. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -1
  29. package/dist/agent/channels/notion-supervisor.js +3 -3
  30. package/dist/agent/channels/notion-supervisor.js.map +1 -1
  31. package/dist/agent/channels/notion-webhook-timing.d.ts +3 -2
  32. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -1
  33. package/dist/agent/channels/notion-webhook-timing.js.map +1 -1
  34. package/dist/agent/channels/notion.d.ts.map +1 -1
  35. package/dist/agent/channels/notion.js +5 -2
  36. package/dist/agent/channels/notion.js.map +1 -1
  37. package/dist/agent/docs-generated.d.ts.map +1 -1
  38. package/dist/agent/docs-generated.js +422 -3
  39. package/dist/agent/docs-generated.js.map +1 -1
  40. package/dist/agent/register.d.ts.map +1 -1
  41. package/dist/agent/register.js +5 -1
  42. package/dist/agent/register.js.map +1 -1
  43. package/dist/agent/surface-parity.d.ts.map +1 -1
  44. package/dist/agent/surface-parity.js +1 -0
  45. package/dist/agent/surface-parity.js.map +1 -1
  46. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -1
  47. package/dist/agent/tools/read_notion_comments.js +21 -2
  48. package/dist/agent/tools/read_notion_comments.js.map +1 -1
  49. package/dist/agent/tools/read_notion_page.d.ts.map +1 -1
  50. package/dist/agent/tools/read_notion_page.js +2 -0
  51. package/dist/agent/tools/read_notion_page.js.map +1 -1
  52. package/package.json +6 -2
@@ -23,6 +23,7 @@ import {
23
23
  CredentialError,
24
24
  findConnectorCredential,
25
25
  guardCredentialFetch,
26
+ friendlyOutboundText,
26
27
  withRetryAfter,
27
28
  type CredentialEndpointRules,
28
29
  type RateLimitRetryOptions,
@@ -33,7 +34,7 @@ import type { NotionParentLink } from "./notion-ancestry-trust.js";
33
34
  /** Notion REST base + the API version header value. The version is a code constant (OPE-2411):
34
35
  * it is a stable version that supports the comment retrieve/create endpoints and rich-text
35
36
  * `mention` objects — everything this surface needs — and a version bump is a code change. */
36
- export const NOTION_API_BASE = "https://api.notion.com";
37
+ const NOTION_API_BASE = "https://api.notion.com";
37
38
  const NOTION_API_VERSION = "2022-06-28";
38
39
 
39
40
  /**
@@ -421,6 +422,25 @@ export function notionOperation(method: string | undefined, url: string): string
421
422
  return `${verb} ${shape}`;
422
423
  }
423
424
 
425
+ /**
426
+ * THE FRIENDLY-NAMES GUARD (OPE-2482), on the one Notion transport. A comment create or edit renders
427
+ * every raw id in its rich-text content as a name or plain words before it leaves. Every other call, and
428
+ * a body this cannot parse, goes out unchanged.
429
+ */
430
+ export async function friendlyNotionInit(url: string, init: RequestInit): Promise<RequestInit> {
431
+ if (typeof init.body !== "string" || !/\/v1\/comments(?:\/|$|\?)/.test(url) || (init.method !== "POST" && init.method !== "PATCH")) return init;
432
+ try {
433
+ const parsed = JSON.parse(init.body) as { rich_text?: unknown };
434
+ if (!Array.isArray(parsed.rich_text)) return init;
435
+ for (const item of parsed.rich_text as Array<{ text?: { content?: unknown } }>) {
436
+ if (item?.text && typeof item.text.content === "string") item.text.content = await friendlyOutboundText(item.text.content, "notion");
437
+ }
438
+ return { ...init, body: JSON.stringify(parsed) };
439
+ } catch {
440
+ return init;
441
+ }
442
+ }
443
+
424
444
  /**
425
445
  * THE ONE PLACE a Notion REST call leaves this host. Every helper below dispatches through here, so
426
446
  * the credential's endpoint fence ({@link NotionCallOpts.endpoints}) is applied on every path
@@ -459,9 +479,11 @@ export function notionOperation(method: string | undefined, url: string): string
459
479
  * per call, not streams a first attempt would consume. A body that WAS a stream could not be replayed
460
480
  * — do not introduce one here without giving `send` a fresh body per attempt.
461
481
  */
462
- function notionFetch(url: string, init: RequestInit, opts: NotionCallOpts): Promise<Response> {
482
+ async function notionFetch(url: string, init: RequestInit, opts: NotionCallOpts): Promise<Response> {
463
483
  const base = opts.fetch ?? (globalThis.fetch as FetchLike);
464
484
  const send = guardCredentialFetch(opts.endpoints, "notion credential", base);
485
+ // The friendly-names guard renders a comment body ONCE, so every retry re-sends the same bytes.
486
+ init = await friendlyNotionInit(url, init);
465
487
  return withRetryAfter(() => send(url, init), {
466
488
  ...opts.retry,
467
489
  surface: "notion",
@@ -584,7 +606,7 @@ export interface NotionBlockParent {
584
606
 
585
607
  /** GET /v1/blocks/{block_id} — used ONLY to answer "which page owns this block?" for the domain
586
608
  * fence (channels/notion-scope.ts). Throws on a non-2xx response. */
587
- export async function retrieveBlock(blockId: string, opts: NotionCallOpts): Promise<NotionBlockParent> {
609
+ async function retrieveBlock(blockId: string, opts: NotionCallOpts): Promise<NotionBlockParent> {
588
610
  const res = await notionFetch(`${NOTION_API_BASE}/v1/blocks/${encodeURIComponent(blockId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
589
611
  if (!res.ok) throw new Error(`retrieveBlock ${blockId} failed: ${res.status} ${await safeText(res)}`);
590
612
  return (await res.json()) as NotionBlockParent;
@@ -602,7 +624,7 @@ export async function retrieveBlock(blockId: string, opts: NotionCallOpts): Prom
602
624
  * runs out of hops, or the lookup throws — every one of which the caller must treat as a REFUSAL,
603
625
  * never as permission.
604
626
  */
605
- export const BLOCK_PARENT_MAX_HOPS = 5;
627
+ const BLOCK_PARENT_MAX_HOPS = 5;
606
628
  export async function resolveBlockOwningPage(blockId: string, opts: NotionCallOpts): Promise<string | undefined> {
607
629
  let id = blockId;
608
630
  for (let hop = 0; hop < BLOCK_PARENT_MAX_HOPS; hop++) {
@@ -637,7 +659,7 @@ export interface NotionPageParent {
637
659
 
638
660
  /** GET /v1/pages/{page_id} — used ONLY to answer "what owns this page?" for the ancestor walk
639
661
  * (channels/notion-ancestry-trust.ts). Throws on a non-2xx response. */
640
- export async function retrievePage(pageId: string, opts: NotionCallOpts): Promise<NotionPageParent> {
662
+ async function retrievePage(pageId: string, opts: NotionCallOpts): Promise<NotionPageParent> {
641
663
  const res = await notionFetch(`${NOTION_API_BASE}/v1/pages/${encodeURIComponent(pageId)}`, { method: "GET", headers: notionHeaders(opts) }, opts);
642
664
  if (!res.ok) throw new Error(`retrievePage ${pageId} failed: ${res.status} ${await safeText(res)}`);
643
665
  return (await res.json()) as NotionPageParent;
@@ -28,6 +28,7 @@ import { buildNotionAuth, notionConversationKey } from "./notion-identity.js";
28
28
  // TYPE ONLY — it erases, so the "this module imports only notion-identity.ts and core" note above
29
29
  // `markNotionEngaged`'s use in notion-supervisor.ts still holds and no cycle is added.
30
30
  import type { NotionVia } from "./notion-scope.js";
31
+ import type { AuditDetail, AuditDetailBuilder } from "@open-everywhere/core/agent/adapter-api.js";
31
32
 
32
33
  /** The facts one Notion delivery contributes to the seam — everything the anchor and the partition
33
34
  * need, and nothing about the comment's text. */
@@ -363,8 +364,8 @@ export interface NotionDispatchJoinIds {
363
364
  * An absent value is OMITTED rather than written as `undefined`: an audit row that carries a key
364
365
  * with no value reads, to anyone querying it, like a value that was looked for and found empty.
365
366
  */
366
- export function notionDispatchJoinDetail(ids: NotionDispatchJoinIds): Record<string, string> {
367
- const out: Record<string, string> = {};
367
+ export function notionDispatchJoinDetail(ids: NotionDispatchJoinIds): AuditDetail {
368
+ const out: AuditDetailBuilder = {};
368
369
  if (ids.deliveryId) out.deliveryId = ids.deliveryId;
369
370
  if (ids.commentId) out.commentId = ids.commentId;
370
371
  if (ids.wakeId) out.wakeId = ids.wakeId;
@@ -0,0 +1,36 @@
1
+ // DO NOTION'S EVENTS REACH THIS DEPLOYMENT? (OPE-2547) — Notion's probe for core's routines/event-reach.ts.
2
+ //
3
+ // The native Notion route hands a delivery to the event plane only when an integration's inbound secret
4
+ // VERIFIED it (./notion.ts, the event-plane leg is gated on `verified`). The `webhooks.allowUnverifiedNotion`
5
+ // hatch never fires a routine, so it does not count here. With no integration's inbound secret configured,
6
+ // no Notion routine can fire. This probe resolves each integration's secret through `getOperatorSecret`, the
7
+ // resolver the route uses, and keeps only whether one resolved.
8
+ //
9
+ // 🔴 ONLY "NOT SET" IS A NO. `OperatorSecretNotSetError` says the secret is set nowhere, so the answer is
10
+ // false. Every other failure (an unreadable config, a key that is not armed, a decrypt error) is no answer at
11
+ // all, so the probe THROWS and core reads `unknown`. A false "no" would tell every Notion watch's room that it
12
+ // is dead. One case remains: `getOperatorSecret` falls back to the env var when the credential store is
13
+ // unreachable, so a secret kept only in the store reads as not set during a store outage.
14
+
15
+ import { getConfig, getOperatorSecret, OperatorSecretNotSetError } from "@open-everywhere/core/agent/adapter-api.js";
16
+ import { notionIntegrations } from "./notion-credentials.js";
17
+
18
+ /**
19
+ * True when at least one Notion integration's inbound signing secret resolves. The route admits a delivery signed
20
+ * with ANY key that resolves, so one broken key does not decide: the probe tries every name. It throws only when no
21
+ * key resolved and at least one failed in a way other than "not set".
22
+ */
23
+ export async function notionEventsReach(get: typeof getOperatorSecret = getOperatorSecret): Promise<boolean> {
24
+ // An unreadable config throws through: no answer, never "no integration".
25
+ const names = [...new Set(notionIntegrations(getConfig().loadDomains()).map((i) => i.inboundSecret))];
26
+ let failure: unknown;
27
+ for (const name of names) {
28
+ try {
29
+ if ((await get(name)).value.length > 0) return true;
30
+ } catch (err) {
31
+ if (!(err instanceof OperatorSecretNotSetError) && failure === undefined) failure = err;
32
+ }
33
+ }
34
+ if (failure !== undefined) throw failure;
35
+ return false;
36
+ }
@@ -44,6 +44,7 @@
44
44
 
45
45
  import {
46
46
  appendAudit,
47
+ auditSurfaceModeFrom,
47
48
  bindFrom,
48
49
  domainReachVerdict,
49
50
  domainRoster,
@@ -431,6 +432,8 @@ async function auditNotionReadRefusal(
431
432
  platform: "notion",
432
433
  workspace: foreignCaller ? "unknown" : partition.workspace,
433
434
  surface: foreignCaller ? "unknown" : partition.surface,
435
+ // OPE-1940: the mode belongs to the caller's own surface, so a foreign-caller row carries none.
436
+ ...(foreignCaller ? {} : auditSurfaceModeFrom(ctx)),
434
437
  ...(principalId ? { principalId } : {}),
435
438
  action: "read.refused",
436
439
  detail: {
@@ -658,8 +658,8 @@ export function notionIngestedMessages(d: {
658
658
  }
659
659
 
660
660
  /**
661
- * THE EXTERNAL-AUTHOR DECISION for one Notion dispatch (OPE-507). Pure, exported so the three
662
- * behaviours are testable without a store or a live wake.
661
+ * THE EXTERNAL-AUTHOR DECISION for one Notion dispatch (OPE-507). Pure, so the three behaviours
662
+ * need no store and no live wake.
663
663
  *
664
664
  * Notion's three answers, and why they are what they are:
665
665
  * • the TRIGGER's author is excluded ⇒ NO WAKE AT ALL (`suppressed`). That covers behaviour 2
@@ -676,7 +676,7 @@ export function notionIngestedMessages(d: {
676
676
  * message" into a shared doc is a disclosure, not a notice, so the Notion answer is to do nothing.
677
677
  * The surfaces that HAVE a private channel (a Discord DM, a Slack DM) post the notice there.
678
678
  */
679
- export function notionExternalAuthorDecision(d: {
679
+ function notionExternalAuthorDecision(d: {
680
680
  readonly trigger: NotionWakeMessage;
681
681
  readonly threadComments: readonly NotionWakeMessage[];
682
682
  readonly ingested: readonly IngestedChatMessage[];
@@ -5,6 +5,8 @@
5
5
  // this collector pure and injectable: production records one content-free sample, while a fake
6
6
  // clock can prove the attribution without sleeping or calling a service.
7
7
 
8
+ import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
9
+
8
10
  export const NOTION_WEBHOOK_TIMING_ACTION = "notion.webhook_timing";
9
11
 
10
12
  export type NotionWebhookTimingPhase =
@@ -17,8 +19,8 @@ export type NotionWebhookTimingPhase =
17
19
  | "eveSend"
18
20
  | "finalAudit";
19
21
 
20
- export interface NotionWebhookTimingDetail {
21
- readonly [key: string]: unknown;
22
+ /** An audit detail, so its error-named keys carry the OPE-2549 brand guard. It holds none today. */
23
+ export interface NotionWebhookTimingDetail extends AuditDetail {
22
24
  readonly outcome: string;
23
25
  readonly failedPhase?: NotionWebhookTimingPhase;
24
26
  readonly hmacCredentialMs?: number;
@@ -58,11 +58,13 @@
58
58
  // notion-ambient-surface-design.md Step 5 / the e2e-issue-surfaces driver investigation).
59
59
  // ────────────────────────────────────────────────────────────────────────────────────────
60
60
 
61
+ import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
61
62
  import { defineChannel, POST, type RouteHandlerArgs } from "eve/channels";
62
63
  import {
63
64
  receiptEventTime,
64
65
  spreadVendorTs,
65
66
  appendAudit,
67
+ auditSurfaceModeFrom,
66
68
  ingressSpendGate,
67
69
  auditAmbientSkip,
68
70
  deploymentOrg,
@@ -592,7 +594,7 @@ async function deliverNotionAnswer(input: {
592
594
  { continuationToken: input.token, context: input.context },
593
595
  );
594
596
  } catch (err) {
595
- const error = (err instanceof Error ? err.message : String(err)).slice(0, 300);
597
+ const error = auditErrorDetail(err).error;
596
598
  log(`answer ${input.answer.requestId} could NOT be delivered (${error}) — falling back to an ordinary wake, the question stays open`);
597
599
  await appendAudit({
598
600
  org: input.answerOrg,
@@ -841,6 +843,8 @@ const notionWebhookRouteHandler = timedNotionWebhookRoute(
841
843
  platform: "notion",
842
844
  workspace: d.workspaceId ?? "unknown",
843
845
  surface: d.pageId || d.discussionId || "unknown",
846
+ // OPE-1940: the RESOLVED mode, from the same delivery auth the wake binds.
847
+ ...auditSurfaceModeFrom({ channel: { kind: "notion" }, session: { auth: { current: d.auth } } } as unknown as Parameters<typeof auditSurfaceModeFrom>[0]),
844
848
  principalId: d.auth.principalId || undefined,
845
849
  action: "notion.dispatched",
846
850
  detail: {