@powerhousedao/switchboard 6.2.3-dev.34 → 6.2.3-dev.36

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/dist/server.d.mts CHANGED
@@ -3,11 +3,22 @@ import { ChannelScheme, IDocumentModelLoader, IReactorClient, InProcessReactorCl
3
3
  import { AttachmentReferenceProjectionCapability } from "@powerhousedao/reactor-api";
4
4
  import { IAttachmentService } from "@powerhousedao/reactor-attachments";
5
5
  import { IRenown } from "@renown/sdk/node";
6
+ import { IErasureService } from "@powerhousedao/reactor-privacy";
6
7
  import { DriveInput } from "@powerhousedao/shared/document-drive";
7
- import { DocumentModelModule, SignaturePolicy, UpgradeManifest } from "@powerhousedao/shared/document-model";
8
+ import { DocumentModelModule, ISigner, SignaturePolicy, UpgradeManifest } from "@powerhousedao/shared/document-model";
8
9
  import "@powerhousedao/reactor-workflow";
9
10
  import "@powerhousedao/shared/processors";
10
11
  import { IRenown as IRenown$1 } from "@renown/sdk";
12
+ //#region src/privacy.d.mts
13
+ type PrivacyOptions = {
14
+ /** Wins over PH_PRIVACY_ENABLED. */
15
+ enabled?: boolean;
16
+ /** Wins over PH_PRIVACY_DEPLOYMENT_SECRET. */
17
+ deploymentSecret?: string;
18
+ /** The reactor's signer, for a caller-provided reactor. */
19
+ signer?: ISigner;
20
+ };
21
+ //#endregion
11
22
  //#region src/workflow-runtime.d.mts
12
23
  /** Whether the operation intake is indexing. Same shape as the attachment
13
24
  * reference projection, because it is the same limitation. */
@@ -116,6 +127,8 @@ export type StartServerOptions = {
116
127
  workflows?: {
117
128
  enabled?: boolean;
118
129
  };
130
+ /** The GDPR add-on; off unless this or PH_PRIVACY_ENABLED turns it on. */
131
+ privacy?: PrivacyOptions;
119
132
  processorConfig?: Map<string, unknown>;
120
133
  disableLocalPackages?: boolean;
121
134
  enableDocumentModelSubgraphs?: boolean;
@@ -193,6 +206,10 @@ export type SwitchboardReactor = {
193
206
  workflowTriggers?: WorkflowTriggersCapability;
194
207
  /** Whether workflows are on, after the option, PH_WORKFLOWS_ENABLED and the config file. */
195
208
  workflowsEnabled: boolean;
209
+ /** Present when the privacy add-on runs: its erasure service. */
210
+ privacy?: {
211
+ erasure: IErasureService;
212
+ };
196
213
  /** Whether `/mcp` is mounted, after the option and MCP_ENABLED. */
197
214
  mcpEnabled: boolean;
198
215
  /** The Renown instance if identity was initialized */
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.mts","names":[],"sources":["../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;;KA2DY;EACN;;EAEA;EACA;;;;;;;;;;YC5CM;;YAKA,wBAAwB;EAClC,eAAe;;YAGL;EACV;EACA;EACA;;YAGU;;EAEV;;;;;EAKA;;EAGA;;YAGU;;;;;;;;;;;;;;;;;;;;;EAqBV,UAAU;;;;;;;;EAQV;;;;;;;EAOA;EACA;EACA;;;;;;EAMA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;IAEM;IACA;;;;;;;EASN,WAAW;;EAEX;;;EAGA;;;EAGA;IACE;;EAEF,kBAAkB;EAClB;EACA;;;;;;EAMA;EACA,SAAS;;;;;;;;;;EAUT;;;;;;;;;;EAUA;;;;;;;;;EASA;IACE;IACA;IACA;;;;;;;;;;;EAWF;IACE;IACA;;;;;;;;;EASF,6BAA6B;;YAGnB;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KCtMN;EAIV,iBAAiB;;EAEjB,mBAAmB;;EAEnB;;;;;;EAMA,gBAAgB;;EAEhB;;EAEA;IACE;IACA,eAAe,QAAQ;;;EAGzB,sBAAsB;EACtB,SAAS;;;;;;EAMT,SAAS;;;;;EAKT,cAAc,KAAK;;EAEnB,wBAAwB;;wBAsBV,gCACd,gBAAgB,gBAChB,eAAe,sBACf,UAAS;;;;wBC3EK,kCACd,OAAO,wCACP,KAAK,OAAO,aACX;;qBAaU,oCAAoC;WACtC;IAAqB;IAAkB;;WACvC;EAET,YAAY,OAAO;SAcZ,QAAQ,iBAAiB,SAAS;;;;;;;;;wBC4G3B,gBAAgB,eAAe;;wBAoI/B,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAk7BvB,mBAAgB,UAClB,uBACR,QAAQ"}
1
+ {"version":3,"file":"server.d.mts","names":[],"sources":["../src/privacy.mts","../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;KAyBY;;EAEV;;EAEA;;EAEA,SAAS;;;;;;KCsBC;EACN;;EAEA;EACA;;;;;;;;;;YCpCM;;YAKA,wBAAwB;EAClC,eAAe;;YAGL;EACV;EACA;EACA;;YAGU;;EAEV;;;;;EAKA;;EAGA;;YAGU;;;;;;;;;;;;;;;;;;;;;EAqBV,UAAU;;;;;;;;EAQV;;;;;;;EAOA;EACA;EACA;;;;;;EAMA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;IAEM;IACA;;;;;;;EASN,WAAW;;EAEX;;;EAGA;;;EAGA;IACE;;;EAGF,UAAU;EACV,kBAAkB;EAClB;EACA;;;;;;EAMA;EACA,SAAS;;;;;;;;;;EAUT;;;;;;;;;;EAUA;;;;;;;;;EASA;IACE;IACA;IACA;;;;;;;;;;;EAWF;IACE;IACA;;;;;;;;;EASF,6BAA6B;;YAGnB;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA;IAAY,SAAS;;;EAErB;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KC5MN;EAIV,iBAAiB;;EAEjB,mBAAmB;;EAEnB;;;;;;EAMA,gBAAgB;;EAEhB;;EAEA;IACE;IACA,eAAe,QAAQ;;;EAGzB,sBAAsB;EACtB,SAAS;;;;;;EAMT,SAAS;;;;;EAKT,cAAc,KAAK;;EAEnB,wBAAwB;;wBAsBV,gCACd,gBAAgB,gBAChB,eAAe,sBACf,UAAS;;;;wBC3EK,kCACd,OAAO,wCACP,KAAK,OAAO,aACX;;qBAaU,oCAAoC;WACtC;IAAqB;IAAkB;;WACvC;EAET,YAAY,OAAO;SAcZ,QAAQ,iBAAiB,SAAS;;;;;;;;;wBCyH3B,gBAAgB,eAAe;;wBAwI/B,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAg9BvB,mBAAgB,UAClB,uBACR,QAAQ"}
package/dist/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as resolveUnsupportedStoredDocuments, i as StoredDocumentsRefusedError, n as isPortAvailable, o as applySwitchboardReactorDefaults, r as startSwitchboard, t as deriveAttachmentServiceConfig } from "./server-sQoWp21G.mjs";
2
+ import { a as resolveUnsupportedStoredDocuments, i as StoredDocumentsRefusedError, n as isPortAvailable, o as applySwitchboardReactorDefaults, r as startSwitchboard, t as deriveAttachmentServiceConfig } from "./server-D3CYuEka.mjs";
3
3
  export { StoredDocumentsRefusedError, applySwitchboardReactorDefaults, deriveAttachmentServiceConfig, isPortAvailable, resolveUnsupportedStoredDocuments, startSwitchboard };
4
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1396e5c4-5148-5e73-aaff-79e70b5910cc")}catch(e){}}();
5
- //# debugId=1396e5c4-5148-5e73-aaff-79e70b5910cc
4
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="784a735c-87ca-55fa-bea5-af4fb82863ba")}catch(e){}}();
5
+ //# debugId=784a735c-87ca-55fa-bea5-af4fb82863ba
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@powerhousedao/switchboard",
3
3
  "type": "module",
4
- "version": "6.2.3-dev.34",
4
+ "version": "6.2.3-dev.36",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
7
7
  ".": {
@@ -41,7 +41,7 @@
41
41
  "@openfeature/core": "1.9.1",
42
42
  "@openfeature/env-var-provider": "0.3.1",
43
43
  "@openfeature/server-sdk": "1.19.0",
44
- "@powerhousedao/config": "6.2.3-dev.34",
44
+ "@powerhousedao/config": "6.2.3-dev.36",
45
45
  "@opentelemetry/api": "^1.9.0",
46
46
  "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
47
47
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
@@ -55,22 +55,23 @@
55
55
  "@opentelemetry/sdk-node": "^0.218.0",
56
56
  "@opentelemetry/sdk-trace-base": "^2.7.1",
57
57
  "@opentelemetry/semantic-conventions": "^1.41.1",
58
- "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.34",
59
- "@powerhousedao/reactor": "6.2.3-dev.34",
60
- "@powerhousedao/shared": "6.2.3-dev.34",
61
- "@powerhousedao/vetra": "6.2.3-dev.34",
62
- "@powerhousedao/reactor-api": "6.2.3-dev.34",
63
- "@powerhousedao/reactor-attachments": "6.2.3-dev.34",
64
- "@powerhousedao/reactor-drive": "6.2.3-dev.34",
65
- "@powerhousedao/reactor-group": "6.2.3-dev.34",
66
- "@powerhousedao/reactor-workflow": "6.2.3-dev.34",
67
- "@powerhousedao/workflow": "6.2.3-dev.34",
68
- "@powerhousedao/pglite-fs": "6.2.3-dev.34",
58
+ "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.36",
59
+ "@powerhousedao/reactor": "6.2.3-dev.36",
60
+ "@powerhousedao/shared": "6.2.3-dev.36",
61
+ "@powerhousedao/vetra": "6.2.3-dev.36",
62
+ "@powerhousedao/reactor-api": "6.2.3-dev.36",
63
+ "@powerhousedao/reactor-attachments": "6.2.3-dev.36",
64
+ "@powerhousedao/reactor-drive": "6.2.3-dev.36",
65
+ "@powerhousedao/reactor-group": "6.2.3-dev.36",
66
+ "@powerhousedao/reactor-privacy": "6.2.3-dev.36",
67
+ "@powerhousedao/reactor-workflow": "6.2.3-dev.36",
68
+ "@powerhousedao/workflow": "6.2.3-dev.36",
69
+ "@powerhousedao/pglite-fs": "6.2.3-dev.36",
69
70
  "@pyroscope/nodejs": "^0.4.5",
70
- "@renown/sdk": "6.2.3-dev.34",
71
+ "@renown/sdk": "6.2.3-dev.36",
71
72
  "@sentry/node": "^10.53.1",
72
73
  "@sentry/opentelemetry": "^10.53.1",
73
- "document-model": "6.2.3-dev.34",
74
+ "document-model": "6.2.3-dev.36",
74
75
  "dotenv": "^16.4.7",
75
76
  "express": "^4.22.1",
76
77
  "graphql": "^16",
@@ -232,6 +232,8 @@ describe("Switchboard attachment-reference read model registration", () => {
232
232
  writtenOrdinals.push(...references.map(({ ordinal }) => ordinal));
233
233
  await attachmentReferenceIndex.store.addReferences(references);
234
234
  },
235
+ removeDocuments: (documentIds) =>
236
+ attachmentReferenceIndex.store.removeDocuments(documentIds),
235
237
  };
236
238
 
237
239
  const coordinator =
@@ -283,7 +285,10 @@ describe("Switchboard attachment-reference read model registration", () => {
283
285
  .withReadModelCoordinator(customCoordinator)
284
286
  .buildModule();
285
287
  const addReferences = vi.fn();
286
- const writer = { addReferences } as IAttachmentReferenceWriter;
288
+ const writer: IAttachmentReferenceWriter = {
289
+ addReferences,
290
+ removeDocuments: vi.fn(),
291
+ };
287
292
 
288
293
  await expect(
289
294
  registerAttachmentReferenceReadModelOnModule(
@@ -94,7 +94,10 @@ function makeAccess(result: AttachmentAccessResult | Error) {
94
94
  result instanceof Error ? Promise.reject(result) : Promise.resolve(result),
95
95
  );
96
96
  return {
97
- access: { canReadAttachment } as IAttachmentAccessService,
97
+ access: {
98
+ canReadAttachment,
99
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
100
+ } as IAttachmentAccessService,
98
101
  canReadAttachment,
99
102
  };
100
103
  }
@@ -91,7 +91,13 @@ function makeAccess(result: AttachmentAccessResult): {
91
91
  spy: ReturnType<typeof vi.fn>;
92
92
  } {
93
93
  const spy = vi.fn().mockResolvedValue(result);
94
- return { access: { canReadAttachment: spy }, spy };
94
+ return {
95
+ access: {
96
+ canReadAttachment: spy,
97
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
98
+ },
99
+ spy,
100
+ };
95
101
  }
96
102
 
97
103
  function makeAttachments(opts: {
@@ -0,0 +1,96 @@
1
+ import {
2
+ InvalidAttachmentMetadata,
3
+ validateReserveMetadata,
4
+ type ReserveMetadata,
5
+ } from "@powerhousedao/reactor-attachments";
6
+ import { describe, expect, it } from "vitest";
7
+ import { parseReserveOptions } from "../../src/attachments/routes.js";
8
+
9
+ type Case = [name: string, meta: Record<string, unknown>];
10
+
11
+ const base = { mimeType: "text/plain", fileName: "note.txt" };
12
+
13
+ // The download route's setHeader throws on each of these.
14
+ const headerBreaking: Case[] = [
15
+ ["CRLF before a parameter", { ...base, mimeType: "text/plain\r\n;a=b" }],
16
+ ["LF after a ;", { ...base, mimeType: "text/plain;\na=b" }],
17
+ ["a control char quoted", { ...base, mimeType: 'text/plain; a="\x01"' }],
18
+ ["a DEL quoted", { ...base, mimeType: 'text/plain; a="\x7f"' }],
19
+ ["a char above 0xFF quoted", { ...base, mimeType: 'text/plain;a="\u0100"' }],
20
+ ];
21
+
22
+ const cases: Case[] = [
23
+ ["plain metadata", base],
24
+ ["an extension", { ...base, extension: "txt" }],
25
+ ["a null extension", { ...base, extension: null }],
26
+ ["a parameter", { ...base, mimeType: "text/plain; charset=utf-8" }],
27
+ ["a quoted parameter", { ...base, mimeType: 'text/plain; a="b c"' }],
28
+ ["a unicode file name", { ...base, fileName: "résumé.pdf" }],
29
+ ["a 255-char mimeType", { ...base, mimeType: `a/${"b".repeat(253)}` }],
30
+ ["a 256-char mimeType", { ...base, mimeType: `a/${"b".repeat(254)}` }],
31
+ ["a 255-char fileName", { ...base, fileName: "f".repeat(255) }],
32
+ ["a 256-char fileName", { ...base, fileName: "f".repeat(256) }],
33
+ ["an empty mimeType", { ...base, mimeType: "" }],
34
+ ["an empty fileName", { ...base, fileName: "" }],
35
+ ["an empty extension", { ...base, extension: "" }],
36
+ ["a mimeType with no slash", { ...base, mimeType: "text" }],
37
+ ["a mimeType with no subtype", { ...base, mimeType: "text/" }],
38
+ ["a mimeType with no type", { ...base, mimeType: "/plain" }],
39
+ ["a mimeType with two slashes", { ...base, mimeType: "text/plain/x" }],
40
+ ["a mimeType with a space", { ...base, mimeType: "text plain" }],
41
+ ["a mimeType with a dangling ;", { ...base, mimeType: "text/plain;" }],
42
+ ["a non-string mimeType", { ...base, mimeType: 5 }],
43
+ ["a trailing CRLF in mimeType", { ...base, mimeType: "text/plain\r\n" }],
44
+ [
45
+ "an injected header in mimeType",
46
+ { ...base, mimeType: "text/plain\nX-Injected: 1" },
47
+ ],
48
+ ["a NUL in mimeType", { ...base, mimeType: "text/pl\x00ain" }],
49
+ ...headerBreaking,
50
+ ["a NUL in fileName", { ...base, fileName: "a\x00b" }],
51
+ ["a LF in fileName", { ...base, fileName: "a\nb" }],
52
+ ["a CR in fileName", { ...base, fileName: "a\rb" }],
53
+ ["a tab in fileName", { ...base, fileName: "a\tb" }],
54
+ ["a DEL in fileName", { ...base, fileName: "a\x7fb" }],
55
+ ["a non-string fileName", { ...base, fileName: null }],
56
+ ["a slash in extension", { ...base, extension: "a/b" }],
57
+ ["a backslash in extension", { ...base, extension: "a\\b" }],
58
+ ["a bare slash extension", { ...base, extension: "/" }],
59
+ ["a non-string extension", { ...base, extension: 5 }],
60
+ ];
61
+
62
+ function routeAccepts(meta: Record<string, unknown>): boolean {
63
+ return parseReserveOptions(meta) !== null;
64
+ }
65
+
66
+ function validatorAccepts(meta: Record<string, unknown>): boolean {
67
+ try {
68
+ validateReserveMetadata(meta as ReserveMetadata);
69
+ return true;
70
+ } catch (err) {
71
+ if (err instanceof InvalidAttachmentMetadata) return false;
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ describe("reserve metadata parity between the route and validateReserveMetadata", () => {
77
+ it("covers both accepted and rejected metadata", () => {
78
+ const verdicts = cases.map(([, meta]) => routeAccepts(meta));
79
+ expect(verdicts).toContain(true);
80
+ expect(verdicts).toContain(false);
81
+ });
82
+
83
+ it.each(cases)("agrees on %s", (_name, meta) => {
84
+ expect(validatorAccepts(meta)).toBe(routeAccepts(meta));
85
+ });
86
+ });
87
+
88
+ describe("header-breaking mimeType values", () => {
89
+ it.each(headerBreaking)("the route rejects %s", (_name, meta) => {
90
+ expect(routeAccepts(meta)).toBe(false);
91
+ });
92
+
93
+ it.each(headerBreaking)("the validator rejects %s", (_name, meta) => {
94
+ expect(validatorAccepts(meta)).toBe(false);
95
+ });
96
+ });
@@ -31,6 +31,7 @@ const ALLOW_ALL: IAttachmentAccessService = {
31
31
  documentId: request.documentId as never,
32
32
  ref: request.attachmentRef as never,
33
33
  }),
34
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
34
35
  };
35
36
 
36
37
  describe("attachment routes through the real Express middleware stack", () => {
@@ -39,6 +39,7 @@ const ALLOW_ALL: IAttachmentAccessService = {
39
39
  documentId: request.documentId as never,
40
40
  ref: request.attachmentRef as never,
41
41
  }),
42
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
42
43
  };
43
44
  const grantedDownload = (attachments: AttachmentBuildResult) =>
44
45
  makeDownloadHandler(attachments, ALLOW_ALL, null);
@@ -0,0 +1,243 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { createServer } from "node:net";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ILogger } from "document-model";
6
+ import pg from "pg";
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import { startSwitchboard } from "../src/server.mjs";
9
+
10
+ const ADMIN = "0xadad00000000000000000000000000000000adad";
11
+ const SECRET = "switchboard-privacy-test-deployment-secret-0123";
12
+
13
+ function stubLogger(): ILogger & { error: ReturnType<typeof vi.fn> } {
14
+ const logger = {
15
+ verbose: vi.fn(),
16
+ debug: vi.fn(),
17
+ info: vi.fn(),
18
+ warn: vi.fn(),
19
+ error: vi.fn(),
20
+ child: vi.fn(),
21
+ };
22
+ logger.child.mockReturnValue(logger);
23
+ return logger as unknown as ILogger & { error: ReturnType<typeof vi.fn> };
24
+ }
25
+
26
+ type Switchboard = Awaited<ReturnType<typeof startSwitchboard>>;
27
+
28
+ async function withEnv<T>(
29
+ env: Record<string, string>,
30
+ run: (tempRoot: string) => Promise<T>,
31
+ ): Promise<T> {
32
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-privacy-"));
33
+ const names = [
34
+ "PH_REACTOR_DATABASE_URL",
35
+ "AUTH_ENABLED",
36
+ "ADMINS",
37
+ "PH_PRIVACY_ENABLED",
38
+ "PH_PRIVACY_DEPLOYMENT_SECRET",
39
+ ...Object.keys(env),
40
+ ];
41
+ const previous = names.map((name) => [name, process.env[name]] as const);
42
+ for (const name of names) delete process.env[name];
43
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
44
+ Object.assign(process.env, env);
45
+ try {
46
+ return await run(tempRoot);
47
+ } finally {
48
+ for (const [name, value] of previous) {
49
+ if (value === undefined) {
50
+ delete process.env[name];
51
+ } else {
52
+ process.env[name] = value;
53
+ }
54
+ }
55
+ await rm(tempRoot, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ async function freePort(): Promise<number> {
60
+ const server = createServer();
61
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
62
+ const address = server.address();
63
+ await new Promise((resolve) => server.close(resolve));
64
+ if (address === null || typeof address === "string") {
65
+ throw new Error("no port");
66
+ }
67
+ return address.port;
68
+ }
69
+
70
+ async function boot(
71
+ tempRoot: string,
72
+ logger: ILogger = stubLogger(),
73
+ ): Promise<Switchboard> {
74
+ return startSwitchboard({
75
+ workflows: { enabled: false },
76
+ dbPath: join(tempRoot, "read-model"),
77
+ port: await freePort(),
78
+ strictPort: true,
79
+ mcp: false,
80
+ disableLocalPackages: true,
81
+ identity: { keypairPath: join(tempRoot, "identity.json") },
82
+ logger,
83
+ });
84
+ }
85
+
86
+ async function queryErasure(switchboard: Switchboard) {
87
+ const response = await fetch(`http://127.0.0.1:${switchboard.port}/graphql`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({
91
+ query: `{ erasureRequest(requestId: "none") { requestId } }`,
92
+ }),
93
+ });
94
+ return (await response.json()) as {
95
+ errors?: { message: string; extensions?: { code?: string } }[];
96
+ };
97
+ }
98
+
99
+ describe("booting Switchboard with the privacy add-on", () => {
100
+ it("mounts nothing when PH_PRIVACY_ENABLED is off", async () => {
101
+ await withEnv({}, async (tempRoot) => {
102
+ const switchboard = await boot(tempRoot);
103
+ try {
104
+ expect(switchboard.privacy).toBeUndefined();
105
+ const result = await queryErasure(switchboard);
106
+ expect(result.errors?.[0]?.message).toMatch(/erasureRequest/);
107
+ expect(result.errors?.[0]?.extensions?.code).not.toBe("FORBIDDEN");
108
+ } finally {
109
+ await switchboard.shutdown();
110
+ }
111
+ });
112
+ }, 60_000);
113
+
114
+ it("serves erasure to admins only when it is on under authentication", async () => {
115
+ await withEnv(
116
+ {
117
+ AUTH_ENABLED: "true",
118
+ ADMINS: ADMIN,
119
+ PH_PRIVACY_ENABLED: "true",
120
+ PH_PRIVACY_DEPLOYMENT_SECRET: SECRET,
121
+ PH_PRIVACY_INTERVAL_MS: "50",
122
+ },
123
+ async (tempRoot) => {
124
+ const logger = stubLogger();
125
+ const switchboard = await boot(tempRoot, logger);
126
+ try {
127
+ expect(switchboard.privacy).toBeDefined();
128
+ expect(await switchboard.privacy!.erasure.plan([])).toEqual({
129
+ maxPurgeOperations: expect.any(Number) as number,
130
+ items: [],
131
+ });
132
+ await vi.waitUntil(
133
+ async () =>
134
+ (await queryErasure(switchboard)).errors?.[0]?.extensions
135
+ ?.code === "FORBIDDEN",
136
+ { timeout: 20_000, interval: 100 },
137
+ );
138
+ const document = await switchboard.reactor.createEmpty(
139
+ "powerhouse/document-model",
140
+ );
141
+ const id = document.header.id;
142
+ await switchboard.reactor.deleteDocument(id);
143
+ const erasure = switchboard.privacy!.erasure;
144
+ const { requestId } = await erasure.request([id], {
145
+ requestedBy: ADMIN,
146
+ });
147
+ await vi.waitUntil(
148
+ async () => (await erasure.status(requestId)).status === "complete",
149
+ { timeout: 30_000, interval: 100 },
150
+ );
151
+ expect(
152
+ logger.error.mock.calls.filter(([message]) =>
153
+ String(message).startsWith("Erasure"),
154
+ ),
155
+ ).toEqual([]);
156
+ } finally {
157
+ await switchboard.shutdown();
158
+ }
159
+ },
160
+ );
161
+ }, 60_000);
162
+
163
+ it("refuses to boot under the OPEN policy", async () => {
164
+ await withEnv(
165
+ {
166
+ PH_PRIVACY_ENABLED: "true",
167
+ PH_PRIVACY_DEPLOYMENT_SECRET: SECRET,
168
+ },
169
+ async (tempRoot) => {
170
+ await expect(boot(tempRoot)).rejects.toThrow(/OPEN/);
171
+ },
172
+ );
173
+ }, 60_000);
174
+
175
+ it("refuses to boot without a deployment secret", async () => {
176
+ await withEnv(
177
+ { AUTH_ENABLED: "true", ADMINS: ADMIN, PH_PRIVACY_ENABLED: "true" },
178
+ async (tempRoot) => {
179
+ await expect(boot(tempRoot)).rejects.toThrow(
180
+ /PH_PRIVACY_DEPLOYMENT_SECRET/,
181
+ );
182
+ },
183
+ );
184
+ }, 60_000);
185
+ });
186
+
187
+ const PG_URL = process.env.REACTOR_TEST_PG_URL;
188
+
189
+ describe.skipIf(!PG_URL)(
190
+ "the privacy add-on on a Postgres reactor store",
191
+ () => {
192
+ it("erases a document end to end", async () => {
193
+ const name = `sb_privacy_${process.pid}`;
194
+ const admin = new pg.Pool({ connectionString: PG_URL });
195
+ await admin.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
196
+ await admin.query(`CREATE DATABASE "${name}"`);
197
+ const url = new URL(PG_URL!);
198
+ url.pathname = `/${name}`;
199
+ try {
200
+ await withEnv(
201
+ {
202
+ AUTH_ENABLED: "true",
203
+ ADMINS: ADMIN,
204
+ PH_PRIVACY_ENABLED: "true",
205
+ PH_PRIVACY_DEPLOYMENT_SECRET: SECRET,
206
+ PH_PRIVACY_INTERVAL_MS: "50",
207
+ },
208
+ async (tempRoot) => {
209
+ process.env.PH_REACTOR_DATABASE_URL = url.toString();
210
+ const logger = stubLogger();
211
+ const switchboard = await boot(tempRoot, logger);
212
+ try {
213
+ const document = await switchboard.reactor.createEmpty(
214
+ "powerhouse/document-model",
215
+ );
216
+ const id = document.header.id;
217
+ await switchboard.reactor.deleteDocument(id);
218
+ const erasure = switchboard.privacy!.erasure;
219
+ const { requestId } = await erasure.request([id], {
220
+ requestedBy: ADMIN,
221
+ });
222
+ await vi.waitUntil(
223
+ async () =>
224
+ (await erasure.status(requestId)).status === "complete",
225
+ { timeout: 30_000, interval: 100 },
226
+ );
227
+ expect(
228
+ logger.error.mock.calls.filter(([message]) =>
229
+ String(message).startsWith("Erasure"),
230
+ ),
231
+ ).toEqual([]);
232
+ } finally {
233
+ await switchboard.shutdown();
234
+ }
235
+ },
236
+ );
237
+ } finally {
238
+ await admin.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
239
+ await admin.end();
240
+ }
241
+ }, 90_000);
242
+ },
243
+ );
@@ -89,7 +89,8 @@ async function seedStore(dir: string): Promise<void> {
89
89
  const { status } = await module.reactor.getJobStatus(created.id);
90
90
  return status === JobStatus.READ_READY || status === JobStatus.FAILED;
91
91
  },
92
- { timeout: 10_000, interval: 5 },
92
+ // One job is ~40 full snapshots; the Windows runner needs the boot budget.
93
+ { timeout: BOOT_TIMEOUT, interval: 5 },
93
94
  );
94
95
  expect((await module.reactor.getJobStatus(created.id)).status).toBe(
95
96
  JobStatus.READ_READY,
@@ -100,6 +101,8 @@ async function seedStore(dir: string): Promise<void> {
100
101
 
101
102
  // PGlite boots are several times slower on Windows runners.
102
103
  const BOOT_TIMEOUT = 120_000;
104
+ // Windows can briefly keep just-closed store files locked (EBUSY/EPERM).
105
+ const RM_OPTIONS = { recursive: true, force: true, maxRetries: 10 };
103
106
 
104
107
  describe("booting over documents this build does not run", () => {
105
108
  let seeded: string;
@@ -112,8 +115,8 @@ describe("booting over documents this build does not run", () => {
112
115
  }, BOOT_TIMEOUT);
113
116
 
114
117
  afterAll(async () => {
115
- await rm(seeded, { recursive: true, force: true });
116
- });
118
+ await rm(seeded, RM_OPTIONS);
119
+ }, BOOT_TIMEOUT);
117
120
 
118
121
  beforeEach(async () => {
119
122
  tempRoot = await mkdtemp(join(tmpdir(), "switchboard-stored-protocol-"));
@@ -126,7 +129,7 @@ describe("booting over documents this build does not run", () => {
126
129
  process.env.PH_REACTOR_DATABASE_URL,
127
130
  { recursive: true },
128
131
  );
129
- });
132
+ }, BOOT_TIMEOUT);
130
133
 
131
134
  afterEach(async () => {
132
135
  for (const key of ENV_KEYS) {
@@ -136,8 +139,8 @@ describe("booting over documents this build does not run", () => {
136
139
  process.env[key] = previous[key];
137
140
  }
138
141
  }
139
- await rm(tempRoot, { recursive: true, force: true });
140
- });
142
+ await rm(tempRoot, RM_OPTIONS);
143
+ }, BOOT_TIMEOUT);
141
144
 
142
145
  function boot(logger: ILogger) {
143
146
  return startSwitchboard({
@@ -1,6 +1,9 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
3
+ import {
4
+ installFatalErrorShutdown,
5
+ triggerFatalShutdown,
6
+ } from "../src/fatal-shutdown.mjs";
4
7
 
5
8
  function makeProc() {
6
9
  const emitter = new EventEmitter();
@@ -91,6 +94,33 @@ describe("installFatalErrorShutdown", () => {
91
94
  expect(proc.exit).not.toHaveBeenCalled();
92
95
  });
93
96
 
97
+ it("routes a triggered fatal error through SIGTERM with exit code 1", () => {
98
+ const proc = makeProc();
99
+ const onSigterm = vi.fn();
100
+ proc.on("SIGTERM", onSigterm);
101
+ installFatalErrorShutdown(logger, proc as never);
102
+
103
+ expect(
104
+ triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
105
+ ).toBe(true);
106
+ triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never);
107
+ proc.emit("uncaughtException", new Error("later"));
108
+
109
+ expect(proc.kill).toHaveBeenCalledOnce();
110
+ expect(onSigterm).toHaveBeenCalledOnce();
111
+ expect(proc.exitCode).toBe(1);
112
+ });
113
+
114
+ it("ignores a trigger when shutdown on fatal errors is not installed", () => {
115
+ const proc = makeProc();
116
+
117
+ expect(
118
+ triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
119
+ ).toBe(false);
120
+ expect(proc.kill).not.toHaveBeenCalled();
121
+ expect(proc.exitCode).toBeUndefined();
122
+ });
123
+
94
124
  it("installs its listeners once per process", () => {
95
125
  const proc = makeProc();
96
126
  installFatalErrorShutdown(logger, proc as never);
@@ -26,6 +26,7 @@ import { startSwitchboard } from "../src/server.mjs";
26
26
  import {
27
27
  PH_WORKFLOWS_ENABLED,
28
28
  bindPackagePieces,
29
+ canReadAttachmentRef,
29
30
  composeWorkflowRuntime,
30
31
  assertWorkflowPackageLoadable,
31
32
  resolveWorkflowsEnabled,
@@ -245,7 +246,19 @@ describe("composeWorkflowRuntime", () => {
245
246
  expect(deps.webhooks).toEqual({ id: "webhooks" });
246
247
  expect(typeof deps.assertCanRead).toBe("function");
247
248
  expect(typeof deps.assertCanWrite).toBe("function");
248
- expect(typeof deps.canReadAttachmentRef).toBe("function");
249
+ expect(deps.canReadAttachmentRef).toBe(canReadAttachmentRef);
250
+ });
251
+
252
+ it("lets a step read any well-formed attachment ref", async () => {
253
+ const hash = "a".repeat(64);
254
+ // No document references it: runs read attachments as they read documents.
255
+ expect(await canReadAttachmentRef("wf-1", `attachment://v1:${hash}`)).toBe(
256
+ true,
257
+ );
258
+ expect(await canReadAttachmentRef("wf-1", `attachment://v2:${hash}`)).toBe(
259
+ false,
260
+ );
261
+ expect(await canReadAttachmentRef("wf-1", "not-a-ref")).toBe(false);
249
262
  });
250
263
 
251
264
  it("serves a subgraph the GraphQL manager can construct", async () => {
package/tsconfig.json CHANGED
@@ -33,6 +33,9 @@
33
33
  {
34
34
  "path": "../../packages/reactor-group"
35
35
  },
36
+ {
37
+ "path": "../../packages/reactor-privacy"
38
+ },
36
39
  {
37
40
  "path": "../../packages/reactor-workflow"
38
41
  },