@powerhousedao/switchboard 6.2.2-dev.16 → 6.2.2-dev.18

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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 6.2.2-dev.18 (2026-07-27)
2
+
3
+ This was a version bump only for @powerhousedao/switchboard to align it with other projects, there were no code changes.
4
+
5
+ ## 6.2.2-dev.17 (2026-07-26)
6
+
7
+ ### 🚀 Features
8
+
9
+ - **attachments:** S3-compatible storage, direct transfers, and document-derived authorization ([#2884](https://github.com/powerhouse-inc/powerhouse/pull/2884))
10
+
11
+ ### ❤️ Thank You
12
+
13
+ - Guillermo Puente Sandoval @gpuente
14
+
1
15
  ## 6.2.2-dev.16 (2026-07-25)
2
16
 
3
17
  ### 🩹 Fixes
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]="5a65c537-fadb-5705-95c0-4cec44d7b4d2")}catch(e){}}();
4
- import { a as parseForcePgVersion, r as startSwitchboard } from "./server-Cgud_ONM.mjs";
4
+ import { a as parseForcePgVersion, r as startSwitchboard } from "./server-ChK0GLG8.mjs";
5
5
  import "./utils-Baw7rThP.mjs";
6
6
  import { metrics } from "@opentelemetry/api";
7
7
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
@@ -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]="5eb0a978-689a-51dd-978a-cd3d7fe5df4b")}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]="b1867ed0-059f-587c-9a5f-677da3666de7")}catch(e){}}();
3
3
  import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-Baw7rThP.mjs";
4
4
  import { register } from "node:module";
5
5
  import * as Sentry from "@sentry/node";
@@ -10,10 +10,10 @@ import { existsSync, promises, realpathSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
12
12
  import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
13
- import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, parseDriveUrl } from "@powerhousedao/reactor";
13
+ import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
14
14
  import { HttpPackageLoader, ImportPackageLoader, PackageManagementService, PackagesSubgraph, getUniqueDocumentModels, initializeAndStartAPI } from "@powerhousedao/reactor-api";
15
15
  import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
16
- import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRemoteAttachmentService } from "@powerhousedao/reactor-attachments";
16
+ import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget } from "@powerhousedao/reactor-attachments";
17
17
  import { DriveNodeView, NodeProcessor, ReactorDriveClient, createReactorDriveResolvers, reactorDriveDocumentModelModule, reactorDriveSubgraphTypeDefs } from "@powerhousedao/reactor-drive";
18
18
  import { Kysely, PostgresDialect } from "kysely";
19
19
  import net from "node:net";
@@ -64,13 +64,20 @@ async function loadPgDump(major) {
64
64
  }
65
65
  //#endregion
66
66
  //#region src/attachments/auth.ts
67
+ const ANONYMOUS_ACTOR = {
68
+ user: void 0,
69
+ authEnabled: false
70
+ };
67
71
  /**
68
72
  * Wrap a Node-style handler so that, when `authService` is provided and auth is
69
- * enabled, the request must carry a verifiable Bearer token.
73
+ * enabled, the request must carry a verifiable Bearer token. The handler always
74
+ * receives an actor context: the verified bearer user when auth is enabled, or
75
+ * the anonymous context when it is disabled. With `allowAnonymous`, a missing
76
+ * bearer yields an anonymous actor with `authEnabled: true` instead of a 401.
70
77
  */
71
- function requireAuth(authService, handler) {
72
- if (!authService) return handler;
73
- return async (req, res) => {
78
+ function requireAuth(authService, handler, options) {
79
+ if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
80
+ return async (req, res, body) => {
74
81
  let result;
75
82
  try {
76
83
  result = await authService.verifyBearer(req.headers.authorization);
@@ -88,25 +95,32 @@ function requireAuth(authService, handler) {
88
95
  res.end(body);
89
96
  return;
90
97
  }
91
- if (result.auth_enabled && !result.user) {
98
+ if (result.auth_enabled && !result.user && !options?.allowAnonymous) {
92
99
  res.statusCode = 401;
93
100
  res.setHeader("Content-Type", "application/json");
94
101
  res.end(JSON.stringify({ error: "Authentication required" }));
95
102
  return;
96
103
  }
97
- await handler(req, res);
104
+ await handler(req, res, body, {
105
+ user: result.user,
106
+ authEnabled: result.auth_enabled
107
+ });
98
108
  };
99
109
  }
100
110
  //#endregion
101
111
  //#region src/attachments/mount-auth.ts
102
112
  /**
103
113
  * Mount a Node-style attachment route with `requireAuth` applied unconditionally.
104
- * When `api.authService` is undefined (auth disabled), `requireAuth` returns the
105
- * handler unchanged that is the only way to opt out. To register a route
106
- * without auth wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
114
+ * When `api.authService` is undefined (auth disabled), the handler still runs
115
+ * through `requireAuth` and receives the anonymous actor context that is the
116
+ * only way to opt out of bearer verification. To register a route without auth
117
+ * wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
118
+ *
119
+ * `allowAnonymous` is reserved for routes whose handlers make a per-document
120
+ * authorization decision themselves; identity-only routes must not use it.
107
121
  */
108
- function mountAuthenticatedNodeRoute(api, method, path, handler) {
109
- api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler));
122
+ function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
123
+ api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, options));
110
124
  }
111
125
  //#endregion
112
126
  //#region src/attachments/routes.ts
@@ -216,7 +230,8 @@ function makeReserveHandler(attachments) {
216
230
  sendJson(res, 201, {
217
231
  reservationId: upload.reservationId,
218
232
  ref: upload.ref,
219
- expiresAtUtc: upload.expiresAtUtc
233
+ expiresAtUtc: upload.expiresAtUtc,
234
+ ...upload.uploadTarget ? { uploadTarget: upload.uploadTarget } : {}
220
235
  });
221
236
  };
222
237
  }
@@ -227,6 +242,10 @@ function makeUploadHandler(attachments) {
227
242
  sendError(res, 400, "Missing reservationId");
228
243
  return;
229
244
  }
245
+ if (attachments.backend?.kind === "s3") {
246
+ sendError(res, 405, "Use the reservation uploadTarget for S3 uploads");
247
+ return;
248
+ }
230
249
  let reservation;
231
250
  try {
232
251
  reservation = await attachments.reservations.get(reservationId);
@@ -373,6 +392,144 @@ function makeDeleteReservationHandler(attachments) {
373
392
  function extractParam(req, name) {
374
393
  return req.params?.[name];
375
394
  }
395
+ const MAX_DOCUMENT_ID_LEN = 512;
396
+ const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
397
+ const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 10080 * 60;
398
+ /**
399
+ * Returns the single `documentId` query value, or null when it is missing,
400
+ * duplicated, blank, or oversized. Validation happens before authorization so
401
+ * malformed requests never reach the access service.
402
+ */
403
+ function extractSingleDocumentId(req) {
404
+ if (!req.url) return null;
405
+ let url;
406
+ try {
407
+ url = new URL(req.url, "http://switchboard.invalid");
408
+ } catch {
409
+ return null;
410
+ }
411
+ const values = url.searchParams.getAll("documentId");
412
+ if (values.length !== 1) return null;
413
+ const value = values[0];
414
+ if (value.trim().length === 0 || value.length > MAX_DOCUMENT_ID_LEN) return null;
415
+ return value;
416
+ }
417
+ /**
418
+ * Returns the requested target TTL in seconds: undefined when absent,
419
+ * "invalid" when malformed (duplicated, non-integer, or non-positive), and
420
+ * otherwise the value clamped to the presigning ceiling.
421
+ */
422
+ function extractExpiresIn(req) {
423
+ if (!req.url) return void 0;
424
+ let url;
425
+ try {
426
+ url = new URL(req.url, "http://switchboard.invalid");
427
+ } catch {
428
+ return;
429
+ }
430
+ const values = url.searchParams.getAll("expiresIn");
431
+ if (values.length === 0) return void 0;
432
+ if (values.length > 1) return "invalid";
433
+ const parsed = Number(values[0]);
434
+ if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
435
+ return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
436
+ }
437
+ /**
438
+ * Base URL of this Switchboard as seen by the caller, used to build
439
+ * filesystem `switchboard` download targets that point back at the existing
440
+ * authenticated byte route.
441
+ */
442
+ function requestBaseUrl(req) {
443
+ const forwardedProto = req.headers["x-forwarded-proto"];
444
+ const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0]?.trim() || (req.socket.encrypted ? "https" : "http");
445
+ const forwardedHost = req.headers["x-forwarded-host"];
446
+ const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? req.headers.host;
447
+ if (!host) return null;
448
+ return `${proto}://${host}`;
449
+ }
450
+ function makeDownloadTargetHandler(attachments, attachmentAccess) {
451
+ return async (req, res, _body, actor) => {
452
+ res.setHeader("Cache-Control", "no-store");
453
+ const hash = extractParam(req, "hash");
454
+ if (!hash || !HASH_PATTERN.test(hash)) {
455
+ sendError(res, 400, "Invalid attachment hash");
456
+ return;
457
+ }
458
+ const documentId = extractSingleDocumentId(req);
459
+ if (documentId === null) {
460
+ sendError(res, 400, "documentId is required exactly once as a non-empty query parameter");
461
+ return;
462
+ }
463
+ const expiresIn = extractExpiresIn(req);
464
+ if (expiresIn === "invalid") {
465
+ sendError(res, 400, "expiresIn must be a single positive integer number of seconds");
466
+ return;
467
+ }
468
+ const canonicalHash = hash.toLowerCase();
469
+ let decision;
470
+ try {
471
+ decision = await attachmentAccess.canReadAttachment({
472
+ documentId,
473
+ attachmentRef: createRef(canonicalHash),
474
+ userAddress: actor?.user?.address
475
+ });
476
+ } catch (err) {
477
+ logger$1.error("Attachment access decision failed: @error", err);
478
+ sendError(res, 500, "Internal error");
479
+ return;
480
+ }
481
+ if (decision.kind === "projection-unavailable") {
482
+ sendError(res, 503, "Attachment downloads are temporarily unavailable");
483
+ return;
484
+ }
485
+ if (decision.kind === "denied") {
486
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
487
+ return;
488
+ }
489
+ let header;
490
+ try {
491
+ header = await attachments.store.stat(canonicalHash);
492
+ } catch (err) {
493
+ if (err instanceof AttachmentNotFound) {
494
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
495
+ return;
496
+ }
497
+ logger$1.error("Attachment metadata lookup failed: @error", err);
498
+ sendError(res, 500, "Internal error");
499
+ return;
500
+ }
501
+ if (header.status !== "available") {
502
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
503
+ return;
504
+ }
505
+ let target;
506
+ if (attachments.backend && attachments.backend.kind !== "filesystem") try {
507
+ target = await attachments.backend.prepareDownloadTarget(canonicalHash, expiresIn);
508
+ } catch {
509
+ sendError(res, 502, "Attachment download target unavailable");
510
+ return;
511
+ }
512
+ else {
513
+ const base = requestBaseUrl(req);
514
+ if (!base) {
515
+ sendError(res, 500, "Internal error");
516
+ return;
517
+ }
518
+ try {
519
+ target = parseAttachmentDownloadTarget({
520
+ kind: "switchboard",
521
+ method: "GET",
522
+ url: `${base}/attachments/${canonicalHash}`,
523
+ headers: {}
524
+ });
525
+ } catch {
526
+ sendError(res, 500, "Internal error");
527
+ return;
528
+ }
529
+ }
530
+ sendJson(res, 200, target);
531
+ };
532
+ }
376
533
  //#endregion
377
534
  //#region src/attachments/index.ts
378
535
  function registerAttachmentRoutes(api) {
@@ -382,9 +539,50 @@ function registerAttachmentRoutes(api) {
382
539
  mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
383
540
  mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
384
541
  mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments));
542
+ mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, api.attachmentAccess), { allowAnonymous: true });
385
543
  mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments));
386
544
  }
387
545
  //#endregion
546
+ //#region src/attachment-reference-read-model.mts
547
+ const attachmentSchemaCompiler = new AttachmentSchemaCompiler();
548
+ function createAttachmentReferenceReadModel(baseKysely, dependencies, attachmentReferenceWriter) {
549
+ return new AttachmentReferenceReadModel(baseKysely.withSchema(REACTOR_SCHEMA), dependencies.operationIndex, dependencies.writeCache, dependencies.processorManagerConsistencyTracker, dependencies.documentModelRegistry, attachmentSchemaCompiler, attachmentReferenceWriter);
550
+ }
551
+ function registerAttachmentReferenceReadModel(reactorBuilder, registration) {
552
+ reactorBuilder.withReadModelFactory(async ({ documentModelRegistry, operationIndex, writeCache, processorManagerConsistencyTracker }) => {
553
+ const readModel = createAttachmentReferenceReadModel(registration.baseKysely, {
554
+ operationIndex,
555
+ writeCache,
556
+ processorManagerConsistencyTracker,
557
+ documentModelRegistry
558
+ }, registration.attachmentReferenceWriter);
559
+ await readModel.init();
560
+ return readModel;
561
+ });
562
+ }
563
+ async function registerAttachmentReferenceReadModelOnModule(clientModule, attachmentReferenceWriter) {
564
+ const reactorModule = clientModule.reactorModule;
565
+ if (!reactorModule) return {
566
+ status: "unavailable",
567
+ reason: "in-process-reactor-module-unavailable"
568
+ };
569
+ const coordinator = reactorModule.readModelCoordinator;
570
+ if (!supportsLiveReadModelRegistration(coordinator)) return {
571
+ status: "unavailable",
572
+ reason: "live-read-model-registration-unsupported"
573
+ };
574
+ const readModel = createAttachmentReferenceReadModel(reactorModule.database, {
575
+ operationIndex: reactorModule.operationIndex,
576
+ writeCache: reactorModule.writeCache,
577
+ processorManagerConsistencyTracker: reactorModule.processorManagerConsistencyTracker,
578
+ documentModelRegistry: reactorModule.documentModelRegistry
579
+ }, attachmentReferenceWriter);
580
+ await readModel.init();
581
+ coordinator.addReadModel(readModel, "pre_ready");
582
+ await readModel.init();
583
+ return { status: "available" };
584
+ }
585
+ //#endregion
388
586
  //#region src/builder-defaults.mts
389
587
  /**
390
588
  * Apply switchboard's standard configuration to a reactor + client builder
@@ -911,13 +1109,17 @@ async function initServer(serverPort, options, renown) {
911
1109
  });
912
1110
  }
913
1111
  const reactorLogger = logger.child(["reactor"]);
914
- const initializeClient = async (documentModels) => {
1112
+ const initializeClient = async (documentModels, { attachmentReferenceWriter }) => {
915
1113
  if (options.reactor) {
1114
+ const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
916
1115
  if (options.reactor.reactorModule) {
917
1116
  new ReactorInstrumentation(options.reactor.reactorModule).start();
918
1117
  reactorLogger.info("Reactor metrics instrumentation started (using caller-provided reactor)");
919
1118
  }
920
- return { module: options.reactor };
1119
+ return {
1120
+ module: options.reactor,
1121
+ attachmentReferenceProjection
1122
+ };
921
1123
  }
922
1124
  const baseKysely = await createReactorKysely({
923
1125
  reactorDbUrl,
@@ -955,6 +1157,10 @@ async function initServer(serverPort, options, renown) {
955
1157
  await nodeProcessor.init();
956
1158
  return nodeProcessor;
957
1159
  });
1160
+ registerAttachmentReferenceReadModel(reactorBuilder, {
1161
+ baseKysely,
1162
+ attachmentReferenceWriter
1163
+ });
958
1164
  reactorBuilder.withShutdownHook(async () => {
959
1165
  if (apiRef.current) await apiRef.current.dispose();
960
1166
  });
@@ -969,7 +1175,8 @@ async function initServer(serverPort, options, renown) {
969
1175
  reactorDriveClient: new ReactorDriveClient({
970
1176
  reactor: module.client,
971
1177
  readModel: driveNodeView
972
- })
1178
+ }),
1179
+ attachmentReferenceProjection: { status: "available" }
973
1180
  };
974
1181
  };
975
1182
  let defaultDriveUrl = void 0;
@@ -1100,6 +1307,7 @@ async function initServer(serverPort, options, renown) {
1100
1307
  api,
1101
1308
  reactor: client,
1102
1309
  attachmentService,
1310
+ attachmentReferenceProjection: api.attachmentReferenceProjection,
1103
1311
  renown,
1104
1312
  port: serverPort,
1105
1313
  shutdown: () => api.dispose()
@@ -1157,5 +1365,5 @@ if (import.meta.main) await startSwitchboard();
1157
1365
  //#endregion
1158
1366
  export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
1159
1367
 
1160
- //# sourceMappingURL=server-Cgud_ONM.mjs.map
1161
- //# debugId=5eb0a978-689a-51dd-978a-cd3d7fe5df4b
1368
+ //# sourceMappingURL=server-ChK0GLG8.mjs.map
1369
+ //# debugId=b1867ed0-059f-587c-9a5f-677da3666de7