@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/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +56 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-sQoWp21G.mjs → server-D3CYuEka.mjs} +154 -52
- package/dist/server-D3CYuEka.mjs.map +1 -0
- package/dist/server.d.mts +18 -1
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +16 -15
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/byte-grants.test.ts +4 -1
- package/test/attachments/download-target.test.ts +7 -1
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +1 -0
- package/test/attachments/routes.test.ts +1 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +9 -6
- package/test/fatal-shutdown.test.ts +31 -1
- package/test/workflow-runtime.test.ts +14 -1
- package/tsconfig.json +3 -0
- package/dist/server-sQoWp21G.mjs.map +0 -1
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 */
|
package/dist/server.d.mts.map
CHANGED
|
@@ -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":"
|
|
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-
|
|
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]="
|
|
5
|
-
//# debugId=
|
|
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.
|
|
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.
|
|
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.
|
|
59
|
-
"@powerhousedao/reactor": "6.2.3-dev.
|
|
60
|
-
"@powerhousedao/shared": "6.2.3-dev.
|
|
61
|
-
"@powerhousedao/vetra": "6.2.3-dev.
|
|
62
|
-
"@powerhousedao/reactor-api": "6.2.3-dev.
|
|
63
|
-
"@powerhousedao/reactor-attachments": "6.2.3-dev.
|
|
64
|
-
"@powerhousedao/reactor-drive": "6.2.3-dev.
|
|
65
|
-
"@powerhousedao/reactor-group": "6.2.3-dev.
|
|
66
|
-
"@powerhousedao/reactor-
|
|
67
|
-
"@powerhousedao/workflow": "6.2.3-dev.
|
|
68
|
-
"@powerhousedao/
|
|
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.
|
|
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.
|
|
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 = {
|
|
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: {
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 {
|
|
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(
|
|
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 () => {
|