@powerhousedao/switchboard 6.2.3-dev.34 → 6.2.3-dev.35
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/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 +40 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-sQoWp21G.mjs → server-CXAp5TOI.mjs} +131 -34
- package/dist/server-CXAp5TOI.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/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;;;;;;;;;;;;;;;;;;;qBA88BvB,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-CXAp5TOI.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]="91c41927-1b39-5750-b103-5886cde48ae6")}catch(e){}}();
|
|
5
|
+
//# debugId=91c41927-1b39-5750-b103-5886cde48ae6
|
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.35",
|
|
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.35",
|
|
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.35",
|
|
59
|
+
"@powerhousedao/reactor": "6.2.3-dev.35",
|
|
60
|
+
"@powerhousedao/shared": "6.2.3-dev.35",
|
|
61
|
+
"@powerhousedao/vetra": "6.2.3-dev.35",
|
|
62
|
+
"@powerhousedao/reactor-api": "6.2.3-dev.35",
|
|
63
|
+
"@powerhousedao/reactor-attachments": "6.2.3-dev.35",
|
|
64
|
+
"@powerhousedao/reactor-drive": "6.2.3-dev.35",
|
|
65
|
+
"@powerhousedao/reactor-group": "6.2.3-dev.35",
|
|
66
|
+
"@powerhousedao/reactor-privacy": "6.2.3-dev.35",
|
|
67
|
+
"@powerhousedao/reactor-workflow": "6.2.3-dev.35",
|
|
68
|
+
"@powerhousedao/workflow": "6.2.3-dev.35",
|
|
69
|
+
"@powerhousedao/pglite-fs": "6.2.3-dev.35",
|
|
69
70
|
"@pyroscope/nodejs": "^0.4.5",
|
|
70
|
-
"@renown/sdk": "6.2.3-dev.
|
|
71
|
+
"@renown/sdk": "6.2.3-dev.35",
|
|
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.35",
|
|
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(
|
|
@@ -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 () => {
|