@powerhousedao/switchboard 6.2.3-dev.25 → 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/Auth.md +4 -2
- package/CHANGELOG.md +23 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-C3WaP3kE.mjs → server-DQCIIJzK.mjs} +184 -28
- package/dist/server-DQCIIJzK.mjs.map +1 -0
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +17 -17
- 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 +124 -24
- 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/dist/server-C3WaP3kE.mjs.map +0 -1
package/Auth.md
CHANGED
|
@@ -275,8 +275,10 @@ including authenticated ones.
|
|
|
275
275
|
It also covers the attachment routes, which are mounted on the HTTP adapter and
|
|
276
276
|
never pass the GraphQL fetch chain. Their own 401 keys on `AUTH_ENABLED`, so
|
|
277
277
|
without this switch a deployment running `OPEN` serves them to anyone; with it,
|
|
278
|
-
every attachment route refuses a caller it cannot name — `download-target`
|
|
279
|
-
|
|
278
|
+
every attachment route refuses a caller it cannot name — `download-target` and
|
|
279
|
+
the `GET`/`HEAD /attachments/:hash` byte routes included, even though they
|
|
280
|
+
decide per document (or by a signed URL) on their own. A filesystem signed
|
|
281
|
+
download URL therefore needs a bearer too while this switch is on.
|
|
280
282
|
|
|
281
283
|
##### Serving one path anonymously
|
|
282
284
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 6.2.3-dev.26 (2026-09-26)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- ⚠️ **switchboard:** serve attachment bytes only under a document grant ([275e88da0](https://github.com/powerhouse-inc/powerhouse/commit/275e88da0))
|
|
6
|
+
- **switchboard:** sign short-lived attachment byte-route URLs ([18e09cdb5](https://github.com/powerhouse-inc/powerhouse/commit/18e09cdb5))
|
|
7
|
+
|
|
8
|
+
### 🩹 Fixes
|
|
9
|
+
|
|
10
|
+
- **switchboard:** sign byte-route URLs under main's download-target TTL cap ([d503b4f67](https://github.com/powerhouse-inc/powerhouse/commit/d503b4f67))
|
|
11
|
+
- **reactor-workflow:** serve workflow reads and runs as the caller ([b2ddd126a](https://github.com/powerhouse-inc/powerhouse/commit/b2ddd126a))
|
|
12
|
+
- **reactor-drive:** gate the reactor-drive subgraph as the caller ([7041635f4](https://github.com/powerhouse-inc/powerhouse/commit/7041635f4))
|
|
13
|
+
- **reactor-api:** gate attachment reads through the reactor's read gate ([d6c3b4df1](https://github.com/powerhouse-inc/powerhouse/commit/d6c3b4df1))
|
|
14
|
+
|
|
15
|
+
### ⚠️ Breaking Changes
|
|
16
|
+
|
|
17
|
+
- **switchboard:** serve attachment bytes only under a document grant ([275e88da0](https://github.com/powerhouse-inc/powerhouse/commit/275e88da0))
|
|
18
|
+
|
|
19
|
+
### ❤️ Thank You
|
|
20
|
+
|
|
21
|
+
- Benjamin Jordan
|
|
22
|
+
- Claude Opus 5.5
|
|
23
|
+
|
|
1
24
|
## 6.2.3-dev.25 (2026-09-25)
|
|
2
25
|
|
|
3
26
|
### 🚀 Features
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f6ffead6-4742-59d9-948c-1c651716e65b")}catch(e){}}();
|
|
4
|
-
import { a as parseForcePgVersion, r as startSwitchboard } from "./server-
|
|
4
|
+
import { a as parseForcePgVersion, r as startSwitchboard } from "./server-DQCIIJzK.mjs";
|
|
5
5
|
import { metrics } from "@opentelemetry/api";
|
|
6
6
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
7
7
|
import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3a84ab97-af9a-5a44-94fa-3b59ffe5eed8")}catch(e){}}();
|
|
3
3
|
import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-uaBlzyjS.mjs";
|
|
4
4
|
import { createRequire, register } from "node:module";
|
|
5
5
|
import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
|
|
@@ -11,7 +11,7 @@ import { existsSync, promises, realpathSync } from "node:fs";
|
|
|
11
11
|
import path, { resolve } from "node:path";
|
|
12
12
|
import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
|
|
13
13
|
import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
|
|
14
|
-
import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
|
|
14
|
+
import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
|
|
15
15
|
import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
|
|
16
16
|
import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef } from "@powerhousedao/reactor-attachments";
|
|
17
17
|
import { createAttachmentClient } from "@powerhousedao/reactor-attachments/client";
|
|
@@ -22,6 +22,7 @@ import net from "node:net";
|
|
|
22
22
|
import path$1 from "path";
|
|
23
23
|
import { Pool } from "pg";
|
|
24
24
|
import { Readable } from "node:stream";
|
|
25
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
25
26
|
import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
|
|
26
27
|
import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
|
|
27
28
|
import { DEFAULT_SIGNATURE_POLICY, isSignaturePolicy } from "@powerhousedao/shared/document-model";
|
|
@@ -147,7 +148,7 @@ function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
|
|
|
147
148
|
}
|
|
148
149
|
//#endregion
|
|
149
150
|
//#region src/attachments/routes.ts
|
|
150
|
-
const logger$
|
|
151
|
+
const logger$2 = childLogger(["switchboard", "attachments"]);
|
|
151
152
|
const RETRY_AFTER_SECONDS = 5;
|
|
152
153
|
const HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
|
153
154
|
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
@@ -172,7 +173,7 @@ function statusForError(err) {
|
|
|
172
173
|
function sendErrorFromException(res, err) {
|
|
173
174
|
const status = statusForError(err);
|
|
174
175
|
if (status >= 500) {
|
|
175
|
-
logger$
|
|
176
|
+
logger$2.error("Attachment route error: @error", err);
|
|
176
177
|
sendError(res, status, "Internal error");
|
|
177
178
|
return;
|
|
178
179
|
}
|
|
@@ -301,22 +302,25 @@ function makeUploadHandler(attachments) {
|
|
|
301
302
|
}
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
|
-
function makeDownloadHandler(attachments) {
|
|
305
|
-
return async (req, res) => {
|
|
305
|
+
function makeDownloadHandler(attachments, attachmentAccess, urlSigner) {
|
|
306
|
+
return async (req, res, _body, actor) => {
|
|
306
307
|
const hash = extractParam(req, "hash");
|
|
307
308
|
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
308
309
|
sendError(res, 400, "Invalid attachment hash");
|
|
309
310
|
return;
|
|
310
311
|
}
|
|
312
|
+
const canonicalHash = hash.toLowerCase();
|
|
313
|
+
const grant = await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner);
|
|
314
|
+
if (!admitGrant(res, grant)) return;
|
|
311
315
|
const controller = new AbortController();
|
|
312
316
|
req.once("close", () => controller.abort());
|
|
313
|
-
const canonicalHash = hash.toLowerCase();
|
|
314
317
|
let response;
|
|
315
318
|
try {
|
|
316
|
-
response = await attachments.store.get(canonicalHash, controller.signal);
|
|
319
|
+
response = await attachments.store.get(canonicalHash, controller.signal, grant.documentId);
|
|
317
320
|
} catch (err) {
|
|
318
321
|
if (err instanceof AttachmentPending) {
|
|
319
322
|
res.statusCode = 202;
|
|
323
|
+
res.setHeader("Cache-Control", "private");
|
|
320
324
|
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
321
325
|
res.setHeader("Attachment-Pending", buildPendingHeader({
|
|
322
326
|
expiresAtUtc: err.expiresAtUtc,
|
|
@@ -330,6 +334,7 @@ function makeDownloadHandler(attachments) {
|
|
|
330
334
|
}
|
|
331
335
|
const { header, body } = response;
|
|
332
336
|
res.statusCode = 200;
|
|
337
|
+
res.setHeader("Cache-Control", "private");
|
|
333
338
|
res.setHeader("Content-Type", header.mimeType);
|
|
334
339
|
res.setHeader("Content-Length", String(header.sizeBytes));
|
|
335
340
|
res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
|
|
@@ -353,14 +358,15 @@ function buildMetadataHeader(header) {
|
|
|
353
358
|
lastAccessedAtUtc: header.lastAccessedAtUtc
|
|
354
359
|
});
|
|
355
360
|
}
|
|
356
|
-
function makeStatHandler(attachments) {
|
|
357
|
-
return async (req, res) => {
|
|
361
|
+
function makeStatHandler(attachments, attachmentAccess, urlSigner) {
|
|
362
|
+
return async (req, res, _body, actor) => {
|
|
358
363
|
const hash = extractParam(req, "hash");
|
|
359
364
|
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
360
365
|
sendError(res, 400, "Invalid attachment hash");
|
|
361
366
|
return;
|
|
362
367
|
}
|
|
363
368
|
const canonicalHash = hash.toLowerCase();
|
|
369
|
+
if (!admitGrant(res, await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner))) return;
|
|
364
370
|
let header;
|
|
365
371
|
try {
|
|
366
372
|
header = await attachments.store.stat(canonicalHash);
|
|
@@ -368,6 +374,7 @@ function makeStatHandler(attachments) {
|
|
|
368
374
|
sendErrorFromException(res, err);
|
|
369
375
|
return;
|
|
370
376
|
}
|
|
377
|
+
res.setHeader("Cache-Control", "private");
|
|
371
378
|
if (header.status === "pending") {
|
|
372
379
|
res.statusCode = 202;
|
|
373
380
|
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
@@ -449,7 +456,7 @@ function downloadTargetTtlCapSeconds() {
|
|
|
449
456
|
if (configured === void 0) return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
450
457
|
const parsed = Number(configured);
|
|
451
458
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
452
|
-
logger$
|
|
459
|
+
logger$2.warn(`Ignoring invalid ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS="${configured}" (expected a positive integer number of seconds) — using ${DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS}`);
|
|
453
460
|
return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
454
461
|
}
|
|
455
462
|
return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
|
|
@@ -498,10 +505,71 @@ function extractExpiresIn(req) {
|
|
|
498
505
|
if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
|
|
499
506
|
return Math.min(parsed, cap);
|
|
500
507
|
}
|
|
508
|
+
function singleQueryValue(params, name) {
|
|
509
|
+
const values = params.getAll(name);
|
|
510
|
+
return values.length === 1 ? values[0] : null;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Byte routes serve only under a grant: a signed URL minted by the
|
|
514
|
+
* download-target route, or a documentId the caller may read the attachment
|
|
515
|
+
* through. A signed request is judged by its signature alone.
|
|
516
|
+
*/
|
|
517
|
+
async function resolveByteGrant(req, hash, actor, attachmentAccess, urlSigner) {
|
|
518
|
+
let params;
|
|
519
|
+
try {
|
|
520
|
+
params = new URL(req.url ?? "", "http://switchboard.invalid").searchParams;
|
|
521
|
+
} catch {
|
|
522
|
+
return { kind: "denied" };
|
|
523
|
+
}
|
|
524
|
+
const documentId = extractSingleDocumentId(req);
|
|
525
|
+
if (documentId === null) return { kind: "denied" };
|
|
526
|
+
if (params.has("signature") || params.has("expires")) {
|
|
527
|
+
const signature = singleQueryValue(params, "signature");
|
|
528
|
+
const expires = singleQueryValue(params, "expires");
|
|
529
|
+
if (!urlSigner || signature === null || expires === null) return { kind: "denied" };
|
|
530
|
+
return urlSigner.verify(hash, documentId, expires, signature) ? {
|
|
531
|
+
kind: "granted",
|
|
532
|
+
documentId
|
|
533
|
+
} : { kind: "denied" };
|
|
534
|
+
}
|
|
535
|
+
let decision;
|
|
536
|
+
try {
|
|
537
|
+
decision = await attachmentAccess.canReadAttachment({
|
|
538
|
+
documentId,
|
|
539
|
+
attachmentRef: createRef(hash),
|
|
540
|
+
userAddress: actor?.user?.address,
|
|
541
|
+
appKey: actor?.user?.appKey
|
|
542
|
+
});
|
|
543
|
+
} catch (err) {
|
|
544
|
+
logger$2.error("Attachment access decision failed: @error", err);
|
|
545
|
+
return { kind: "error" };
|
|
546
|
+
}
|
|
547
|
+
if (decision.kind === "projection-unavailable") return { kind: "unavailable" };
|
|
548
|
+
if (decision.kind === "denied") return { kind: "denied" };
|
|
549
|
+
return {
|
|
550
|
+
kind: "granted",
|
|
551
|
+
documentId: decision.documentId
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
/** Answers a refused grant; true when the request may proceed. */
|
|
555
|
+
function admitGrant(res, grant) {
|
|
556
|
+
switch (grant.kind) {
|
|
557
|
+
case "granted": return true;
|
|
558
|
+
case "denied":
|
|
559
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
560
|
+
return false;
|
|
561
|
+
case "unavailable":
|
|
562
|
+
sendError(res, 503, "Attachment downloads are temporarily unavailable");
|
|
563
|
+
return false;
|
|
564
|
+
case "error":
|
|
565
|
+
sendError(res, 500, "Internal error");
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
501
569
|
/**
|
|
502
570
|
* Base URL of this Switchboard as seen by the caller, used to build
|
|
503
|
-
* filesystem `switchboard` download targets that point back at the
|
|
504
|
-
*
|
|
571
|
+
* filesystem `switchboard` download targets that point back at the signed
|
|
572
|
+
* byte route.
|
|
505
573
|
*/
|
|
506
574
|
function requestBaseUrl(req) {
|
|
507
575
|
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
@@ -511,7 +579,7 @@ function requestBaseUrl(req) {
|
|
|
511
579
|
if (!host) return null;
|
|
512
580
|
return `${proto}://${host}`;
|
|
513
581
|
}
|
|
514
|
-
function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
582
|
+
function makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner) {
|
|
515
583
|
return async (req, res, _body, actor) => {
|
|
516
584
|
res.setHeader("Cache-Control", "no-store");
|
|
517
585
|
const hash = extractParam(req, "hash");
|
|
@@ -539,7 +607,7 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
539
607
|
appKey: actor?.user?.appKey
|
|
540
608
|
});
|
|
541
609
|
} catch (err) {
|
|
542
|
-
logger$
|
|
610
|
+
logger$2.error("Attachment access decision failed: @error", err);
|
|
543
611
|
sendError(res, 500, "Internal error");
|
|
544
612
|
return;
|
|
545
613
|
}
|
|
@@ -559,7 +627,7 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
559
627
|
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
560
628
|
return;
|
|
561
629
|
}
|
|
562
|
-
logger$
|
|
630
|
+
logger$2.error("Attachment metadata lookup failed: @error", err);
|
|
563
631
|
sendError(res, 500, "Internal error");
|
|
564
632
|
return;
|
|
565
633
|
}
|
|
@@ -575,17 +643,23 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
575
643
|
return;
|
|
576
644
|
}
|
|
577
645
|
else {
|
|
646
|
+
if (!urlSigner) {
|
|
647
|
+
sendError(res, 503, "Attachment download signing is not configured");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
578
650
|
const base = requestBaseUrl(req);
|
|
579
651
|
if (!base) {
|
|
580
652
|
sendError(res, 500, "Internal error");
|
|
581
653
|
return;
|
|
582
654
|
}
|
|
655
|
+
const signed = urlSigner.sign(canonicalHash, decision.documentId, expiresIn);
|
|
583
656
|
try {
|
|
584
657
|
target = parseAttachmentDownloadTarget({
|
|
585
658
|
kind: "switchboard",
|
|
586
659
|
method: "GET",
|
|
587
|
-
url: `${base}/attachments/${canonicalHash}`,
|
|
588
|
-
headers: {}
|
|
660
|
+
url: `${base}/attachments/${canonicalHash}?${signed.query}`,
|
|
661
|
+
headers: {},
|
|
662
|
+
expiresAtUtc: signed.expiresAtUtc
|
|
589
663
|
});
|
|
590
664
|
} catch {
|
|
591
665
|
sendError(res, 500, "Internal error");
|
|
@@ -596,16 +670,82 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
596
670
|
};
|
|
597
671
|
}
|
|
598
672
|
//#endregion
|
|
673
|
+
//#region src/attachments/url-signer.ts
|
|
674
|
+
const ATTACHMENT_URL_SIGNING_SECRET_ENV = "PH_ATTACHMENT_URL_SIGNING_SECRET";
|
|
675
|
+
const MIN_SECRET_LENGTH = 32;
|
|
676
|
+
const EXPIRES_PATTERN = /^[1-9]\d{0,15}$/;
|
|
677
|
+
const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
678
|
+
var AttachmentUrlSigner = class {
|
|
679
|
+
secret;
|
|
680
|
+
now;
|
|
681
|
+
constructor(secret, now = Date.now) {
|
|
682
|
+
this.secret = secret;
|
|
683
|
+
this.now = now;
|
|
684
|
+
}
|
|
685
|
+
sign(hash, documentId, ttlSeconds) {
|
|
686
|
+
const expires = Math.floor(this.now() / 1e3) + ttlSeconds;
|
|
687
|
+
return {
|
|
688
|
+
query: new URLSearchParams({
|
|
689
|
+
documentId,
|
|
690
|
+
expires: String(expires),
|
|
691
|
+
signature: this.mac(hash, documentId, String(expires))
|
|
692
|
+
}).toString(),
|
|
693
|
+
expiresAtUtc: (/* @__PURE__ */ new Date(expires * 1e3)).toISOString()
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
verify(hash, documentId, expires, signature) {
|
|
697
|
+
if (!EXPIRES_PATTERN.test(expires) || !SIGNATURE_PATTERN.test(signature)) return false;
|
|
698
|
+
if (Number(expires) * 1e3 <= this.now()) return false;
|
|
699
|
+
const expected = Buffer.from(this.mac(hash, documentId, expires));
|
|
700
|
+
const presented = Buffer.from(signature);
|
|
701
|
+
return expected.length === presented.length && timingSafeEqual(expected, presented);
|
|
702
|
+
}
|
|
703
|
+
mac(hash, documentId, expires) {
|
|
704
|
+
return createHmac("sha256", this.secret).update(JSON.stringify([
|
|
705
|
+
"attachment-download/v1",
|
|
706
|
+
hash,
|
|
707
|
+
documentId,
|
|
708
|
+
expires
|
|
709
|
+
])).digest("base64url");
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
function resolveAttachmentUrlSigning(env = process.env) {
|
|
713
|
+
const secret = env[ATTACHMENT_URL_SIGNING_SECRET_ENV];
|
|
714
|
+
if (secret !== void 0) {
|
|
715
|
+
if (secret.trim() !== secret || secret.length < MIN_SECRET_LENGTH) throw new Error(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} must be at least ${MIN_SECRET_LENGTH} characters with no leading or trailing whitespace`);
|
|
716
|
+
return {
|
|
717
|
+
status: "configured",
|
|
718
|
+
signer: new AttachmentUrlSigner(secret)
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
if (env.NODE_ENV === "production") return { status: "unconfigured" };
|
|
722
|
+
return {
|
|
723
|
+
status: "ephemeral",
|
|
724
|
+
signer: new AttachmentUrlSigner(randomBytes(32))
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
599
728
|
//#region src/attachments/index.ts
|
|
600
|
-
|
|
601
|
-
|
|
729
|
+
const logger$1 = childLogger(["switchboard", "attachments"]);
|
|
730
|
+
function registerAttachmentRoutes(api, options = {}) {
|
|
731
|
+
const { attachments, attachmentAccess } = api;
|
|
732
|
+
const urlSigner = options.urlSigner === void 0 ? signerFromEnvironment(attachments.backend?.kind ?? "filesystem") : options.urlSigner;
|
|
602
733
|
mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
|
|
603
734
|
mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
|
|
604
735
|
mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
|
|
605
736
|
mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
|
|
606
|
-
mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments));
|
|
607
|
-
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments,
|
|
608
|
-
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments));
|
|
737
|
+
mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
738
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
739
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
740
|
+
}
|
|
741
|
+
function signerFromEnvironment(backend) {
|
|
742
|
+
const signing = resolveAttachmentUrlSigning();
|
|
743
|
+
if (signing.status === "unconfigured") {
|
|
744
|
+
if (backend === "filesystem") logger$1.error(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} is not set: filesystem attachment download targets are refused until it is`);
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
if (signing.status === "ephemeral" && backend === "filesystem") logger$1.warn(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} is not set: signing attachment download URLs with a per-process secret`);
|
|
748
|
+
return signing.signer;
|
|
609
749
|
}
|
|
610
750
|
//#endregion
|
|
611
751
|
//#region src/attachment-reference-read-model.mts
|
|
@@ -1357,8 +1497,7 @@ async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao
|
|
|
1357
1497
|
}
|
|
1358
1498
|
function readAssertion(authorizationService, reactorClient) {
|
|
1359
1499
|
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
1360
|
-
|
|
1361
|
-
const ctx = caller;
|
|
1500
|
+
const hostCanRead = async (identifier, ctx) => {
|
|
1362
1501
|
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
1363
1502
|
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
1364
1503
|
let documentId;
|
|
@@ -1369,6 +1508,17 @@ function readAssertion(authorizationService, reactorClient) {
|
|
|
1369
1508
|
}
|
|
1370
1509
|
if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
|
|
1371
1510
|
};
|
|
1511
|
+
return async (identifier, caller) => {
|
|
1512
|
+
const ctx = caller;
|
|
1513
|
+
await hostCanRead(identifier, ctx);
|
|
1514
|
+
let served;
|
|
1515
|
+
try {
|
|
1516
|
+
served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
|
|
1517
|
+
} catch {
|
|
1518
|
+
throw new ForbiddenError();
|
|
1519
|
+
}
|
|
1520
|
+
if (!served) throw new ForbiddenError("to read this document");
|
|
1521
|
+
};
|
|
1372
1522
|
}
|
|
1373
1523
|
/** The same, for a design-time call that writes what it names. */
|
|
1374
1524
|
function writeAssertion(authorizationService, reactorClient) {
|
|
@@ -1446,6 +1596,7 @@ async function composeWorkflowRuntime(deps) {
|
|
|
1446
1596
|
relationalDb: deps.relationalDb,
|
|
1447
1597
|
reactorClient: deps.reactorClient,
|
|
1448
1598
|
assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
|
|
1599
|
+
subjectOf: (caller) => callerSubject(caller.user),
|
|
1449
1600
|
assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
|
|
1450
1601
|
webhooks: deps.webhooks,
|
|
1451
1602
|
attachments: deps.attachments,
|
|
@@ -2215,10 +2366,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2215
2366
|
}
|
|
2216
2367
|
if (driveNodeView) {
|
|
2217
2368
|
graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
|
|
2369
|
+
const authorizationService = graphqlManager.getAuthorizationService();
|
|
2218
2370
|
const reactorDriveSubgraph = {
|
|
2219
2371
|
name: "reactor-drive",
|
|
2220
2372
|
path: graphqlManager.getBasePath(),
|
|
2221
|
-
resolvers: createReactorDriveResolvers(
|
|
2373
|
+
resolvers: createReactorDriveResolvers({
|
|
2374
|
+
reactorClient: client,
|
|
2375
|
+
readModel: driveNodeView,
|
|
2376
|
+
hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
|
|
2377
|
+
}),
|
|
2222
2378
|
typeDefs: reactorDriveSubgraphTypeDefs,
|
|
2223
2379
|
reactorClient: client,
|
|
2224
2380
|
relationalDb: void 0
|
|
@@ -2334,5 +2490,5 @@ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
|
|
|
2334
2490
|
//#endregion
|
|
2335
2491
|
export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
|
|
2336
2492
|
|
|
2337
|
-
//# sourceMappingURL=server-
|
|
2338
|
-
//# debugId=
|
|
2493
|
+
//# sourceMappingURL=server-DQCIIJzK.mjs.map
|
|
2494
|
+
//# debugId=3a84ab97-af9a-5a44-94fa-3b59ffe5eed8
|