@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.
Files changed (137) hide show
  1. package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
  2. package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
  3. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
  4. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
  5. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
  7. package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
  8. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  19. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  20. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  21. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  24. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  25. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  26. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  27. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  28. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  29. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  30. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  31. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  32. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  33. package/.tsbuild/test/observability-signals.test.d.ts +2 -0
  34. package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
  35. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  36. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  37. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  38. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  39. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  40. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  41. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  42. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  44. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  49. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  50. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  51. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  52. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  53. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  54. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  55. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  56. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  57. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  58. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  59. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  60. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  61. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  62. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  63. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  64. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  65. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  66. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  67. package/.tsbuild/vitest.config.d.ts.map +1 -1
  68. package/Auth.md +94 -0
  69. package/CHANGELOG.md +509 -0
  70. package/README.md +35 -0
  71. package/dist/index.d.mts +2 -1
  72. package/dist/index.mjs +48 -49
  73. package/dist/index.mjs.map +1 -1
  74. package/dist/install-packages.d.mts +2 -1
  75. package/dist/install-packages.mjs +2 -2
  76. package/dist/install-packages.mjs.map +1 -1
  77. package/dist/migrate.d.mts +2 -1
  78. package/dist/migrate.mjs +4 -3
  79. package/dist/migrate.mjs.map +1 -1
  80. package/dist/server-Zz7SqTwO.mjs +3335 -0
  81. package/dist/server-Zz7SqTwO.mjs.map +1 -0
  82. package/dist/server.d.mts +154 -30
  83. package/dist/server.d.mts.map +1 -1
  84. package/dist/server.mjs +4 -5
  85. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  86. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  87. package/dist/utils.d.mts +4 -5
  88. package/dist/utils.d.mts.map +1 -1
  89. package/dist/utils.mjs +3 -3
  90. package/package.json +22 -18
  91. package/test/attachment-reference-read-model.test.ts +6 -1
  92. package/test/attachments/auth.test.ts +85 -0
  93. package/test/attachments/byte-grants.test.ts +290 -0
  94. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  95. package/test/attachments/download-target.test.ts +201 -32
  96. package/test/attachments/index.test.ts +34 -3
  97. package/test/attachments/reserve-metadata-parity.test.ts +96 -0
  98. package/test/attachments/routes-integration.test.ts +60 -4
  99. package/test/attachments/routes.test.ts +166 -23
  100. package/test/attachments/service-config.test.ts +13 -3
  101. package/test/attachments/url-signer.test.ts +105 -0
  102. package/test/boot-create-signature-policy.test.ts +108 -0
  103. package/test/boot-mcp-enabled.test.ts +121 -0
  104. package/test/boot-privacy.test.ts +243 -0
  105. package/test/boot-unsupported-stored-documents.test.ts +213 -0
  106. package/test/boot-without-renown.test.ts +81 -0
  107. package/test/boot-without-require-signatures.test.ts +74 -0
  108. package/test/boot-worker-trust-policy.test.ts +90 -0
  109. package/test/builder-defaults.test.ts +80 -0
  110. package/test/create-signature-policy.test.ts +69 -0
  111. package/test/durable-node-fs-pg16.test.ts +80 -0
  112. package/test/fatal-shutdown.test.ts +165 -0
  113. package/test/mcp-flag.test.ts +56 -0
  114. package/test/observability-signals.test.ts +131 -0
  115. package/test/pglite-dialect.test.ts +159 -2
  116. package/test/pglite-preflight.test.ts +313 -0
  117. package/test/pglite-version.test.ts +9 -1
  118. package/test/renown-trust-policy.test.ts +315 -0
  119. package/test/snapshot-writer.ts +94 -0
  120. package/test/unsupported-stored-documents.test.ts +72 -0
  121. package/test/worker-pool.test.ts +27 -1
  122. package/test/workflow/harness.ts +289 -0
  123. package/test/workflow/oauth.test.ts +214 -0
  124. package/test/workflow/resolvers.test.ts +322 -0
  125. package/test/workflow/webhook-dedupe.test.ts +295 -0
  126. package/test/workflow/webhook-signed.test.ts +509 -0
  127. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  128. package/test/workflow/webhook-unsigned.test.ts +345 -0
  129. package/test/workflow-reactor-access.test.ts +384 -0
  130. package/test/workflow-reactor-enforcement.test.ts +805 -0
  131. package/test/workflow-read-gate.test.ts +210 -0
  132. package/test/workflow-runtime.test.ts +572 -0
  133. package/tsconfig.json +9 -3
  134. package/tsdown.config.ts +3 -1
  135. package/dist/server-CiKkVE6g.mjs +0 -1555
  136. package/dist/server-CiKkVE6g.mjs.map +0 -1
  137. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -0,0 +1,290 @@
1
+ import type { AttachmentHash } from "@powerhousedao/reactor";
2
+ import type { AttachmentBuildResult } from "@powerhousedao/reactor-attachments";
3
+ import type {
4
+ AttachmentAccessResult,
5
+ IAttachmentAccessService,
6
+ } from "@powerhousedao/reactor-api";
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+ import { Writable } from "node:stream";
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import type { AttachmentActorContext } from "../../src/attachments/auth.js";
11
+ import {
12
+ makeDownloadHandler,
13
+ makeStatHandler,
14
+ } from "../../src/attachments/routes.js";
15
+ import { AttachmentUrlSigner } from "../../src/attachments/url-signer.js";
16
+
17
+ const HASH = "c".repeat(64) as AttachmentHash;
18
+ const REF = `attachment://v1:${HASH}`;
19
+ const NOW_MS = Date.parse("2026-09-24T00:00:00.000Z");
20
+ const SIGNER = new AttachmentUrlSigner("s".repeat(32), () => NOW_MS);
21
+
22
+ const ACTOR: AttachmentActorContext = {
23
+ user: {
24
+ address: "0xreader",
25
+ chainId: 1,
26
+ networkId: "mainnet",
27
+ appKey: "did:key:zApp",
28
+ },
29
+ authEnabled: true,
30
+ };
31
+
32
+ const HEADER = {
33
+ hash: HASH,
34
+ status: "available",
35
+ mimeType: "text/plain",
36
+ fileName: "a.txt",
37
+ sizeBytes: 0,
38
+ extension: "txt",
39
+ createdAtUtc: "2026-09-01T00:00:00.000Z",
40
+ lastAccessedAtUtc: "2026-09-01T00:00:00.000Z",
41
+ expiresAtUtc: null,
42
+ };
43
+
44
+ function makeReq(query: string): IncomingMessage {
45
+ return {
46
+ method: "GET",
47
+ url: `/attachments/${HASH}${query}`,
48
+ headers: {},
49
+ params: { hash: HASH },
50
+ once: () => undefined,
51
+ } as unknown as IncomingMessage;
52
+ }
53
+
54
+ function makeRes() {
55
+ const headers: Record<string, string> = {};
56
+ const chunks: Buffer[] = [];
57
+ const res = new Writable({
58
+ write(chunk: Buffer, _encoding, callback) {
59
+ chunks.push(Buffer.from(chunk));
60
+ callback();
61
+ },
62
+ });
63
+ Object.assign(res, {
64
+ statusCode: 200,
65
+ setHeader(name: string, value: string | number | readonly string[]) {
66
+ headers[name.toLowerCase()] = String(value);
67
+ },
68
+ });
69
+ Object.defineProperty(res, "_headers", { get: () => headers });
70
+ Object.defineProperty(res, "_body", {
71
+ get: () => Buffer.concat(chunks).toString("utf8"),
72
+ });
73
+ return res as unknown as ServerResponse & {
74
+ readonly _headers: Record<string, string>;
75
+ readonly _body: string;
76
+ };
77
+ }
78
+
79
+ function makeAttachments() {
80
+ const stat = vi.fn().mockResolvedValue(HEADER);
81
+ const get = vi.fn().mockResolvedValue({
82
+ header: HEADER,
83
+ body: new ReadableStream<Uint8Array>({ start: (c) => c.close() }),
84
+ });
85
+ return {
86
+ attachments: { store: { stat, get } } as unknown as AttachmentBuildResult,
87
+ stat,
88
+ get,
89
+ };
90
+ }
91
+
92
+ function makeAccess(result: AttachmentAccessResult | Error) {
93
+ const canReadAttachment = vi.fn(() =>
94
+ result instanceof Error ? Promise.reject(result) : Promise.resolve(result),
95
+ );
96
+ return {
97
+ access: {
98
+ canReadAttachment,
99
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
100
+ } as IAttachmentAccessService,
101
+ canReadAttachment,
102
+ };
103
+ }
104
+
105
+ const ALLOWED: AttachmentAccessResult = {
106
+ kind: "allowed",
107
+ documentId: "canonical-doc" as never,
108
+ ref: REF as never,
109
+ };
110
+
111
+ function signedQuery(documentId = "doc-1", ttl = 300): string {
112
+ return `?${SIGNER.sign(HASH, documentId, ttl).query}`;
113
+ }
114
+
115
+ describe.each([
116
+ ["HEAD", makeStatHandler, "stat"],
117
+ ["GET", makeDownloadHandler, "get"],
118
+ ] as const)("%s /attachments/:hash grants", (_, makeHandler, storeCall) => {
119
+ async function run(
120
+ query: string,
121
+ access: IAttachmentAccessService,
122
+ actor: AttachmentActorContext | undefined = ACTOR,
123
+ signer: AttachmentUrlSigner | null = SIGNER,
124
+ ) {
125
+ const store = makeAttachments();
126
+ const res = makeRes();
127
+ await makeHandler(store.attachments, access, signer)(
128
+ makeReq(query),
129
+ res,
130
+ undefined,
131
+ actor,
132
+ );
133
+ return { res, store: store[storeCall] };
134
+ }
135
+
136
+ it("refuses a hash-only request with 404 without consulting access or storage", async () => {
137
+ const { access, canReadAttachment } = makeAccess(ALLOWED);
138
+ const { res, store } = await run("", access);
139
+
140
+ expect(res.statusCode).toBe(404);
141
+ expect(JSON.parse(res._body)).toEqual({ error: "Attachment not found" });
142
+ expect(canReadAttachment).not.toHaveBeenCalled();
143
+ expect(store).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it.each([
147
+ ["duplicated", "?documentId=a&documentId=b"],
148
+ ["blank", "?documentId=%20"],
149
+ ])("refuses a %s documentId with 404", async (_, query) => {
150
+ const { access, canReadAttachment } = makeAccess(ALLOWED);
151
+ const { res } = await run(query, access);
152
+
153
+ expect(res.statusCode).toBe(404);
154
+ expect(canReadAttachment).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("authorizes a documentId through the access service as the caller", async () => {
158
+ const { access, canReadAttachment } = makeAccess(ALLOWED);
159
+ const { res, store } = await run("?documentId=doc-1", access);
160
+
161
+ expect(res.statusCode).toBe(200);
162
+ expect(res._headers["cache-control"]).toBe("private");
163
+ expect(canReadAttachment).toHaveBeenCalledWith({
164
+ documentId: "doc-1",
165
+ attachmentRef: REF,
166
+ userAddress: "0xreader",
167
+ appKey: "did:key:zApp",
168
+ });
169
+ expect(store).toHaveBeenCalledOnce();
170
+ });
171
+
172
+ it("passes the anonymous caller as an empty subject", async () => {
173
+ const { access, canReadAttachment } = makeAccess(ALLOWED);
174
+ await run("?documentId=doc-1", access, {
175
+ user: undefined,
176
+ authEnabled: true,
177
+ });
178
+
179
+ expect(canReadAttachment).toHaveBeenCalledWith(
180
+ expect.objectContaining({ userAddress: undefined, appKey: undefined }),
181
+ );
182
+ });
183
+
184
+ it("answers a denied decision with the same 404 as an unknown hash", async () => {
185
+ const { access } = makeAccess({ kind: "denied" });
186
+ const { res, store } = await run("?documentId=doc-1", access);
187
+
188
+ expect(res.statusCode).toBe(404);
189
+ expect(JSON.parse(res._body)).toEqual({ error: "Attachment not found" });
190
+ expect(store).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("answers projection-unavailable with 503", async () => {
194
+ const { access } = makeAccess({ kind: "projection-unavailable" });
195
+ const { res, store } = await run("?documentId=doc-1", access);
196
+
197
+ expect(res.statusCode).toBe(503);
198
+ expect(store).not.toHaveBeenCalled();
199
+ });
200
+
201
+ it("answers an access failure with an opaque 500", async () => {
202
+ const { access } = makeAccess(new Error("db down"));
203
+ const { res, store } = await run("?documentId=doc-1", access);
204
+
205
+ expect(res.statusCode).toBe(500);
206
+ expect(res._body).not.toContain("db down");
207
+ expect(store).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("serves a valid signed URL without re-running the gate, even anonymously", async () => {
211
+ const { access, canReadAttachment } = makeAccess({ kind: "denied" });
212
+ const { res, store } = await run(signedQuery(), access, {
213
+ user: undefined,
214
+ authEnabled: true,
215
+ });
216
+
217
+ expect(res.statusCode).toBe(200);
218
+ expect(canReadAttachment).not.toHaveBeenCalled();
219
+ expect(store).toHaveBeenCalledOnce();
220
+ });
221
+
222
+ it.each([
223
+ [
224
+ "tampered documentId",
225
+ () => signedQuery().replace("documentId=doc-1", "documentId=doc-2"),
226
+ ],
227
+ [
228
+ "tampered signature",
229
+ () =>
230
+ signedQuery().replace(
231
+ /signature=(.)/,
232
+ (_, c: string) => `signature=${c === "A" ? "B" : "A"}`,
233
+ ),
234
+ ],
235
+ [
236
+ "extended expiry",
237
+ () => signedQuery().replace(/expires=\d+/, "expires=9999999999"),
238
+ ],
239
+ ["missing signature", () => signedQuery().replace(/&signature=[^&]+/, "")],
240
+ ["missing expiry", () => signedQuery().replace(/&expires=\d+/, "")],
241
+ ])(
242
+ "refuses a %s with 404 and never falls back to the gate",
243
+ async (_, query) => {
244
+ const { access, canReadAttachment } = makeAccess(ALLOWED);
245
+ const { res, store } = await run(query(), access);
246
+
247
+ expect(res.statusCode).toBe(404);
248
+ expect(canReadAttachment).not.toHaveBeenCalled();
249
+ expect(store).not.toHaveBeenCalled();
250
+ },
251
+ );
252
+
253
+ it("refuses an expired signed URL with 404", async () => {
254
+ const { access } = makeAccess(ALLOWED);
255
+ const expired = new AttachmentUrlSigner(
256
+ "s".repeat(32),
257
+ () => NOW_MS + 301_000,
258
+ );
259
+ const { res } = await run(signedQuery(), access, ACTOR, expired);
260
+
261
+ expect(res.statusCode).toBe(404);
262
+ });
263
+
264
+ it("refuses a signed URL when this server has no signer", async () => {
265
+ const { access } = makeAccess(ALLOWED);
266
+ const { res } = await run(signedQuery(), access, ACTOR, null);
267
+
268
+ expect(res.statusCode).toBe(404);
269
+ });
270
+ });
271
+
272
+ describe("GET /attachments/:hash re-fetch anchor", () => {
273
+ it("passes the granted canonical documentId to the store", async () => {
274
+ const { attachments, get } = makeAttachments();
275
+ const { access } = makeAccess(ALLOWED);
276
+
277
+ await makeDownloadHandler(attachments, access, SIGNER)(
278
+ makeReq("?documentId=doc-1"),
279
+ makeRes(),
280
+ undefined,
281
+ ACTOR,
282
+ );
283
+
284
+ expect(get).toHaveBeenCalledWith(
285
+ HASH,
286
+ expect.any(AbortSignal),
287
+ "canonical-doc",
288
+ );
289
+ });
290
+ });
@@ -0,0 +1,314 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import {
3
+ ReactorBuilder,
4
+ ReactorClientBuilder,
5
+ type AttachmentHash,
6
+ type AttachmentRef,
7
+ type InProcessReactorClientModule,
8
+ } from "@powerhousedao/reactor";
9
+ import {
10
+ AttachmentBuilder,
11
+ AttachmentNotFound,
12
+ AttachmentReferenceIndexBuilder,
13
+ SwitchboardAttachmentTransport,
14
+ createRef,
15
+ type AttachmentBuildResult,
16
+ } from "@powerhousedao/reactor-attachments";
17
+ import {
18
+ AttachmentAccessService,
19
+ AuthorizationPolicy,
20
+ createCanonicalDocumentIdResolver,
21
+ createHttpAdapter,
22
+ type API,
23
+ type IAuthorizationService,
24
+ } from "@powerhousedao/reactor-api";
25
+ import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
26
+ import {
27
+ initializeAuth,
28
+ withSignaturePolicy,
29
+ type DocumentModelModule,
30
+ } from "@powerhousedao/shared/document-model";
31
+ import { documentModelDocumentModelModule } from "document-model";
32
+ import { Kysely } from "kysely";
33
+ import { PGliteDialect } from "kysely-pglite-dialect";
34
+ import { mkdtemp, rm } from "node:fs/promises";
35
+ import type { Server } from "node:http";
36
+ import { tmpdir } from "node:os";
37
+ import { join } from "node:path";
38
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
39
+ import { registerAttachmentRoutes } from "../../src/attachments/index.js";
40
+ import { AttachmentUrlSigner } from "../../src/attachments/url-signer.js";
41
+
42
+ const READER = "0xreader";
43
+ const OUTSIDER = "0xoutsider";
44
+ const DOC_ID = "byte-routes-policed";
45
+ const PAYLOAD = "policed attachment bytes";
46
+
47
+ const openAuthorization: IAuthorizationService = {
48
+ config: {
49
+ admins: [],
50
+ defaultProtection: false,
51
+ policy: AuthorizationPolicy.OPEN,
52
+ },
53
+ isSupremeAdmin: () => true,
54
+ canCreate: () => true,
55
+ canRead: () => Promise.resolve(true),
56
+ canWrite: () => Promise.resolve(true),
57
+ canManage: () => Promise.resolve(true),
58
+ canMutate: () => Promise.resolve(true),
59
+ };
60
+
61
+ // Bearer tokens name the caller directly; the gate is what is under test.
62
+ const verifyBearer = (authorization: string | undefined) => {
63
+ const token = authorization?.replace(/^Bearer /, "");
64
+ const address =
65
+ token === "reader" ? READER : token === "outsider" ? OUTSIDER : undefined;
66
+ return Promise.resolve({
67
+ user: address ? { address, chainId: 1, networkId: "mainnet" } : undefined,
68
+ admins: [],
69
+ auth_enabled: true,
70
+ });
71
+ };
72
+
73
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
74
+
75
+ describe("attachment byte routes behind the reactor's read gate", () => {
76
+ let module: InProcessReactorClientModule;
77
+ let db: Kysely<unknown>;
78
+ let replicaDb: Kysely<unknown>;
79
+ let storagePath: string;
80
+ let replicaPath: string;
81
+ let attachments: AttachmentBuildResult;
82
+ let server: Server;
83
+ let baseUrl: string;
84
+ let now = Date.now();
85
+ let hash: AttachmentHash;
86
+ let unreferencedHash: AttachmentHash;
87
+
88
+ async function upload(content: string, token = "reader") {
89
+ const reserved = await fetch(`${baseUrl}/attachments/reservations`, {
90
+ method: "POST",
91
+ headers: { ...bearer(token), "content-type": "application/json" },
92
+ body: JSON.stringify({ mimeType: "text/plain", fileName: "a.txt" }),
93
+ });
94
+ const { reservationId } = (await reserved.json()) as {
95
+ reservationId: string;
96
+ };
97
+ const sent = await fetch(
98
+ `${baseUrl}/attachments/reservations/${reservationId}`,
99
+ {
100
+ method: "PUT",
101
+ headers: {
102
+ ...bearer(token),
103
+ "content-type": "application/octet-stream",
104
+ },
105
+ body: content,
106
+ },
107
+ );
108
+ expect(sent.status).toBe(200);
109
+ return ((await sent.json()) as { hash: AttachmentHash }).hash;
110
+ }
111
+
112
+ beforeAll(async () => {
113
+ module = await new ReactorClientBuilder()
114
+ .withReactorBuilder(
115
+ new ReactorBuilder()
116
+ .withDocumentModelSources([
117
+ driveDocumentModelModule as unknown as DocumentModelModule,
118
+ documentModelDocumentModelModule as unknown as DocumentModelModule,
119
+ ])
120
+ .withExecutorConfig({
121
+ featureFlags: { documentDecisions: true, authEnforcement: true },
122
+ }),
123
+ )
124
+ .buildModule();
125
+
126
+ await module.client.create(
127
+ withSignaturePolicy(
128
+ documentModelDocumentModelModule.utils.createDocument(),
129
+ "legacy",
130
+ { id: DOC_ID },
131
+ ),
132
+ );
133
+ await module.client.execute(DOC_ID, "main", [
134
+ initializeAuth({
135
+ version: 1,
136
+ grants: [
137
+ {
138
+ id: "g-read",
139
+ description: "the reader reads the global scope",
140
+ effect: "allow",
141
+ principal: { address: READER },
142
+ capability: { can: "read", scope: "global" },
143
+ },
144
+ {
145
+ id: "g-admin",
146
+ description: "administration stays reachable",
147
+ effect: "allow",
148
+ principal: { anyone: true },
149
+ capability: { can: "execute", scope: "auth" },
150
+ },
151
+ ],
152
+ }),
153
+ ]);
154
+
155
+ db = new Kysely<unknown>({ dialect: new PGliteDialect(new PGlite()) });
156
+ storagePath = await mkdtemp(join(tmpdir(), "byte-routes-gate-"));
157
+ attachments = await new AttachmentBuilder(db, storagePath).build();
158
+ const index = await new AttachmentReferenceIndexBuilder(db).build();
159
+ const attachmentAccess = new AttachmentAccessService(
160
+ createCanonicalDocumentIdResolver(module.client),
161
+ openAuthorization,
162
+ index.store,
163
+ { status: "available" },
164
+ module.client,
165
+ );
166
+
167
+ const { adapter } = await createHttpAdapter("express");
168
+ adapter.setupMiddleware({});
169
+ registerAttachmentRoutes(
170
+ {
171
+ httpAdapter: adapter,
172
+ attachments,
173
+ attachmentAccess,
174
+ authService: { verifyBearer },
175
+ } as unknown as API,
176
+ {
177
+ urlSigner: new AttachmentUrlSigner("i".repeat(32), () => now),
178
+ },
179
+ );
180
+ server = await adapter.listen(0, undefined, "127.0.0.1");
181
+ const address = server.address();
182
+ if (!address || typeof address === "string") throw new Error("no addr");
183
+ baseUrl = `http://127.0.0.1:${address.port}`;
184
+
185
+ hash = await upload(PAYLOAD);
186
+ unreferencedHash = await upload("never referenced");
187
+ await index.store.addReferences([
188
+ {
189
+ documentId: DOC_ID,
190
+ ref: createRef(hash) as AttachmentRef,
191
+ operationId: `${DOC_ID}-global`,
192
+ branch: "main",
193
+ scope: "global",
194
+ ordinal: 1,
195
+ },
196
+ ]);
197
+
198
+ replicaDb = new Kysely<unknown>({
199
+ dialect: new PGliteDialect(new PGlite()),
200
+ });
201
+ replicaPath = await mkdtemp(join(tmpdir(), "byte-routes-replica-"));
202
+ });
203
+
204
+ afterAll(async () => {
205
+ await new Promise<void>((resolve) => server.close(() => resolve()));
206
+ attachments.destroy();
207
+ module.reactor.kill();
208
+ await db.destroy();
209
+ await replicaDb.destroy();
210
+ await rm(storagePath, { recursive: true, force: true });
211
+ await rm(replicaPath, { recursive: true, force: true });
212
+ });
213
+
214
+ it("refuses a hash-only GET and HEAD, even to a reader", async () => {
215
+ for (const method of ["GET", "HEAD"]) {
216
+ for (const headers of [{}, bearer("reader")]) {
217
+ const res = await fetch(`${baseUrl}/attachments/${hash}`, {
218
+ method,
219
+ headers,
220
+ });
221
+ expect(res.status, `${method} ${JSON.stringify(headers)}`).toBe(404);
222
+ await res.arrayBuffer();
223
+ }
224
+ }
225
+ });
226
+
227
+ it("serves a documentId read to a reader and refuses an outsider or anonymous caller", async () => {
228
+ const url = `${baseUrl}/attachments/${hash}?documentId=${DOC_ID}`;
229
+
230
+ const got = await fetch(url, { headers: bearer("reader") });
231
+ expect(got.status).toBe(200);
232
+ expect(await got.text()).toBe(PAYLOAD);
233
+
234
+ const head = await fetch(url, {
235
+ method: "HEAD",
236
+ headers: bearer("reader"),
237
+ });
238
+ expect(head.status).toBe(200);
239
+
240
+ for (const headers of [bearer("outsider"), {}]) {
241
+ for (const method of ["GET", "HEAD"]) {
242
+ const res = await fetch(url, { method, headers });
243
+ expect(res.status, `${method} ${JSON.stringify(headers)}`).toBe(404);
244
+ await res.arrayBuffer();
245
+ }
246
+ }
247
+ });
248
+
249
+ it("serves the unreferenced blob to no one, its uploader included", async () => {
250
+ const res = await fetch(
251
+ `${baseUrl}/attachments/${unreferencedHash}?documentId=${DOC_ID}`,
252
+ { method: "HEAD", headers: bearer("reader") },
253
+ );
254
+ expect(res.status).toBe(404);
255
+ });
256
+
257
+ it("mints a signed URL that works without credentials and fails when tampered or expired", async () => {
258
+ const minted = await fetch(
259
+ `${baseUrl}/attachments/${hash}/download-target?documentId=${DOC_ID}`,
260
+ { headers: bearer("reader") },
261
+ );
262
+ expect(minted.status).toBe(200);
263
+ const target = (await minted.json()) as { url: string; kind: string };
264
+ expect(target.kind).toBe("switchboard");
265
+
266
+ const signed = await fetch(target.url);
267
+ expect(signed.status).toBe(200);
268
+ expect(await signed.text()).toBe(PAYLOAD);
269
+
270
+ const outsiderTarget = await fetch(
271
+ `${baseUrl}/attachments/${hash}/download-target?documentId=${DOC_ID}`,
272
+ { headers: bearer("outsider") },
273
+ );
274
+ expect(outsiderTarget.status).toBe(404);
275
+
276
+ const tampered = new URL(target.url);
277
+ tampered.pathname = `/attachments/${unreferencedHash}`;
278
+ const tamperedRes = await fetch(tampered);
279
+ expect(tamperedRes.status).toBe(404);
280
+
281
+ const mintedAt = now;
282
+ now = mintedAt + 301_000;
283
+ try {
284
+ const expired = await fetch(target.url);
285
+ expect(expired.status).toBe(404);
286
+ } finally {
287
+ now = mintedAt;
288
+ }
289
+ });
290
+
291
+ it("lets a replica pull the bytes for a document its subject reads", async () => {
292
+ const replicaFor = async (token: string, path: string) =>
293
+ (
294
+ await new AttachmentBuilder(replicaDb, path)
295
+ .withTransport(
296
+ new SwitchboardAttachmentTransport({
297
+ remoteUrl: baseUrl,
298
+ jwtHandler: () => Promise.resolve(token),
299
+ }),
300
+ )
301
+ .build()
302
+ ).store;
303
+
304
+ const outsider = await replicaFor("outsider", join(replicaPath, "out"));
305
+ await expect(outsider.get(hash, undefined, DOC_ID)).rejects.toBeInstanceOf(
306
+ AttachmentNotFound,
307
+ );
308
+
309
+ const reader = await replicaFor("reader", join(replicaPath, "in"));
310
+ const pulled = await reader.get(hash, undefined, DOC_ID);
311
+ expect(await new Response(pulled.body).text()).toBe(PAYLOAD);
312
+ expect(await reader.has(hash)).toBe(true);
313
+ });
314
+ });