@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
|
@@ -1,6 +1,6 @@
|
|
|
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]="
|
|
3
|
-
import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-
|
|
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
|
+
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";
|
|
6
6
|
import * as Sentry from "@sentry/node";
|
|
@@ -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));
|
|
@@ -423,7 +430,37 @@ function extractParam(req, name) {
|
|
|
423
430
|
}
|
|
424
431
|
const MAX_DOCUMENT_ID_LEN = 512;
|
|
425
432
|
const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
|
|
426
|
-
const MAX_DOWNLOAD_TARGET_TTL_SECONDS =
|
|
433
|
+
const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 604800;
|
|
434
|
+
/**
|
|
435
|
+
* How long a download target may live, by default.
|
|
436
|
+
*
|
|
437
|
+
* The authorization behind a presigned URL is decided once, when it is issued,
|
|
438
|
+
* and the URL keeps working until it expires however the document's policy
|
|
439
|
+
* changes in between. A week of that is a revocation that does not revoke: an
|
|
440
|
+
* account deactivated, a company reassigned or a group left, and the bytes stay
|
|
441
|
+
* fetchable by anyone holding the link. Minutes are enough to open a file;
|
|
442
|
+
* days are only enough to lose control of it.
|
|
443
|
+
*
|
|
444
|
+
* A deployment that needs longer sets ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS,
|
|
445
|
+
* still bounded by the signing ceiling above.
|
|
446
|
+
*/
|
|
447
|
+
const DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS = 300;
|
|
448
|
+
/**
|
|
449
|
+
* The ceiling in force, read per call so a deployment can set it without the
|
|
450
|
+
* value being frozen at import — which is also what lets a test cover it.
|
|
451
|
+
* Anything unparseable falls back to the default rather than to the maximum:
|
|
452
|
+
* a typo must not quietly widen the window it was meant to narrow.
|
|
453
|
+
*/
|
|
454
|
+
function downloadTargetTtlCapSeconds() {
|
|
455
|
+
const configured = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
456
|
+
if (configured === void 0) return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
457
|
+
const parsed = Number(configured);
|
|
458
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
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}`);
|
|
460
|
+
return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
461
|
+
}
|
|
462
|
+
return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
|
|
463
|
+
}
|
|
427
464
|
/**
|
|
428
465
|
* Returns the single `documentId` query value, or null when it is missing,
|
|
429
466
|
* duplicated, blank, or oversized. Validation happens before authorization so
|
|
@@ -444,29 +481,95 @@ function extractSingleDocumentId(req) {
|
|
|
444
481
|
return value;
|
|
445
482
|
}
|
|
446
483
|
/**
|
|
447
|
-
* Returns the
|
|
448
|
-
*
|
|
449
|
-
*
|
|
484
|
+
* Returns the target TTL in seconds, or "invalid" when the caller's own value
|
|
485
|
+
* is malformed (duplicated, non-integer, or non-positive).
|
|
486
|
+
*
|
|
487
|
+
* The cap applies whether or not the caller asked for a lifetime: omitting
|
|
488
|
+
* `expiresIn` used to fall through to whatever the storage backend was
|
|
489
|
+
* configured with, which would make the ceiling something a client opts out of
|
|
490
|
+
* by saying nothing.
|
|
450
491
|
*/
|
|
451
492
|
function extractExpiresIn(req) {
|
|
452
|
-
|
|
493
|
+
const cap = downloadTargetTtlCapSeconds();
|
|
494
|
+
if (!req.url) return cap;
|
|
453
495
|
let url;
|
|
454
496
|
try {
|
|
455
497
|
url = new URL(req.url, "http://switchboard.invalid");
|
|
456
498
|
} catch {
|
|
457
|
-
return;
|
|
499
|
+
return cap;
|
|
458
500
|
}
|
|
459
501
|
const values = url.searchParams.getAll("expiresIn");
|
|
460
|
-
if (values.length === 0) return
|
|
502
|
+
if (values.length === 0) return cap;
|
|
461
503
|
if (values.length > 1) return "invalid";
|
|
462
504
|
const parsed = Number(values[0]);
|
|
463
505
|
if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
|
|
464
|
-
return Math.min(parsed,
|
|
506
|
+
return Math.min(parsed, cap);
|
|
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
|
+
}
|
|
465
568
|
}
|
|
466
569
|
/**
|
|
467
570
|
* Base URL of this Switchboard as seen by the caller, used to build
|
|
468
|
-
* filesystem `switchboard` download targets that point back at the
|
|
469
|
-
*
|
|
571
|
+
* filesystem `switchboard` download targets that point back at the signed
|
|
572
|
+
* byte route.
|
|
470
573
|
*/
|
|
471
574
|
function requestBaseUrl(req) {
|
|
472
575
|
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
@@ -476,7 +579,7 @@ function requestBaseUrl(req) {
|
|
|
476
579
|
if (!host) return null;
|
|
477
580
|
return `${proto}://${host}`;
|
|
478
581
|
}
|
|
479
|
-
function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
582
|
+
function makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner) {
|
|
480
583
|
return async (req, res, _body, actor) => {
|
|
481
584
|
res.setHeader("Cache-Control", "no-store");
|
|
482
585
|
const hash = extractParam(req, "hash");
|
|
@@ -500,10 +603,11 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
500
603
|
decision = await attachmentAccess.canReadAttachment({
|
|
501
604
|
documentId,
|
|
502
605
|
attachmentRef: createRef(canonicalHash),
|
|
503
|
-
userAddress: actor?.user?.address
|
|
606
|
+
userAddress: actor?.user?.address,
|
|
607
|
+
appKey: actor?.user?.appKey
|
|
504
608
|
});
|
|
505
609
|
} catch (err) {
|
|
506
|
-
logger$
|
|
610
|
+
logger$2.error("Attachment access decision failed: @error", err);
|
|
507
611
|
sendError(res, 500, "Internal error");
|
|
508
612
|
return;
|
|
509
613
|
}
|
|
@@ -523,7 +627,7 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
523
627
|
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
524
628
|
return;
|
|
525
629
|
}
|
|
526
|
-
logger$
|
|
630
|
+
logger$2.error("Attachment metadata lookup failed: @error", err);
|
|
527
631
|
sendError(res, 500, "Internal error");
|
|
528
632
|
return;
|
|
529
633
|
}
|
|
@@ -539,17 +643,23 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
539
643
|
return;
|
|
540
644
|
}
|
|
541
645
|
else {
|
|
646
|
+
if (!urlSigner) {
|
|
647
|
+
sendError(res, 503, "Attachment download signing is not configured");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
542
650
|
const base = requestBaseUrl(req);
|
|
543
651
|
if (!base) {
|
|
544
652
|
sendError(res, 500, "Internal error");
|
|
545
653
|
return;
|
|
546
654
|
}
|
|
655
|
+
const signed = urlSigner.sign(canonicalHash, decision.documentId, expiresIn);
|
|
547
656
|
try {
|
|
548
657
|
target = parseAttachmentDownloadTarget({
|
|
549
658
|
kind: "switchboard",
|
|
550
659
|
method: "GET",
|
|
551
|
-
url: `${base}/attachments/${canonicalHash}`,
|
|
552
|
-
headers: {}
|
|
660
|
+
url: `${base}/attachments/${canonicalHash}?${signed.query}`,
|
|
661
|
+
headers: {},
|
|
662
|
+
expiresAtUtc: signed.expiresAtUtc
|
|
553
663
|
});
|
|
554
664
|
} catch {
|
|
555
665
|
sendError(res, 500, "Internal error");
|
|
@@ -560,16 +670,82 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
560
670
|
};
|
|
561
671
|
}
|
|
562
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
|
|
563
728
|
//#region src/attachments/index.ts
|
|
564
|
-
|
|
565
|
-
|
|
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;
|
|
566
733
|
mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
|
|
567
734
|
mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
|
|
568
735
|
mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
|
|
569
736
|
mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
|
|
570
|
-
mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments));
|
|
571
|
-
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments,
|
|
572
|
-
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;
|
|
573
749
|
}
|
|
574
750
|
//#endregion
|
|
575
751
|
//#region src/attachment-reference-read-model.mts
|
|
@@ -957,7 +1133,9 @@ function toStepRecord(row) {
|
|
|
957
1133
|
input: parseJson(row.input),
|
|
958
1134
|
output: parseJson(row.output),
|
|
959
1135
|
port: row.port,
|
|
960
|
-
error: row.error
|
|
1136
|
+
error: row.error,
|
|
1137
|
+
startedAt: row.started_at,
|
|
1138
|
+
endedAt: row.ended_at
|
|
961
1139
|
};
|
|
962
1140
|
}
|
|
963
1141
|
function toRunRecord(row, steps) {
|
|
@@ -1204,6 +1382,8 @@ const schema = gql`
|
|
|
1204
1382
|
output: Unknown
|
|
1205
1383
|
port: String
|
|
1206
1384
|
error: String
|
|
1385
|
+
startedAt: String
|
|
1386
|
+
endedAt: String
|
|
1207
1387
|
}
|
|
1208
1388
|
|
|
1209
1389
|
type WorkflowRunRecord {
|
|
@@ -1317,8 +1497,7 @@ async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao
|
|
|
1317
1497
|
}
|
|
1318
1498
|
function readAssertion(authorizationService, reactorClient) {
|
|
1319
1499
|
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
1320
|
-
|
|
1321
|
-
const ctx = caller;
|
|
1500
|
+
const hostCanRead = async (identifier, ctx) => {
|
|
1322
1501
|
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
1323
1502
|
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
1324
1503
|
let documentId;
|
|
@@ -1329,6 +1508,17 @@ function readAssertion(authorizationService, reactorClient) {
|
|
|
1329
1508
|
}
|
|
1330
1509
|
if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
|
|
1331
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
|
+
};
|
|
1332
1522
|
}
|
|
1333
1523
|
/** The same, for a design-time call that writes what it names. */
|
|
1334
1524
|
function writeAssertion(authorizationService, reactorClient) {
|
|
@@ -1406,6 +1596,7 @@ async function composeWorkflowRuntime(deps) {
|
|
|
1406
1596
|
relationalDb: deps.relationalDb,
|
|
1407
1597
|
reactorClient: deps.reactorClient,
|
|
1408
1598
|
assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
|
|
1599
|
+
subjectOf: (caller) => callerSubject(caller.user),
|
|
1409
1600
|
assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
|
|
1410
1601
|
webhooks: deps.webhooks,
|
|
1411
1602
|
attachments: deps.attachments,
|
|
@@ -2010,7 +2201,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2010
2201
|
new ReactorInstrumentation(module.reactorModule).start();
|
|
2011
2202
|
reactorLogger.info("Reactor metrics instrumentation started");
|
|
2012
2203
|
}
|
|
2013
|
-
|
|
2204
|
+
const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
|
|
2205
|
+
driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
|
|
2014
2206
|
const reactorDriveClient = new ReactorDriveClient({
|
|
2015
2207
|
reactor: module.client,
|
|
2016
2208
|
readModel: driveNodeView
|
|
@@ -2174,10 +2366,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2174
2366
|
}
|
|
2175
2367
|
if (driveNodeView) {
|
|
2176
2368
|
graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
|
|
2369
|
+
const authorizationService = graphqlManager.getAuthorizationService();
|
|
2177
2370
|
const reactorDriveSubgraph = {
|
|
2178
2371
|
name: "reactor-drive",
|
|
2179
2372
|
path: graphqlManager.getBasePath(),
|
|
2180
|
-
resolvers: createReactorDriveResolvers(
|
|
2373
|
+
resolvers: createReactorDriveResolvers({
|
|
2374
|
+
reactorClient: client,
|
|
2375
|
+
readModel: driveNodeView,
|
|
2376
|
+
hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
|
|
2377
|
+
}),
|
|
2181
2378
|
typeDefs: reactorDriveSubgraphTypeDefs,
|
|
2182
2379
|
reactorClient: client,
|
|
2183
2380
|
relationalDb: void 0
|
|
@@ -2293,5 +2490,5 @@ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
|
|
|
2293
2490
|
//#endregion
|
|
2294
2491
|
export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
|
|
2295
2492
|
|
|
2296
|
-
//# sourceMappingURL=server-
|
|
2297
|
-
//# debugId=
|
|
2493
|
+
//# sourceMappingURL=server-DQCIIJzK.mjs.map
|
|
2494
|
+
//# debugId=3a84ab97-af9a-5a44-94fa-3b59ffe5eed8
|