@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.
Files changed (44) 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/url-signer.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  7. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  8. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  9. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  10. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  11. package/.tsbuild/vitest.config.d.ts.map +1 -1
  12. package/Auth.md +55 -2
  13. package/CHANGELOG.md +41 -0
  14. package/dist/index.d.mts +2 -1
  15. package/dist/index.mjs +4 -5
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/install-packages.d.mts +2 -1
  18. package/dist/install-packages.mjs +2 -2
  19. package/dist/install-packages.mjs.map +1 -1
  20. package/dist/migrate.d.mts +2 -1
  21. package/dist/migrate.mjs +4 -3
  22. package/dist/migrate.mjs.map +1 -1
  23. package/dist/{server-BFbus-at.mjs → server-DQCIIJzK.mjs} +236 -39
  24. package/dist/server-DQCIIJzK.mjs.map +1 -0
  25. package/dist/server.d.mts +38 -24
  26. package/dist/server.d.mts.map +1 -1
  27. package/dist/server.mjs +3 -4
  28. package/dist/{utils-BBHt2kdo.mjs → utils-uaBlzyjS.mjs} +3 -3
  29. package/dist/{utils-BBHt2kdo.mjs.map → utils-uaBlzyjS.mjs.map} +1 -1
  30. package/dist/utils.d.mts +4 -5
  31. package/dist/utils.d.mts.map +1 -1
  32. package/dist/utils.mjs +3 -3
  33. package/package.json +18 -18
  34. package/test/attachments/byte-grants.test.ts +287 -0
  35. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  36. package/test/attachments/download-target.test.ts +194 -31
  37. package/test/attachments/routes-integration.test.ts +20 -5
  38. package/test/attachments/routes.test.ts +43 -25
  39. package/test/attachments/service-config.test.ts +13 -3
  40. package/test/attachments/url-signer.test.ts +105 -0
  41. package/test/workflow/harness.ts +1 -1
  42. package/test/workflow-read-gate.test.ts +145 -0
  43. package/tsdown.config.ts +3 -1
  44. 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]="dd6695ea-7590-5100-84c4-edefa353d65e")}catch(e){}}();
3
- import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-BBHt2kdo.mjs";
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$1 = childLogger(["switchboard", "attachments"]);
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$1.error("Attachment route error: @error", err);
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 = 10080 * 60;
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 requested target TTL in seconds: undefined when absent,
448
- * "invalid" when malformed (duplicated, non-integer, or non-positive), and
449
- * otherwise the value clamped to the presigning ceiling.
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
- if (!req.url) return void 0;
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 void 0;
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, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
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 existing
469
- * authenticated byte route.
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$1.error("Attachment access decision failed: @error", err);
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$1.error("Attachment metadata lookup failed: @error", err);
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
- function registerAttachmentRoutes(api) {
565
- const { attachments } = api;
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, api.attachmentAccess), { allowAnonymous: true });
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
- return async (identifier, caller) => {
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
- driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
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-BFbus-at.mjs.map
2297
- //# debugId=dd6695ea-7590-5100-84c4-edefa353d65e
2493
+ //# sourceMappingURL=server-DQCIIJzK.mjs.map
2494
+ //# debugId=3a84ab97-af9a-5a44-94fa-3b59ffe5eed8