@powerhousedao/reactor-api 6.0.0-dev.216 → 6.0.0-dev.218

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/dist/index.mjs CHANGED
@@ -928,7 +928,7 @@ function parseBodyLimit(limit) {
928
928
  var FastifyHttpAdapter = class {
929
929
  #fetchRoutes = [];
930
930
  #getRoutes = /* @__PURE__ */ new Map();
931
- #nodeRoutes = /* @__PURE__ */ new Map();
931
+ #nodeRoutes = [];
932
932
  #setupOps = [];
933
933
  #instance;
934
934
  get handle() {
@@ -955,8 +955,11 @@ var FastifyHttpAdapter = class {
955
955
  });
956
956
  }
957
957
  mountNodeRoute(method, path, handler) {
958
- if (!this.#nodeRoutes.has(path)) this.#nodeRoutes.set(path, /* @__PURE__ */ new Map());
959
- this.#nodeRoutes.get(path).set(method, handler);
958
+ this.#nodeRoutes.push({
959
+ method,
960
+ matcher: match(normalizePath(path)),
961
+ handler
962
+ });
960
963
  }
961
964
  mountRawMiddleware(middleware) {
962
965
  if (this.#instance) this.#instance.use(middleware);
@@ -1026,14 +1029,14 @@ var FastifyHttpAdapter = class {
1026
1029
  #dispatch(req, reply) {
1027
1030
  const pathname = new URL(req.url, "http://localhost").pathname;
1028
1031
  const method = req.method.toUpperCase();
1029
- const nodeHandlers = this.#nodeRoutes.get(pathname);
1030
- if (nodeHandlers) {
1031
- const handler = nodeHandlers.get(method);
1032
- if (handler) {
1033
- reply.hijack();
1034
- handler(req.raw, reply.raw, req.body);
1035
- return;
1036
- }
1032
+ for (const entry of this.#nodeRoutes) {
1033
+ if (entry.method !== method) continue;
1034
+ const result = entry.matcher(pathname);
1035
+ if (!result) continue;
1036
+ req.raw.params = result.params;
1037
+ reply.hijack();
1038
+ entry.handler(req.raw, reply.raw, req.body);
1039
+ return;
1037
1040
  }
1038
1041
  if (method === "GET") {
1039
1042
  for (const entry of this.#getRoutes.values()) if (entry.matcher(pathname)) return this.#serveGetEntry(entry, req, reply);
@@ -2721,8 +2724,15 @@ function matchesJobFilter(payload, args) {
2721
2724
  return payload.jobId === args.jobId;
2722
2725
  }
2723
2726
  //#endregion
2724
- //#region src/graphql/reactor/resolvers.ts
2727
+ //#region src/graphql/reactor/constants.ts
2728
+ /**
2729
+ * Document-type sentinel for drive documents. Drives are the unit
2730
+ * the LB shards on (via the `Drive-Id` request header) and the unit
2731
+ * the drive-ownership cache tracks on each switchboard instance.
2732
+ */
2725
2733
  const DRIVE_DOCUMENT_TYPE = "powerhouse/document-drive";
2734
+ //#endregion
2735
+ //#region src/graphql/reactor/resolvers.ts
2726
2736
  const POLL_SYNC_ENVELOPES_MAX_LIMIT = 100;
2727
2737
  async function documentModels(reactorClient, args) {
2728
2738
  const namespace = fromInputMaybe(args.namespace);
@@ -2919,7 +2929,7 @@ async function createDocument(reactorClient, args) {
2919
2929
  const parentIdentifier = fromInputMaybe(args.parentIdentifier);
2920
2930
  let result;
2921
2931
  try {
2922
- if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === DRIVE_DOCUMENT_TYPE) result = await reactorClient.drives.addFile(parentIdentifier, document);
2932
+ if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === "powerhouse/document-drive") result = await reactorClient.drives.addFile(parentIdentifier, document);
2923
2933
  else result = await reactorClient.create(document, parentIdentifier);
2924
2934
  else result = await reactorClient.create(document);
2925
2935
  } catch (error) {
@@ -2936,7 +2946,7 @@ async function createEmptyDocument(reactorClient, args) {
2936
2946
  const name = fromInputMaybe(args.name);
2937
2947
  let result;
2938
2948
  try {
2939
- if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === DRIVE_DOCUMENT_TYPE) {
2949
+ if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === "powerhouse/document-drive") {
2940
2950
  const document = (await reactorClient.getDocumentModelModule(args.documentType)).utils.createDocument();
2941
2951
  if (name) document.header.name = name;
2942
2952
  result = await reactorClient.drives.addFile(parentIdentifier, document);
@@ -2983,7 +2993,7 @@ async function createDocumentWithInitialState(reactorClient, args) {
2983
2993
  } catch (error) {
2984
2994
  throw new GraphQLError(`Parent document not found: ${error instanceof Error ? error.message : "Unknown error"}`);
2985
2995
  }
2986
- if (parent.header.documentType === DRIVE_DOCUMENT_TYPE) try {
2996
+ if (parent.header.documentType === "powerhouse/document-drive") try {
2987
2997
  result = await reactorClient.drives.addFile(parentIdentifier, document);
2988
2998
  } catch (error) {
2989
2999
  throw new GraphQLError(`Failed to create document in drive: ${error instanceof Error ? error.message : "Unknown error"}`);
@@ -3478,6 +3488,110 @@ var DocumentModelSubgraph = class extends BaseSubgraph {
3478
3488
  }
3479
3489
  };
3480
3490
  //#endregion
3491
+ //#region src/graphql/gateway/drive-middleware.ts
3492
+ const DRIVE_ID_HEADER = "drive-id";
3493
+ /**
3494
+ * Operations that legitimately run before the target drive exists in the
3495
+ * cache. Drive creation is the obvious case (a brand-new drive cannot be
3496
+ * in the ownership set yet); other create-shaped operations that may
3497
+ * synthesize a drive must be added here too.
3498
+ */
3499
+ const CACHE_BYPASS_OPERATIONS = new Set(["createDocument", "createEmptyDocument"]);
3500
+ const driveIdMap = /* @__PURE__ */ new WeakMap();
3501
+ /** Internal — only `graphql-manager.ts` should call this. */
3502
+ function getRequestDriveId(request) {
3503
+ return driveIdMap.get(request);
3504
+ }
3505
+ /**
3506
+ * Returns a fetch middleware that validates the `Drive-Id` header against
3507
+ * the in-memory ownership cache. Layout:
3508
+ *
3509
+ * - No header → pass through. The LB has already round-robined; nothing
3510
+ * to validate here.
3511
+ * - Header present and drive in cache → record on the request map (for
3512
+ * the context factory to read into `context.driveId`) and pass through.
3513
+ * - Header present, drive missing, but the operation is `createDocument`
3514
+ * or `createEmptyDocument` → pass through. The drive may be in the
3515
+ * process of being created.
3516
+ * - Otherwise → return `421 Misdirected Request` with a structured body.
3517
+ * The client (or LB) can surface this as a wrong-shard signal.
3518
+ */
3519
+ function createDriveFetchMiddleware(cache) {
3520
+ return (next) => async (request) => {
3521
+ const driveId = request.headers.get(DRIVE_ID_HEADER) ?? "";
3522
+ if (driveId === "") return next(request);
3523
+ if (cache.has(driveId)) {
3524
+ driveIdMap.set(request, driveId);
3525
+ return next(request);
3526
+ }
3527
+ if (await isCacheBypassOperation(request)) return next(request);
3528
+ return wrongShardResponse(driveId);
3529
+ };
3530
+ }
3531
+ async function isCacheBypassOperation(request) {
3532
+ if (request.method !== "POST") return false;
3533
+ try {
3534
+ const body = await request.clone().json();
3535
+ if (typeof body.operationName === "string") return CACHE_BYPASS_OPERATIONS.has(body.operationName);
3536
+ if (typeof body.query === "string") return CACHE_BYPASS_OPERATIONS.has(extractOperationName(body.query));
3537
+ return false;
3538
+ } catch {
3539
+ return false;
3540
+ }
3541
+ }
3542
+ const OPERATION_NAME_PATTERN = /\b(?:mutation|query|subscription)\s+(\w+)/;
3543
+ function extractOperationName(query) {
3544
+ const match = OPERATION_NAME_PATTERN.exec(query);
3545
+ return match ? match[1] : "";
3546
+ }
3547
+ function wrongShardResponse(driveId) {
3548
+ return new globalThis.Response(JSON.stringify({
3549
+ error: "wrong-shard",
3550
+ driveId
3551
+ }), {
3552
+ status: 421,
3553
+ headers: { "content-type": "application/json" }
3554
+ });
3555
+ }
3556
+ //#endregion
3557
+ //#region src/graphql/gateway/drive-ownership-cache.ts
3558
+ /**
3559
+ * In-memory record of which drives this switchboard instance owns.
3560
+ *
3561
+ * Populated at startup by walking the reactor for documents of type
3562
+ * `powerhouse/document-drive`. Mutated explicitly by resolver hooks
3563
+ * after successful drive create / delete operations. Read by the
3564
+ * drive-validation fetch middleware to short-circuit wrong-shard
3565
+ * requests with a structured 421 response.
3566
+ */
3567
+ var DriveOwnershipCache = class {
3568
+ drives = /* @__PURE__ */ new Set();
3569
+ constructor(reactorClient) {
3570
+ this.reactorClient = reactorClient;
3571
+ }
3572
+ async init() {
3573
+ this.drives.clear();
3574
+ let page = await this.reactorClient.find({ type: DRIVE_DOCUMENT_TYPE });
3575
+ while (true) {
3576
+ for (const drive of page.results) this.drives.add(drive.header.id);
3577
+ if (!page.next) return;
3578
+ page = await page.next();
3579
+ }
3580
+ }
3581
+ has(driveId) {
3582
+ return this.drives.has(driveId);
3583
+ }
3584
+ add(driveId) {
3585
+ this.drives.add(driveId);
3586
+ }
3587
+ remove(driveId) {
3588
+ this.drives.delete(driveId);
3589
+ }
3590
+ size() {
3591
+ return this.drives.size;
3592
+ }
3593
+ };
3594
+ //#endregion
3481
3595
  //#region src/graphql/sse.ts
3482
3596
  /**
3483
3597
  * Create a Fetch-API-compatible SSE handler for GraphQL subscriptions
@@ -3541,6 +3655,8 @@ var GraphQLManager = class {
3541
3655
  authService = null;
3542
3656
  subgraphWsDisposers = /* @__PURE__ */ new Map();
3543
3657
  #authMiddleware;
3658
+ #driveMiddleware;
3659
+ driveOwnershipCache;
3544
3660
  /** Cached document models for schema generation - updated on init and regenerate */
3545
3661
  cachedDocumentModels = [];
3546
3662
  subgraphHandlerCache = /* @__PURE__ */ new Map();
@@ -3561,11 +3677,15 @@ var GraphQLManager = class {
3561
3677
  this.port = port;
3562
3678
  this.authorizationService = authorizationService;
3563
3679
  if (this.authConfig) this.authService = new AuthService(this.authConfig);
3680
+ this.driveOwnershipCache = new DriveOwnershipCache(this.reactorClient);
3564
3681
  this.wsServer.setMaxListeners(0);
3565
3682
  }
3566
3683
  async init(coreSubgraphs, authMiddleware) {
3567
3684
  this.#authMiddleware = authMiddleware;
3568
3685
  this.logger.debug(`Initializing Subgraph Manager...`);
3686
+ await this.driveOwnershipCache.init();
3687
+ this.#driveMiddleware = createDriveFetchMiddleware(this.driveOwnershipCache);
3688
+ this.logger.debug(`Drive ownership cache populated with ${this.driveOwnershipCache.size()} drives`);
3569
3689
  const models = (await this.reactorClient.getDocumentModelModules()).results;
3570
3690
  this.cachedDocumentModels = models;
3571
3691
  if (!models.find((it) => it.documentModel.global.name === "DocumentDrive")) throw new Error("DocumentDrive model required");
@@ -3580,12 +3700,12 @@ var GraphQLManager = class {
3580
3700
  if (!driveIdOrSlug) return Response.json({ error: "Drive ID or slug is required" }, { status: 400 });
3581
3701
  try {
3582
3702
  const driveDoc = await this.reactorClient.get(driveIdOrSlug);
3583
- const graphqlEndpoint = `${(request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "")) + ":"}//${request.headers.get("host") ?? ""}${this.path === "/" ? "" : this.path}/graphql/r`;
3703
+ const graphqlEndpoint = `${(request.headers.get("x-forwarded-proto")?.split(",")[0].trim() ?? url.protocol.replace(":", "")) + ":"}//${request.headers.get("x-forwarded-host")?.split(",")[0].trim() ?? request.headers.get("host") ?? ""}${this.path === "/" ? "" : this.path}/graphql/r`;
3584
3704
  return Response.json({
3585
3705
  id: driveDoc.header.id,
3586
3706
  slug: driveDoc.header.slug,
3587
3707
  meta: driveDoc.header.meta,
3588
- name: driveDoc.state.global.name,
3708
+ name: driveDoc.state.global.name || driveDoc.header.name,
3589
3709
  icon: driveDoc.state.global.icon ?? void 0,
3590
3710
  ...graphqlEndpoint && { graphqlEndpoint }
3591
3711
  });
@@ -3725,6 +3845,7 @@ var GraphQLManager = class {
3725
3845
  #makeContextFactory() {
3726
3846
  return (request) => {
3727
3847
  const authCtx = getAuthContext(request);
3848
+ const driveId = getRequestDriveId(request);
3728
3849
  const headers = {};
3729
3850
  request.headers.forEach((v, k) => {
3730
3851
  headers[k] = v;
@@ -3733,6 +3854,7 @@ var GraphQLManager = class {
3733
3854
  headers,
3734
3855
  db: this.relationalDb,
3735
3856
  ...this.getAdditionalContextFields(),
3857
+ driveId,
3736
3858
  user: authCtx?.user,
3737
3859
  isAdmin: authCtx ? (addr) => !authCtx.auth_enabled ? true : authCtx.admins.includes(addr.toLowerCase()) : () => true
3738
3860
  });
@@ -3771,7 +3893,7 @@ var GraphQLManager = class {
3771
3893
  if (this.subgraphHandlerCache.has(subgraphPath)) continue;
3772
3894
  const schema = createSchema(this.cachedDocumentModels, subgraph.resolvers, subgraph.typeDefs);
3773
3895
  const rawHandler = await this.gatewayAdapter.createHandler(schema, this.#makeContextFactory());
3774
- const fetchHandler = this.#authMiddleware ? this.#authMiddleware(rawHandler) : rawHandler;
3896
+ const fetchHandler = this.#composeFetchMiddleware(rawHandler);
3775
3897
  this.subgraphHandlerCache.set(subgraphPath, fetchHandler);
3776
3898
  this.httpAdapter.mount(subgraphPath, fetchHandler);
3777
3899
  if (subgraph.hasSubscriptions) {
@@ -3821,7 +3943,7 @@ var GraphQLManager = class {
3821
3943
  async #createSupergraphGateway() {
3822
3944
  const superGraphPath = path.join(this.path, "graphql");
3823
3945
  const rawHandler = await this.gatewayAdapter.createSupergraphHandler(() => this.#getSubgraphDefinitions(), this.httpServer, this.#makeContextFactory());
3824
- const fetchHandler = this.#authMiddleware ? this.#authMiddleware(rawHandler) : rawHandler;
3946
+ const fetchHandler = this.#composeFetchMiddleware(rawHandler);
3825
3947
  this.httpAdapter.mount(superGraphPath, fetchHandler);
3826
3948
  this.#setupSupergraphSSE(superGraphPath);
3827
3949
  if (!this.initialized) {
@@ -3861,9 +3983,20 @@ var GraphQLManager = class {
3861
3983
  schema,
3862
3984
  contextFactory: this.#makeContextFactory()
3863
3985
  });
3864
- const handler = this.#authMiddleware ? this.#authMiddleware(rawHandler) : rawHandler;
3986
+ const handler = this.#composeFetchMiddleware(rawHandler);
3865
3987
  this.httpAdapter.mount(ssePath, handler, { exact: true });
3866
3988
  }
3989
+ /**
3990
+ * Compose the request-level fetch middleware chain. Auth runs first
3991
+ * (so we don't validate shard before knowing the request is even
3992
+ * authorized), drive-ownership validation runs after.
3993
+ */
3994
+ #composeFetchMiddleware(rawHandler) {
3995
+ let handler = rawHandler;
3996
+ if (this.#driveMiddleware) handler = this.#driveMiddleware(handler);
3997
+ if (this.#authMiddleware) handler = this.#authMiddleware(handler);
3998
+ return handler;
3999
+ }
3867
4000
  };
3868
4001
  //#endregion
3869
4002
  //#region src/graphql/packages/resolvers.ts
@@ -4379,6 +4512,21 @@ var ReactorSubgraph = class extends BaseSubgraph {
4379
4512
  await this.assertCanExecuteOperation(documentId, operationType, ctx);
4380
4513
  }
4381
4514
  }
4515
+ /**
4516
+ * Returns the drive id when the given identifier (id or slug) refers
4517
+ * to a drive document, otherwise undefined. Used by deleteDocument to
4518
+ * decide whether to invalidate the drive-ownership cache after a
4519
+ * successful delete.
4520
+ */
4521
+ async #resolveDriveId(identifier) {
4522
+ try {
4523
+ const doc = await this.reactorClient.get(identifier);
4524
+ if (doc.header.documentType === "powerhouse/document-drive") return doc.header.id;
4525
+ return;
4526
+ } catch {
4527
+ return;
4528
+ }
4529
+ }
4382
4530
  typeDefs = gql(schema_default);
4383
4531
  resolvers = {
4384
4532
  PHDocument: { operations: async (parent, args, ctx) => {
@@ -4519,6 +4667,7 @@ var ReactorSubgraph = class extends BaseSubgraph {
4519
4667
  if (!ctx.user?.address) throw new GraphQLError("Forbidden: authentication required to create documents");
4520
4668
  } else if (!this.hasGlobalAdminAccess(ctx)) throw new GraphQLError("Forbidden: insufficient permissions to create documents");
4521
4669
  const result = await createDocument(this.reactorClient, args);
4670
+ if (result?.id && result.documentType === "powerhouse/document-drive") this.graphqlManager.driveOwnershipCache.add(result.id);
4522
4671
  if (this.authorizationService && ctx.user?.address && result?.id) await this.documentPermissionService?.initializeDocumentProtection(result.id, ctx.user.address, this.authorizationService.config.defaultProtection);
4523
4672
  return result;
4524
4673
  } catch (error) {
@@ -4536,6 +4685,7 @@ var ReactorSubgraph = class extends BaseSubgraph {
4536
4685
  if (!ctx.user?.address) throw new GraphQLError("Forbidden: authentication required to create documents");
4537
4686
  } else if (!this.hasGlobalAdminAccess(ctx)) throw new GraphQLError("Forbidden: insufficient permissions to create documents");
4538
4687
  const result = await createEmptyDocument(this.reactorClient, args);
4688
+ if (result?.id && result.documentType === "powerhouse/document-drive") this.graphqlManager.driveOwnershipCache.add(result.id);
4539
4689
  if (this.authorizationService && ctx.user?.address && result?.id) await this.documentPermissionService?.initializeDocumentProtection(result.id, ctx.user.address, this.authorizationService.config.defaultProtection);
4540
4690
  return result;
4541
4691
  } catch (error) {
@@ -4610,7 +4760,10 @@ var ReactorSubgraph = class extends BaseSubgraph {
4610
4760
  this.logger.debug("deleteDocument(@args)", args);
4611
4761
  try {
4612
4762
  await this.assertCanWrite(args.identifier, ctx);
4613
- return await deleteDocument(this.reactorClient, args);
4763
+ const driveIdToInvalidate = await this.#resolveDriveId(args.identifier);
4764
+ const result = await deleteDocument(this.reactorClient, args);
4765
+ if (result && driveIdToInvalidate) this.graphqlManager.driveOwnershipCache.remove(driveIdToInvalidate);
4766
+ return result;
4614
4767
  } catch (error) {
4615
4768
  this.logger.error("Error in deleteDocument(@args): @Error", error);
4616
4769
  throw error;