@powerhousedao/reactor-api 6.0.0-dev.215 → 6.0.0-dev.217

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);
@@ -1234,6 +1237,118 @@ var AuthService = class {
1234
1237
  }
1235
1238
  };
1236
1239
  //#endregion
1240
+ //#region src/graphql/reactor/constants.ts
1241
+ /**
1242
+ * Document-type sentinel for drive documents. Drives are the unit
1243
+ * the LB shards on (via the `Drive-Id` request header) and the unit
1244
+ * the drive-ownership cache tracks on each switchboard instance.
1245
+ */
1246
+ const DRIVE_DOCUMENT_TYPE = "powerhouse/document-drive";
1247
+ //#endregion
1248
+ //#region src/graphql/gateway/drive-ownership-cache.ts
1249
+ /**
1250
+ * In-memory record of which drives this switchboard instance owns.
1251
+ *
1252
+ * Populated at startup by walking the reactor for documents of type
1253
+ * `powerhouse/document-drive`. Mutated explicitly by resolver hooks
1254
+ * after successful drive create / delete operations. Read by the
1255
+ * drive-validation fetch middleware to short-circuit wrong-shard
1256
+ * requests with a structured 421 response.
1257
+ */
1258
+ var DriveOwnershipCache = class {
1259
+ drives = /* @__PURE__ */ new Set();
1260
+ constructor(reactorClient) {
1261
+ this.reactorClient = reactorClient;
1262
+ }
1263
+ async init() {
1264
+ this.drives.clear();
1265
+ let page = await this.reactorClient.find({ type: DRIVE_DOCUMENT_TYPE });
1266
+ while (true) {
1267
+ for (const drive of page.results) this.drives.add(drive.header.id);
1268
+ if (!page.next) return;
1269
+ page = await page.next();
1270
+ }
1271
+ }
1272
+ has(driveId) {
1273
+ return this.drives.has(driveId);
1274
+ }
1275
+ add(driveId) {
1276
+ this.drives.add(driveId);
1277
+ }
1278
+ remove(driveId) {
1279
+ this.drives.delete(driveId);
1280
+ }
1281
+ size() {
1282
+ return this.drives.size;
1283
+ }
1284
+ };
1285
+ //#endregion
1286
+ //#region src/graphql/gateway/drive-middleware.ts
1287
+ const DRIVE_ID_HEADER = "drive-id";
1288
+ /**
1289
+ * Operations that legitimately run before the target drive exists in the
1290
+ * cache. Drive creation is the obvious case (a brand-new drive cannot be
1291
+ * in the ownership set yet); other create-shaped operations that may
1292
+ * synthesize a drive must be added here too.
1293
+ */
1294
+ const CACHE_BYPASS_OPERATIONS = new Set(["createDocument", "createEmptyDocument"]);
1295
+ const driveIdMap = /* @__PURE__ */ new WeakMap();
1296
+ /** Internal — only `graphql-manager.ts` should call this. */
1297
+ function getRequestDriveId(request) {
1298
+ return driveIdMap.get(request);
1299
+ }
1300
+ /**
1301
+ * Returns a fetch middleware that validates the `Drive-Id` header against
1302
+ * the in-memory ownership cache. Layout:
1303
+ *
1304
+ * - No header → pass through. The LB has already round-robined; nothing
1305
+ * to validate here.
1306
+ * - Header present and drive in cache → record on the request map (for
1307
+ * the context factory to read into `context.driveId`) and pass through.
1308
+ * - Header present, drive missing, but the operation is `createDocument`
1309
+ * or `createEmptyDocument` → pass through. The drive may be in the
1310
+ * process of being created.
1311
+ * - Otherwise → return `421 Misdirected Request` with a structured body.
1312
+ * The client (or LB) can surface this as a wrong-shard signal.
1313
+ */
1314
+ function createDriveFetchMiddleware(cache) {
1315
+ return (next) => async (request) => {
1316
+ const driveId = request.headers.get(DRIVE_ID_HEADER) ?? "";
1317
+ if (driveId === "") return next(request);
1318
+ if (cache.has(driveId)) {
1319
+ driveIdMap.set(request, driveId);
1320
+ return next(request);
1321
+ }
1322
+ if (await isCacheBypassOperation(request)) return next(request);
1323
+ return wrongShardResponse(driveId);
1324
+ };
1325
+ }
1326
+ async function isCacheBypassOperation(request) {
1327
+ if (request.method !== "POST") return false;
1328
+ try {
1329
+ const body = await request.clone().json();
1330
+ if (typeof body.operationName === "string") return CACHE_BYPASS_OPERATIONS.has(body.operationName);
1331
+ if (typeof body.query === "string") return CACHE_BYPASS_OPERATIONS.has(extractOperationName(body.query));
1332
+ return false;
1333
+ } catch {
1334
+ return false;
1335
+ }
1336
+ }
1337
+ const OPERATION_NAME_PATTERN = /\b(?:mutation|query|subscription)\s+(\w+)/;
1338
+ function extractOperationName(query) {
1339
+ const match = OPERATION_NAME_PATTERN.exec(query);
1340
+ return match ? match[1] : "";
1341
+ }
1342
+ function wrongShardResponse(driveId) {
1343
+ return new globalThis.Response(JSON.stringify({
1344
+ error: "wrong-shard",
1345
+ driveId
1346
+ }), {
1347
+ status: 421,
1348
+ headers: { "content-type": "application/json" }
1349
+ });
1350
+ }
1351
+ //#endregion
1237
1352
  //#region src/utils/create-schema.ts
1238
1353
  const logger = childLogger(["reactor-api", "create-schema"]);
1239
1354
  /**
@@ -2722,7 +2837,6 @@ function matchesJobFilter(payload, args) {
2722
2837
  }
2723
2838
  //#endregion
2724
2839
  //#region src/graphql/reactor/resolvers.ts
2725
- const DRIVE_DOCUMENT_TYPE = "powerhouse/document-drive";
2726
2840
  const POLL_SYNC_ENVELOPES_MAX_LIMIT = 100;
2727
2841
  async function documentModels(reactorClient, args) {
2728
2842
  const namespace = fromInputMaybe(args.namespace);
@@ -2919,7 +3033,7 @@ async function createDocument(reactorClient, args) {
2919
3033
  const parentIdentifier = fromInputMaybe(args.parentIdentifier);
2920
3034
  let result;
2921
3035
  try {
2922
- if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === DRIVE_DOCUMENT_TYPE) result = await reactorClient.drives.addFile(parentIdentifier, document);
3036
+ if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === "powerhouse/document-drive") result = await reactorClient.drives.addFile(parentIdentifier, document);
2923
3037
  else result = await reactorClient.create(document, parentIdentifier);
2924
3038
  else result = await reactorClient.create(document);
2925
3039
  } catch (error) {
@@ -2936,7 +3050,7 @@ async function createEmptyDocument(reactorClient, args) {
2936
3050
  const name = fromInputMaybe(args.name);
2937
3051
  let result;
2938
3052
  try {
2939
- if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === DRIVE_DOCUMENT_TYPE) {
3053
+ if (parentIdentifier) if ((await reactorClient.get(parentIdentifier)).header.documentType === "powerhouse/document-drive") {
2940
3054
  const document = (await reactorClient.getDocumentModelModule(args.documentType)).utils.createDocument();
2941
3055
  if (name) document.header.name = name;
2942
3056
  result = await reactorClient.drives.addFile(parentIdentifier, document);
@@ -2983,7 +3097,7 @@ async function createDocumentWithInitialState(reactorClient, args) {
2983
3097
  } catch (error) {
2984
3098
  throw new GraphQLError(`Parent document not found: ${error instanceof Error ? error.message : "Unknown error"}`);
2985
3099
  }
2986
- if (parent.header.documentType === DRIVE_DOCUMENT_TYPE) try {
3100
+ if (parent.header.documentType === "powerhouse/document-drive") try {
2987
3101
  result = await reactorClient.drives.addFile(parentIdentifier, document);
2988
3102
  } catch (error) {
2989
3103
  throw new GraphQLError(`Failed to create document in drive: ${error instanceof Error ? error.message : "Unknown error"}`);
@@ -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");
@@ -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;