@gmickel/gno 1.28.0 → 1.29.1

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 (143) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +33 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.1.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.1.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +1 -1
  10. package/spec/cli.md +41 -1
  11. package/spec/db/schema.sql +19 -0
  12. package/spec/mcp.md +66 -0
  13. package/spec/output-schemas/ask.schema.json +31 -1
  14. package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
  15. package/spec/output-schemas/collection-egress-check.schema.json +91 -0
  16. package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
  17. package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +89 -2
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
  20. package/spec/output-schemas/egress-audit-management.schema.json +88 -0
  21. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  22. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  23. package/spec/output-schemas/record-import.schema.json +30 -0
  24. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  25. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  26. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  27. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  28. package/spec/output-schemas/search-results.schema.json +30 -0
  29. package/src/app/context-runtime-contract.ts +16 -7
  30. package/src/cli/commands/ask.ts +7 -0
  31. package/src/cli/commands/collection/index.ts +5 -0
  32. package/src/cli/commands/collection/policy.ts +126 -0
  33. package/src/cli/commands/context-build.ts +5 -0
  34. package/src/cli/commands/daemon.ts +45 -3
  35. package/src/cli/commands/egress-audit.ts +68 -0
  36. package/src/cli/commands/embed.ts +2 -0
  37. package/src/cli/commands/query.ts +12 -0
  38. package/src/cli/commands/replay.ts +13 -3
  39. package/src/cli/commands/vsearch.ts +3 -1
  40. package/src/cli/program.ts +123 -0
  41. package/src/config/index.ts +9 -0
  42. package/src/config/types.ts +50 -0
  43. package/src/core/browser-clip.ts +16 -1
  44. package/src/core/collection-egress-policy-projection.ts +28 -0
  45. package/src/core/collection-egress-policy-service.ts +443 -0
  46. package/src/core/collection-egress-policy-validation.ts +242 -0
  47. package/src/core/config-mutation.ts +31 -19
  48. package/src/core/context-capsule-schema.ts +111 -1
  49. package/src/core/context-capsule-validation.ts +4 -4
  50. package/src/core/context-capsule-verification.ts +5 -1
  51. package/src/core/context-capsule.ts +177 -132
  52. package/src/core/context-evidence.ts +86 -14
  53. package/src/core/destination-classifier.ts +402 -0
  54. package/src/core/egress-audit.ts +239 -0
  55. package/src/core/egress-authorization.ts +50 -0
  56. package/src/core/egress-enforcement.ts +264 -0
  57. package/src/core/egress-policy.ts +440 -0
  58. package/src/core/egress-provenance.ts +336 -0
  59. package/src/core/job-manager.ts +37 -0
  60. package/src/core/network-boundary-inventory.ts +261 -0
  61. package/src/core/retrieval-qrels.ts +6 -0
  62. package/src/core/retrieval-replay.ts +18 -4
  63. package/src/core/retrieval-trace-export.ts +23 -4
  64. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  65. package/src/core/retrieval-trace-management-types.ts +3 -0
  66. package/src/core/retrieval-trace-management.ts +9 -1
  67. package/src/core/retrieval-trace-session.ts +94 -0
  68. package/src/core/retrieval-trace.ts +22 -0
  69. package/src/ingestion/record-adapter.ts +12 -1
  70. package/src/ingestion/record-container.ts +38 -22
  71. package/src/ingestion/sync.ts +15 -0
  72. package/src/ingestion/types.ts +2 -0
  73. package/src/llm/errors.ts +10 -0
  74. package/src/llm/http-inference.ts +175 -0
  75. package/src/llm/http-policy.ts +537 -0
  76. package/src/llm/httpEmbedding.ts +47 -28
  77. package/src/llm/httpGeneration.ts +31 -18
  78. package/src/llm/httpRerank.ts +30 -18
  79. package/src/llm/index.ts +1 -0
  80. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  81. package/src/llm/pinned-http-connection.ts +177 -0
  82. package/src/mcp/context.ts +53 -1
  83. package/src/mcp/http-egress.ts +220 -0
  84. package/src/mcp/http-security.ts +32 -21
  85. package/src/mcp/http-session.ts +13 -2
  86. package/src/mcp/http-transport.ts +96 -6
  87. package/src/mcp/sync-egress.ts +24 -0
  88. package/src/mcp/tools/add-collection.ts +4 -0
  89. package/src/mcp/tools/ask.ts +1 -1
  90. package/src/mcp/tools/context.ts +9 -2
  91. package/src/mcp/tools/egress.ts +247 -0
  92. package/src/mcp/tools/embed.ts +1 -0
  93. package/src/mcp/tools/index-cmd.ts +7 -0
  94. package/src/mcp/tools/index.ts +98 -0
  95. package/src/mcp/tools/query.ts +10 -0
  96. package/src/mcp/tools/remove-collection.ts +4 -0
  97. package/src/mcp/tools/sync.ts +6 -0
  98. package/src/mcp/tools/trace.ts +7 -1
  99. package/src/mcp/tools/vsearch.ts +1 -0
  100. package/src/pipeline/egress-lineage.ts +124 -0
  101. package/src/pipeline/hybrid.ts +14 -0
  102. package/src/pipeline/search.ts +12 -0
  103. package/src/pipeline/types.ts +3 -0
  104. package/src/pipeline/vsearch.ts +41 -20
  105. package/src/publish/artifact.ts +31 -3
  106. package/src/publish/export-service.ts +25 -0
  107. package/src/sdk/client.ts +119 -2
  108. package/src/sdk/embed.ts +1 -0
  109. package/src/sdk/types.ts +33 -0
  110. package/src/serve/clipper-capture.ts +5 -1
  111. package/src/serve/closed-json.ts +61 -0
  112. package/src/serve/config-sync.ts +4 -1
  113. package/src/serve/context.ts +1 -0
  114. package/src/serve/doc-events.ts +110 -39
  115. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  116. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  117. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  118. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  119. package/src/serve/public/globals.built.css +1 -1
  120. package/src/serve/public/pages/Collections.tsx +3 -0
  121. package/src/serve/resident-request.ts +80 -3
  122. package/src/serve/resident-runtime.ts +63 -1
  123. package/src/serve/routes/api.ts +174 -0
  124. package/src/serve/routes/clipper.ts +64 -1
  125. package/src/serve/routes/mcp.ts +6 -0
  126. package/src/serve/routes/traces.ts +27 -4
  127. package/src/serve/server.ts +118 -7
  128. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  129. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  130. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  131. package/src/store/migrations/index.ts +6 -0
  132. package/src/store/retrieval-trace-codec.ts +15 -0
  133. package/src/store/sqlite/adapter.ts +170 -7
  134. package/src/store/sqlite/change-journal-store.ts +41 -2
  135. package/src/store/sqlite/egress-audit-store.ts +485 -0
  136. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  137. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  138. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  139. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  140. package/src/store/types.ts +126 -1
  141. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip +0 -0
  142. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +0 -1
  143. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
package/src/sdk/client.ts CHANGED
@@ -79,6 +79,11 @@ import {
79
79
  planCapture,
80
80
  } from "../core/capture";
81
81
  import { writeCapturePlanFile } from "../core/capture-write";
82
+ import { projectCollectionEgressPolicy } from "../core/collection-egress-policy-projection";
83
+ import { CollectionEgressPolicyService } from "../core/collection-egress-policy-service";
84
+ import { applyConfigChange } from "../core/config-mutation";
85
+ import { EgressAuditService } from "../core/egress-audit";
86
+ import { authorizeCurrentEgress } from "../core/egress-authorization";
82
87
  import {
83
88
  atomicWrite,
84
89
  copyFilePath,
@@ -270,7 +275,7 @@ async function resolveClientState(
270
275
  }
271
276
 
272
277
  class GnoClientImpl implements GnoClient {
273
- readonly config: Config;
278
+ config: Config;
274
279
  readonly dbPath: string;
275
280
  readonly configPath: string | null;
276
281
  readonly configSource: "file" | "inline";
@@ -331,6 +336,9 @@ class GnoClientImpl implements GnoClient {
331
336
  rerankModel?: string;
332
337
  }): Promise<RuntimePorts> {
333
338
  this.assertOpen();
339
+ const egressCollections = options.collection
340
+ ? [options.collection]
341
+ : ("all" as const);
334
342
 
335
343
  let embedPort: EmbeddingPort | null = null;
336
344
  let expandPort: GenerationPort | null = null;
@@ -347,6 +355,7 @@ class GnoClientImpl implements GnoClient {
347
355
  options.collection
348
356
  ),
349
357
  {
358
+ egressCollections,
350
359
  policy: this.downloadPolicy,
351
360
  }
352
361
  );
@@ -391,6 +400,7 @@ class GnoClientImpl implements GnoClient {
391
400
  options.collection
392
401
  ),
393
402
  {
403
+ egressCollections,
394
404
  policy: this.downloadPolicy,
395
405
  }
396
406
  );
@@ -415,6 +425,7 @@ class GnoClientImpl implements GnoClient {
415
425
  options.collection
416
426
  ),
417
427
  {
428
+ egressCollections,
418
429
  policy: this.downloadPolicy,
419
430
  }
420
431
  );
@@ -442,6 +453,7 @@ class GnoClientImpl implements GnoClient {
442
453
  options.collection
443
454
  ),
444
455
  {
456
+ egressCollections,
445
457
  policy: this.downloadPolicy,
446
458
  }
447
459
  );
@@ -1230,7 +1242,19 @@ class GnoClientImpl implements GnoClient {
1230
1242
  ) {
1231
1243
  this.assertOpen();
1232
1244
  return unwrapTraceStore(
1233
- await new RetrievalTraceManagementService(this.store).export(input)
1245
+ await new RetrievalTraceManagementService(this.store, {
1246
+ authorizeExport: async (lineage) => {
1247
+ return authorizeCurrentEgress({
1248
+ store: this.store,
1249
+ config: this.config,
1250
+ lineage,
1251
+ action: "export",
1252
+ destinationZone: "local_process",
1253
+ caller: { authenticated: true, operationAuthorized: true },
1254
+ contentClass: "retrieval_trace",
1255
+ });
1256
+ },
1257
+ }).export(input)
1234
1258
  );
1235
1259
  }
1236
1260
 
@@ -1248,6 +1272,99 @@ class GnoClientImpl implements GnoClient {
1248
1272
  );
1249
1273
  }
1250
1274
 
1275
+ async getCollectionEgressPolicy(collection: string) {
1276
+ this.assertOpen();
1277
+ const state = new CollectionEgressPolicyService({
1278
+ getConfig: () => this.config,
1279
+ }).get(collection);
1280
+ if (!state.ok) {
1281
+ throw sdkError(
1282
+ state.code === "NOT_FOUND" ? "NOT_FOUND" : "VALIDATION",
1283
+ state.error
1284
+ );
1285
+ }
1286
+ return state.value;
1287
+ }
1288
+
1289
+ async setCollectionEgressPolicy(
1290
+ collection: string,
1291
+ policy: import("../config/types").EgressPolicy,
1292
+ confirmation?: import("../core/collection-egress-policy-service").EgressRelaxationConfirmation
1293
+ ) {
1294
+ this.assertOpen();
1295
+ if (!this.configPath) {
1296
+ throw sdkError(
1297
+ "VALIDATION",
1298
+ "Inline-config clients cannot persist collection policy changes"
1299
+ );
1300
+ }
1301
+ const service = new CollectionEgressPolicyService({
1302
+ getConfig: () => this.config,
1303
+ mutateConfig: (mutate) =>
1304
+ applyConfigChange(
1305
+ {
1306
+ store: this.store,
1307
+ configPath: this.configPath ?? undefined,
1308
+ onConfigUpdated: (config) => {
1309
+ this.config = config;
1310
+ },
1311
+ projectStore: (store, config) =>
1312
+ projectCollectionEgressPolicy(store, config, collection),
1313
+ },
1314
+ mutate
1315
+ ),
1316
+ });
1317
+ const result = await service.set({
1318
+ collection,
1319
+ policy,
1320
+ confirmation,
1321
+ });
1322
+ if (!result.ok) throw sdkError("VALIDATION", result.error);
1323
+ return result.value;
1324
+ }
1325
+
1326
+ async checkEgress(
1327
+ input: import("../core/collection-egress-policy-service").CollectionEgressCheckInput
1328
+ ) {
1329
+ this.assertOpen();
1330
+ const result = new CollectionEgressPolicyService({
1331
+ getConfig: () => this.config,
1332
+ }).check(input);
1333
+ if (!result.ok) throw sdkError("VALIDATION", result.error);
1334
+ return result.value;
1335
+ }
1336
+
1337
+ async listEgressAudits(options: { limit?: number; cursor?: string } = {}) {
1338
+ this.assertOpen();
1339
+ return unwrapTraceStore(
1340
+ await new EgressAuditService(this.store).list(options)
1341
+ );
1342
+ }
1343
+
1344
+ async getEgressAudit(auditId: string) {
1345
+ this.assertOpen();
1346
+ return unwrapTraceStore(
1347
+ await new EgressAuditService(this.store).show(auditId)
1348
+ );
1349
+ }
1350
+
1351
+ async getEgressAuditStatus() {
1352
+ this.assertOpen();
1353
+ return unwrapTraceStore(await new EgressAuditService(this.store).status());
1354
+ }
1355
+
1356
+ async deleteEgressAudit(auditId: string) {
1357
+ this.assertOpen();
1358
+ return unwrapTraceStore(
1359
+ await new EgressAuditService(this.store).delete(auditId)
1360
+ );
1361
+ }
1362
+
1363
+ async purgeEgressAudits() {
1364
+ this.assertOpen();
1365
+ return unwrapTraceStore(await new EgressAuditService(this.store).purge());
1366
+ }
1367
+
1251
1368
  async update(options: GnoUpdateOptions = {}): Promise<SyncResult> {
1252
1369
  this.assertOpen();
1253
1370
  const collections = this.getCollections(options.collection);
package/src/sdk/embed.ts CHANGED
@@ -283,6 +283,7 @@ export async function runEmbed(
283
283
  }
284
284
 
285
285
  const embedResult = await runtime.llm.createEmbeddingPort(modelUri, {
286
+ egressCollections: options.collection ? [options.collection] : "all",
286
287
  policy: runtime.downloadPolicy,
287
288
  });
288
289
  if (!embedResult.ok) {
package/src/sdk/types.ts CHANGED
@@ -11,6 +11,13 @@ import type {
11
11
  import type { ContentTypeBoostStatus } from "../config/content-types";
12
12
  import type { Config } from "../config/types";
13
13
  import type { CaptureInput, CaptureReceipt } from "../core/capture";
14
+ import type {
15
+ CollectionEgressCheckInput,
16
+ CollectionEgressCheckResult,
17
+ CollectionEgressPolicySetResult,
18
+ CollectionEgressPolicyState,
19
+ EgressRelaxationConfirmation,
20
+ } from "../core/collection-egress-policy-service";
14
21
  import type {
15
22
  ContextCapsuleErrorCode,
16
23
  ContextCapsuleV1,
@@ -18,6 +25,13 @@ import type {
18
25
  } from "../core/context-capsule";
19
26
  import type { ContextEvidenceErrorCode } from "../core/context-evidence";
20
27
  import type { ContextVerifierErrorCode } from "../core/context-verifier";
28
+ import type {
29
+ EgressAuditDeleteResult,
30
+ EgressAuditListResult,
31
+ EgressAuditPurgeManagementResult,
32
+ EgressAuditShowResult,
33
+ EgressAuditStatusResult,
34
+ } from "../core/egress-audit";
21
35
  import type {
22
36
  KnowledgeChangesResult,
23
37
  KnowledgeDiffResult,
@@ -300,6 +314,25 @@ export interface GnoClient {
300
314
  ): Promise<RetrievalTraceExportResult>;
301
315
  deleteRetrievalTrace(traceId: string): Promise<RetrievalTraceDeleteResult>;
302
316
  purgeRetrievalTraces(): Promise<RetrievalTraceManagementPurgeResult>;
317
+ getCollectionEgressPolicy(
318
+ collection: string
319
+ ): Promise<CollectionEgressPolicyState>;
320
+ setCollectionEgressPolicy(
321
+ collection: string,
322
+ policy: import("../config/types").EgressPolicy,
323
+ confirmation?: EgressRelaxationConfirmation
324
+ ): Promise<CollectionEgressPolicySetResult>;
325
+ checkEgress(
326
+ input: CollectionEgressCheckInput
327
+ ): Promise<CollectionEgressCheckResult>;
328
+ listEgressAudits(options?: {
329
+ limit?: number;
330
+ cursor?: string;
331
+ }): Promise<EgressAuditListResult>;
332
+ getEgressAudit(auditId: string): Promise<EgressAuditShowResult>;
333
+ getEgressAuditStatus(): Promise<EgressAuditStatusResult>;
334
+ deleteEgressAudit(auditId: string): Promise<EgressAuditDeleteResult>;
335
+ purgeEgressAudits(): Promise<EgressAuditPurgeManagementResult>;
303
336
  update(options?: GnoUpdateOptions): Promise<SyncResult>;
304
337
  embed(options?: GnoEmbedOptions): Promise<GnoEmbedResult>;
305
338
  index(options?: GnoIndexOptions): Promise<GnoIndexResult>;
@@ -3,6 +3,7 @@
3
3
  import type { Database } from "bun:sqlite";
4
4
 
5
5
  import type { PreparedBrowserClip } from "../core/browser-clip";
6
+ import type { EgressLineage } from "../core/egress-provenance";
6
7
  import type { SqliteAdapter } from "../store/sqlite/adapter";
7
8
  import type { ClipperIdempotencyReplay } from "../store/sqlite/clipper-store-types";
8
9
  import type { ResidentCaptureContext } from "./capture-service";
@@ -35,6 +36,7 @@ interface ExecuteClipperCaptureInput {
35
36
  context: ResidentCaptureContext;
36
37
  store: SqliteAdapter;
37
38
  pairing: ClipperPairingService;
39
+ egressLineage?: EgressLineage;
38
40
  }
39
41
 
40
42
  const inFlightRequests = new Set<string>();
@@ -153,7 +155,9 @@ export const executeClipperCapture = async (
153
155
  );
154
156
  }
155
157
 
156
- const prepared = prepareBrowserClip(parsed.data.payload);
158
+ const prepared = prepareBrowserClip(parsed.data.payload, {
159
+ egressLineage: input.egressLineage,
160
+ });
157
161
  if (prepared.preview.digest !== parsed.data.previewDigest) {
158
162
  return clipperErrorResponse(
159
163
  "CLIPPER_PREVIEW_MISMATCH",
@@ -0,0 +1,61 @@
1
+ /** Bounded, content-free JSON request parsing for closed REST contracts. */
2
+
3
+ const DEFAULT_MAX_BYTES = 16 * 1024;
4
+
5
+ export type ClosedJsonResult =
6
+ | { ok: true; value: unknown }
7
+ | { ok: false; code: "VALIDATION"; error: string };
8
+
9
+ export const parseClosedJson = async (
10
+ request: Request,
11
+ maxBytes = DEFAULT_MAX_BYTES
12
+ ): Promise<ClosedJsonResult> => {
13
+ try {
14
+ const contentLength = Number(request.headers.get("content-length"));
15
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
16
+ return {
17
+ ok: false,
18
+ code: "VALIDATION",
19
+ error: "JSON body exceeds the allowed size",
20
+ };
21
+ }
22
+ const reader = request.body?.getReader();
23
+ if (!reader) {
24
+ return {
25
+ ok: false,
26
+ code: "VALIDATION",
27
+ error: "Invalid JSON body",
28
+ };
29
+ }
30
+ const chunks: Uint8Array[] = [];
31
+ let totalBytes = 0;
32
+ while (true) {
33
+ const chunk = await reader.read();
34
+ if (chunk.done) break;
35
+ totalBytes += chunk.value.byteLength;
36
+ if (totalBytes > maxBytes) {
37
+ await reader.cancel("JSON body exceeds the allowed size");
38
+ return {
39
+ ok: false,
40
+ code: "VALIDATION",
41
+ error: "JSON body exceeds the allowed size",
42
+ };
43
+ }
44
+ chunks.push(chunk.value);
45
+ }
46
+ const bytes = new Uint8Array(totalBytes);
47
+ let offset = 0;
48
+ for (const chunk of chunks) {
49
+ bytes.set(chunk, offset);
50
+ offset += chunk.byteLength;
51
+ }
52
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
53
+ return { ok: true, value: JSON.parse(text) as unknown };
54
+ } catch {
55
+ return {
56
+ ok: false,
57
+ code: "VALIDATION",
58
+ error: "Invalid JSON body",
59
+ };
60
+ }
61
+ };
@@ -11,6 +11,7 @@ import type { ContextHolder } from "./routes/api";
11
11
 
12
12
  import {
13
13
  applyConfigChange as applyConfigChangeCore,
14
+ type ConfigMutationContext,
14
15
  type ApplyConfigResult,
15
16
  type MutationResult,
16
17
  } from "../core/config-mutation";
@@ -67,11 +68,13 @@ export async function applyConfigChangeTyped<T>(
67
68
  ctxHolder: ContextHolder,
68
69
  store: SqliteAdapter,
69
70
  mutate: (config: Config) => Promise<MutationResult<T>> | MutationResult<T>,
70
- configPath?: string
71
+ configPath?: string,
72
+ options: Pick<ConfigMutationContext, "projectStore"> = {}
71
73
  ): Promise<ApplyConfigResult<T>> {
72
74
  const result = await applyConfigChangeCore(
73
75
  {
74
76
  store,
77
+ projectStore: options.projectStore,
75
78
  configPath: configPath ?? ctxHolder.actualConfigPath,
76
79
  onConfigUpdated: (config) => {
77
80
  ctxHolder.config = config;
@@ -115,6 +115,7 @@ export async function createServerContext(
115
115
 
116
116
  // Progress callback updates downloadState for WebUI polling
117
117
  const createPortOptions = (type: ModelType): CreatePortOptions => ({
118
+ egressCollections: "all",
118
119
  policy,
119
120
  onProgress: (progress) => {
120
121
  downloadState.active = true;
@@ -25,46 +25,87 @@ export interface DocumentEventBusState {
25
25
  retryMs: number;
26
26
  }
27
27
 
28
+ export interface DocumentEventStreamAuthorization {
29
+ authorizationEpoch: string;
30
+ isAuthorizationEpochCurrent: () => boolean;
31
+ onClose?: () => void;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ interface DocumentEventSubscriber {
36
+ authorization: DocumentEventStreamAuthorization;
37
+ closed: boolean;
38
+ controller: ReadableStreamDefaultController<Uint8Array>;
39
+ keepaliveTimer: ReturnType<typeof setInterval> | null;
40
+ removeAbortListener: (() => void) | null;
41
+ }
42
+
28
43
  const encoder = new TextEncoder();
29
44
  const EVENT_RETRY_MS = 2_000;
30
45
  const KEEPALIVE_MS = 15_000;
46
+ const POLICY_CHANGED_FRAME = encoder.encode(
47
+ `retry: ${EVENT_RETRY_MS}\nevent: egress-policy-changed\ndata: {"error":{"code":"EGRESS_POLICY_CHANGED","message":"Collection policy changed; retry"}}\n\n`
48
+ );
31
49
 
32
50
  export class DocumentEventBus {
33
- readonly #controllers = new Set<
34
- ReadableStreamDefaultController<Uint8Array>
35
- >();
36
-
37
- createResponse(): Response {
38
- const controllers = this.#controllers;
39
- let streamController: ReadableStreamDefaultController<Uint8Array> | null =
40
- null;
41
- let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
51
+ readonly #subscribers = new Set<DocumentEventSubscriber>();
52
+ readonly #keepaliveMs: number;
53
+
54
+ constructor(options: { keepaliveMs?: number } = {}) {
55
+ this.#keepaliveMs = options.keepaliveMs ?? KEEPALIVE_MS;
56
+ }
57
+
58
+ createResponse(
59
+ authorization: DocumentEventStreamAuthorization = {
60
+ authorizationEpoch: "unrestricted",
61
+ isAuthorizationEpochCurrent: () => true,
62
+ }
63
+ ): Response {
64
+ let subscriber: DocumentEventSubscriber | null = null;
42
65
  const stream = new ReadableStream<Uint8Array>({
43
- start(controller) {
44
- streamController = controller;
45
- controllers.add(controller);
66
+ start: (controller) => {
67
+ subscriber = {
68
+ authorization,
69
+ closed: false,
70
+ controller,
71
+ keepaliveTimer: null,
72
+ removeAbortListener: null,
73
+ };
74
+ this.#subscribers.add(subscriber);
75
+ if (!authorization.isAuthorizationEpochCurrent()) {
76
+ this.#invalidateSubscriber(subscriber);
77
+ return;
78
+ }
46
79
  controller.enqueue(
47
80
  encoder.encode(`retry: ${EVENT_RETRY_MS}\n: connected\n\n`)
48
81
  );
49
- keepaliveTimer = setInterval(() => {
50
- try {
51
- controller.enqueue(encoder.encode(": keepalive\n\n"));
52
- } catch {
53
- if (keepaliveTimer) {
54
- clearInterval(keepaliveTimer);
82
+ subscriber.keepaliveTimer = setInterval(
83
+ () => {
84
+ if (!authorization.isAuthorizationEpochCurrent()) {
85
+ this.#invalidateSubscriber(subscriber);
86
+ return;
55
87
  }
56
- controllers.delete(controller);
57
- }
58
- }, KEEPALIVE_MS);
59
- },
60
- cancel() {
61
- if (keepaliveTimer) {
62
- clearInterval(keepaliveTimer);
63
- }
64
- if (streamController) {
65
- controllers.delete(streamController);
88
+ try {
89
+ controller.enqueue(encoder.encode(": keepalive\n\n"));
90
+ } catch {
91
+ this.#closeSubscriber(subscriber);
92
+ }
93
+ },
94
+ this.#keepaliveMs
95
+ );
96
+ if (authorization.signal) {
97
+ const onAbort = (): void => this.#closeSubscriber(subscriber);
98
+ authorization.signal.addEventListener("abort", onAbort, {
99
+ once: true,
100
+ });
101
+ subscriber.removeAbortListener = () =>
102
+ authorization.signal?.removeEventListener("abort", onAbort);
103
+ if (authorization.signal.aborted) onAbort();
66
104
  }
67
105
  },
106
+ cancel: () => {
107
+ this.#closeSubscriber(subscriber, false);
108
+ },
68
109
  });
69
110
 
70
111
  return new Response(stream, {
@@ -81,30 +122,60 @@ export class DocumentEventBus {
81
122
  `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
82
123
  );
83
124
 
84
- for (const controller of this.#controllers) {
125
+ for (const subscriber of this.#subscribers) {
126
+ if (!subscriber.authorization.isAuthorizationEpochCurrent()) {
127
+ this.#invalidateSubscriber(subscriber);
128
+ continue;
129
+ }
85
130
  try {
86
- controller.enqueue(payload);
131
+ subscriber.controller.enqueue(payload);
87
132
  } catch {
88
- this.#controllers.delete(controller);
133
+ this.#closeSubscriber(subscriber);
89
134
  }
90
135
  }
91
136
  }
92
137
 
93
138
  close(): void {
94
- for (const controller of this.#controllers) {
95
- try {
96
- controller.close();
97
- } catch {
98
- // Best-effort shutdown.
99
- }
139
+ for (const subscriber of this.#subscribers) {
140
+ this.#closeSubscriber(subscriber);
100
141
  }
101
- this.#controllers.clear();
102
142
  }
103
143
 
104
144
  getState(): DocumentEventBusState {
105
145
  return {
106
- connectedClients: this.#controllers.size,
146
+ connectedClients: this.#subscribers.size,
107
147
  retryMs: EVENT_RETRY_MS,
108
148
  };
109
149
  }
150
+
151
+ #invalidateSubscriber(subscriber: DocumentEventSubscriber | null): void {
152
+ if (!subscriber || subscriber.closed) return;
153
+ try {
154
+ subscriber.controller.enqueue(POLICY_CHANGED_FRAME);
155
+ } catch {
156
+ // The stream may already be cancelled.
157
+ }
158
+ this.#closeSubscriber(subscriber);
159
+ }
160
+
161
+ #closeSubscriber(
162
+ subscriber: DocumentEventSubscriber | null,
163
+ closeController = true
164
+ ): void {
165
+ if (!subscriber || subscriber.closed) return;
166
+ subscriber.closed = true;
167
+ if (subscriber.keepaliveTimer) {
168
+ clearInterval(subscriber.keepaliveTimer);
169
+ }
170
+ subscriber.removeAbortListener?.();
171
+ this.#subscribers.delete(subscriber);
172
+ if (closeController) {
173
+ try {
174
+ subscriber.controller.close();
175
+ } catch {
176
+ // Best-effort stream shutdown.
177
+ }
178
+ }
179
+ subscriber.authorization.onClose?.();
180
+ }
110
181
  }