@powerhousedao/switchboard 6.2.3-dev.8 → 6.2.3-staging.0
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/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-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/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/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.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-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 +302 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +10 -8
- 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-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
- package/dist/server-sQoWp21G.mjs.map +1 -0
- package/dist/server.d.mts +103 -29
- 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 +21 -17
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +287 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +194 -31
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/routes-integration.test.ts +59 -4
- package/test/attachments/routes.test.ts +165 -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-unsupported-stored-documents.test.ts +210 -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/fatal-shutdown.test.ts +102 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +296 -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-read-gate.test.ts +145 -0
- package/test/workflow-runtime.test.ts +486 -0
- package/tsconfig.json +6 -0
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -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,102 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
|
|
4
|
+
|
|
5
|
+
function makeProc() {
|
|
6
|
+
const emitter = new EventEmitter();
|
|
7
|
+
const proc = Object.assign(emitter, {
|
|
8
|
+
pid: 1234,
|
|
9
|
+
exitCode: undefined as number | undefined,
|
|
10
|
+
kill: vi.fn((_pid: number, signal: string) => {
|
|
11
|
+
emitter.emit(signal);
|
|
12
|
+
return true;
|
|
13
|
+
}),
|
|
14
|
+
exit: vi.fn(),
|
|
15
|
+
});
|
|
16
|
+
return proc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const logger = {
|
|
20
|
+
error: vi.fn(),
|
|
21
|
+
} as unknown as Parameters<typeof installFatalErrorShutdown>[0];
|
|
22
|
+
|
|
23
|
+
describe("installFatalErrorShutdown", () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each(["uncaughtException", "unhandledRejection"])(
|
|
29
|
+
"routes %s through SIGTERM with exit code 1",
|
|
30
|
+
(event) => {
|
|
31
|
+
const proc = makeProc();
|
|
32
|
+
const onSigterm = vi.fn();
|
|
33
|
+
proc.on("SIGTERM", onSigterm);
|
|
34
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
35
|
+
|
|
36
|
+
proc.emit(event, new Error("boom"));
|
|
37
|
+
|
|
38
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
39
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
40
|
+
expect(proc.exitCode).toBe(1);
|
|
41
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
it("leaves an unhandled rejection to another listener, such as Sentry's", () => {
|
|
46
|
+
const proc = makeProc();
|
|
47
|
+
const other = vi.fn();
|
|
48
|
+
proc.on("unhandledRejection", other);
|
|
49
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
50
|
+
|
|
51
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
52
|
+
|
|
53
|
+
expect(other).toHaveBeenCalledOnce();
|
|
54
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
55
|
+
expect(proc.exitCode).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("still shuts down on an uncaught exception when another listener exists", () => {
|
|
59
|
+
const proc = makeProc();
|
|
60
|
+
proc.on("uncaughtException", vi.fn());
|
|
61
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
62
|
+
|
|
63
|
+
proc.emit("uncaughtException", new Error("boom"));
|
|
64
|
+
|
|
65
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
66
|
+
expect(proc.exitCode).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("signals once when a second fatal error arrives during shutdown", () => {
|
|
70
|
+
const proc = makeProc();
|
|
71
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
72
|
+
|
|
73
|
+
proc.emit("unhandledRejection", new Error("first"));
|
|
74
|
+
proc.emit("uncaughtException", new Error("second"));
|
|
75
|
+
|
|
76
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("forces exit 1 through the original exit when shutdown hangs", () => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
const proc = makeProc();
|
|
82
|
+
const originalExit = proc.exit;
|
|
83
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
84
|
+
// The builder swaps process.exit for a recording shim during shutdown.
|
|
85
|
+
proc.exit = vi.fn();
|
|
86
|
+
|
|
87
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
88
|
+
vi.advanceTimersByTime(15_000);
|
|
89
|
+
|
|
90
|
+
expect(originalExit).toHaveBeenCalledWith(1);
|
|
91
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("installs its listeners once per process", () => {
|
|
95
|
+
const proc = makeProc();
|
|
96
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
97
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
98
|
+
|
|
99
|
+
expect(proc.listenerCount("uncaughtException")).toBe(1);
|
|
100
|
+
expect(proc.listenerCount("unhandledRejection")).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionSigner,
|
|
3
|
+
ISigner,
|
|
4
|
+
} from "@powerhousedao/shared/document-model";
|
|
5
|
+
import {
|
|
6
|
+
ReactorBuilder,
|
|
7
|
+
ReactorClientBuilder,
|
|
8
|
+
type SignatureTrustPolicy,
|
|
9
|
+
} from "@powerhousedao/reactor";
|
|
10
|
+
import type { IRenown } from "@renown/sdk/node";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
|
|
15
|
+
import {
|
|
16
|
+
assertWorkerTrustPolicy,
|
|
17
|
+
getRenownTrustPolicyConfig,
|
|
18
|
+
} from "../src/renown.js";
|
|
19
|
+
|
|
20
|
+
const WALLET = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
|
21
|
+
const CONNECT_KEY = "did:key:zDnaeConnectKey";
|
|
22
|
+
const SWITCHBOARD_KEY = "did:key:zDnaeSwitchboardKey";
|
|
23
|
+
const SWITCHBOARD_USER = {
|
|
24
|
+
address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
|
|
25
|
+
networkId: "eip155",
|
|
26
|
+
chainId: 1,
|
|
27
|
+
};
|
|
28
|
+
const GRAPHQL = "http://renown.test/graphql";
|
|
29
|
+
|
|
30
|
+
// Signed by WALLET's Anvil dev key; its expiry is ignored by the policy.
|
|
31
|
+
const CREDENTIAL_ROW = {
|
|
32
|
+
documentId: "doc-cred",
|
|
33
|
+
credentialId: "urn:uuid:c7882913-68ef-48e5-9e89-f2948d2ca22c",
|
|
34
|
+
context: ["https://www.w3.org/2018/credentials/v1"],
|
|
35
|
+
type: ["VerifiableCredential", "RenownCredential"],
|
|
36
|
+
issuerId: "did:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
|
|
37
|
+
issuerEthereumAddress: WALLET,
|
|
38
|
+
issuanceDate: "2026-09-24T00:48:25.566Z",
|
|
39
|
+
expirationDate: "2026-10-01T00:48:25.566Z",
|
|
40
|
+
credentialSubjectId: CONNECT_KEY,
|
|
41
|
+
credentialSubjectApp: "connect",
|
|
42
|
+
credentialStatusId: null,
|
|
43
|
+
credentialStatusType: null,
|
|
44
|
+
credentialSchemaId: "https://renown.id/schemas/renown-credential/v1",
|
|
45
|
+
credentialSchemaType: "JsonSchemaValidator2018",
|
|
46
|
+
proofVerificationMethod:
|
|
47
|
+
"did:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
|
|
48
|
+
proofEthereumAddress: WALLET,
|
|
49
|
+
proofCreated: "2026-09-24T00:48:25.566Z",
|
|
50
|
+
proofPurpose: "assertionMethod",
|
|
51
|
+
proofType: "EthereumEip712Signature2021",
|
|
52
|
+
proofValue:
|
|
53
|
+
"0xe8d7a2468b9eb21286483fd88601d350ac7a8ed908506e51a34c6b119f93a7d75c6284a3f231698f6fcc8a3ad0ec5096a34811c7b6adef2d1e4146ddb22826a91c",
|
|
54
|
+
proofEip712Domain: JSON.stringify({ version: "1", chainId: 1 }),
|
|
55
|
+
proofEip712PrimaryType: "VerifiableCredential",
|
|
56
|
+
revoked: false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function stubRenown(user: ActionSigner["user"] = SWITCHBOARD_USER): IRenown {
|
|
60
|
+
const signer = {
|
|
61
|
+
user,
|
|
62
|
+
app: { name: "switchboard", key: SWITCHBOARD_KEY },
|
|
63
|
+
} as unknown as ISigner;
|
|
64
|
+
return { signer } as unknown as IRenown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function claim(address: string, key: string): ActionSigner {
|
|
68
|
+
return {
|
|
69
|
+
user: { address, networkId: "eip155", chainId: 1 },
|
|
70
|
+
app: { name: "connect", key },
|
|
71
|
+
signatures: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Answers the renown read model with `rows`; counts the requests. */
|
|
76
|
+
function mockReadModel(rows: unknown[] = [CREDENTIAL_ROW]) {
|
|
77
|
+
return vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
78
|
+
Promise.resolve(
|
|
79
|
+
new Response(JSON.stringify({ data: { renownCredentials: rows } }), {
|
|
80
|
+
status: 200,
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function remotePolicy(
|
|
87
|
+
renown = stubRenown(),
|
|
88
|
+
): Promise<SignatureTrustPolicy> {
|
|
89
|
+
const { trustPolicy } = await getRenownTrustPolicyConfig(
|
|
90
|
+
{ source: "remote", switchboardUrl: GRAPHQL },
|
|
91
|
+
renown,
|
|
92
|
+
);
|
|
93
|
+
return trustPolicy!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("getRenownTrustPolicyConfig", () => {
|
|
97
|
+
afterEach(() => vi.restoreAllMocks());
|
|
98
|
+
|
|
99
|
+
it("accepts a key a credential binds to the claimed wallet", async () => {
|
|
100
|
+
const fetchMock = mockReadModel();
|
|
101
|
+
const policy = await remotePolicy();
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
|
|
105
|
+
).resolves.toBe(true);
|
|
106
|
+
expect(fetchMock.mock.calls[0][0]).toBe(GRAPHQL);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("refuses a mismatched address or key", async () => {
|
|
110
|
+
mockReadModel();
|
|
111
|
+
const policy = await remotePolicy();
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
policy.authorizeSigner(
|
|
115
|
+
claim(SWITCHBOARD_USER.address, CONNECT_KEY),
|
|
116
|
+
CONNECT_KEY,
|
|
117
|
+
"doc",
|
|
118
|
+
),
|
|
119
|
+
).resolves.toBe(false);
|
|
120
|
+
await expect(
|
|
121
|
+
policy.authorizeSigner(
|
|
122
|
+
claim(WALLET, "did:key:zDnaeOther"),
|
|
123
|
+
"did:key:zDnaeOther",
|
|
124
|
+
"doc",
|
|
125
|
+
),
|
|
126
|
+
).resolves.toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("refuses a key no credential binds yet", async () => {
|
|
130
|
+
const fetchMock = mockReadModel([]);
|
|
131
|
+
const policy = await remotePolicy();
|
|
132
|
+
|
|
133
|
+
await expect(
|
|
134
|
+
policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
|
|
135
|
+
).resolves.toBe(false);
|
|
136
|
+
await expect(
|
|
137
|
+
policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
|
|
138
|
+
).resolves.toBe(false);
|
|
139
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("caches an acceptance per (address, key)", async () => {
|
|
143
|
+
const fetchMock = mockReadModel();
|
|
144
|
+
const policy = await remotePolicy();
|
|
145
|
+
const signer = claim(WALLET, CONNECT_KEY);
|
|
146
|
+
|
|
147
|
+
await policy.authorizeSigner(signer, CONNECT_KEY, "doc-1");
|
|
148
|
+
fetchMock.mockRejectedValue(new TypeError("offline"));
|
|
149
|
+
|
|
150
|
+
await expect(
|
|
151
|
+
policy.authorizeSigner(signer, CONNECT_KEY, "doc-2"),
|
|
152
|
+
).resolves.toBe(true);
|
|
153
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("throws on a network failure rather than refusing", async () => {
|
|
157
|
+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("offline"));
|
|
158
|
+
const policy = await remotePolicy();
|
|
159
|
+
|
|
160
|
+
await expect(
|
|
161
|
+
policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
|
|
162
|
+
).rejects.toThrow("offline");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("accepts the switchboard's own key for its own user without a lookup", async () => {
|
|
166
|
+
const fetchMock = mockReadModel([]);
|
|
167
|
+
const policy = await remotePolicy();
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
policy.authorizeSigner(
|
|
171
|
+
claim(SWITCHBOARD_USER.address, SWITCHBOARD_KEY),
|
|
172
|
+
SWITCHBOARD_KEY,
|
|
173
|
+
"doc",
|
|
174
|
+
),
|
|
175
|
+
).resolves.toBe(true);
|
|
176
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("matches the switchboard's own key against its current user", async () => {
|
|
180
|
+
const fetchMock = mockReadModel([]);
|
|
181
|
+
const renown = stubRenown();
|
|
182
|
+
const policy = await remotePolicy(renown);
|
|
183
|
+
|
|
184
|
+
renown.signer.user = { address: WALLET, networkId: "eip155", chainId: 1 };
|
|
185
|
+
|
|
186
|
+
await expect(
|
|
187
|
+
policy.authorizeSigner(
|
|
188
|
+
claim(WALLET, SWITCHBOARD_KEY),
|
|
189
|
+
SWITCHBOARD_KEY,
|
|
190
|
+
"doc",
|
|
191
|
+
),
|
|
192
|
+
).resolves.toBe(true);
|
|
193
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
194
|
+
await expect(
|
|
195
|
+
policy.authorizeSigner(
|
|
196
|
+
claim(SWITCHBOARD_USER.address, SWITCHBOARD_KEY),
|
|
197
|
+
SWITCHBOARD_KEY,
|
|
198
|
+
"doc",
|
|
199
|
+
),
|
|
200
|
+
).resolves.toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("gives pooled workers a spec that builds the same policy", async () => {
|
|
204
|
+
const fetchMock = mockReadModel();
|
|
205
|
+
const { workerTrustPolicy } = await getRenownTrustPolicyConfig(
|
|
206
|
+
{ source: "remote", switchboardUrl: GRAPHQL },
|
|
207
|
+
stubRenown(),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const module = workerTrustPolicy!.module;
|
|
211
|
+
expect(module.exportName).toBe("createRenownTrustPolicy");
|
|
212
|
+
const filePath = "filePath" in module ? module.filePath : "";
|
|
213
|
+
expect(existsSync(filePath)).toBe(true);
|
|
214
|
+
expect(workerTrustPolicy!.initArgs).toEqual({
|
|
215
|
+
switchboard: GRAPHQL,
|
|
216
|
+
self: { key: SWITCHBOARD_KEY, user: SWITCHBOARD_USER },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const imported = (await import(pathToFileURL(filePath).href)) as Record<
|
|
220
|
+
string,
|
|
221
|
+
(args: unknown) => SignatureTrustPolicy
|
|
222
|
+
>;
|
|
223
|
+
const policy = imported[module.exportName](workerTrustPolicy!.initArgs);
|
|
224
|
+
await expect(
|
|
225
|
+
policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
|
|
226
|
+
).resolves.toBe(true);
|
|
227
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("reads a self source in process and gives workers no spec", async () => {
|
|
231
|
+
const request = vi.fn(() =>
|
|
232
|
+
Promise.resolve({ renownCredentials: [CREDENTIAL_ROW] }),
|
|
233
|
+
);
|
|
234
|
+
const config = await getRenownTrustPolicyConfig(
|
|
235
|
+
{ source: "self", request },
|
|
236
|
+
null,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(config.workerTrustPolicy).toBeUndefined();
|
|
240
|
+
await expect(
|
|
241
|
+
config.trustPolicy!.authorizeSigner(
|
|
242
|
+
claim(WALLET, CONNECT_KEY),
|
|
243
|
+
CONNECT_KEY,
|
|
244
|
+
"doc",
|
|
245
|
+
),
|
|
246
|
+
).resolves.toBe(true);
|
|
247
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("assertWorkerTrustPolicy", () => {
|
|
252
|
+
it("fails with workers, authEnforcement and a self source", () => {
|
|
253
|
+
expect(() =>
|
|
254
|
+
assertWorkerTrustPolicy({
|
|
255
|
+
workers: 2,
|
|
256
|
+
authEnforcement: true,
|
|
257
|
+
renownSource: "self",
|
|
258
|
+
}),
|
|
259
|
+
).toThrow(
|
|
260
|
+
/REACTOR_WORKERS[\s\S]*REACTOR_AUTH_ENFORCEMENT[\s\S]*RENOWN_SOURCE/,
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it.each([
|
|
265
|
+
{ workers: 0, authEnforcement: true, renownSource: "self" as const },
|
|
266
|
+
{ workers: 2, authEnforcement: false, renownSource: "self" as const },
|
|
267
|
+
{ workers: 2, authEnforcement: true, renownSource: "remote" as const },
|
|
268
|
+
])("passes %o", (config) => {
|
|
269
|
+
expect(() => assertWorkerTrustPolicy(config)).not.toThrow();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("applySwitchboardReactorDefaults with a trust policy", () => {
|
|
274
|
+
it("gives the reactor the policy and its worker spec", () => {
|
|
275
|
+
const reactorBuilder = new ReactorBuilder();
|
|
276
|
+
const withTrustPolicy = vi.spyOn(reactorBuilder, "withTrustPolicy");
|
|
277
|
+
const trustPolicy: SignatureTrustPolicy = {
|
|
278
|
+
authorizeSigner: () => Promise.resolve(true),
|
|
279
|
+
};
|
|
280
|
+
const workerTrustPolicy = {
|
|
281
|
+
module: { filePath: "/trust.js", exportName: "createTrustPolicy" },
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
applySwitchboardReactorDefaults(
|
|
285
|
+
reactorBuilder,
|
|
286
|
+
new ReactorClientBuilder(),
|
|
287
|
+
{
|
|
288
|
+
includeBaseModels: false,
|
|
289
|
+
signalHandlers: false,
|
|
290
|
+
trustPolicy: { trustPolicy, workerTrustPolicy },
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(withTrustPolicy).toHaveBeenCalledWith(
|
|
295
|
+
trustPolicy,
|
|
296
|
+
workerTrustPolicy,
|
|
297
|
+
);
|
|
298
|
+
expect(reactorBuilder.hasTrustPolicy()).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("leaves the reactor on the default without one", () => {
|
|
302
|
+
const reactorBuilder = new ReactorBuilder();
|
|
303
|
+
|
|
304
|
+
applySwitchboardReactorDefaults(
|
|
305
|
+
reactorBuilder,
|
|
306
|
+
new ReactorClientBuilder(),
|
|
307
|
+
{
|
|
308
|
+
includeBaseModels: false,
|
|
309
|
+
signalHandlers: false,
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(reactorBuilder.hasTrustPolicy()).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
StoredDocumentsRefusedError,
|
|
5
|
+
resolveUnsupportedStoredDocuments,
|
|
6
|
+
} from "../src/unsupported-stored-documents.mjs";
|
|
7
|
+
|
|
8
|
+
describe("resolveUnsupportedStoredDocuments", () => {
|
|
9
|
+
it("refuses by default", () => {
|
|
10
|
+
expect(resolveUnsupportedStoredDocuments(undefined, {})).toBe("refuse");
|
|
11
|
+
expect(
|
|
12
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
13
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: " ",
|
|
14
|
+
}),
|
|
15
|
+
).toBe("refuse");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("reads REACTOR_UNSUPPORTED_STORED_DOCUMENTS", () => {
|
|
19
|
+
expect(
|
|
20
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
21
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: " read-only ",
|
|
22
|
+
}),
|
|
23
|
+
).toBe("read-only");
|
|
24
|
+
expect(
|
|
25
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
26
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "refuse",
|
|
27
|
+
}),
|
|
28
|
+
).toBe("refuse");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("lets the option win over the env var", () => {
|
|
32
|
+
expect(
|
|
33
|
+
resolveUnsupportedStoredDocuments("refuse", {
|
|
34
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "read-only",
|
|
35
|
+
}),
|
|
36
|
+
).toBe("refuse");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("refuses an unknown value", () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
42
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "readonly",
|
|
43
|
+
}),
|
|
44
|
+
).toThrow(
|
|
45
|
+
'REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "readonly"',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("StoredDocumentsRefusedError", () => {
|
|
51
|
+
it("names the versions, the count and both ways forward", () => {
|
|
52
|
+
const cause = new UnsupportedStoredProtocolError(
|
|
53
|
+
[
|
|
54
|
+
{ protocol: "base-reducer", version: 3 },
|
|
55
|
+
{ protocol: "base-reducer", version: 7 },
|
|
56
|
+
],
|
|
57
|
+
4,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const error = new StoredDocumentsRefusedError(cause);
|
|
61
|
+
|
|
62
|
+
expect(error.message).toBe(
|
|
63
|
+
"Refusing to start: 4 stored document(s) require base-reducer 3, base-reducer 7, which this switchboard does not run. " +
|
|
64
|
+
"Either start a switchboard build that runs base-reducer 3, base-reducer 7, or set REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only to start with those documents read-only.",
|
|
65
|
+
);
|
|
66
|
+
expect(error.documents).toBe(4);
|
|
67
|
+
expect(error.versions).toBe(cause.versions);
|
|
68
|
+
expect(error.cause).toBe(cause);
|
|
69
|
+
expect(StoredDocumentsRefusedError.isError(error)).toBe(true);
|
|
70
|
+
expect(StoredDocumentsRefusedError.isError(cause)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|