@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/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
- included, even though that route decides per document on its own.
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-C3WaP3kE.mjs";
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]="8174bb20-5463-598a-9c2d-44a8760037a5")}catch(e){}}();
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$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));
@@ -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$1.warn(`Ignoring invalid ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS="${configured}" (expected a positive integer number of seconds) — using ${DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS}`);
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 existing
504
- * authenticated byte route.
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$1.error("Attachment access decision failed: @error", err);
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$1.error("Attachment metadata lookup failed: @error", err);
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
- function registerAttachmentRoutes(api) {
601
- 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;
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, api.attachmentAccess), { allowAnonymous: true });
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
- return async (identifier, caller) => {
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-C3WaP3kE.mjs.map
2338
- //# debugId=8174bb20-5463-598a-9c2d-44a8760037a5
2493
+ //# sourceMappingURL=server-DQCIIJzK.mjs.map
2494
+ //# debugId=3a84ab97-af9a-5a44-94fa-3b59ffe5eed8