@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.50
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/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- 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/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.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/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +509 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/server-Zz7SqTwO.mjs +3335 -0
- package/dist/server-Zz7SqTwO.mjs.map +1 -0
- package/dist/server.d.mts +154 -30
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +159 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +322 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +805 -0
- package/test/workflow-read-gate.test.ts +210 -0
- package/test/workflow-runtime.test.ts +572 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ILogger } from "document-model";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { startSwitchboard } from "../src/server.mjs";
|
|
7
|
+
|
|
8
|
+
function stubLogger(): ILogger & { info: ReturnType<typeof vi.fn> } {
|
|
9
|
+
const logger = {
|
|
10
|
+
verbose: vi.fn(),
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
info: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
child: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
logger.child.mockReturnValue(logger);
|
|
18
|
+
return logger as unknown as ILogger & { info: ReturnType<typeof vi.fn> };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("booting Switchboard", () => {
|
|
22
|
+
it("has no REQUIRE_SIGNATURES flag, and ignores the variable", async () => {
|
|
23
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-signatures-"));
|
|
24
|
+
const previous = {
|
|
25
|
+
reactorDb: process.env.PH_REACTOR_DATABASE_URL,
|
|
26
|
+
requireSignatures: process.env.REQUIRE_SIGNATURES,
|
|
27
|
+
};
|
|
28
|
+
process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
|
|
29
|
+
process.env.REQUIRE_SIGNATURES = "true";
|
|
30
|
+
const logger = stubLogger();
|
|
31
|
+
let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
switchboard = await startSwitchboard({
|
|
35
|
+
workflows: { enabled: false },
|
|
36
|
+
dbPath: join(tempRoot, "read-model"),
|
|
37
|
+
port: 0,
|
|
38
|
+
mcp: false,
|
|
39
|
+
disableLocalPackages: true,
|
|
40
|
+
identity: { keypairPath: join(tempRoot, "identity.json") },
|
|
41
|
+
logger,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const flagLog = logger.info.mock.calls.find(
|
|
45
|
+
([message]) => message === "Feature flags: @flags",
|
|
46
|
+
);
|
|
47
|
+
expect(flagLog).toBeDefined();
|
|
48
|
+
expect(JSON.parse(flagLog![1] as string)).not.toHaveProperty(
|
|
49
|
+
"REQUIRE_SIGNATURES",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const created = await switchboard.reactor.createEmpty(
|
|
53
|
+
"powerhouse/document-model",
|
|
54
|
+
);
|
|
55
|
+
expect(created.header.id).toBeTruthy();
|
|
56
|
+
|
|
57
|
+
await switchboard.shutdown();
|
|
58
|
+
switchboard = undefined;
|
|
59
|
+
} finally {
|
|
60
|
+
await switchboard?.shutdown();
|
|
61
|
+
for (const [name, value] of [
|
|
62
|
+
["PH_REACTOR_DATABASE_URL", previous.reactorDb],
|
|
63
|
+
["REQUIRE_SIGNATURES", previous.requireSignatures],
|
|
64
|
+
] as const) {
|
|
65
|
+
if (value === undefined) {
|
|
66
|
+
delete process.env[name];
|
|
67
|
+
} else {
|
|
68
|
+
process.env[name] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}, 60_000);
|
|
74
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ILogger } from "document-model";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type * as RenownModule from "../src/renown.js";
|
|
7
|
+
import { startSwitchboard } from "../src/server.mjs";
|
|
8
|
+
|
|
9
|
+
vi.mock("../src/renown.js", async (importOriginal) => ({
|
|
10
|
+
...(await importOriginal<typeof RenownModule>()),
|
|
11
|
+
initRenown: vi.fn().mockRejectedValue(new Error("Renown is unreachable")),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const TRUST_FAILURE =
|
|
15
|
+
/REACTOR_WORKERS[\s\S]*REACTOR_AUTH_ENFORCEMENT[\s\S]*RENOWN_SOURCE/;
|
|
16
|
+
const ENV_KEYS = [
|
|
17
|
+
"PH_REACTOR_DATABASE_URL",
|
|
18
|
+
"RENOWN_SOURCE",
|
|
19
|
+
"REACTOR_DOCUMENT_DECISIONS",
|
|
20
|
+
"REACTOR_AUTH_ENFORCEMENT",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
function stubLogger(): ILogger {
|
|
24
|
+
const logger = {
|
|
25
|
+
verbose: vi.fn(),
|
|
26
|
+
debug: vi.fn(),
|
|
27
|
+
info: vi.fn(),
|
|
28
|
+
warn: vi.fn(),
|
|
29
|
+
error: vi.fn(),
|
|
30
|
+
child: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
logger.child.mockReturnValue(logger);
|
|
33
|
+
return logger as unknown as ILogger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("booting with pooled workers under authEnforcement", () => {
|
|
37
|
+
let tempRoot: string;
|
|
38
|
+
let previous: Partial<Record<(typeof ENV_KEYS)[number], string>>;
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
tempRoot = await mkdtemp(join(tmpdir(), "switchboard-worker-trust-"));
|
|
42
|
+
previous = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
43
|
+
process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
|
|
44
|
+
process.env.REACTOR_DOCUMENT_DECISIONS = "true";
|
|
45
|
+
process.env.REACTOR_AUTH_ENFORCEMENT = "true";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
for (const key of ENV_KEYS) {
|
|
50
|
+
if (previous[key] === undefined) {
|
|
51
|
+
delete process.env[key];
|
|
52
|
+
} else {
|
|
53
|
+
process.env[key] = previous[key];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function boot(numWorkers: number) {
|
|
60
|
+
return startSwitchboard({
|
|
61
|
+
workflows: { enabled: false },
|
|
62
|
+
dbPath: join(tempRoot, "read-model"),
|
|
63
|
+
port: 0,
|
|
64
|
+
mcp: false,
|
|
65
|
+
disableLocalPackages: true,
|
|
66
|
+
logger: stubLogger(),
|
|
67
|
+
workerPool: { numWorkers },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it("fails with a self Renown source", async () => {
|
|
72
|
+
process.env.RENOWN_SOURCE = "self";
|
|
73
|
+
|
|
74
|
+
await expect(boot(2)).rejects.toThrow(TRUST_FAILURE);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Postgres is the next requirement a pooled boot has; reaching it passes the check.
|
|
78
|
+
it("passes the check with a remote Renown source", async () => {
|
|
79
|
+
process.env.RENOWN_SOURCE = "remote";
|
|
80
|
+
|
|
81
|
+
await expect(boot(2)).rejects.toThrow(/requires a Postgres/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// No loaded package serves the renown read model here; reaching that check passes this one.
|
|
85
|
+
it("passes the check without workers", async () => {
|
|
86
|
+
process.env.RENOWN_SOURCE = "self";
|
|
87
|
+
|
|
88
|
+
await expect(boot(0)).rejects.toThrow(/no loaded package serves/);
|
|
89
|
+
}, 60_000);
|
|
90
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ISigner } from "@powerhousedao/shared/document-model";
|
|
2
|
+
import {
|
|
3
|
+
ReactorBuilder,
|
|
4
|
+
ReactorClientBuilder,
|
|
5
|
+
type FactorySpec,
|
|
6
|
+
} from "@powerhousedao/reactor";
|
|
7
|
+
import type { IRenown } from "@renown/sdk/node";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
|
|
12
|
+
import {
|
|
13
|
+
getRenownSignerConfig,
|
|
14
|
+
getRenownWorkerSignerSpec,
|
|
15
|
+
} from "../src/renown.js";
|
|
16
|
+
|
|
17
|
+
const USER = { address: "0xswitchboard", networkId: "eip155", chainId: 1 };
|
|
18
|
+
|
|
19
|
+
function stubRenown(user = USER): IRenown {
|
|
20
|
+
const signer = {
|
|
21
|
+
user,
|
|
22
|
+
app: { name: "switchboard", key: "did:key:zSwitchboard" },
|
|
23
|
+
} as unknown as ISigner;
|
|
24
|
+
return { signer } as unknown as IRenown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("applySwitchboardReactorDefaults", () => {
|
|
28
|
+
it("gives the reactor the signer and its worker spec", () => {
|
|
29
|
+
const reactorBuilder = new ReactorBuilder();
|
|
30
|
+
const clientBuilder = new ReactorClientBuilder();
|
|
31
|
+
const reactorWithSigner = vi.spyOn(reactorBuilder, "withSigner");
|
|
32
|
+
const clientWithSigner = vi.spyOn(clientBuilder, "withSigner");
|
|
33
|
+
const signer = getRenownSignerConfig(stubRenown(), ".ph/key.json");
|
|
34
|
+
|
|
35
|
+
applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
|
|
36
|
+
includeBaseModels: false,
|
|
37
|
+
signalHandlers: false,
|
|
38
|
+
signer,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(clientWithSigner).toHaveBeenCalledWith(signer);
|
|
42
|
+
expect(reactorWithSigner).toHaveBeenCalledWith(
|
|
43
|
+
signer.signer,
|
|
44
|
+
signer.workerSigner,
|
|
45
|
+
);
|
|
46
|
+
expect(reactorBuilder.hasSigner()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("getRenownWorkerSignerSpec", () => {
|
|
51
|
+
it("points workers at createNodeRenownSigner over the stored key", () => {
|
|
52
|
+
const spec: FactorySpec = getRenownWorkerSignerSpec(
|
|
53
|
+
stubRenown(),
|
|
54
|
+
".ph/key.json",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(spec.module.exportName).toBe("createNodeRenownSigner");
|
|
58
|
+
const filePath = "filePath" in spec.module ? spec.module.filePath : "";
|
|
59
|
+
expect(existsSync(filePath)).toBe(true);
|
|
60
|
+
expect(spec.initArgs).toEqual({
|
|
61
|
+
appName: "switchboard",
|
|
62
|
+
keypairPath: resolve(".ph/key.json"),
|
|
63
|
+
did: "did:key:zSwitchboard",
|
|
64
|
+
user: USER,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("leaves the user out when none is logged in", () => {
|
|
69
|
+
const renown = stubRenown();
|
|
70
|
+
(renown.signer as { user?: unknown }).user = undefined;
|
|
71
|
+
|
|
72
|
+
const spec = getRenownWorkerSignerSpec(renown);
|
|
73
|
+
|
|
74
|
+
expect(spec.initArgs).toEqual({
|
|
75
|
+
appName: "switchboard",
|
|
76
|
+
keypairPath: resolve(".ph/.keypair.json"),
|
|
77
|
+
did: "did:key:zSwitchboard",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ReactorBuilder, ReactorClientBuilder } from "@powerhousedao/reactor";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
|
|
4
|
+
import { resolveCreateSignaturePolicy } from "../src/create-signature-policy.mjs";
|
|
5
|
+
|
|
6
|
+
describe("resolveCreateSignaturePolicy", () => {
|
|
7
|
+
it("defaults to v2-required with a signer", () => {
|
|
8
|
+
expect(resolveCreateSignaturePolicy({}, { hasSigner: true })).toBe(
|
|
9
|
+
"v2-required",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("reads CREATE_SIGNATURE_POLICY", () => {
|
|
14
|
+
expect(
|
|
15
|
+
resolveCreateSignaturePolicy(
|
|
16
|
+
{ CREATE_SIGNATURE_POLICY: "legacy" },
|
|
17
|
+
{ hasSigner: true },
|
|
18
|
+
),
|
|
19
|
+
).toBe("legacy");
|
|
20
|
+
expect(
|
|
21
|
+
resolveCreateSignaturePolicy(
|
|
22
|
+
{ CREATE_SIGNATURE_POLICY: " v2-required " },
|
|
23
|
+
{ hasSigner: true },
|
|
24
|
+
),
|
|
25
|
+
).toBe("v2-required");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("refuses an unknown value", () => {
|
|
29
|
+
expect(() =>
|
|
30
|
+
resolveCreateSignaturePolicy(
|
|
31
|
+
{ CREATE_SIGNATURE_POLICY: "v2" },
|
|
32
|
+
{ hasSigner: true },
|
|
33
|
+
),
|
|
34
|
+
).toThrow(/CREATE_SIGNATURE_POLICY/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls back to legacy with a warning when there is no signer", () => {
|
|
38
|
+
const warn = vi.fn();
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
resolveCreateSignaturePolicy({}, { hasSigner: false, logger: { warn } }),
|
|
42
|
+
).toBe("legacy");
|
|
43
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
44
|
+
|
|
45
|
+
warn.mockClear();
|
|
46
|
+
expect(
|
|
47
|
+
resolveCreateSignaturePolicy(
|
|
48
|
+
{ CREATE_SIGNATURE_POLICY: "legacy" },
|
|
49
|
+
{ hasSigner: false, logger: { warn } },
|
|
50
|
+
),
|
|
51
|
+
).toBe("legacy");
|
|
52
|
+
expect(warn).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("applySwitchboardReactorDefaults", () => {
|
|
57
|
+
it("hands the creation policy to the client builder", () => {
|
|
58
|
+
const clientBuilder = new ReactorClientBuilder();
|
|
59
|
+
const withPolicy = vi.spyOn(clientBuilder, "withCreateSignaturePolicy");
|
|
60
|
+
|
|
61
|
+
applySwitchboardReactorDefaults(new ReactorBuilder(), clientBuilder, {
|
|
62
|
+
includeBaseModels: false,
|
|
63
|
+
signalHandlers: false,
|
|
64
|
+
createSignaturePolicy: "legacy",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(withPolicy).toHaveBeenCalledWith("legacy");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createDurableNodeFs } from "@powerhousedao/reactor-api/pglite-node";
|
|
2
|
+
import nodeFs, { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { loadNodeFsClass, loadPGliteModule } from "../src/pglite-version.js";
|
|
7
|
+
|
|
8
|
+
// A fresh initdb takes ~10 s under suite load; the Windows runner is slower.
|
|
9
|
+
const BOOT = 180_000;
|
|
10
|
+
const COMMITS = 10;
|
|
11
|
+
const RM = { recursive: true, force: true, maxRetries: 10 } as const;
|
|
12
|
+
|
|
13
|
+
describe("durable NodeFS over pglite-legacy-02 (PG16)", () => {
|
|
14
|
+
const tempDirs: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
for (const dir of tempDirs.splice(0)) {
|
|
18
|
+
await fs.rm(dir, RM);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it(
|
|
23
|
+
"opens a PG16 dir over pglite-legacy-02 with the durable fs",
|
|
24
|
+
async () => {
|
|
25
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "durable-pg16-"));
|
|
26
|
+
tempDirs.push(dir);
|
|
27
|
+
const counts = { fsync: 0, fdatasync: 0 };
|
|
28
|
+
const hostFs = {
|
|
29
|
+
fsyncSync: (fd: number) => {
|
|
30
|
+
counts.fsync++;
|
|
31
|
+
nodeFs.fsyncSync(fd);
|
|
32
|
+
},
|
|
33
|
+
fdatasyncSync: (fd: number) => {
|
|
34
|
+
counts.fdatasync++;
|
|
35
|
+
nodeFs.fdatasyncSync(fd);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const [{ PGlite }, NodeFS] = await Promise.all([
|
|
39
|
+
loadPGliteModule(16),
|
|
40
|
+
loadNodeFsClass(16),
|
|
41
|
+
]);
|
|
42
|
+
const pg = new PGlite({
|
|
43
|
+
fs: createDurableNodeFs(NodeFS, dir, {
|
|
44
|
+
hostFs,
|
|
45
|
+
maintenanceIntervalMs: 0,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
await pg.waitReady;
|
|
50
|
+
const version = await pg.query<{ v: string }>(
|
|
51
|
+
"SELECT current_setting('server_version_num') AS v",
|
|
52
|
+
);
|
|
53
|
+
expect(version.rows[0].v.startsWith("16")).toBe(true);
|
|
54
|
+
await pg.exec("CREATE TABLE t (v int)");
|
|
55
|
+
const before = counts.fdatasync;
|
|
56
|
+
for (let i = 0; i < COMMITS; i++) {
|
|
57
|
+
await pg.exec(`INSERT INTO t VALUES (${i})`);
|
|
58
|
+
}
|
|
59
|
+
const perCommit = counts.fdatasync - before;
|
|
60
|
+
const fsyncBefore = counts.fsync;
|
|
61
|
+
await pg.exec("CHECKPOINT");
|
|
62
|
+
const checkpointSyncs = counts.fsync - fsyncBefore;
|
|
63
|
+
console.info(
|
|
64
|
+
`pglite-legacy-02: ${perCommit} host fdatasync over ${COMMITS} commits, ${checkpointSyncs} host fsync on CHECKPOINT, ${fsyncBefore} host fsync since open`,
|
|
65
|
+
);
|
|
66
|
+
// Pins decision 5's outcome on 0.2.17: WAL goes through
|
|
67
|
+
// open_datasync, which never reaches the fdatasync hook, so commits
|
|
68
|
+
// are not synced; the stream-op patch does take (CHECKPOINT syncs).
|
|
69
|
+
expect(perCommit).toBe(0);
|
|
70
|
+
expect(checkpointSyncs).toBeGreaterThan(0);
|
|
71
|
+
} finally {
|
|
72
|
+
await pg.close();
|
|
73
|
+
}
|
|
74
|
+
expect(
|
|
75
|
+
(await fs.readFile(path.join(dir, "PG_VERSION"), "utf8")).trim(),
|
|
76
|
+
).toBe("16");
|
|
77
|
+
},
|
|
78
|
+
BOOT,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
installFatalErrorShutdown,
|
|
5
|
+
triggerFatalShutdown,
|
|
6
|
+
} from "../src/fatal-shutdown.mjs";
|
|
7
|
+
|
|
8
|
+
function makeProc() {
|
|
9
|
+
const emitter = new EventEmitter();
|
|
10
|
+
const proc = Object.assign(emitter, {
|
|
11
|
+
pid: 1234,
|
|
12
|
+
exitCode: undefined as number | undefined,
|
|
13
|
+
kill: vi.fn((_pid: number, signal: string) => {
|
|
14
|
+
emitter.emit(signal);
|
|
15
|
+
return true;
|
|
16
|
+
}),
|
|
17
|
+
exit: vi.fn(),
|
|
18
|
+
});
|
|
19
|
+
return proc;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const logger = {
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
} as unknown as Parameters<typeof installFatalErrorShutdown>[0];
|
|
25
|
+
|
|
26
|
+
describe("installFatalErrorShutdown", () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each(["uncaughtException", "unhandledRejection"])(
|
|
32
|
+
"routes %s through SIGTERM with exit code 1",
|
|
33
|
+
(event) => {
|
|
34
|
+
const proc = makeProc();
|
|
35
|
+
const onSigterm = vi.fn();
|
|
36
|
+
proc.on("SIGTERM", onSigterm);
|
|
37
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
38
|
+
|
|
39
|
+
proc.emit(event, new Error("boom"));
|
|
40
|
+
|
|
41
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
42
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
43
|
+
expect(proc.exitCode).toBe(1);
|
|
44
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
it("leaves an unhandled rejection to another listener, such as Sentry's", () => {
|
|
49
|
+
const proc = makeProc();
|
|
50
|
+
const other = vi.fn();
|
|
51
|
+
proc.on("unhandledRejection", other);
|
|
52
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
53
|
+
|
|
54
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
55
|
+
|
|
56
|
+
expect(other).toHaveBeenCalledOnce();
|
|
57
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
58
|
+
expect(proc.exitCode).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("still shuts down on an uncaught exception when another listener exists", () => {
|
|
62
|
+
const proc = makeProc();
|
|
63
|
+
proc.on("uncaughtException", vi.fn());
|
|
64
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
65
|
+
|
|
66
|
+
proc.emit("uncaughtException", new Error("boom"));
|
|
67
|
+
|
|
68
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
69
|
+
expect(proc.exitCode).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("signals once when a second fatal error arrives during shutdown", () => {
|
|
73
|
+
const proc = makeProc();
|
|
74
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
75
|
+
|
|
76
|
+
proc.emit("unhandledRejection", new Error("first"));
|
|
77
|
+
proc.emit("uncaughtException", new Error("second"));
|
|
78
|
+
|
|
79
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("forces exit 1 through the original exit when shutdown hangs", () => {
|
|
83
|
+
vi.useFakeTimers();
|
|
84
|
+
const proc = makeProc();
|
|
85
|
+
const originalExit = proc.exit;
|
|
86
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
87
|
+
// The builder swaps process.exit for a recording shim during shutdown.
|
|
88
|
+
proc.exit = vi.fn();
|
|
89
|
+
|
|
90
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
91
|
+
vi.advanceTimersByTime(15_000);
|
|
92
|
+
|
|
93
|
+
expect(originalExit).toHaveBeenCalledWith(1);
|
|
94
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("still reaches exit when the logger itself throws EPIPE inside onFatal", () => {
|
|
98
|
+
vi.useFakeTimers();
|
|
99
|
+
const proc = makeProc();
|
|
100
|
+
const onSigterm = vi.fn();
|
|
101
|
+
proc.on("SIGTERM", onSigterm);
|
|
102
|
+
const error = vi.fn(() => {
|
|
103
|
+
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
|
104
|
+
});
|
|
105
|
+
const throwingLogger = { error } as unknown as Parameters<
|
|
106
|
+
typeof installFatalErrorShutdown
|
|
107
|
+
>[0];
|
|
108
|
+
installFatalErrorShutdown(throwingLogger, proc as never);
|
|
109
|
+
|
|
110
|
+
// Without the guard the EPIPE propagates out of the uncaughtException
|
|
111
|
+
// listener, which in a real process re-enters fatal handling instead of
|
|
112
|
+
// running the shutdown.
|
|
113
|
+
expect(() =>
|
|
114
|
+
proc.emit("uncaughtException", new Error("boom")),
|
|
115
|
+
).not.toThrow();
|
|
116
|
+
|
|
117
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
118
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
119
|
+
expect(proc.exitCode).toBe(1);
|
|
120
|
+
|
|
121
|
+
vi.advanceTimersByTime(15_000);
|
|
122
|
+
|
|
123
|
+
expect(proc.exit).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(proc.exit).toHaveBeenCalledWith(1);
|
|
125
|
+
// Exactly the two guarded log attempts: the fatal report and the
|
|
126
|
+
// forced-exit notice. A re-entered handler would log again.
|
|
127
|
+
expect(error).toHaveBeenCalledTimes(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("routes a triggered fatal error through SIGTERM with exit code 1", () => {
|
|
131
|
+
const proc = makeProc();
|
|
132
|
+
const onSigterm = vi.fn();
|
|
133
|
+
proc.on("SIGTERM", onSigterm);
|
|
134
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
|
|
138
|
+
).toBe(true);
|
|
139
|
+
triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never);
|
|
140
|
+
proc.emit("uncaughtException", new Error("later"));
|
|
141
|
+
|
|
142
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
143
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
144
|
+
expect(proc.exitCode).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("ignores a trigger when shutdown on fatal errors is not installed", () => {
|
|
148
|
+
const proc = makeProc();
|
|
149
|
+
|
|
150
|
+
expect(
|
|
151
|
+
triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
|
|
152
|
+
).toBe(false);
|
|
153
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
154
|
+
expect(proc.exitCode).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("installs its listeners once per process", () => {
|
|
158
|
+
const proc = makeProc();
|
|
159
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
160
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
161
|
+
|
|
162
|
+
expect(proc.listenerCount("uncaughtException")).toBe(1);
|
|
163
|
+
expect(proc.listenerCount("unhandledRejection")).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { initFeatureFlags } from "../src/feature-flags.js";
|
|
3
|
+
import { MCP_ENABLED, resolveMcpEnabled } from "../src/mcp-flag.mjs";
|
|
4
|
+
import type { BooleanFlagSource } from "../src/workflow-runtime.mjs";
|
|
5
|
+
|
|
6
|
+
describe("resolveMcpEnabled", () => {
|
|
7
|
+
let featureFlags: BooleanFlagSource;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
featureFlags = await initFeatureFlags();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
delete process.env[MCP_ENABLED];
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("defaults to on", async () => {
|
|
18
|
+
await expect(resolveMcpEnabled({ featureFlags })).resolves.toBe(true);
|
|
19
|
+
await expect(
|
|
20
|
+
resolveMcpEnabled({ featureFlags, option: true }),
|
|
21
|
+
).resolves.toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('is turned off by MCP_ENABLED="false"', async () => {
|
|
25
|
+
process.env[MCP_ENABLED] = "false";
|
|
26
|
+
|
|
27
|
+
await expect(resolveMcpEnabled({ featureFlags })).resolves.toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// `ph vetra` passes `mcp: true` explicitly; the env var still wins.
|
|
31
|
+
it("turns off a host that asked for MCP", async () => {
|
|
32
|
+
process.env[MCP_ENABLED] = "false";
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
resolveMcpEnabled({ featureFlags, option: true }),
|
|
36
|
+
).resolves.toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("never turns on a host that switched MCP off", async () => {
|
|
40
|
+
process.env[MCP_ENABLED] = "true";
|
|
41
|
+
|
|
42
|
+
await expect(
|
|
43
|
+
resolveMcpEnabled({ featureFlags, option: false }),
|
|
44
|
+
).resolves.toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("leaves MCP on for any value but the string false", async () => {
|
|
48
|
+
for (const raw of ["true", "0", "no", ""]) {
|
|
49
|
+
process.env[MCP_ENABLED] = raw;
|
|
50
|
+
await expect(
|
|
51
|
+
resolveMcpEnabled({ featureFlags }),
|
|
52
|
+
`MCP_ENABLED=${JSON.stringify(raw)}`,
|
|
53
|
+
).resolves.toBe(true);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|