@spfn/core 0.3.0-beta.19 → 0.3.0-beta.20

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/ops/index.js CHANGED
@@ -283,6 +283,7 @@ function createRouterInstance(state) {
283
283
  newPackageRouters.push(...pkgRouter._packageRouters);
284
284
  }
285
285
  }
286
+ assertDistinctSurfaces(newPackageRouters);
286
287
  return createRouterInstance({ ...state, packageRouters: newPackageRouters });
287
288
  },
288
289
  use(middlewares) {
@@ -304,6 +305,21 @@ function assertContractVersion(version) {
304
305
  );
305
306
  }
306
307
  }
308
+ function assertDistinctSurfaces(packageRouters) {
309
+ const namespaces = /* @__PURE__ */ new Set();
310
+ for (const router of packageRouters) {
311
+ const namespace = router._surfaceNamespace;
312
+ if (namespace === void 0) {
313
+ continue;
314
+ }
315
+ if (namespaces.has(namespace)) {
316
+ throw new Error(
317
+ `.packages() mounts two command surfaces on "${namespace}". Both serve ${namespace}/_manifest, so the second one's commands would answer while no manifest lists them. Mount one router per namespace.`
318
+ );
319
+ }
320
+ namespaces.add(namespace);
321
+ }
322
+ }
307
323
  function defineUnmappedRouter(routes) {
308
324
  return createRouterInstance({
309
325
  routes,
@@ -314,15 +330,25 @@ function defineUnmappedRouter(routes) {
314
330
  });
315
331
  }
316
332
 
317
- // src/ops/error.ts
318
- var OpsRouterError = class extends Error {
333
+ // src/surface/error.ts
334
+ var SurfaceError = class extends Error {
319
335
  constructor(message) {
320
336
  super(message);
321
- this.name = "OpsRouterError";
337
+ this.name = "SurfaceError";
322
338
  }
323
339
  };
324
340
 
325
- // src/ops/manifest.ts
341
+ // src/surface/wording.ts
342
+ var SURFACE_WORDING = {
343
+ noun: "surface",
344
+ builder: "`<surface>.route`",
345
+ factory: "defineSurface"
346
+ };
347
+ function capitalized(noun) {
348
+ return noun.charAt(0).toUpperCase() + noun.slice(1);
349
+ }
350
+
351
+ // src/surface/manifest.ts
326
352
  function isRouter(value) {
327
353
  return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
328
354
  }
@@ -346,22 +372,22 @@ function toCommandInput(input) {
346
372
  }
347
373
  return sections;
348
374
  }
349
- function collectOpsCommands(routes) {
375
+ function collectSurfaceCommands(routes, wording = SURFACE_WORDING) {
350
376
  const commands = [];
351
- visit(routes, commands, /* @__PURE__ */ new Map());
377
+ visit(routes, commands, /* @__PURE__ */ new Map(), wording);
352
378
  commands.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
353
379
  return commands;
354
380
  }
355
- function visit(routes, commands, claimed) {
381
+ function visit(routes, commands, claimed, wording) {
356
382
  for (const [name, entry] of Object.entries(routes)) {
357
383
  if (isRouter(entry)) {
358
- visit(entry.routes, commands, claimed);
384
+ visit(entry.routes, commands, claimed, wording);
359
385
  continue;
360
386
  }
361
387
  if (!isRouteDef(entry) || !entry.method || !entry.path) {
362
388
  continue;
363
389
  }
364
- assertUnclaimedName(name, entry.path, claimed);
390
+ assertUnclaimedName(name, entry.path, claimed, wording);
365
391
  commands.push({
366
392
  name,
367
393
  method: entry.method,
@@ -370,43 +396,17 @@ function visit(routes, commands, claimed) {
370
396
  });
371
397
  }
372
398
  }
373
- function assertUnclaimedName(name, path, claimed) {
399
+ function assertUnclaimedName(name, path, claimed, wording) {
374
400
  const existing = claimed.get(name);
375
401
  if (existing !== void 0) {
376
- throw new OpsRouterError(
377
- `Two ops routes are named "${name}" ("${existing}" and "${path}"). Command names are flattened across nested routers, so each must be unique for the CLI to resolve the one an operator asked for.`
402
+ throw new SurfaceError(
403
+ `Two ${wording.noun} routes are named "${name}" ("${existing}" and "${path}"). Command names are flattened across nested routers, so each must be unique for a client to resolve the one its caller asked for.`
378
404
  );
379
405
  }
380
406
  claimed.set(name, path);
381
407
  }
382
408
 
383
- // src/ops/ops-route.ts
384
- var OPS_PATH_ROOT = "/_ops";
385
- function toOpsPath(path) {
386
- if (!path.startsWith("/")) {
387
- throw new OpsRouterError(
388
- `Ops route path "${path}" must start with "/". It is appended to "${OPS_PATH_ROOT}", so "${path}" would read as "${OPS_PATH_ROOT}${path}".`
389
- );
390
- }
391
- if (path === "/") {
392
- throw new OpsRouterError(
393
- `Ops route path "/" names no command \u2014 "${OPS_PATH_ROOT}" itself is not a command.`
394
- );
395
- }
396
- return OPS_PATH_ROOT + path;
397
- }
398
- function opsMethod(method) {
399
- return (path) => route[method](toOpsPath(path));
400
- }
401
- var opsRoute = {
402
- get: opsMethod("get"),
403
- post: opsMethod("post"),
404
- put: opsMethod("put"),
405
- patch: opsMethod("patch"),
406
- delete: opsMethod("delete")
407
- };
408
-
409
- // src/ops/route-overlap.ts
409
+ // src/surface/route-overlap.ts
410
410
  function routeSegments(path) {
411
411
  return path.split("/").slice(1).map((raw) => {
412
412
  if (raw === "*" || raw.endsWith("*")) {
@@ -441,7 +441,7 @@ function remainingStaticPath(segments, from) {
441
441
  const remaining = segments.slice(from);
442
442
  return remaining.every((segment) => segment.kind === "static") ? remaining.map((segment) => segment.value).join("/") : null;
443
443
  }
444
- function opsRoutePatternsOverlap(firstPath, secondPath) {
444
+ function routePatternsOverlap(firstPath, secondPath) {
445
445
  const first = routeSegments(firstPath);
446
446
  const second = routeSegments(secondPath);
447
447
  const length = Math.max(first.length, second.length);
@@ -492,28 +492,28 @@ function opsRoutePatternsOverlap(firstPath, secondPath) {
492
492
  return true;
493
493
  }
494
494
 
495
- // src/ops/module.ts
495
+ // src/surface/module.ts
496
496
  var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
497
497
  var COMMAND_NAME = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/;
498
498
  var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
499
499
  var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
500
- var OPS_PATH_BASE = "https://spfn.invalid";
500
+ var PATH_BASE = "https://spfn.invalid";
501
501
  function assertText(label, value) {
502
502
  if (typeof value !== "string" || value.trim().length === 0) {
503
- throw new OpsRouterError(`${label} must be a non-empty string.`);
503
+ throw new SurfaceError(`${label} must be a non-empty string.`);
504
504
  }
505
505
  }
506
- function assertStableModulePath(moduleId, commandName, path) {
507
- const label = `Ops command "${moduleId}.${commandName}" path "${path}"`;
506
+ function assertStableModulePath(at, moduleId, commandName, path) {
507
+ const label = `${at.label} command "${moduleId}.${commandName}" path "${path}"`;
508
508
  const transportPath = path.replace(/[{}?#]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
509
509
  let url;
510
510
  try {
511
- url = new URL(transportPath, OPS_PATH_BASE);
511
+ url = new URL(transportPath, PATH_BASE);
512
512
  } catch {
513
- throw new OpsRouterError(`${label} is not a valid URL path.`);
513
+ throw new SurfaceError(`${label} is not a valid URL path.`);
514
514
  }
515
- if (url.origin !== OPS_PATH_BASE || url.search !== "" || url.hash !== "" || url.pathname !== transportPath) {
516
- throw new OpsRouterError(`${label} is not a stable plain absolute path.`);
515
+ if (url.origin !== PATH_BASE || url.search !== "" || url.hash !== "" || url.pathname !== transportPath) {
516
+ throw new SurfaceError(`${label} is not a stable plain absolute path.`);
517
517
  }
518
518
  const slashCount = (path.match(/\//g) ?? []).length;
519
519
  let decoded = path;
@@ -522,89 +522,90 @@ function assertStableModulePath(moduleId, commandName, path) {
522
522
  try {
523
523
  next = decodeURIComponent(decoded);
524
524
  } catch {
525
- throw new OpsRouterError(`${label} contains malformed percent encoding.`);
525
+ throw new SurfaceError(`${label} contains malformed percent encoding.`);
526
526
  }
527
527
  if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
528
- throw new OpsRouterError(`${label} contains encoded path separators or dot segments.`);
528
+ throw new SurfaceError(`${label} contains encoded path separators or dot segments.`);
529
529
  }
530
530
  if (next === decoded) {
531
531
  return;
532
532
  }
533
533
  decoded = next;
534
534
  }
535
- throw new OpsRouterError(`${label} uses too many percent-encoding layers.`);
535
+ throw new SurfaceError(`${label} uses too many percent-encoding layers.`);
536
536
  }
537
- function assertCliCallableModulePath(moduleId, commandName, path) {
537
+ function assertCliCallableModulePath(at, moduleId, commandName, path) {
538
538
  const unsupported = path.split("/").slice(1).find((segment) => segment === "*" || segment.includes("*") || (segment.startsWith(":") ? !/^:[A-Za-z0-9_]+$/.test(segment) : !/^[A-Za-z0-9._~-]+$/.test(segment)));
539
539
  if (unsupported !== void 0) {
540
- throw new OpsRouterError(
541
- `Ops command "${moduleId}.${commandName}" path segment "${unsupported}" is not CLI-callable. Module routes support URL-safe static segments and simple :name parameters only; optional, wildcard, and custom-regex parameters are not supported.`
540
+ throw new SurfaceError(
541
+ `${at.label} command "${moduleId}.${commandName}" path segment "${unsupported}" is not CLI-callable. Module routes support URL-safe static segments and simple :name parameters only; optional, wildcard, and custom-regex parameters are not supported.`
542
542
  );
543
543
  }
544
544
  }
545
- function assertCommand(moduleId, name, command) {
545
+ function assertCommand(at, moduleId, name, command) {
546
546
  if (!COMMAND_NAME.test(name)) {
547
- throw new OpsRouterError(
548
- `Ops module "${moduleId}" has invalid command name "${name}". Use dot-separated alphanumeric names.`
547
+ throw new SurfaceError(
548
+ `${at.label} module "${moduleId}" has invalid command name "${name}". Use dot-separated alphanumeric names.`
549
549
  );
550
550
  }
551
- assertText(`Ops command "${moduleId}.${name}" summary`, command?.summary);
551
+ assertText(`${at.label} command "${moduleId}.${name}" summary`, command?.summary);
552
552
  if (!EFFECTS.has(command?.effect)) {
553
- throw new OpsRouterError(
554
- `Ops command "${moduleId}.${name}" has invalid effect ${JSON.stringify(command?.effect)}.`
553
+ throw new SurfaceError(
554
+ `${at.label} command "${moduleId}.${name}" has invalid effect ${JSON.stringify(command?.effect)}.`
555
555
  );
556
556
  }
557
557
  if (!Array.isArray(command?.scopes) || command.scopes.length === 0 || command.scopes.some((scope) => typeof scope !== "string" || scope.trim().length === 0)) {
558
- throw new OpsRouterError(`Ops command "${moduleId}.${name}" must declare at least one non-empty scope.`);
558
+ throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must declare at least one non-empty scope.`);
559
559
  }
560
560
  const route2 = command?.route;
561
561
  if (!route2 || typeof route2.handler !== "function" || !route2.method || !route2.path) {
562
- throw new OpsRouterError(`Ops command "${moduleId}.${name}" must carry a complete ops route.`);
562
+ throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must carry a complete route.`);
563
563
  }
564
- assertStableModulePath(moduleId, name, route2.path);
565
- assertCliCallableModulePath(moduleId, name, route2.path);
566
- const modulePath = `${OPS_PATH_ROOT}/${moduleId}/`;
564
+ assertStableModulePath(at, moduleId, name, route2.path);
565
+ assertCliCallableModulePath(at, moduleId, name, route2.path);
566
+ const modulePath = `${at.namespace}/${moduleId}/`;
567
567
  if (!route2.path.startsWith(modulePath)) {
568
- throw new OpsRouterError(
569
- `Ops command "${moduleId}.${name}" is at "${route2.path}", outside "${modulePath}".`
568
+ throw new SurfaceError(
569
+ `${at.label} command "${moduleId}.${name}" is at "${route2.path}", outside "${modulePath}". A module mounted on "${at.namespace}" keeps every command under "${modulePath}".`
570
570
  );
571
571
  }
572
572
  if (route2.path.split("/").includes("..")) {
573
- throw new OpsRouterError(`Ops command "${moduleId}.${name}" path climbs out of its module namespace.`);
573
+ throw new SurfaceError(`${at.label} command "${moduleId}.${name}" path climbs out of its module namespace.`);
574
574
  }
575
575
  }
576
- function defineOpsModule(module) {
576
+ function validateSurfaceModule(module, namespace, wording = SURFACE_WORDING) {
577
+ const at = { namespace, label: capitalized(wording.noun) };
577
578
  if (!MODULE_ID.test(module?.id ?? "")) {
578
- throw new OpsRouterError(
579
- `Ops module id ${JSON.stringify(module?.id)} is invalid. Use lower-kebab-case.`
579
+ throw new SurfaceError(
580
+ `${at.label} module id ${JSON.stringify(module?.id)} is invalid. Use lower-kebab-case.`
580
581
  );
581
582
  }
582
- assertText(`Ops module "${module.id}" source`, module.source);
583
- assertText(`Ops module "${module.id}" summary`, module.summary);
583
+ assertText(`${at.label} module "${module.id}" source`, module.source);
584
+ assertText(`${at.label} module "${module.id}" summary`, module.summary);
584
585
  if (!CONTRACT_VERSION.test(module.contractVersion)) {
585
- throw new OpsRouterError(
586
- `Ops module "${module.id}" contractVersion must be a semantic version.`
586
+ throw new SurfaceError(
587
+ `${at.label} module "${module.id}" contractVersion must be a semantic version.`
587
588
  );
588
589
  }
589
590
  if (!module.commands || typeof module.commands !== "object" || Array.isArray(module.commands)) {
590
- throw new OpsRouterError(`Ops module "${module.id}" commands must be an object.`);
591
+ throw new SurfaceError(`${at.label} module "${module.id}" commands must be an object.`);
591
592
  }
592
593
  const signatures = /* @__PURE__ */ new Map();
593
594
  const claimedRoutes = [];
594
595
  for (const [name, command] of Object.entries(module.commands)) {
595
- assertCommand(module.id, name, command);
596
+ assertCommand(at, module.id, name, command);
596
597
  const signature = `${command.route.method} ${command.route.path}`;
597
598
  const existing = signatures.get(signature);
598
599
  if (existing) {
599
- throw new OpsRouterError(
600
- `Ops module "${module.id}" commands "${existing}" and "${name}" both use ${signature}.`
600
+ throw new SurfaceError(
601
+ `${at.label} module "${module.id}" commands "${existing}" and "${name}" both use ${signature}.`
601
602
  );
602
603
  }
603
604
  signatures.set(signature, name);
604
- const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method && opsRoutePatternsOverlap(claim.path, command.route.path));
605
+ const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method && routePatternsOverlap(claim.path, command.route.path));
605
606
  if (overlapping) {
606
- throw new OpsRouterError(
607
- `Ops module "${module.id}" commands "${overlapping.name}" and "${name}" have overlapping ${command.route.method} routes ("${overlapping.path}" and "${command.route.path}").`
607
+ throw new SurfaceError(
608
+ `${at.label} module "${module.id}" commands "${overlapping.name}" and "${name}" have overlapping ${command.route.method} routes ("${overlapping.path}" and "${command.route.path}").`
608
609
  );
609
610
  }
610
611
  claimedRoutes.push({
@@ -616,180 +617,330 @@ function defineOpsModule(module) {
616
617
  return module;
617
618
  }
618
619
 
619
- // src/ops/create-ops-router.ts
620
- var OPS_PATH_PREFIX = "/_ops/";
621
- var OPS_MANIFEST_PATH = "/_ops/_manifest";
622
- var OPS_MANIFEST_NAME = "getOpsManifest";
620
+ // src/surface/namespace.ts
621
+ var NAMESPACE = /^(?:\/[A-Za-z0-9_-]+)+$/;
622
+ function assertNamespace(namespace) {
623
+ if (typeof namespace !== "string" || !NAMESPACE.test(namespace)) {
624
+ throw new SurfaceError(
625
+ `Surface namespace ${JSON.stringify(namespace)} is invalid. Use an absolute path of [A-Za-z0-9_-] segments without a trailing slash, such as "/_app".`
626
+ );
627
+ }
628
+ }
629
+ function manifestPathOf(namespace) {
630
+ return `${namespace}/_manifest`;
631
+ }
632
+ function manifestNameOf(namespace) {
633
+ return `get${namespace.slice(1).replace(/_[a-z]|[A-Z_/-]/g, encodedCharacter)}Manifest`;
634
+ }
635
+ var ESCAPES = { "_": "_", "-": "d", "/": "s" };
636
+ function encodedCharacter(match) {
637
+ if (match.length === 2) {
638
+ return match.charAt(1).toUpperCase();
639
+ }
640
+ return `_${ESCAPES[match] ?? match}`;
641
+ }
642
+ function toSurfacePath(namespace, path, wording) {
643
+ const label = capitalized(wording.noun);
644
+ if (!path.startsWith("/")) {
645
+ throw new SurfaceError(
646
+ `${label} route path "${path}" must start with "/". It is appended to "${namespace}", so "${path}" would read as "${namespace}${path}".`
647
+ );
648
+ }
649
+ if (path === "/") {
650
+ throw new SurfaceError(
651
+ `${label} route path "/" names no command \u2014 "${namespace}" itself is not a command.`
652
+ );
653
+ }
654
+ return namespace + path;
655
+ }
656
+ function surfaceRouteBuilder(namespace, wording) {
657
+ const method = (name) => (path) => route[name](toSurfacePath(namespace, path, wording));
658
+ return {
659
+ get: method("get"),
660
+ post: method("post"),
661
+ put: method("put"),
662
+ patch: method("patch"),
663
+ delete: method("delete")
664
+ };
665
+ }
666
+
667
+ // src/surface/define-surface.ts
623
668
  function isRouter2(value) {
624
669
  return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
625
670
  }
626
671
  function isRouteDef2(value) {
627
672
  return value !== null && typeof value === "object" && "handler" in value;
628
673
  }
629
- function assertOpsRoute(name, def) {
674
+ function assertSurfaceRoute(plan, name, def) {
675
+ const label = capitalized(plan.wording.noun);
630
676
  if (!def.method || !def.path) {
631
- throw new OpsRouterError(
632
- `Ops route "${name}" has no method or path. An ops command is invoked on the wire, so both are required.`
677
+ throw new SurfaceError(
678
+ `${label} route "${name}" has no method or path. A command is invoked on the wire, so both are required.`
633
679
  );
634
680
  }
635
- if (!def.path.startsWith(OPS_PATH_PREFIX)) {
636
- throw new OpsRouterError(
637
- `Ops route "${name}" is at "${def.path}", outside "${OPS_PATH_PREFIX}". Build ops routes with \`opsRoute\` rather than \`route\` \u2014 it applies the namespace, so the path a definition carries is only the part the app owns.`
681
+ if (!def.path.startsWith(plan.prefix)) {
682
+ throw new SurfaceError(
683
+ `${label} route "${name}" is at "${def.path}", outside "${plan.prefix}". Build ${plan.wording.noun} routes with ${plan.wording.builder} rather than \`route\` \u2014 it applies the namespace, so the path a definition carries is only the part the app owns.`
638
684
  );
639
685
  }
640
- if (def.path === OPS_MANIFEST_PATH) {
641
- throw new OpsRouterError(
642
- `Ops route "${name}" claims "${OPS_MANIFEST_PATH}", which is reserved for the manifest. The manifest is registered first, so this route would never answer.`
686
+ if (def.path === plan.manifestPath) {
687
+ throw new SurfaceError(
688
+ `${label} route "${name}" claims "${plan.manifestPath}", which is reserved for the manifest. The manifest is registered first, so this route would never answer.`
643
689
  );
644
690
  }
645
691
  }
646
- function assertOpsName(name) {
647
- if (name === OPS_MANIFEST_NAME) {
648
- throw new OpsRouterError(
649
- `Ops route name "${OPS_MANIFEST_NAME}" is reserved for the manifest route.`
692
+ function assertSurfaceName(plan, name) {
693
+ if (name === plan.manifestName) {
694
+ throw new SurfaceError(
695
+ `${capitalized(plan.wording.noun)} route name "${plan.manifestName}" is reserved for the manifest route.`
650
696
  );
651
697
  }
652
698
  }
653
- function rebuildNestedRouter(name, router, auth, inherited) {
699
+ function rebuildNestedRouter(plan, name, router, guards, inherited) {
654
700
  if (router._packageRouters?.length > 0) {
655
- throw new OpsRouterError(
656
- `Ops router "${name}" mounts package routers with .packages(). Their routes bypass the prefix check and the auth injection, so an ops surface cannot carry them.`
701
+ throw new SurfaceError(
702
+ `${capitalized(plan.wording.noun)} router "${name}" mounts package routers with .packages(). Their routes bypass the prefix check and the auth injection, so a surface cannot carry them.`
657
703
  );
658
704
  }
659
705
  const handedDown = [...inherited, ...router._globalMiddlewares ?? []];
660
706
  let rebuilt = defineUnmappedRouter(
661
- secureRoutes(router.routes, auth, handedDown)
707
+ secureRoutes(plan, router.routes, guards, handedDown)
662
708
  );
663
709
  if (router._contractVersion) {
664
710
  rebuilt = rebuilt.contractVersion(router._contractVersion);
665
711
  }
666
712
  return rebuilt;
667
713
  }
668
- function secureRoutes(routes, auth, inherited = []) {
714
+ function secureRoutes(plan, routes, guards, inherited = []) {
669
715
  const secured = {};
670
716
  for (const [name, entry] of Object.entries(routes)) {
671
- assertOpsName(name);
717
+ assertSurfaceName(plan, name);
672
718
  if (isRouter2(entry)) {
673
- secured[name] = rebuildNestedRouter(name, entry, auth, inherited);
719
+ secured[name] = rebuildNestedRouter(plan, name, entry, guards, inherited);
674
720
  continue;
675
721
  }
676
722
  if (!isRouteDef2(entry)) {
677
- throw new OpsRouterError(`Ops router entry "${name}" is neither a route nor a router.`);
723
+ throw new SurfaceError(
724
+ `${capitalized(plan.wording.noun)} router entry "${name}" is neither a route nor a router.`
725
+ );
678
726
  }
679
- assertOpsRoute(name, entry);
727
+ assertSurfaceRoute(plan, name, entry);
680
728
  secured[name] = {
681
729
  ...entry,
682
- middlewares: [auth, ...inherited, ...entry.middlewares ?? []]
730
+ middlewares: commandChain(plan, name, guards.get(name), [...inherited, ...entry.middlewares ?? []])
683
731
  };
684
732
  }
685
733
  return secured;
686
734
  }
687
- function compileModules(modules, appCommands) {
688
- const descriptors = [];
689
- const commands = [];
690
- const routes = {};
735
+ function commandChain(plan, name, guard, declared) {
736
+ if (guard === void 0) {
737
+ return [plan.auth, ...declared];
738
+ }
739
+ const guardName = middlewareNameOf(guard);
740
+ if (guardName !== void 0) {
741
+ refuseGuardCollision(plan, name, guardName, declared);
742
+ }
743
+ return [plan.auth, guard, ...declared];
744
+ }
745
+ function refuseGuardCollision(plan, name, guardName, declared) {
746
+ const noun = capitalized(plan.wording.noun);
747
+ if (middlewareNameOf(plan.auth) === guardName) {
748
+ throw new SurfaceError(
749
+ `${noun} command "${name}"'s guard shares the name "${guardName}" with the surface's auth middleware. Route registration keeps one middleware per name, so the command's guard would never run. Give one of the two a distinct name.`
750
+ );
751
+ }
752
+ if (declared.some((middleware) => middlewareNameOf(middleware) === guardName)) {
753
+ throw new SurfaceError(
754
+ `${noun} command "${name}" declares a middleware named "${guardName}", the name of the guard authorize gave it. Route registration keeps one middleware per name, so the command's own guard would never run. Give one of the two a distinct name.`
755
+ );
756
+ }
757
+ }
758
+ function middlewareNameOf(middleware) {
759
+ return typeof middleware === "function" ? void 0 : middleware.name;
760
+ }
761
+ function guardsFor(plan, commands) {
762
+ return new Map(commands.map((command) => [command.name, plan.authorize?.(command)]));
763
+ }
764
+ function assertModuleCommandFree(plan, command, appCommands, claimed) {
765
+ const noun = plan.wording.noun;
766
+ if (claimed.names.has(command.name)) {
767
+ throw new SurfaceError(`Two ${noun} commands are named "${command.name}".`);
768
+ }
769
+ const signature = `${command.method} ${command.path}`;
770
+ const existing = claimed.signatures.get(signature);
771
+ if (existing) {
772
+ throw new SurfaceError(
773
+ `${capitalized(noun)} commands "${existing}" and "${command.name}" both use ${signature}.`
774
+ );
775
+ }
776
+ const overlapping = appCommands.find((app) => app.method === command.method && routePatternsOverlap(app.path, command.path));
777
+ if (overlapping) {
778
+ throw new SurfaceError(
779
+ `App ${noun} command "${overlapping.name}" at ${command.method} ${overlapping.path} overlaps module command "${command.name}" at ${command.method} ${command.path}. Which one answers cannot depend on route registration order.`
780
+ );
781
+ }
782
+ claimed.names.add(command.name);
783
+ claimed.signatures.set(signature, command.name);
784
+ }
785
+ function compileModules(plan, appCommands) {
786
+ const compiled = { descriptors: [], commands: [], routes: {} };
691
787
  const moduleIds = /* @__PURE__ */ new Set();
692
- const commandNames = new Set(appCommands.map((command) => command.name));
693
- const routeSignatures = new Map(
694
- appCommands.map((command) => [`${command.method} ${command.path}`, command.name])
695
- );
696
- for (const rawModule of modules) {
697
- const module = defineOpsModule(rawModule);
788
+ const claimed = {
789
+ names: new Set(appCommands.map((command) => command.name)),
790
+ signatures: new Map(appCommands.map((command) => [`${command.method} ${command.path}`, command.name]))
791
+ };
792
+ for (const rawModule of plan.modules) {
793
+ const module = validateSurfaceModule(rawModule, plan.namespace, plan.wording);
698
794
  if (moduleIds.has(module.id)) {
699
- throw new OpsRouterError(`Two ops modules use id "${module.id}".`);
795
+ throw new SurfaceError(`Two ${plan.wording.noun} modules use id "${module.id}".`);
700
796
  }
701
797
  moduleIds.add(module.id);
702
- descriptors.push({
798
+ compiled.descriptors.push({
703
799
  id: module.id,
704
800
  source: module.source,
705
801
  contractVersion: module.contractVersion,
706
802
  summary: module.summary
707
803
  });
708
804
  for (const [localName, definition] of Object.entries(module.commands)) {
709
- const name = `${module.id}.${localName}`;
710
- if (commandNames.has(name)) {
711
- throw new OpsRouterError(`Two ops commands are named "${name}".`);
712
- }
713
- commandNames.add(name);
714
- const method = definition.route.method;
715
- const path = definition.route.path;
716
- const signature = `${method} ${path}`;
717
- const existing = routeSignatures.get(signature);
718
- if (existing) {
719
- throw new OpsRouterError(
720
- `Ops commands "${existing}" and "${name}" both use ${signature}.`
721
- );
722
- }
723
- const overlappingAppCommand = appCommands.find((command) => command.method === method && opsRoutePatternsOverlap(command.path, path));
724
- if (overlappingAppCommand) {
725
- throw new OpsRouterError(
726
- `App ops command "${overlappingAppCommand.name}" at ${method} ${overlappingAppCommand.path} overlaps module command "${name}" at ${method} ${path}. Which one answers cannot depend on route registration order.`
727
- );
728
- }
729
- routeSignatures.set(signature, name);
730
- commands.push({
731
- name,
805
+ const command = {
806
+ name: `${module.id}.${localName}`,
732
807
  module: module.id,
733
808
  summary: definition.summary,
734
809
  effect: definition.effect,
735
810
  scopes: [...definition.scopes],
736
- method,
737
- path,
738
- input: collectOpsCommands({ [localName]: definition.route })[0].input
739
- });
740
- routes[name] = { route: definition.route, scopes: definition.scopes };
811
+ method: definition.route.method,
812
+ path: definition.route.path,
813
+ input: collectSurfaceCommands({ [localName]: definition.route }, plan.wording)[0].input
814
+ };
815
+ assertModuleCommandFree(plan, command, appCommands, claimed);
816
+ compiled.commands.push(command);
817
+ compiled.routes[command.name] = { route: definition.route, command };
741
818
  }
742
819
  }
743
- descriptors.sort((a, b) => a.id.localeCompare(b.id));
744
- commands.sort((a, b) => a.name.localeCompare(b.name));
745
- return { descriptors, commands, routes };
820
+ compiled.descriptors.sort((a, b) => a.id.localeCompare(b.id));
821
+ compiled.commands.sort((a, b) => a.name.localeCompare(b.name));
822
+ return compiled;
746
823
  }
747
- function secureModuleRoutes(routes, auth, authorize) {
824
+ function secureModuleRoutes(plan, routes) {
748
825
  const secured = {};
749
826
  for (const [name, definition] of Object.entries(routes)) {
750
- assertOpsName(name);
751
- assertOpsRoute(name, definition.route);
827
+ assertSurfaceName(plan, name);
828
+ assertSurfaceRoute(plan, name, definition.route);
752
829
  secured[name] = {
753
830
  ...definition.route,
754
- middlewares: [
755
- auth,
756
- authorize(...definition.scopes),
757
- ...definition.route.middlewares ?? []
758
- ]
831
+ middlewares: commandChain(
832
+ plan,
833
+ name,
834
+ plan.authorize?.(definition.command),
835
+ definition.route.middlewares ?? []
836
+ )
759
837
  };
760
838
  }
761
839
  return secured;
762
840
  }
763
- function createOpsRouter(routes, options) {
764
- if (!options?.auth) {
765
- throw new OpsRouterError(
766
- "createOpsRouter requires an auth middleware ({ auth: ... }). An ops surface reachable without authentication cannot be created."
767
- );
768
- }
769
- const modules = options.modules ?? [];
770
- if (modules.length > 0 && !options.authorize) {
771
- throw new OpsRouterError(
772
- "createOpsRouter requires an authorize scope factory when modules are mounted."
773
- );
774
- }
775
- const appCommands = collectOpsCommands(routes);
776
- const moduleSurface = compileModules(modules, appCommands);
841
+ function manifestRoute(plan, manifest) {
842
+ const builder = route.get(plan.manifestPath);
843
+ return (plan.manifest === "public" ? builder.skip(["auth"]) : builder.use([plan.auth])).handler(async () => manifest);
844
+ }
845
+ function buildRouter(plan, routes) {
846
+ const appCommands = collectSurfaceCommands(routes, plan.wording);
847
+ const moduleSurface = compileModules(plan, appCommands);
777
848
  const commands = [...appCommands, ...moduleSurface.commands].sort((a, b) => a.name.localeCompare(b.name));
778
849
  const manifest = {
779
850
  manifestVersion: 1,
780
851
  ...moduleSurface.descriptors.length > 0 ? { modules: moduleSurface.descriptors } : {},
781
852
  commands
782
853
  };
783
- const secured = secureRoutes(routes, options.auth);
784
- const securedModules = moduleSurface.descriptors.length > 0 ? secureModuleRoutes(moduleSurface.routes, options.auth, options.authorize) : {};
785
- const manifestRoute = route.get(OPS_MANIFEST_PATH).use([options.auth]).handler(async () => manifest);
786
- return defineUnmappedRouter({
787
- [OPS_MANIFEST_NAME]: manifestRoute,
854
+ const secured = secureRoutes(plan, routes, guardsFor(plan, appCommands));
855
+ const securedModules = secureModuleRoutes(plan, moduleSurface.routes);
856
+ return Object.defineProperty(defineUnmappedRouter({
857
+ [plan.manifestName]: manifestRoute(plan, manifest),
788
858
  ...secured,
789
859
  ...securedModules
790
- });
860
+ }), "_surfaceNamespace", { value: plan.namespace, enumerable: false });
861
+ }
862
+ function planOf(options, wording) {
863
+ assertNamespace(options?.namespace);
864
+ if (!options.auth) {
865
+ throw new SurfaceError(
866
+ `${wording.factory} requires an auth middleware ({ auth: ... }). A surface reachable without authentication cannot be created.`
867
+ );
868
+ }
869
+ const modules = options.modules ?? [];
870
+ if (modules.length > 0 && !options.authorize) {
871
+ throw new SurfaceError(`${wording.factory} requires an authorize function when modules are mounted.`);
872
+ }
873
+ const manifest = options.manifest ?? "authenticated";
874
+ if (manifest !== "authenticated" && manifest !== "public") {
875
+ throw new SurfaceError(
876
+ `${wording.factory} manifest must be 'authenticated' or 'public', got ${JSON.stringify(manifest)}.`
877
+ );
878
+ }
879
+ return {
880
+ namespace: options.namespace,
881
+ prefix: `${options.namespace}/`,
882
+ manifestPath: manifestPathOf(options.namespace),
883
+ manifestName: manifestNameOf(options.namespace),
884
+ auth: options.auth,
885
+ authorize: options.authorize,
886
+ manifest,
887
+ modules,
888
+ wording
889
+ };
890
+ }
891
+ function createSurface(options, wording) {
892
+ const plan = planOf(options, wording);
893
+ return {
894
+ namespace: plan.namespace,
895
+ route: surfaceRouteBuilder(plan.namespace, wording),
896
+ router: (routes) => buildRouter(plan, routes)
897
+ };
898
+ }
899
+
900
+ // src/ops/wording.ts
901
+ var OPS_WORDING = {
902
+ noun: "ops",
903
+ builder: "`opsRoute`",
904
+ factory: "createOpsRouter"
905
+ };
906
+
907
+ // src/ops/ops-route.ts
908
+ var OPS_PATH_ROOT = "/_ops";
909
+ var opsRoute = surfaceRouteBuilder(OPS_PATH_ROOT, OPS_WORDING);
910
+
911
+ // src/ops/create-ops-router.ts
912
+ var OPS_PATH_PREFIX = "/_ops/";
913
+ var OPS_MANIFEST_PATH = "/_ops/_manifest";
914
+ function moduleScopeGuard(authorize) {
915
+ if (!authorize) {
916
+ return void 0;
917
+ }
918
+ return (command) => command.module === void 0 ? void 0 : authorize(...command.scopes ?? []);
919
+ }
920
+ function createOpsRouter(routes, options) {
921
+ if ((options?.modules ?? []).length > 0 && !options.authorize) {
922
+ throw new SurfaceError(
923
+ "createOpsRouter requires an authorize scope factory when modules are mounted."
924
+ );
925
+ }
926
+ return createSurface({
927
+ namespace: OPS_PATH_ROOT,
928
+ auth: options?.auth,
929
+ authorize: moduleScopeGuard(options?.authorize),
930
+ modules: options?.modules
931
+ }, OPS_WORDING).router(routes);
932
+ }
933
+
934
+ // src/ops/manifest.ts
935
+ function collectOpsCommands(routes) {
936
+ return collectSurfaceCommands(routes, OPS_WORDING);
937
+ }
938
+
939
+ // src/ops/module.ts
940
+ function defineOpsModule(module) {
941
+ return validateSurfaceModule(module, OPS_PATH_ROOT, OPS_WORDING);
791
942
  }
792
943
 
793
- export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, OpsRouterError, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
944
+ export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, SurfaceError as OpsRouterError, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
794
945
  //# sourceMappingURL=index.js.map
795
946
  //# sourceMappingURL=index.js.map