@powerhousedao/switchboard 6.2.3-dev.24 → 6.2.3-dev.26
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/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.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 +55 -2
- package/CHANGELOG.md +41 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +4 -5
- 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-BFbus-at.mjs → server-DQCIIJzK.mjs} +236 -39
- package/dist/server-DQCIIJzK.mjs.map +1 -0
- package/dist/server.d.mts +38 -24
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -4
- package/dist/{utils-BBHt2kdo.mjs → utils-uaBlzyjS.mjs} +3 -3
- package/dist/{utils-BBHt2kdo.mjs.map → utils-uaBlzyjS.mjs.map} +1 -1
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +18 -18
- 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/routes-integration.test.ts +20 -5
- package/test/attachments/routes.test.ts +43 -25
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/workflow/harness.ts +1 -1
- package/test/workflow-read-gate.test.ts +145 -0
- package/tsdown.config.ts +3 -1
- package/dist/server-BFbus-at.mjs.map +0 -1
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { Readable, Writable } from "node:stream";
|
|
17
|
+
import type { IAttachmentAccessService } from "@powerhousedao/reactor-api";
|
|
17
18
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
19
|
import {
|
|
19
20
|
buildContentDisposition,
|
|
@@ -29,6 +30,21 @@ import {
|
|
|
29
30
|
quoteFilename,
|
|
30
31
|
} from "../../src/attachments/routes.js";
|
|
31
32
|
|
|
33
|
+
// Byte-serving mechanics under a granted read; grant decisions are tested in
|
|
34
|
+
// byte-grants.test.ts.
|
|
35
|
+
const ALLOW_ALL: IAttachmentAccessService = {
|
|
36
|
+
canReadAttachment: (request) =>
|
|
37
|
+
Promise.resolve({
|
|
38
|
+
kind: "allowed",
|
|
39
|
+
documentId: request.documentId as never,
|
|
40
|
+
ref: request.attachmentRef as never,
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
const grantedDownload = (attachments: AttachmentBuildResult) =>
|
|
44
|
+
makeDownloadHandler(attachments, ALLOW_ALL, null);
|
|
45
|
+
const grantedStat = (attachments: AttachmentBuildResult) =>
|
|
46
|
+
makeStatHandler(attachments, ALLOW_ALL, null);
|
|
47
|
+
|
|
32
48
|
type CapturedRes = ServerResponse & {
|
|
33
49
|
_headers: Record<string, string>;
|
|
34
50
|
_body: Buffer;
|
|
@@ -52,7 +68,7 @@ function makeReq(opts: {
|
|
|
52
68
|
params?: Record<string, string>;
|
|
53
69
|
};
|
|
54
70
|
req.method = opts.method;
|
|
55
|
-
req.url = opts.url ?? "
|
|
71
|
+
req.url = opts.url ?? "/?documentId=doc-1";
|
|
56
72
|
req.headers = {};
|
|
57
73
|
if (opts.params) req.params = opts.params;
|
|
58
74
|
return req as unknown as IncomingMessage;
|
|
@@ -222,7 +238,7 @@ describe("attachment routes", () => {
|
|
|
222
238
|
expect(row!.deleted_at_utc).not.toBeNull();
|
|
223
239
|
|
|
224
240
|
// Download
|
|
225
|
-
const downloadHandler =
|
|
241
|
+
const downloadHandler = grantedDownload(attachments);
|
|
226
242
|
const downloadReq = makeReq({
|
|
227
243
|
method: "GET",
|
|
228
244
|
params: { hash: upload.hash },
|
|
@@ -271,7 +287,7 @@ describe("attachment routes", () => {
|
|
|
271
287
|
hash: string;
|
|
272
288
|
};
|
|
273
289
|
|
|
274
|
-
const downloadHandler =
|
|
290
|
+
const downloadHandler = grantedDownload(attachments);
|
|
275
291
|
const downloadReq = makeReq({
|
|
276
292
|
method: "GET",
|
|
277
293
|
params: { hash: upload.hash },
|
|
@@ -314,7 +330,7 @@ describe("attachment routes", () => {
|
|
|
314
330
|
hash: string;
|
|
315
331
|
};
|
|
316
332
|
|
|
317
|
-
const statHandler =
|
|
333
|
+
const statHandler = grantedStat(attachments);
|
|
318
334
|
const statReq = makeReq({
|
|
319
335
|
method: "HEAD",
|
|
320
336
|
params: { hash: upload.hash },
|
|
@@ -373,9 +389,9 @@ describe("attachment routes", () => {
|
|
|
373
389
|
};
|
|
374
390
|
|
|
375
391
|
const { createServer } = await import("node:http");
|
|
376
|
-
const statHandler =
|
|
392
|
+
const statHandler = grantedStat(attachments);
|
|
377
393
|
const server = createServer((req, res) => {
|
|
378
|
-
const m = /^\/attachments\/([
|
|
394
|
+
const m = /^\/attachments\/([^/?]+)(?:\?|$)/.exec(req.url ?? "/");
|
|
379
395
|
if (!m) {
|
|
380
396
|
res.statusCode = 404;
|
|
381
397
|
res.end();
|
|
@@ -386,12 +402,14 @@ describe("attachment routes", () => {
|
|
|
386
402
|
};
|
|
387
403
|
void statHandler(req, res);
|
|
388
404
|
});
|
|
389
|
-
await new Promise<void>((resolve) =>
|
|
405
|
+
await new Promise<void>((resolve) =>
|
|
406
|
+
server.listen(0, "127.0.0.1", resolve),
|
|
407
|
+
);
|
|
390
408
|
const port = (server.address() as { port: number }).port;
|
|
391
409
|
|
|
392
410
|
try {
|
|
393
411
|
const response = await fetch(
|
|
394
|
-
`http://127.0.0.1:${port}/attachments/${upload.hash}`,
|
|
412
|
+
`http://127.0.0.1:${port}/attachments/${upload.hash}?documentId=doc-1`,
|
|
395
413
|
{
|
|
396
414
|
method: "HEAD",
|
|
397
415
|
},
|
|
@@ -412,7 +430,7 @@ describe("attachment routes", () => {
|
|
|
412
430
|
});
|
|
413
431
|
|
|
414
432
|
it("HEAD stat returns 404 for unknown hash", async () => {
|
|
415
|
-
const handler =
|
|
433
|
+
const handler = grantedStat(attachments);
|
|
416
434
|
const req = makeReq({ method: "HEAD", params: { hash: "a".repeat(64) } });
|
|
417
435
|
const res = makeRes();
|
|
418
436
|
await handler(req, res);
|
|
@@ -421,7 +439,7 @@ describe("attachment routes", () => {
|
|
|
421
439
|
});
|
|
422
440
|
|
|
423
441
|
it("HEAD stat returns 400 for malformed hash", async () => {
|
|
424
|
-
const handler =
|
|
442
|
+
const handler = grantedStat(attachments);
|
|
425
443
|
const req = makeReq({ method: "HEAD", params: { hash: "not-a-hash" } });
|
|
426
444
|
const res = makeRes();
|
|
427
445
|
await handler(req, res);
|
|
@@ -523,7 +541,7 @@ describe("attachment routes", () => {
|
|
|
523
541
|
});
|
|
524
542
|
|
|
525
543
|
it("GET download returns 404 for unknown hash", async () => {
|
|
526
|
-
const handler =
|
|
544
|
+
const handler = grantedDownload(attachments);
|
|
527
545
|
const req = makeReq({
|
|
528
546
|
method: "GET",
|
|
529
547
|
params: { hash: "a".repeat(64) },
|
|
@@ -535,7 +553,7 @@ describe("attachment routes", () => {
|
|
|
535
553
|
});
|
|
536
554
|
|
|
537
555
|
it("GET download returns 400 for malformed hash", async () => {
|
|
538
|
-
const handler =
|
|
556
|
+
const handler = grantedDownload(attachments);
|
|
539
557
|
const req = makeReq({
|
|
540
558
|
method: "GET",
|
|
541
559
|
params: { hash: "not-a-hash" },
|
|
@@ -574,7 +592,7 @@ describe("attachment routes", () => {
|
|
|
574
592
|
hash: string;
|
|
575
593
|
};
|
|
576
594
|
|
|
577
|
-
const handler =
|
|
595
|
+
const handler = grantedDownload(attachments);
|
|
578
596
|
const req = makeReq({
|
|
579
597
|
method: "GET",
|
|
580
598
|
params: { hash: upload.hash.toUpperCase() },
|
|
@@ -612,7 +630,7 @@ describe("attachment routes", () => {
|
|
|
612
630
|
hash: string;
|
|
613
631
|
};
|
|
614
632
|
|
|
615
|
-
const statHandler =
|
|
633
|
+
const statHandler = grantedStat(attachments);
|
|
616
634
|
const statReq = makeReq({
|
|
617
635
|
method: "HEAD",
|
|
618
636
|
params: { hash: upload.hash.toUpperCase() },
|
|
@@ -660,7 +678,7 @@ describe("attachment routes", () => {
|
|
|
660
678
|
throw new Error(secret);
|
|
661
679
|
};
|
|
662
680
|
try {
|
|
663
|
-
const handler =
|
|
681
|
+
const handler = grantedDownload(attachments);
|
|
664
682
|
const req = makeReq({
|
|
665
683
|
method: "GET",
|
|
666
684
|
params: { hash: "a".repeat(64) },
|
|
@@ -933,7 +951,7 @@ describe("attachment routes", () => {
|
|
|
933
951
|
it("HEAD stat returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
934
952
|
const { hash } = await createPendingReservation("pending content data");
|
|
935
953
|
|
|
936
|
-
const handler =
|
|
954
|
+
const handler = grantedStat(attachments);
|
|
937
955
|
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
938
956
|
const res = makeRes();
|
|
939
957
|
await handler(req, res);
|
|
@@ -958,7 +976,7 @@ describe("attachment routes", () => {
|
|
|
958
976
|
it("HEAD stat 202 for pending hash does not set Content-Disposition or Attachment-Metadata", async () => {
|
|
959
977
|
const { hash } = await createPendingReservation("no metadata content");
|
|
960
978
|
|
|
961
|
-
const handler =
|
|
979
|
+
const handler = grantedStat(attachments);
|
|
962
980
|
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
963
981
|
const res = makeRes();
|
|
964
982
|
await handler(req, res);
|
|
@@ -972,7 +990,7 @@ describe("attachment routes", () => {
|
|
|
972
990
|
it("GET download returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
973
991
|
const { hash } = await createPendingReservation("download pending data");
|
|
974
992
|
|
|
975
|
-
const handler =
|
|
993
|
+
const handler = grantedDownload(attachments);
|
|
976
994
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
977
995
|
const res = makeRes();
|
|
978
996
|
await handler(req, res);
|
|
@@ -994,7 +1012,7 @@ describe("attachment routes", () => {
|
|
|
994
1012
|
// the response as a successful zero-byte attachment.
|
|
995
1013
|
const { hash } = await createPendingReservation("check-headers content");
|
|
996
1014
|
|
|
997
|
-
const handler =
|
|
1015
|
+
const handler = grantedDownload(attachments);
|
|
998
1016
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
999
1017
|
const res = makeRes();
|
|
1000
1018
|
await handler(req, res);
|
|
@@ -1014,8 +1032,8 @@ describe("attachment routes", () => {
|
|
|
1014
1032
|
);
|
|
1015
1033
|
|
|
1016
1034
|
for (const [method, handler] of [
|
|
1017
|
-
["HEAD",
|
|
1018
|
-
["GET",
|
|
1035
|
+
["HEAD", grantedStat(attachments)],
|
|
1036
|
+
["GET", grantedDownload(attachments)],
|
|
1019
1037
|
] as const) {
|
|
1020
1038
|
const req = makeReq({ method, params: { hash } });
|
|
1021
1039
|
const res = makeRes();
|
|
@@ -1034,7 +1052,7 @@ describe("attachment routes", () => {
|
|
|
1034
1052
|
it("GET download 202 body is empty (not a zero-byte attachment)", async () => {
|
|
1035
1053
|
const { hash } = await createPendingReservation("empty body assertion");
|
|
1036
1054
|
|
|
1037
|
-
const handler =
|
|
1055
|
+
const handler = grantedDownload(attachments);
|
|
1038
1056
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
1039
1057
|
const res = makeRes();
|
|
1040
1058
|
await handler(req, res);
|
|
@@ -1322,7 +1340,7 @@ describe("attachment routes", () => {
|
|
|
1322
1340
|
it("download with non-ASCII fileName produces RFC 6266 Content-Disposition", async () => {
|
|
1323
1341
|
const reserveHandler = makeReserveHandler(attachments);
|
|
1324
1342
|
const uploadHandler = makeUploadHandler(attachments);
|
|
1325
|
-
const downloadHandler =
|
|
1343
|
+
const downloadHandler = grantedDownload(attachments);
|
|
1326
1344
|
|
|
1327
1345
|
const reserveReq = makeReq({
|
|
1328
1346
|
method: "POST",
|
|
@@ -1392,8 +1410,8 @@ describe("attachment routes", () => {
|
|
|
1392
1410
|
};
|
|
1393
1411
|
|
|
1394
1412
|
for (const [method, handler] of [
|
|
1395
|
-
["HEAD",
|
|
1396
|
-
["GET",
|
|
1413
|
+
["HEAD", grantedStat(attachments)],
|
|
1414
|
+
["GET", grantedDownload(attachments)],
|
|
1397
1415
|
] as const) {
|
|
1398
1416
|
const res = makeRes();
|
|
1399
1417
|
await handler(makeReq({ method, params: { hash } }), res);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
-
import type { API } from "@powerhousedao/reactor-api";
|
|
2
|
+
import type { API, IAttachmentAccessService } from "@powerhousedao/reactor-api";
|
|
3
3
|
import { createHttpAdapter } from "@powerhousedao/reactor-api";
|
|
4
4
|
import {
|
|
5
5
|
AttachmentBuilder,
|
|
@@ -109,10 +109,20 @@ describe("attachment service built from deriveAttachmentServiceConfig round-trip
|
|
|
109
109
|
registerAttachmentRoutes({
|
|
110
110
|
httpAdapter: adapter,
|
|
111
111
|
attachments,
|
|
112
|
+
attachmentAccess: {
|
|
113
|
+
canReadAttachment: (
|
|
114
|
+
request: Parameters<IAttachmentAccessService["canReadAttachment"]>[0],
|
|
115
|
+
) =>
|
|
116
|
+
Promise.resolve({
|
|
117
|
+
kind: "allowed",
|
|
118
|
+
documentId: request.documentId as never,
|
|
119
|
+
ref: request.attachmentRef as never,
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
112
122
|
authService: undefined,
|
|
113
123
|
} as unknown as API);
|
|
114
124
|
|
|
115
|
-
server = await adapter.listen(0);
|
|
125
|
+
server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
116
126
|
const addr = server.address();
|
|
117
127
|
if (!addr || typeof addr === "string") throw new Error("no addr");
|
|
118
128
|
port = addr.port;
|
|
@@ -149,7 +159,7 @@ describe("attachment service built from deriveAttachmentServiceConfig round-trip
|
|
|
149
159
|
const result = await upload.send(stream);
|
|
150
160
|
expect(result.header.sizeBytes).toBe(bytes.byteLength);
|
|
151
161
|
|
|
152
|
-
const got = await service.get(result.ref);
|
|
162
|
+
const got = await service.get(result.ref, { documentId: "doc-1" });
|
|
153
163
|
expect(got.header.mimeType).toBe("text/plain");
|
|
154
164
|
const reader = got.body.getReader();
|
|
155
165
|
const chunks: Uint8Array[] = [];
|
|
@@ -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
|
+
});
|
package/test/workflow/harness.ts
CHANGED
|
@@ -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
|
+
});
|
package/tsdown.config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineConfig } from "tsdown";
|
|
2
|
+
import { dtsExportList } from "../../tsdown.dts.mjs";
|
|
2
3
|
|
|
3
4
|
export default defineConfig({
|
|
4
5
|
entry: [
|
|
@@ -11,6 +12,7 @@ export default defineConfig({
|
|
|
11
12
|
platform: "node",
|
|
12
13
|
outDir: "dist",
|
|
13
14
|
clean: true,
|
|
14
|
-
dts:
|
|
15
|
+
dts: { generator: "tsgo" },
|
|
16
|
+
plugins: [dtsExportList()],
|
|
15
17
|
sourcemap: true,
|
|
16
18
|
});
|