@powerhousedao/switchboard 6.2.3-dev.9 → 6.2.3
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
|
@@ -9,15 +9,24 @@ import type {
|
|
|
9
9
|
IAttachmentAccessService,
|
|
10
10
|
} from "@powerhousedao/reactor-api";
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
-
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
13
|
import type { AttachmentActorContext } from "../../src/attachments/auth.js";
|
|
14
14
|
import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
15
15
|
import { makeDownloadTargetHandler } from "../../src/attachments/routes.js";
|
|
16
|
+
import { AttachmentUrlSigner } from "../../src/attachments/url-signer.js";
|
|
16
17
|
|
|
17
18
|
const HASH = "a".repeat(64) as AttachmentHash;
|
|
18
19
|
const REF = `attachment://v1:${HASH}`;
|
|
19
20
|
const DOC_ID = "doc-1";
|
|
20
21
|
const EXPIRES = "2026-07-23T00:00:00.000Z";
|
|
22
|
+
const NOW_MS = Date.parse("2026-07-22T00:00:00.000Z");
|
|
23
|
+
const SIGNER = new AttachmentUrlSigner("s".repeat(32), () => NOW_MS);
|
|
24
|
+
|
|
25
|
+
function signedUrlParts(body: string) {
|
|
26
|
+
const target = JSON.parse(body) as { url: string; expiresAtUtc: string };
|
|
27
|
+
const url = new URL(target.url);
|
|
28
|
+
return { target, url, params: url.searchParams };
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
const ACTOR: AttachmentActorContext = {
|
|
23
32
|
user: {
|
|
@@ -106,29 +115,120 @@ const ALLOWED: AttachmentAccessResult = {
|
|
|
106
115
|
};
|
|
107
116
|
|
|
108
117
|
describe("makeDownloadTargetHandler", () => {
|
|
109
|
-
it("returns a switchboard target for filesystem with no-store", async () => {
|
|
118
|
+
it("returns a signed switchboard target for filesystem with no-store", async () => {
|
|
110
119
|
const { access, spy } = makeAccess(ALLOWED);
|
|
111
120
|
const attachments = makeAttachments({});
|
|
112
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
121
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
113
122
|
const res = makeRes();
|
|
114
123
|
|
|
115
124
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
116
125
|
|
|
117
126
|
expect(res.statusCode).toBe(200);
|
|
118
127
|
expect(res._headers["cache-control"]).toBe("no-store");
|
|
119
|
-
|
|
128
|
+
const { target, url, params } = signedUrlParts(res._body);
|
|
129
|
+
expect(target).toMatchObject({
|
|
120
130
|
kind: "switchboard",
|
|
121
131
|
method: "GET",
|
|
122
|
-
url: `http://sb.example.com/attachments/${HASH}`,
|
|
123
132
|
headers: {},
|
|
133
|
+
expiresAtUtc: new Date(NOW_MS + 300_000).toISOString(),
|
|
124
134
|
});
|
|
135
|
+
expect(`${url.origin}${url.pathname}`).toBe(
|
|
136
|
+
`http://sb.example.com/attachments/${HASH}`,
|
|
137
|
+
);
|
|
138
|
+
expect(params.get("documentId")).toBe(DOC_ID);
|
|
139
|
+
expect(
|
|
140
|
+
SIGNER.verify(
|
|
141
|
+
HASH,
|
|
142
|
+
DOC_ID,
|
|
143
|
+
params.get("expires")!,
|
|
144
|
+
params.get("signature")!,
|
|
145
|
+
),
|
|
146
|
+
).toBe(true);
|
|
125
147
|
expect(spy).toHaveBeenCalledWith({
|
|
126
148
|
documentId: DOC_ID,
|
|
127
149
|
attachmentRef: REF,
|
|
128
150
|
userAddress: "0xverified",
|
|
151
|
+
appKey: "did:key:zAppTest",
|
|
129
152
|
});
|
|
130
153
|
});
|
|
131
154
|
|
|
155
|
+
it("signs for the requested expiresIn, clamped to the download-target ceiling", async () => {
|
|
156
|
+
const { access } = makeAccess(ALLOWED);
|
|
157
|
+
const handler = makeDownloadTargetHandler(
|
|
158
|
+
makeAttachments({}),
|
|
159
|
+
access,
|
|
160
|
+
SIGNER,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
for (const [requested, granted] of [
|
|
164
|
+
[60, 60],
|
|
165
|
+
[3600, 300],
|
|
166
|
+
[99999999, 300],
|
|
167
|
+
]) {
|
|
168
|
+
const res = makeRes();
|
|
169
|
+
await handler(
|
|
170
|
+
makeReq({
|
|
171
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=${requested}`,
|
|
172
|
+
}),
|
|
173
|
+
res,
|
|
174
|
+
undefined,
|
|
175
|
+
ACTOR,
|
|
176
|
+
);
|
|
177
|
+
expect(signedUrlParts(res._body).target.expiresAtUtc).toBe(
|
|
178
|
+
new Date(NOW_MS + granted * 1000).toISOString(),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("signs the canonical document id the access decision resolved", async () => {
|
|
184
|
+
const { access } = makeAccess({ ...ALLOWED, documentId: "canon" as never });
|
|
185
|
+
const handler = makeDownloadTargetHandler(
|
|
186
|
+
makeAttachments({}),
|
|
187
|
+
access,
|
|
188
|
+
SIGNER,
|
|
189
|
+
);
|
|
190
|
+
const res = makeRes();
|
|
191
|
+
|
|
192
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
193
|
+
|
|
194
|
+
expect(signedUrlParts(res._body).params.get("documentId")).toBe("canon");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("fails closed with 503 on filesystem when no signer is configured", async () => {
|
|
198
|
+
const { access } = makeAccess(ALLOWED);
|
|
199
|
+
const handler = makeDownloadTargetHandler(
|
|
200
|
+
makeAttachments({}),
|
|
201
|
+
access,
|
|
202
|
+
null,
|
|
203
|
+
);
|
|
204
|
+
const res = makeRes();
|
|
205
|
+
|
|
206
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
207
|
+
|
|
208
|
+
expect(res.statusCode).toBe(503);
|
|
209
|
+
expect(res._body).not.toContain("/attachments/");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("needs no signer for S3 presigned targets", async () => {
|
|
213
|
+
const { access } = makeAccess(ALLOWED);
|
|
214
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
215
|
+
kind: "presigned-get",
|
|
216
|
+
method: "GET",
|
|
217
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
218
|
+
headers: {},
|
|
219
|
+
expiresAtUtc: EXPIRES,
|
|
220
|
+
});
|
|
221
|
+
const attachments = makeAttachments({
|
|
222
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
223
|
+
});
|
|
224
|
+
const handler = makeDownloadTargetHandler(attachments, access, null);
|
|
225
|
+
const res = makeRes();
|
|
226
|
+
|
|
227
|
+
await handler(makeReq({}), res, undefined, ACTOR);
|
|
228
|
+
|
|
229
|
+
expect(res.statusCode).toBe(200);
|
|
230
|
+
});
|
|
231
|
+
|
|
132
232
|
it("returns the backend presigned-get target in S3 mode", async () => {
|
|
133
233
|
const { access } = makeAccess(ALLOWED);
|
|
134
234
|
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
@@ -141,14 +241,14 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
141
241
|
const attachments = makeAttachments({
|
|
142
242
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
143
243
|
});
|
|
144
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
244
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
145
245
|
const res = makeRes();
|
|
146
246
|
|
|
147
247
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
148
248
|
|
|
149
249
|
expect(res.statusCode).toBe(200);
|
|
150
250
|
expect(JSON.parse(res._body)).toMatchObject({ kind: "presigned-get" });
|
|
151
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
251
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
|
|
152
252
|
});
|
|
153
253
|
|
|
154
254
|
it("passes a requested expiresIn through to the backend presigner", async () => {
|
|
@@ -163,12 +263,12 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
163
263
|
const attachments = makeAttachments({
|
|
164
264
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
165
265
|
});
|
|
166
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
266
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
167
267
|
const res = makeRes();
|
|
168
268
|
|
|
169
269
|
await handler(
|
|
170
270
|
makeReq({
|
|
171
|
-
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=
|
|
271
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=60`,
|
|
172
272
|
}),
|
|
173
273
|
res,
|
|
174
274
|
undefined,
|
|
@@ -176,10 +276,10 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
176
276
|
);
|
|
177
277
|
|
|
178
278
|
expect(res.statusCode).toBe(200);
|
|
179
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
279
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 60);
|
|
180
280
|
});
|
|
181
281
|
|
|
182
|
-
it("clamps expiresIn to the
|
|
282
|
+
it("clamps a requested expiresIn to the ceiling", async () => {
|
|
183
283
|
const { access } = makeAccess(ALLOWED);
|
|
184
284
|
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
185
285
|
kind: "presigned-get",
|
|
@@ -191,7 +291,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
191
291
|
const attachments = makeAttachments({
|
|
192
292
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
193
293
|
});
|
|
194
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
294
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
195
295
|
const res = makeRes();
|
|
196
296
|
|
|
197
297
|
await handler(
|
|
@@ -203,10 +303,10 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
203
303
|
ACTOR,
|
|
204
304
|
);
|
|
205
305
|
|
|
206
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
306
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
|
|
207
307
|
});
|
|
208
308
|
|
|
209
|
-
it("
|
|
309
|
+
it("applies the ceiling when expiresIn is absent, rather than the backend default", async () => {
|
|
210
310
|
const { access } = makeAccess(ALLOWED);
|
|
211
311
|
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
212
312
|
kind: "presigned-get",
|
|
@@ -218,11 +318,73 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
218
318
|
const attachments = makeAttachments({
|
|
219
319
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
220
320
|
});
|
|
221
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
321
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
222
322
|
|
|
223
323
|
await handler(makeReq({}), makeRes(), undefined, ACTOR);
|
|
224
324
|
|
|
225
|
-
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH,
|
|
325
|
+
expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS", () => {
|
|
329
|
+
const ORIGINAL = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
330
|
+
|
|
331
|
+
afterEach(() => {
|
|
332
|
+
if (ORIGINAL === undefined) {
|
|
333
|
+
delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
334
|
+
} else {
|
|
335
|
+
process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = ORIGINAL;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
async function ttlFor(configured: string | undefined, query = "") {
|
|
340
|
+
if (configured === undefined) {
|
|
341
|
+
delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
342
|
+
} else {
|
|
343
|
+
process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = configured;
|
|
344
|
+
}
|
|
345
|
+
const { access } = makeAccess(ALLOWED);
|
|
346
|
+
const prepareDownloadTarget = vi.fn().mockResolvedValue({
|
|
347
|
+
kind: "presigned-get",
|
|
348
|
+
method: "GET",
|
|
349
|
+
url: "https://bucket.example.com/attachments/aa?sig=1",
|
|
350
|
+
headers: {},
|
|
351
|
+
expiresAtUtc: EXPIRES,
|
|
352
|
+
});
|
|
353
|
+
const attachments = makeAttachments({
|
|
354
|
+
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
355
|
+
});
|
|
356
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
357
|
+
await handler(
|
|
358
|
+
makeReq({
|
|
359
|
+
url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}${query}`,
|
|
360
|
+
}),
|
|
361
|
+
makeRes(),
|
|
362
|
+
undefined,
|
|
363
|
+
ACTOR,
|
|
364
|
+
);
|
|
365
|
+
return (prepareDownloadTarget.mock.calls[0] as unknown[])[1];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
it("raises the ceiling when a deployment configures one", async () => {
|
|
369
|
+
await expect(ttlFor("1800")).resolves.toBe(1800);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("never lets a configured ceiling exceed the signing maximum", async () => {
|
|
373
|
+
await expect(ttlFor("99999999")).resolves.toBe(604800);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it.each(["not-a-number", "0", "-5", "12.5"])(
|
|
377
|
+
"falls back to the default, not the maximum, on %s",
|
|
378
|
+
async (configured) => {
|
|
379
|
+
// A typo must narrow nothing and widen nothing: falling back to the
|
|
380
|
+
// signing ceiling would turn a misconfiguration into a week-long URL.
|
|
381
|
+
await expect(ttlFor(configured)).resolves.toBe(300);
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
it("still clamps a caller's request to the configured ceiling", async () => {
|
|
386
|
+
await expect(ttlFor("1800", "&expiresIn=99999")).resolves.toBe(1800);
|
|
387
|
+
});
|
|
226
388
|
});
|
|
227
389
|
|
|
228
390
|
it.each([
|
|
@@ -234,7 +396,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
234
396
|
])("rejects a %s expiresIn with 400 before authorization", async (_, qs) => {
|
|
235
397
|
const { access, spy } = makeAccess(ALLOWED);
|
|
236
398
|
const attachments = makeAttachments({});
|
|
237
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
399
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
238
400
|
const res = makeRes();
|
|
239
401
|
|
|
240
402
|
await handler(
|
|
@@ -253,7 +415,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
253
415
|
it("normalizes an uppercase path hash before authorization and lookup", async () => {
|
|
254
416
|
const { access, spy } = makeAccess(ALLOWED);
|
|
255
417
|
const attachments = makeAttachments({});
|
|
256
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
418
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
257
419
|
const res = makeRes();
|
|
258
420
|
|
|
259
421
|
await handler(makeReq({ hash: HASH.toUpperCase() }), res, undefined, ACTOR);
|
|
@@ -268,7 +430,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
268
430
|
it("rejects an invalid hash before any access or storage call", async () => {
|
|
269
431
|
const { access, spy } = makeAccess(ALLOWED);
|
|
270
432
|
const attachments = makeAttachments({});
|
|
271
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
433
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
272
434
|
const res = makeRes();
|
|
273
435
|
|
|
274
436
|
await handler(makeReq({ hash: "nothex" }), res, undefined, ACTOR);
|
|
@@ -292,7 +454,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
292
454
|
])("rejects a %s documentId before authorization", async (_, url) => {
|
|
293
455
|
const { access, spy } = makeAccess(ALLOWED);
|
|
294
456
|
const attachments = makeAttachments({});
|
|
295
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
457
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
296
458
|
const res = makeRes();
|
|
297
459
|
|
|
298
460
|
await handler(makeReq({ url }), res, undefined, ACTOR);
|
|
@@ -307,7 +469,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
307
469
|
const attachments = makeAttachments({
|
|
308
470
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
309
471
|
});
|
|
310
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
472
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
311
473
|
const res = makeRes();
|
|
312
474
|
|
|
313
475
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -321,7 +483,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
321
483
|
it("maps projection-unavailable to a generic non-cacheable 503 before storage", async () => {
|
|
322
484
|
const { access } = makeAccess({ kind: "projection-unavailable" });
|
|
323
485
|
const attachments = makeAttachments({});
|
|
324
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
486
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
325
487
|
const res = makeRes();
|
|
326
488
|
|
|
327
489
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -337,7 +499,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
337
499
|
const { AttachmentNotFound } =
|
|
338
500
|
await import("@powerhousedao/reactor-attachments");
|
|
339
501
|
attachments.statSpy.mockRejectedValue(new AttachmentNotFound(HASH));
|
|
340
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
502
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
341
503
|
const res = makeRes();
|
|
342
504
|
|
|
343
505
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -351,7 +513,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
351
513
|
const attachments = makeAttachments({
|
|
352
514
|
stat: () => Promise.resolve({ ...AVAILABLE_HEADER, status: "pending" }),
|
|
353
515
|
});
|
|
354
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
516
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
355
517
|
const res = makeRes();
|
|
356
518
|
|
|
357
519
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -371,7 +533,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
371
533
|
const attachments = makeAttachments({
|
|
372
534
|
backend: { kind: "s3", prepareDownloadTarget } as never,
|
|
373
535
|
});
|
|
374
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
536
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
375
537
|
const res = makeRes();
|
|
376
538
|
|
|
377
539
|
await handler(makeReq({}), res, undefined, ACTOR);
|
|
@@ -384,7 +546,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
384
546
|
it("passes an anonymous actor as an undefined address (OPEN mode)", async () => {
|
|
385
547
|
const { access, spy } = makeAccess(ALLOWED);
|
|
386
548
|
const attachments = makeAttachments({});
|
|
387
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
549
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
388
550
|
const res = makeRes();
|
|
389
551
|
|
|
390
552
|
await handler(makeReq({}), res, undefined, {
|
|
@@ -400,7 +562,7 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
400
562
|
it("honours x-forwarded proto/host when building the switchboard target", async () => {
|
|
401
563
|
const { access } = makeAccess(ALLOWED);
|
|
402
564
|
const attachments = makeAttachments({});
|
|
403
|
-
const handler = makeDownloadTargetHandler(attachments, access);
|
|
565
|
+
const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
|
|
404
566
|
const res = makeRes();
|
|
405
567
|
|
|
406
568
|
await handler(
|
|
@@ -415,9 +577,10 @@ describe("makeDownloadTargetHandler", () => {
|
|
|
415
577
|
ACTOR,
|
|
416
578
|
);
|
|
417
579
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
580
|
+
const { url } = signedUrlParts(res._body);
|
|
581
|
+
expect(`${url.origin}${url.pathname}`).toBe(
|
|
582
|
+
`https://public.example.com/attachments/${HASH}`,
|
|
583
|
+
);
|
|
421
584
|
});
|
|
422
585
|
});
|
|
423
586
|
|
|
@@ -435,7 +598,7 @@ describe("registerAttachmentRoutes inventory", () => {
|
|
|
435
598
|
attachmentAccess: { canReadAttachment: vi.fn() },
|
|
436
599
|
} as unknown as API;
|
|
437
600
|
|
|
438
|
-
registerAttachmentRoutes(api);
|
|
601
|
+
registerAttachmentRoutes(api, { urlSigner: null });
|
|
439
602
|
|
|
440
603
|
expect(captured).toEqual([
|
|
441
604
|
{ method: "POST", path: "/attachments/reservations" },
|
|
@@ -13,8 +13,16 @@ type Captured = {
|
|
|
13
13
|
) => void | Promise<void>;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
type FakeApi = Pick<
|
|
17
|
+
API,
|
|
18
|
+
"httpAdapter" | "authService" | "requireAuthenticatedCaller"
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
function makeFakeApi(
|
|
22
|
+
authService: AuthService | undefined,
|
|
23
|
+
requireAuthenticatedCaller = false,
|
|
24
|
+
): {
|
|
25
|
+
api: FakeApi;
|
|
18
26
|
captured: Captured[];
|
|
19
27
|
} {
|
|
20
28
|
const captured: Captured[] = [];
|
|
@@ -29,7 +37,8 @@ function makeFakeApi(authService: AuthService | undefined): {
|
|
|
29
37
|
},
|
|
30
38
|
},
|
|
31
39
|
authService,
|
|
32
|
-
|
|
40
|
+
requireAuthenticatedCaller,
|
|
41
|
+
} as unknown as FakeApi;
|
|
33
42
|
return { api, captured };
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -138,6 +147,28 @@ describe("mountAuthenticatedNodeRoute", () => {
|
|
|
138
147
|
});
|
|
139
148
|
});
|
|
140
149
|
|
|
150
|
+
it("carries the deployment's authenticated-caller floor onto the route, over allowAnonymous", async () => {
|
|
151
|
+
// auth_enabled false is the shape a deployment running the document
|
|
152
|
+
// policies has: the boundary policy is OPEN, and this floor is what keeps
|
|
153
|
+
// an anonymous caller off the attachment routes.
|
|
154
|
+
const verifyBearer = vi.fn(() =>
|
|
155
|
+
Promise.resolve({ user: undefined, admins: [], auth_enabled: false }),
|
|
156
|
+
);
|
|
157
|
+
const authService = { verifyBearer } as unknown as AuthService;
|
|
158
|
+
const { api, captured } = makeFakeApi(authService, true);
|
|
159
|
+
const inner = vi.fn();
|
|
160
|
+
|
|
161
|
+
mountAuthenticatedNodeRoute(api, "GET", "/x", inner, {
|
|
162
|
+
allowAnonymous: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const res = makeRes();
|
|
166
|
+
await captured[0].handler(makeReq(), res);
|
|
167
|
+
|
|
168
|
+
expect(res.statusCode).toBe(401);
|
|
169
|
+
expect(inner).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
141
172
|
it("mounts a wrapper that forwards the anonymous actor when authService is undefined", async () => {
|
|
142
173
|
const { api, captured } = makeFakeApi(undefined);
|
|
143
174
|
const inner = vi.fn();
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { PGlite } from "@electric-sql/pglite";
|
|
2
2
|
import {
|
|
3
|
+
AttachmentAlreadyExists,
|
|
3
4
|
AttachmentBuilder,
|
|
4
5
|
S3AttachmentBackend,
|
|
5
6
|
type AttachmentBuildResult,
|
|
6
7
|
createRemoteAttachmentService,
|
|
7
8
|
} from "@powerhousedao/reactor-attachments";
|
|
8
|
-
import type { API } from "@powerhousedao/reactor-api";
|
|
9
|
+
import type { API, IAttachmentAccessService } from "@powerhousedao/reactor-api";
|
|
9
10
|
import { createHttpAdapter } from "@powerhousedao/reactor-api";
|
|
10
11
|
import { Kysely } from "kysely";
|
|
11
12
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
@@ -22,6 +23,16 @@ import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
|
22
23
|
const EMPTY_STRING_SHA256 =
|
|
23
24
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
24
25
|
|
|
26
|
+
const DOC_ID = "doc-1";
|
|
27
|
+
const ALLOW_ALL: IAttachmentAccessService = {
|
|
28
|
+
canReadAttachment: (request) =>
|
|
29
|
+
Promise.resolve({
|
|
30
|
+
kind: "allowed",
|
|
31
|
+
documentId: request.documentId as never,
|
|
32
|
+
ref: request.attachmentRef as never,
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
|
|
25
36
|
describe("attachment routes through the real Express middleware stack", () => {
|
|
26
37
|
let attachments: AttachmentBuildResult;
|
|
27
38
|
let kysely: Kysely<unknown>;
|
|
@@ -42,10 +53,11 @@ describe("attachment routes through the real Express middleware stack", () => {
|
|
|
42
53
|
registerAttachmentRoutes({
|
|
43
54
|
httpAdapter: adapter,
|
|
44
55
|
attachments,
|
|
56
|
+
attachmentAccess: ALLOW_ALL,
|
|
45
57
|
authService: undefined,
|
|
46
58
|
} as unknown as API);
|
|
47
59
|
|
|
48
|
-
server = await adapter.listen(0);
|
|
60
|
+
server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
49
61
|
const addr = server.address();
|
|
50
62
|
if (!addr || typeof addr === "string") throw new Error("no addr");
|
|
51
63
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
@@ -82,7 +94,7 @@ describe("attachment routes through the real Express middleware stack", () => {
|
|
|
82
94
|
expect(result.hash).not.toBe(EMPTY_STRING_SHA256);
|
|
83
95
|
expect(result.header.sizeBytes).toBe(bytes.byteLength);
|
|
84
96
|
|
|
85
|
-
const got = await service.get(result.ref);
|
|
97
|
+
const got = await service.get(result.ref, { documentId: DOC_ID });
|
|
86
98
|
expect(got.header.sizeBytes).toBe(bytes.byteLength);
|
|
87
99
|
expect(got.header.mimeType).toBe("application/json");
|
|
88
100
|
const reader = got.body.getReader();
|
|
@@ -101,6 +113,48 @@ describe("attachment routes through the real Express middleware stack", () => {
|
|
|
101
113
|
}
|
|
102
114
|
expect(new TextDecoder().decode(merged)).toBe(payload);
|
|
103
115
|
});
|
|
116
|
+
|
|
117
|
+
it("HEAD and GET /attachments/:hash serve a file name above U+00FF", async () => {
|
|
118
|
+
const fileName = "opłata – 報告 📎.pdf";
|
|
119
|
+
const service = createRemoteAttachmentService({ remoteUrl: baseUrl });
|
|
120
|
+
const upload = await service.reserve({
|
|
121
|
+
mimeType: "application/pdf",
|
|
122
|
+
fileName,
|
|
123
|
+
});
|
|
124
|
+
const bytes = new TextEncoder().encode("%PDF-unicode-name");
|
|
125
|
+
const { hash } = await upload.send(
|
|
126
|
+
new ReadableStream<Uint8Array>({
|
|
127
|
+
start(controller) {
|
|
128
|
+
controller.enqueue(bytes);
|
|
129
|
+
controller.close();
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
for (const method of ["HEAD", "GET"]) {
|
|
135
|
+
const res = await fetch(
|
|
136
|
+
`${baseUrl}/attachments/${hash}?documentId=${DOC_ID}`,
|
|
137
|
+
{ method },
|
|
138
|
+
);
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
const meta = JSON.parse(res.headers.get("attachment-metadata")!) as {
|
|
141
|
+
fileName: string;
|
|
142
|
+
};
|
|
143
|
+
expect(meta.fileName).toBe(fileName);
|
|
144
|
+
await res.arrayBuffer();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A hash-first reserve of the same bytes is what the client sends on the
|
|
148
|
+
// next upload; it stats the hash first.
|
|
149
|
+
await expect(
|
|
150
|
+
service.reserve({
|
|
151
|
+
mimeType: "application/pdf",
|
|
152
|
+
fileName,
|
|
153
|
+
clientHash: hash,
|
|
154
|
+
sizeBytes: bytes.byteLength,
|
|
155
|
+
}),
|
|
156
|
+
).rejects.toBeInstanceOf(AttachmentAlreadyExists);
|
|
157
|
+
});
|
|
104
158
|
});
|
|
105
159
|
|
|
106
160
|
describe("authenticated S3 reservation production path", () => {
|
|
@@ -138,9 +192,10 @@ describe("authenticated S3 reservation production path", () => {
|
|
|
138
192
|
registerAttachmentRoutes({
|
|
139
193
|
httpAdapter: adapter,
|
|
140
194
|
attachments,
|
|
195
|
+
attachmentAccess: ALLOW_ALL,
|
|
141
196
|
authService: { verifyBearer },
|
|
142
197
|
} as unknown as API);
|
|
143
|
-
const server = await adapter.listen(0);
|
|
198
|
+
const server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
144
199
|
const address = server.address();
|
|
145
200
|
if (!address || typeof address === "string") throw new Error("no addr");
|
|
146
201
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|