@powerhousedao/switchboard 6.2.3-dev.25 → 6.2.3-dev.27

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.
@@ -0,0 +1,105 @@
1
+ import type { AttachmentHash } from "@powerhousedao/reactor";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ ATTACHMENT_URL_SIGNING_SECRET_ENV,
5
+ AttachmentUrlSigner,
6
+ resolveAttachmentUrlSigning,
7
+ } from "../../src/attachments/url-signer.js";
8
+
9
+ const HASH = "a".repeat(64) as AttachmentHash;
10
+ const OTHER_HASH = "b".repeat(64) as AttachmentHash;
11
+ const SECRET = "k".repeat(32);
12
+
13
+ function signed(now: number, ttl = 300) {
14
+ const signer = new AttachmentUrlSigner(SECRET, () => now);
15
+ const { query, expiresAtUtc } = signer.sign(HASH, "doc-1", ttl);
16
+ const params = new URLSearchParams(query);
17
+ return {
18
+ signer,
19
+ expiresAtUtc,
20
+ documentId: params.get("documentId")!,
21
+ expires: params.get("expires")!,
22
+ signature: params.get("signature")!,
23
+ };
24
+ }
25
+
26
+ describe("AttachmentUrlSigner", () => {
27
+ const NOW = Date.parse("2026-09-24T00:00:00.000Z");
28
+
29
+ it("verifies what it signed until the expiry", () => {
30
+ const { signer, expiresAtUtc, documentId, expires, signature } =
31
+ signed(NOW);
32
+
33
+ expect(documentId).toBe("doc-1");
34
+ expect(expiresAtUtc).toBe(new Date(NOW + 300_000).toISOString());
35
+ expect(signer.verify(HASH, documentId, expires, signature)).toBe(true);
36
+ });
37
+
38
+ it("refuses at and after the expiry", () => {
39
+ const { expires, signature } = signed(NOW);
40
+ const at = new AttachmentUrlSigner(SECRET, () => NOW + 300_000);
41
+
42
+ expect(at.verify(HASH, "doc-1", expires, signature)).toBe(false);
43
+ });
44
+
45
+ it("refuses a different hash, document, expiry or secret", () => {
46
+ const { signer, expires, signature } = signed(NOW);
47
+
48
+ expect(signer.verify(OTHER_HASH, "doc-1", expires, signature)).toBe(false);
49
+ expect(signer.verify(HASH, "doc-2", expires, signature)).toBe(false);
50
+ expect(
51
+ signer.verify(HASH, "doc-1", String(Number(expires) + 1), signature),
52
+ ).toBe(false);
53
+ expect(
54
+ new AttachmentUrlSigner("x".repeat(32), () => NOW).verify(
55
+ HASH,
56
+ "doc-1",
57
+ expires,
58
+ signature,
59
+ ),
60
+ ).toBe(false);
61
+ });
62
+
63
+ it("refuses malformed expiry and signature values", () => {
64
+ const { signer, expires, signature } = signed(NOW);
65
+
66
+ expect(signer.verify(HASH, "doc-1", `${expires}.0`, signature)).toBe(false);
67
+ expect(signer.verify(HASH, "doc-1", expires, `${signature}=`)).toBe(false);
68
+ expect(signer.verify(HASH, "doc-1", expires, "")).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("resolveAttachmentUrlSigning", () => {
73
+ it("uses a configured secret", () => {
74
+ const signing = resolveAttachmentUrlSigning({
75
+ [ATTACHMENT_URL_SIGNING_SECRET_ENV]: SECRET,
76
+ NODE_ENV: "production",
77
+ });
78
+ expect(signing.status).toBe("configured");
79
+ });
80
+
81
+ it("fails closed in production when unset", () => {
82
+ expect(resolveAttachmentUrlSigning({ NODE_ENV: "production" })).toEqual({
83
+ status: "unconfigured",
84
+ });
85
+ });
86
+
87
+ it.each([undefined, "development", "test"])(
88
+ "uses a per-process secret when unset and NODE_ENV is %s",
89
+ (nodeEnv) => {
90
+ const signing = resolveAttachmentUrlSigning({ NODE_ENV: nodeEnv });
91
+ expect(signing.status).toBe("ephemeral");
92
+ },
93
+ );
94
+
95
+ it.each(["short", ` ${"k".repeat(32)}`, ""])(
96
+ "refuses to boot on an unusable secret %j",
97
+ (secret) => {
98
+ expect(() =>
99
+ resolveAttachmentUrlSigning({
100
+ [ATTACHMENT_URL_SIGNING_SECRET_ENV]: secret,
101
+ }),
102
+ ).toThrow(ATTACHMENT_URL_SIGNING_SECRET_ENV);
103
+ },
104
+ );
105
+ });
@@ -97,7 +97,7 @@ export async function startWebhookHost(
97
97
  // constructing it would test a path no consumer can take.
98
98
  const { adapter } = await createHttpAdapter("express");
99
99
  adapter.setupMiddleware({});
100
- const server = await adapter.listen(0);
100
+ const server = await adapter.listen(0, undefined, "127.0.0.1");
101
101
  const { port } = server.address() as { port: number };
102
102
  const url = `http://127.0.0.1:${port}`;
103
103
 
@@ -0,0 +1,145 @@
1
+ import {
2
+ ReactorBuilder,
3
+ ReactorClientBuilder,
4
+ type InProcessReactorClientModule,
5
+ } from "@powerhousedao/reactor";
6
+ import { AuthorizationPolicy, type Context } from "@powerhousedao/reactor-api";
7
+ import {
8
+ initializeAuth,
9
+ withSignaturePolicy,
10
+ type DocumentModelModule,
11
+ } from "@powerhousedao/shared/document-model";
12
+ import type { ILogger } from "document-model";
13
+ import { documentModelDocumentModelModule } from "document-model";
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+ import { composeWorkflowRuntime } from "../src/workflow-runtime.mjs";
16
+
17
+ const READER = "0xreader";
18
+ const OUTSIDER = "0xoutsider";
19
+
20
+ // The host's legacy layer under OPEN admits everyone, anonymous included.
21
+ const openAuthorization = {
22
+ config: {
23
+ admins: [],
24
+ defaultProtection: false,
25
+ policy: AuthorizationPolicy.OPEN,
26
+ },
27
+ isSupremeAdmin: () => true,
28
+ canRead: () => Promise.resolve(true),
29
+ canWrite: () => Promise.resolve(true),
30
+ };
31
+
32
+ const logger = {
33
+ verbose: vi.fn(),
34
+ debug: vi.fn(),
35
+ info: vi.fn(),
36
+ warn: vi.fn(),
37
+ error: vi.fn(),
38
+ child: () => logger,
39
+ } as unknown as ILogger;
40
+
41
+ const contextFor = (address?: string) =>
42
+ ({ headers: {}, db: {}, user: address ? { address } : undefined }) as Context;
43
+
44
+ describe("the workflow runtime's read check", () => {
45
+ let module: InProcessReactorClientModule | undefined;
46
+
47
+ afterEach(() => {
48
+ module?.reactor.kill();
49
+ module = undefined;
50
+ });
51
+
52
+ async function hostDeps() {
53
+ module = await new ReactorClientBuilder()
54
+ .withReactorBuilder(
55
+ new ReactorBuilder()
56
+ .withDocumentModelSources([
57
+ documentModelDocumentModelModule as unknown as DocumentModelModule,
58
+ ])
59
+ .withExecutorConfig({
60
+ featureFlags: { documentDecisions: true, authEnforcement: true },
61
+ }),
62
+ )
63
+ .buildModule();
64
+ const client = module.client;
65
+ const id = "wf-policed";
66
+ await client.create(
67
+ withSignaturePolicy(
68
+ documentModelDocumentModelModule.utils.createDocument(),
69
+ "legacy",
70
+ { id },
71
+ ),
72
+ );
73
+ await client.execute(id, "main", [
74
+ initializeAuth({
75
+ version: 1,
76
+ grants: [
77
+ {
78
+ id: "g-read",
79
+ description: "the reader reads the domain",
80
+ effect: "allow",
81
+ principal: { address: READER },
82
+ capability: { can: "read", scope: "global" },
83
+ },
84
+ {
85
+ id: "g-admin",
86
+ description: "administration stays reachable",
87
+ effect: "allow",
88
+ principal: { anyone: true },
89
+ capability: { can: "execute", scope: "auth" },
90
+ },
91
+ ],
92
+ }),
93
+ ]);
94
+
95
+ const createWorkflowRuntime = vi.fn((_deps: Record<string, unknown>) => ({
96
+ shutdown: vi.fn(),
97
+ }));
98
+ await composeWorkflowRuntime({
99
+ reactorClient: client,
100
+ relationalDb: {} as never,
101
+ attachments: {} as never,
102
+ authorizationService: openAuthorization as never,
103
+ logger,
104
+ load: () =>
105
+ Promise.resolve({
106
+ WORKFLOW_PACKAGE_NAME: "@powerhousedao/workflow",
107
+ setPieceRegistryUrl: vi.fn(),
108
+ createWorkflowRuntime,
109
+ } as never),
110
+ });
111
+ const [deps] = createWorkflowRuntime.mock.calls[0]!;
112
+ return {
113
+ id,
114
+ deps: deps as {
115
+ assertCanRead: (id: string, caller: object) => Promise<unknown>;
116
+ subjectOf: (caller: object) => unknown;
117
+ },
118
+ };
119
+ }
120
+
121
+ it("refuses under OPEN a document the caller is not served", async () => {
122
+ const { id, deps } = await hostDeps();
123
+
124
+ await expect(deps.assertCanRead(id, contextFor())).rejects.toThrow();
125
+ await expect(
126
+ deps.assertCanRead(id, contextFor(OUTSIDER)),
127
+ ).rejects.toThrow();
128
+ await expect(
129
+ deps.assertCanRead(id, contextFor(READER)),
130
+ ).resolves.toBeUndefined();
131
+ });
132
+
133
+ it("reads a caller as its own subject, anonymous as the empty one", async () => {
134
+ const { deps } = await hostDeps();
135
+
136
+ expect(deps.subjectOf(contextFor(READER))).toEqual({
137
+ address: READER,
138
+ key: undefined,
139
+ });
140
+ expect(deps.subjectOf(contextFor())).toEqual({
141
+ address: undefined,
142
+ key: undefined,
143
+ });
144
+ });
145
+ });