@aitne/shared 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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/dist/advisor-models.d.ts +34 -0
  3. package/dist/advisor-models.d.ts.map +1 -0
  4. package/dist/advisor-models.js +39 -0
  5. package/dist/advisor-models.js.map +1 -0
  6. package/dist/agent-identity.d.ts +11 -0
  7. package/dist/agent-identity.d.ts.map +1 -0
  8. package/dist/agent-identity.js +29 -0
  9. package/dist/agent-identity.js.map +1 -0
  10. package/dist/alerts.d.ts +44 -0
  11. package/dist/alerts.d.ts.map +1 -0
  12. package/dist/alerts.js +12 -0
  13. package/dist/alerts.js.map +1 -0
  14. package/dist/backend-api-key-config.d.ts +337 -0
  15. package/dist/backend-api-key-config.d.ts.map +1 -0
  16. package/dist/backend-api-key-config.js +682 -0
  17. package/dist/backend-api-key-config.js.map +1 -0
  18. package/dist/backend.d.ts +93 -0
  19. package/dist/backend.d.ts.map +1 -0
  20. package/dist/backend.js +22 -0
  21. package/dist/backend.js.map +1 -0
  22. package/dist/branding.d.ts +96 -0
  23. package/dist/branding.d.ts.map +1 -0
  24. package/dist/branding.js +102 -0
  25. package/dist/branding.js.map +1 -0
  26. package/dist/chat-session-scope.d.ts +14 -0
  27. package/dist/chat-session-scope.d.ts.map +1 -0
  28. package/dist/chat-session-scope.js +18 -0
  29. package/dist/chat-session-scope.js.map +1 -0
  30. package/dist/date-utils.d.ts +80 -0
  31. package/dist/date-utils.d.ts.map +1 -0
  32. package/dist/date-utils.js +187 -0
  33. package/dist/date-utils.js.map +1 -0
  34. package/dist/docs-frontmatter.d.ts +51 -0
  35. package/dist/docs-frontmatter.d.ts.map +1 -0
  36. package/dist/docs-frontmatter.js +184 -0
  37. package/dist/docs-frontmatter.js.map +1 -0
  38. package/dist/docs-schema.d.ts +79 -0
  39. package/dist/docs-schema.d.ts.map +1 -0
  40. package/dist/docs-schema.js +135 -0
  41. package/dist/docs-schema.js.map +1 -0
  42. package/dist/editable-config-keys.d.ts +14 -0
  43. package/dist/editable-config-keys.d.ts.map +1 -0
  44. package/dist/editable-config-keys.js +157 -0
  45. package/dist/editable-config-keys.js.map +1 -0
  46. package/dist/exec-with-stdin.d.ts +14 -0
  47. package/dist/exec-with-stdin.d.ts.map +1 -0
  48. package/dist/exec-with-stdin.js +35 -0
  49. package/dist/exec-with-stdin.js.map +1 -0
  50. package/dist/index.d.ts +37 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +49 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/integrations-snapshot.d.ts +183 -0
  55. package/dist/integrations-snapshot.d.ts.map +1 -0
  56. package/dist/integrations-snapshot.js +757 -0
  57. package/dist/integrations-snapshot.js.map +1 -0
  58. package/dist/integrations.d.ts +675 -0
  59. package/dist/integrations.d.ts.map +1 -0
  60. package/dist/integrations.js +1656 -0
  61. package/dist/integrations.js.map +1 -0
  62. package/dist/keychain-helper-client.d.ts +31 -0
  63. package/dist/keychain-helper-client.d.ts.map +1 -0
  64. package/dist/keychain-helper-client.js +105 -0
  65. package/dist/keychain-helper-client.js.map +1 -0
  66. package/dist/log-entry.d.ts +14 -0
  67. package/dist/log-entry.d.ts.map +1 -0
  68. package/dist/log-entry.js +2 -0
  69. package/dist/log-entry.js.map +1 -0
  70. package/dist/management-domains.d.ts +369 -0
  71. package/dist/management-domains.d.ts.map +1 -0
  72. package/dist/management-domains.js +499 -0
  73. package/dist/management-domains.js.map +1 -0
  74. package/dist/process-key.d.ts +67 -0
  75. package/dist/process-key.d.ts.map +1 -0
  76. package/dist/process-key.js +366 -0
  77. package/dist/process-key.js.map +1 -0
  78. package/dist/schemas.d.ts +267 -0
  79. package/dist/schemas.d.ts.map +1 -0
  80. package/dist/schemas.js +271 -0
  81. package/dist/schemas.js.map +1 -0
  82. package/dist/secret-client-factory.d.ts +16 -0
  83. package/dist/secret-client-factory.d.ts.map +1 -0
  84. package/dist/secret-client-factory.js +111 -0
  85. package/dist/secret-client-factory.js.map +1 -0
  86. package/dist/secret-client-file.d.ts +51 -0
  87. package/dist/secret-client-file.d.ts.map +1 -0
  88. package/dist/secret-client-file.js +160 -0
  89. package/dist/secret-client-file.js.map +1 -0
  90. package/dist/secret-client-linux.d.ts +26 -0
  91. package/dist/secret-client-linux.d.ts.map +1 -0
  92. package/dist/secret-client-linux.js +63 -0
  93. package/dist/secret-client-linux.js.map +1 -0
  94. package/dist/secret-client-windows.d.ts +37 -0
  95. package/dist/secret-client-windows.d.ts.map +1 -0
  96. package/dist/secret-client-windows.js +82 -0
  97. package/dist/secret-client-windows.js.map +1 -0
  98. package/dist/secret-redaction.d.ts +3 -0
  99. package/dist/secret-redaction.d.ts.map +1 -0
  100. package/dist/secret-redaction.js +31 -0
  101. package/dist/secret-redaction.js.map +1 -0
  102. package/dist/skill-curation/decision-language.d.ts +6 -0
  103. package/dist/skill-curation/decision-language.d.ts.map +1 -0
  104. package/dist/skill-curation/decision-language.js +38 -0
  105. package/dist/skill-curation/decision-language.js.map +1 -0
  106. package/dist/skill-curation/schemas.d.ts +461 -0
  107. package/dist/skill-curation/schemas.d.ts.map +1 -0
  108. package/dist/skill-curation/schemas.js +211 -0
  109. package/dist/skill-curation/schemas.js.map +1 -0
  110. package/dist/types.d.ts +204 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +54 -0
  113. package/dist/types.js.map +1 -0
  114. package/package.json +50 -0
@@ -0,0 +1,757 @@
1
+ import { createHash } from "node:crypto";
2
+ import { INTEGRATION_KEYS } from "./integrations.js";
3
+ /**
4
+ * Default TTL for `integration_writes` entries, per integration.
5
+ *
6
+ * INTEGRATION-DRIFT-DETECTION-PLAN.md §17.11 requires `TTL ≥ slowest_cadence
7
+ * × 1.5` so an agent-originated write at T0 cannot have its attribution
8
+ * mark expire before the next reconcile sees it as a user write. Phase 7
9
+ * (c) tightens these constants to cover the §8.3 default cadences with
10
+ * margin:
11
+ *
12
+ * - google_calendar: slowest cadence is the 24h-window default (60 min)
13
+ * → 60 × 1.5 = 90 min. The 10-min imminent cadence is irrelevant for
14
+ * TTL because an agent-originated event always lands inside the 24h
15
+ * window first; the slowest cadence governs the contract.
16
+ * - gmail: 30 min default → 45 min. The 15-min soft floor gives the
17
+ * same TTL margin if the operator presses cadence to its minimum.
18
+ * - notion: 60 min default → 90 min. Pre-Phase-7 this was 30 min, which
19
+ * left the agent's own page edits at risk of mis-attribution on the
20
+ * very next worker tick.
21
+ *
22
+ * Operators who push a cadence past the corresponding TTL via
23
+ * `runtime_state.delegatedSync.intervals` opt into the pre-fix regime.
24
+ * The DelegatedSyncWorker logs warn at start and surfaces the violation
25
+ * via `getStatus().ttlContractViolations` so the breach is observable
26
+ * rather than silent.
27
+ */
28
+ export const INTEGRATION_WRITE_TTL_MS = {
29
+ google_calendar: 90 * 60 * 1000,
30
+ gmail: 45 * 60 * 1000,
31
+ notion: 90 * 60 * 1000,
32
+ // Git/GitHub do not currently participate in delegated drift snapshots,
33
+ // but IntegrationKey is intentionally exhaustive. Keep a conservative
34
+ // value so generic write-tracker callers never fall through to undefined.
35
+ git: 90 * 60 * 1000,
36
+ github: 90 * 60 * 1000,
37
+ // SETUP-FLOW-REDESIGN-PLAN §6.1 — Outlook mail goes through the unified
38
+ // mail poller, same cadence floor as Gmail. Outlook calendar has no
39
+ // poller in v1 (`observersTouched: []`); the value is set defensively so
40
+ // callers never see an undefined TTL if a snapshot path is ever wired
41
+ // up.
42
+ outlook_mail: 45 * 60 * 1000,
43
+ outlook_calendar: 90 * 60 * 1000,
44
+ };
45
+ function asString(v) {
46
+ return typeof v === "string" && v.length > 0 ? v : null;
47
+ }
48
+ function extractTime(field) {
49
+ if (typeof field === "string")
50
+ return field.length > 0 ? field : null;
51
+ if (field == null)
52
+ return null;
53
+ if (typeof field !== "object")
54
+ return null;
55
+ const dateTime = asString(field.dateTime);
56
+ if (dateTime !== null)
57
+ return dateTime;
58
+ return asString(field.date);
59
+ }
60
+ function normalizeTimeForRange(value) {
61
+ if (value === null)
62
+ return null;
63
+ const ms = Date.parse(value);
64
+ if (!Number.isFinite(ms))
65
+ return null;
66
+ return new Date(ms).toISOString();
67
+ }
68
+ function normalizeAttendees(raw) {
69
+ if (!Array.isArray(raw))
70
+ return [];
71
+ const out = [];
72
+ for (const a of raw) {
73
+ if (a == null || typeof a !== "object")
74
+ continue;
75
+ const email = asString(a.email);
76
+ if (email === null)
77
+ continue;
78
+ const responseStatus = asString(a.responseStatus)
79
+ ?? "needsAction";
80
+ out.push({ email, responseStatus });
81
+ }
82
+ // Sort by email so two responses with the same set produce the same
83
+ // hash regardless of API ordering. Stable enough — duplicate-email
84
+ // entries (Google occasionally yields these) preserve their relative
85
+ // order via Array.prototype.sort's algorithmic stability.
86
+ out.sort((a, b) => (a.email < b.email ? -1 : a.email > b.email ? 1 : 0));
87
+ return out;
88
+ }
89
+ /**
90
+ * Recurring-event instance keys. Per §5.2 + §13: instance-level snapshots
91
+ * keyed as `${seriesId}@${start}` keep instance edits visible even when
92
+ * Google occasionally rewrites `recurringEventId`. A non-recurring event
93
+ * has no `recurringEventId`, so we fall back to the raw id.
94
+ */
95
+ function calendarItemId(raw) {
96
+ const id = asString(raw.id);
97
+ if (id === null) {
98
+ // Defensive — Google's API never omits id on a real event. Throw
99
+ // with a recognisable error so the route handler returns a precise
100
+ // 400 instead of silently storing an empty key.
101
+ throw new Error("calendar event missing id");
102
+ }
103
+ const series = asString(raw.recurringEventId);
104
+ if (series === null)
105
+ return id;
106
+ const start = extractTime(raw.start);
107
+ return start === null ? id : `${series}@${start}`;
108
+ }
109
+ function calendarPayload(raw) {
110
+ return {
111
+ summary: asString(raw.summary),
112
+ start: extractTime(raw.start),
113
+ end: extractTime(raw.end),
114
+ location: asString(raw.location),
115
+ description: normalizeWhitespace(asString(raw.description)),
116
+ status: asString(raw.status),
117
+ attendees: normalizeAttendees(raw.attendees),
118
+ htmlLink: asString(raw.htmlLink),
119
+ };
120
+ }
121
+ /**
122
+ * INTEGRATION-DRIFT-PHASE-7-PLAN.md §3.4 — collapse runs of whitespace and
123
+ * convert non-breaking-space (U+00A0) to regular space before hashing the
124
+ * calendar description. Google occasionally re-wraps long descriptions or
125
+ * substitutes NBSP into the body; without normalization the hash flaps on
126
+ * an unmodified event and surfaces as a phantom `modified` diff every
127
+ * poll. Returns null for empty input so the canonical payload still
128
+ * distinguishes "no description" from "all-whitespace description".
129
+ */
130
+ function normalizeWhitespace(value) {
131
+ if (value === null)
132
+ return null;
133
+ const collapsed = value.replace(/ /g, " ").replace(/\s+/g, " ").trim();
134
+ return collapsed.length > 0 ? collapsed : null;
135
+ }
136
+ /**
137
+ * Hash discipline: serialize a stable, sorted JSON encoding of the fields
138
+ * that semantically constitute the event. `htmlLink` is excluded — it is
139
+ * derivable from the id, and Google occasionally rewrites the path
140
+ * fragment. `updated`, `etag`, `iCalUID`, and snippet-like server-generated
141
+ * fields are likewise excluded so the hash does not flap on every poll.
142
+ */
143
+ function calendarHashableShape(p) {
144
+ return {
145
+ attendees: p.attendees,
146
+ description: p.description,
147
+ end: p.end,
148
+ location: p.location,
149
+ start: p.start,
150
+ status: p.status,
151
+ summary: p.summary,
152
+ };
153
+ }
154
+ /**
155
+ * Lexicographic-key JSON encoder. Drop-in replacement for JSON.stringify
156
+ * that walks objects with a sorted key list at every level so two
157
+ * structurally-equal payloads always serialize identically. Arrays
158
+ * preserve order — the calendar normalizer pre-sorts attendees so
159
+ * `[{email:"a"}, {email:"b"}]` and `[{email:"b"}, {email:"a"}]` are
160
+ * distinct payloads pre-normalize but identical post-normalize.
161
+ */
162
+ export function stableStringify(value) {
163
+ if (value === null || typeof value !== "object") {
164
+ return JSON.stringify(value);
165
+ }
166
+ if (Array.isArray(value)) {
167
+ return `[${value.map((v) => stableStringify(v)).join(",")}]`;
168
+ }
169
+ const keys = Object.keys(value).sort();
170
+ const parts = [];
171
+ for (const k of keys) {
172
+ const v = value[k];
173
+ if (v === undefined)
174
+ continue;
175
+ parts.push(`${JSON.stringify(k)}:${stableStringify(v)}`);
176
+ }
177
+ return `{${parts.join(",")}}`;
178
+ }
179
+ function sha256Hex(s) {
180
+ return createHash("sha256").update(s).digest("hex");
181
+ }
182
+ const calendarNormalizer = {
183
+ itemId: calendarItemId,
184
+ payload: calendarPayload,
185
+ hash: (payload) => sha256Hex(stableStringify(calendarHashableShape(payload))),
186
+ itemStart: (raw) => normalizeTimeForRange(extractTime(raw.start)),
187
+ inWindow: (payload, windowMin, windowMax) => {
188
+ const startMs = payload.start === null ? NaN : Date.parse(payload.start);
189
+ const minMs = Date.parse(windowMin);
190
+ const maxMs = Date.parse(windowMax);
191
+ if (!Number.isFinite(startMs)
192
+ || !Number.isFinite(minMs)
193
+ || !Number.isFinite(maxMs)) {
194
+ return false;
195
+ }
196
+ return startMs >= minMs && startMs < maxMs;
197
+ },
198
+ };
199
+ /**
200
+ * Extract the email address from a connector "from" representation.
201
+ * Accepts `"Name <a@b.com>"`, `"a@b.com"`, `{ email: "a@b.com" }`, and
202
+ * `{ name, email }` shapes; returns `null` for anything else. Lowercased
203
+ * so capitalisation jitter does not flap the hash.
204
+ */
205
+ function gmailCanonicalFrom(raw) {
206
+ if (raw == null)
207
+ return null;
208
+ if (typeof raw === "string") {
209
+ const trimmed = raw.trim();
210
+ if (trimmed.length === 0)
211
+ return null;
212
+ const angled = /<([^>]+)>/.exec(trimmed);
213
+ const candidate = angled?.[1] ?? trimmed;
214
+ return candidate.toLowerCase();
215
+ }
216
+ if (typeof raw === "object" && !Array.isArray(raw)) {
217
+ const email = asString(raw.email);
218
+ if (email !== null)
219
+ return email.toLowerCase();
220
+ }
221
+ return null;
222
+ }
223
+ /**
224
+ * Walk a Gmail headers list. `headers` is sometimes hosted at
225
+ * `payload.headers` (Google API native) and sometimes flattened at the
226
+ * top level (Codex / Gemini search-result wrappers). Returns the first
227
+ * matching header value (case-insensitive name match).
228
+ */
229
+ function gmailHeader(headers, name) {
230
+ if (!Array.isArray(headers))
231
+ return null;
232
+ for (const entry of headers) {
233
+ if (entry == null || typeof entry !== "object")
234
+ continue;
235
+ const headerName = asString(entry.name);
236
+ if (headerName === null)
237
+ continue;
238
+ if (headerName.toLowerCase() !== name.toLowerCase())
239
+ continue;
240
+ return asString(entry.value);
241
+ }
242
+ return null;
243
+ }
244
+ function gmailMessageHeaders(message) {
245
+ if (message.payload && typeof message.payload === "object") {
246
+ const inner = message.payload.headers;
247
+ if (inner !== undefined)
248
+ return inner;
249
+ }
250
+ return message.headers;
251
+ }
252
+ function gmailMessageId(message) {
253
+ return asString(message.id);
254
+ }
255
+ function gmailMessageInternalDateMs(message) {
256
+ const raw = message.internalDate;
257
+ if (typeof raw === "number" && Number.isFinite(raw))
258
+ return raw;
259
+ if (typeof raw === "string" && raw.length > 0) {
260
+ const asNumber = Number(raw);
261
+ if (Number.isFinite(asNumber))
262
+ return asNumber;
263
+ const parsed = Date.parse(raw);
264
+ if (Number.isFinite(parsed))
265
+ return parsed;
266
+ }
267
+ return null;
268
+ }
269
+ function gmailMessageLabelIds(message) {
270
+ if (!Array.isArray(message.labelIds))
271
+ return [];
272
+ const out = [];
273
+ for (const v of message.labelIds) {
274
+ const s = asString(v);
275
+ if (s !== null)
276
+ out.push(s);
277
+ }
278
+ return out;
279
+ }
280
+ function gmailExtractMessages(raw) {
281
+ if (Array.isArray(raw.messages)) {
282
+ return raw.messages.filter((m) => m !== null && typeof m === "object" && !Array.isArray(m));
283
+ }
284
+ return [];
285
+ }
286
+ function gmailItemId(raw) {
287
+ const threadId = asString(raw.threadId);
288
+ if (threadId !== null)
289
+ return threadId;
290
+ // Some Gmail connectors only return `id` on a single-message search hit;
291
+ // Gemini's `gmail.search` is the canonical example. The id IS the thread
292
+ // id when no separate threadId surfaces.
293
+ const id = asString(raw.id);
294
+ if (id !== null)
295
+ return id;
296
+ // Fall back to the first message's threadId if a `messages: [...]` shape
297
+ // was passed without a top-level threadId.
298
+ const messages = gmailExtractMessages(raw);
299
+ for (const m of messages) {
300
+ const tid = asString(m.threadId);
301
+ if (tid !== null)
302
+ return tid;
303
+ const mid = asString(m.id);
304
+ if (mid !== null)
305
+ return mid;
306
+ }
307
+ throw new Error("gmail thread missing threadId and id");
308
+ }
309
+ function gmailPayload(raw) {
310
+ const threadId = gmailItemId(raw);
311
+ const messages = gmailExtractMessages(raw);
312
+ // Gather subject / from / labelIds / messageIds / lastDate. Prefer the
313
+ // latest message (highest internalDate); fall back to the top-level
314
+ // fields when the connector returns a flattened search hit.
315
+ let lastDateMs = null;
316
+ let lastSubject = null;
317
+ let lastFrom = null;
318
+ const labelSet = new Set();
319
+ const messageIds = [];
320
+ for (const m of messages) {
321
+ const mid = gmailMessageId(m);
322
+ if (mid !== null)
323
+ messageIds.push(mid);
324
+ for (const label of gmailMessageLabelIds(m))
325
+ labelSet.add(label);
326
+ const dateMs = gmailMessageInternalDateMs(m);
327
+ if (dateMs !== null && (lastDateMs === null || dateMs > lastDateMs)) {
328
+ lastDateMs = dateMs;
329
+ const headers = gmailMessageHeaders(m);
330
+ lastSubject
331
+ = asString(m.subject)
332
+ ?? gmailHeader(headers, "Subject");
333
+ lastFrom = gmailCanonicalFrom(m.from)
334
+ ?? gmailCanonicalFrom(gmailHeader(headers, "From"));
335
+ }
336
+ }
337
+ // Top-level fallback for flattened search hits (Codex / Gemini singletons).
338
+ if (lastDateMs === null) {
339
+ lastDateMs = gmailMessageInternalDateMs(raw);
340
+ }
341
+ if (lastSubject === null) {
342
+ const topHeaders = gmailMessageHeaders(raw);
343
+ lastSubject = asString(raw.subject) ?? gmailHeader(topHeaders, "Subject");
344
+ }
345
+ if (lastFrom === null) {
346
+ const topHeaders = gmailMessageHeaders(raw);
347
+ lastFrom = gmailCanonicalFrom(raw.from)
348
+ ?? gmailCanonicalFrom(gmailHeader(topHeaders, "From"));
349
+ }
350
+ if (Array.isArray(raw.labelIds)) {
351
+ for (const v of raw.labelIds) {
352
+ const s = asString(v);
353
+ if (s !== null)
354
+ labelSet.add(s);
355
+ }
356
+ }
357
+ if (messageIds.length === 0) {
358
+ // Singleton search-hit shape. The thread's only known message id is the
359
+ // top-level id; if absent (Gemini search returns only `{id, threadId}`)
360
+ // we record an empty messageIds list — the next reconcile that fetches
361
+ // the thread's full body will populate it and surface a `modified`
362
+ // diff at that point.
363
+ const topId = asString(raw.id);
364
+ if (topId !== null && topId !== threadId)
365
+ messageIds.push(topId);
366
+ }
367
+ // Singletons sometimes carry a top-level `messageIds` array (Codex search
368
+ // hits flatten multiple message ids into the search row).
369
+ if (Array.isArray(raw.messageIds)) {
370
+ for (const v of raw.messageIds) {
371
+ const s = asString(v);
372
+ if (s !== null && !messageIds.includes(s))
373
+ messageIds.push(s);
374
+ }
375
+ }
376
+ // Sort labelIds + messageIds so connector ordering jitter doesn't flap
377
+ // the hash.
378
+ const labelIds = [...labelSet].sort();
379
+ messageIds.sort();
380
+ return {
381
+ threadId,
382
+ subject: lastSubject,
383
+ from: lastFrom,
384
+ labelIds,
385
+ messageIds,
386
+ lastMessageInternalDate: lastDateMs === null ? null : new Date(lastDateMs).toISOString(),
387
+ snippet: asString(raw.snippet),
388
+ };
389
+ }
390
+ function gmailHashableShape(p) {
391
+ // §17.10 — snippet is excluded; subject/from/labelIds/messageIds/lastDate
392
+ // is the semantic identity of the thread for diff purposes.
393
+ return {
394
+ from: p.from,
395
+ labelIds: p.labelIds,
396
+ lastMessageInternalDate: p.lastMessageInternalDate,
397
+ messageIds: p.messageIds,
398
+ subject: p.subject,
399
+ threadId: p.threadId,
400
+ };
401
+ }
402
+ const gmailNormalizer = {
403
+ itemId: gmailItemId,
404
+ payload: gmailPayload,
405
+ hash: (payload) => sha256Hex(stableStringify(gmailHashableShape(payload))),
406
+ // Gmail threads have no scheduled start time — `null` keeps them out of
407
+ // the imminent-event index that the calendar scheduler reads.
408
+ itemStart: () => null,
409
+ inWindow: (payload, windowMin, windowMax) => {
410
+ const dateMs = payload.lastMessageInternalDate === null
411
+ ? NaN
412
+ : Date.parse(payload.lastMessageInternalDate);
413
+ const minMs = Date.parse(windowMin);
414
+ const maxMs = Date.parse(windowMax);
415
+ // §5.1: a payload with no parseable date cannot be classified as
416
+ // "still in window" — treat it as out-of-window so the prior row gets
417
+ // pruned silently rather than falsely emitted as `deleted`. This also
418
+ // matches how the calendar normalizer handles a null start.
419
+ if (!Number.isFinite(dateMs)
420
+ || !Number.isFinite(minMs)
421
+ || !Number.isFinite(maxMs)) {
422
+ return false;
423
+ }
424
+ return dateMs >= minMs && dateMs < maxMs;
425
+ },
426
+ };
427
+ function notionItemId(raw) {
428
+ const id = asString(raw.id);
429
+ if (id === null) {
430
+ throw new Error("notion page missing id");
431
+ }
432
+ return id;
433
+ }
434
+ function notionParentDatabaseId(raw) {
435
+ const parent = raw.parent;
436
+ if (parent == null || typeof parent !== "object")
437
+ return null;
438
+ const p = parent;
439
+ // A page can be parented under a `database_id`, `data_source_id` (the
440
+ // newer Notion data-source surface), `page_id`, or `workspace`. The
441
+ // observation source pattern in NotionPoller keys by `databaseId`, so
442
+ // we mirror that and return `database_id` first; data sources fall
443
+ // back next; page-rooted and workspace-rooted return null and the
444
+ // drift-effects layer falls through to `notion:lifecycle`.
445
+ return (asString(p.database_id)
446
+ ?? asString(p.data_source_id)
447
+ ?? null);
448
+ }
449
+ function notionExtractTitle(raw) {
450
+ const props = raw.properties;
451
+ if (props == null || typeof props !== "object")
452
+ return null;
453
+ for (const value of Object.values(props)) {
454
+ if (value == null || typeof value !== "object")
455
+ continue;
456
+ const prop = value;
457
+ if (prop.type !== "title")
458
+ continue;
459
+ const titleArr = prop.title;
460
+ if (!Array.isArray(titleArr))
461
+ continue;
462
+ const parts = [];
463
+ for (const seg of titleArr) {
464
+ if (seg == null || typeof seg !== "object")
465
+ continue;
466
+ const text = asString(seg.plain_text);
467
+ if (text !== null)
468
+ parts.push(text);
469
+ }
470
+ if (parts.length > 0)
471
+ return parts.join("");
472
+ }
473
+ return null;
474
+ }
475
+ /**
476
+ * Per-property-type canonicalisation. Each entry returns a value that
477
+ * `stableStringify` can hash deterministically. Unhandled types map to
478
+ * `{ type }` so the hash captures presence/absence of the property without
479
+ * a per-shape handler — adding richer extractors later is additive.
480
+ *
481
+ * `relation` is intentionally NOT handled here: relation ids feed
482
+ * `relationsHash` via {@link notionRelationsShape} instead, so the page
483
+ * hash stays focused on scalar property changes.
484
+ */
485
+ function notionPropertyValueShape(prop) {
486
+ const type = asString(prop.type);
487
+ if (type === null)
488
+ return null;
489
+ switch (type) {
490
+ case "title":
491
+ case "rich_text": {
492
+ const arr = prop[type];
493
+ if (!Array.isArray(arr))
494
+ return { type };
495
+ const parts = [];
496
+ for (const seg of arr) {
497
+ if (seg == null || typeof seg !== "object")
498
+ continue;
499
+ const text = asString(seg.plain_text);
500
+ if (text !== null)
501
+ parts.push(text);
502
+ }
503
+ return { type, value: parts.join("") };
504
+ }
505
+ case "status":
506
+ case "select": {
507
+ const inner = prop[type];
508
+ if (inner == null || typeof inner !== "object")
509
+ return { type, value: null };
510
+ return { type, value: asString(inner.name) };
511
+ }
512
+ case "multi_select": {
513
+ const arr = prop.multi_select;
514
+ if (!Array.isArray(arr))
515
+ return { type, value: [] };
516
+ const names = [];
517
+ for (const seg of arr) {
518
+ if (seg == null || typeof seg !== "object")
519
+ continue;
520
+ const name = asString(seg.name);
521
+ if (name !== null)
522
+ names.push(name);
523
+ }
524
+ names.sort();
525
+ return { type, value: names };
526
+ }
527
+ case "date": {
528
+ const inner = prop.date;
529
+ if (inner == null || typeof inner !== "object")
530
+ return { type, value: null };
531
+ const d = inner;
532
+ return {
533
+ type,
534
+ value: {
535
+ start: asString(d.start),
536
+ end: asString(d.end),
537
+ time_zone: asString(d.time_zone),
538
+ },
539
+ };
540
+ }
541
+ case "checkbox": {
542
+ const v = prop.checkbox;
543
+ return { type, value: typeof v === "boolean" ? v : null };
544
+ }
545
+ case "number": {
546
+ const v = prop.number;
547
+ return { type, value: typeof v === "number" && Number.isFinite(v) ? v : null };
548
+ }
549
+ case "url":
550
+ case "email":
551
+ case "phone_number": {
552
+ return { type, value: asString(prop[type]) };
553
+ }
554
+ case "people": {
555
+ const arr = prop.people;
556
+ if (!Array.isArray(arr))
557
+ return { type, value: [] };
558
+ const ids = [];
559
+ for (const seg of arr) {
560
+ if (seg == null || typeof seg !== "object")
561
+ continue;
562
+ const id = asString(seg.id);
563
+ if (id !== null)
564
+ ids.push(id);
565
+ }
566
+ ids.sort();
567
+ return { type, value: ids };
568
+ }
569
+ case "files": {
570
+ const arr = prop.files;
571
+ if (!Array.isArray(arr))
572
+ return { type, value: [] };
573
+ const refs = [];
574
+ for (const seg of arr) {
575
+ if (seg == null || typeof seg !== "object")
576
+ continue;
577
+ const name = asString(seg.name);
578
+ const file = seg.file;
579
+ const external = seg.external;
580
+ const url = (file && typeof file === "object" ? asString(file.url) : null)
581
+ ?? (external && typeof external === "object" ? asString(external.url) : null)
582
+ ?? name;
583
+ if (url !== null)
584
+ refs.push(url);
585
+ }
586
+ refs.sort();
587
+ return { type, value: refs };
588
+ }
589
+ case "created_time":
590
+ case "last_edited_time": {
591
+ // Notion regenerates these on every edit — exclude from the hash so
592
+ // the page does not "modify" itself for free on every fetch (the
593
+ // lastEditedTime field on the page itself is the canonical signal).
594
+ return { type };
595
+ }
596
+ default:
597
+ // Unknown type — record the type tag only so a structurally identical
598
+ // unknown property does not flap, and so adding a stable extractor
599
+ // for it later is additive (the new shape will diverge from the
600
+ // type-tag-only previous hash, surfaced as one `modified` per page,
601
+ // then steady).
602
+ return { type };
603
+ }
604
+ }
605
+ function notionPropertiesShape(raw) {
606
+ const props = raw.properties;
607
+ if (props == null || typeof props !== "object")
608
+ return {};
609
+ const out = {};
610
+ for (const [name, value] of Object.entries(props)) {
611
+ if (value == null || typeof value !== "object")
612
+ continue;
613
+ const prop = value;
614
+ if (prop.type === "relation")
615
+ continue;
616
+ out[name] = notionPropertyValueShape(prop);
617
+ }
618
+ return out;
619
+ }
620
+ function notionRelationsShape(raw) {
621
+ const props = raw.properties;
622
+ if (props == null || typeof props !== "object")
623
+ return {};
624
+ const out = {};
625
+ for (const [name, value] of Object.entries(props)) {
626
+ if (value == null || typeof value !== "object")
627
+ continue;
628
+ const prop = value;
629
+ if (prop.type !== "relation")
630
+ continue;
631
+ const arr = prop.relation;
632
+ if (!Array.isArray(arr)) {
633
+ out[name] = [];
634
+ continue;
635
+ }
636
+ const ids = [];
637
+ for (const seg of arr) {
638
+ if (seg == null || typeof seg !== "object")
639
+ continue;
640
+ const id = asString(seg.id);
641
+ if (id !== null)
642
+ ids.push(id);
643
+ }
644
+ ids.sort();
645
+ out[name] = ids;
646
+ }
647
+ return out;
648
+ }
649
+ function notionPropertiesSummaryString(raw) {
650
+ const props = raw.properties;
651
+ if (props == null || typeof props !== "object")
652
+ return null;
653
+ const parts = [];
654
+ for (const [name, value] of Object.entries(props)) {
655
+ if (value == null || typeof value !== "object")
656
+ continue;
657
+ const prop = value;
658
+ const type = asString(prop.type);
659
+ if (type === "status" || type === "select") {
660
+ const inner = prop[type];
661
+ if (inner != null && typeof inner === "object") {
662
+ const n = asString(inner.name);
663
+ if (n !== null)
664
+ parts.push(`${name}: ${n}`);
665
+ }
666
+ }
667
+ else if (type === "date") {
668
+ const inner = prop.date;
669
+ if (inner != null && typeof inner === "object") {
670
+ const start = asString(inner.start);
671
+ if (start !== null)
672
+ parts.push(`${name}: ${start}`);
673
+ }
674
+ }
675
+ }
676
+ return parts.length === 0 ? null : parts.join(" | ");
677
+ }
678
+ function notionPayload(raw) {
679
+ const propertiesShape = notionPropertiesShape(raw);
680
+ const relationsShape = notionRelationsShape(raw);
681
+ const propertiesSummaryHash = sha256Hex(stableStringify(propertiesShape));
682
+ const relationsHash = sha256Hex(stableStringify(relationsShape));
683
+ const inTrash = Boolean(raw.in_trash) || Boolean(raw.archived);
684
+ return {
685
+ pageId: notionItemId(raw),
686
+ title: notionExtractTitle(raw),
687
+ lastEditedTime: asString(raw.last_edited_time),
688
+ parentDatabase: notionParentDatabaseId(raw),
689
+ url: asString(raw.url),
690
+ inTrash,
691
+ propertiesSummary: notionPropertiesSummaryString(raw),
692
+ propertiesSummaryHash,
693
+ relationsHash,
694
+ };
695
+ }
696
+ function notionHashableShape(p) {
697
+ // `propertiesSummary` is excluded — it's a display string derived from
698
+ // the same data the propertiesSummaryHash already covers. `url` and
699
+ // `inTrash` are also excluded: trash transitions show up via
700
+ // `lastEditedTime` (Notion bumps it on archive) and `url` is derivable
701
+ // from id; including either would flap on incidental rewrites.
702
+ return {
703
+ lastEditedTime: p.lastEditedTime,
704
+ pageId: p.pageId,
705
+ parentDatabase: p.parentDatabase,
706
+ propertiesSummaryHash: p.propertiesSummaryHash,
707
+ relationsHash: p.relationsHash,
708
+ title: p.title,
709
+ };
710
+ }
711
+ const notionNormalizer = {
712
+ itemId: notionItemId,
713
+ payload: notionPayload,
714
+ hash: (payload) => sha256Hex(stableStringify(notionHashableShape(payload))),
715
+ itemStart: () => null,
716
+ inWindow: (payload, windowMin, windowMax) => {
717
+ const editedMs = payload.lastEditedTime === null
718
+ ? NaN
719
+ : Date.parse(payload.lastEditedTime);
720
+ const minMs = Date.parse(windowMin);
721
+ const maxMs = Date.parse(windowMax);
722
+ if (!Number.isFinite(editedMs)
723
+ || !Number.isFinite(minMs)
724
+ || !Number.isFinite(maxMs)) {
725
+ return false;
726
+ }
727
+ return editedMs >= minMs && editedMs < maxMs;
728
+ },
729
+ };
730
+ /**
731
+ * Normalizer registry. Phase 5 ships gmail and notion alongside calendar.
732
+ * Adding a future integration is one Record entry — the route handler
733
+ * reads via `getSnapshotNormalizer(key)` and stays integration-key-driven.
734
+ */
735
+ export const SNAPSHOT_NORMALIZERS = {
736
+ google_calendar: calendarNormalizer,
737
+ gmail: gmailNormalizer,
738
+ notion: notionNormalizer,
739
+ };
740
+ /** True if the integration has a Phase-1 normalizer registered. Used by
741
+ * the reconcile route to fail fast with a precise error message before
742
+ * parsing items. */
743
+ export function hasSnapshotNormalizer(key) {
744
+ return key in SNAPSHOT_NORMALIZERS;
745
+ }
746
+ /** List the integrations currently registered with a normalizer. Drives
747
+ * the `supportedIntegrations` field in error responses so a caller sees
748
+ * the contract without grepping the source. */
749
+ export function listSnapshotNormalizers() {
750
+ return INTEGRATION_KEYS.filter((k) => k in SNAPSHOT_NORMALIZERS);
751
+ }
752
+ /** Fetch the normalizer for an integration. Returns `undefined` for
753
+ * unsupported integrations so the caller can return a structured 400. */
754
+ export function getSnapshotNormalizer(key) {
755
+ return SNAPSHOT_NORMALIZERS[key];
756
+ }
757
+ //# sourceMappingURL=integrations-snapshot.js.map