@spfn/core 0.3.0-beta.4 → 0.3.0-beta.5

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.
@@ -1,12 +1,44 @@
1
+ import { MiddlewareHandler } from 'hono';
1
2
  import { a as NamedMiddleware } from '../define-middleware-CVKgqo8S.js';
2
3
  import { R as RouteDef, a as RouteBuilder } from '../route-builder-2ani2jEI.js';
3
4
  import { R as Router } from '../router-DJdpwuB6.js';
4
5
  import { J as JsonSchema } from '../types-ClQVomgV.js';
5
6
  import { HttpMethod } from '../route/types.js';
6
- import 'hono';
7
7
  import '@sinclair/typebox';
8
8
  import 'hono/utils/http-status';
9
9
 
10
+ /**
11
+ * Capability ops module contract
12
+ *
13
+ * A package describes commands here, but an adopter application decides
14
+ * whether to mount the resulting module. Definition-time validation keeps an
15
+ * installed package from becoming an ops surface merely by existing, and
16
+ * gives `createOpsRouter` enough metadata to enforce scopes and describe the
17
+ * module to the CLI from one declaration.
18
+ */
19
+
20
+ type OpsEffect = 'read' | 'write' | 'destructive';
21
+ interface OpsModuleCommand<TRoute extends RouteDef<any> = RouteDef<any>> {
22
+ summary: string;
23
+ effect: OpsEffect;
24
+ scopes: readonly string[];
25
+ route: TRoute;
26
+ }
27
+ interface OpsModule<TCommands extends Record<string, OpsModuleCommand<any>> = Record<string, OpsModuleCommand<any>>> {
28
+ id: string;
29
+ source: string;
30
+ contractVersion: string;
31
+ summary: string;
32
+ commands: TCommands;
33
+ }
34
+ /**
35
+ * Validate and return a capability ops module.
36
+ *
37
+ * This function intentionally does not register anything. Only an adopter's
38
+ * explicit `createOpsRouter({ modules: [...] })` composition exposes it.
39
+ */
40
+ declare function defineOpsModule<TCommands extends Record<string, OpsModuleCommand<any>>>(module: OpsModule<TCommands>): OpsModule<TCommands>;
41
+
10
42
  /**
11
43
  * Ops Router
12
44
  *
@@ -57,6 +89,14 @@ interface OpsRouterOptions {
57
89
  * discovered in production.
58
90
  */
59
91
  auth: NamedMiddleware<string>;
92
+ /**
93
+ * Build the server-side scope guard for a module command. Required when
94
+ * modules are mounted. `@spfn/auth`'s `requireOpsScope` is the standard
95
+ * implementation, but core remains independent of that package.
96
+ */
97
+ authorize?: (...scopes: string[]) => MiddlewareHandler | NamedMiddleware<string>;
98
+ /** Capability ops modules this application explicitly chooses to expose. */
99
+ modules?: readonly OpsModule[];
60
100
  }
61
101
  /**
62
102
  * Build the app's ops surface from its ops routes.
@@ -108,6 +148,11 @@ declare const opsRoute: {
108
148
  delete: (path: string) => RouteBuilder;
109
149
  };
110
150
 
151
+ /** Thrown when a route or module cannot be part of an ops surface. */
152
+ declare class OpsRouterError extends Error {
153
+ constructor(message: string);
154
+ }
155
+
111
156
  /**
112
157
  * Ops Manifest
113
158
  *
@@ -122,11 +167,23 @@ declare const opsRoute: {
122
167
  * and TypeBox schemas serialize to JSON Schema by construction.
123
168
  */
124
169
 
170
+ /** One explicitly mounted capability module, as the CLI sees it. */
171
+ interface OpsModuleDescriptor {
172
+ id: string;
173
+ source: string;
174
+ contractVersion: string;
175
+ summary: string;
176
+ }
125
177
  /** One invokable ops command, as the CLI sees it. */
126
178
  interface OpsCommand {
127
179
  name: string;
128
180
  method: HttpMethod;
129
181
  path: string;
182
+ /** Present for commands contributed by an explicitly mounted ops module. */
183
+ module?: string;
184
+ summary?: string;
185
+ effect?: OpsEffect;
186
+ scopes?: string[];
130
187
  /** Input sections the route declares, each as JSON Schema. */
131
188
  input: {
132
189
  params?: JsonSchema;
@@ -137,12 +194,10 @@ interface OpsCommand {
137
194
  /** What `GET /_ops/_manifest` answers. */
138
195
  interface OpsManifest {
139
196
  manifestVersion: 1;
197
+ /** Additive module metadata. Omitted for an app-only v1 surface. */
198
+ modules?: OpsModuleDescriptor[];
140
199
  commands: OpsCommand[];
141
200
  }
142
- /** Thrown when a route cannot be part of an ops surface. */
143
- declare class OpsRouterError extends Error {
144
- constructor(message: string);
145
- }
146
201
  /**
147
202
  * Walk a routes record (nested routers included) and collect every RouteDef
148
203
  * as an ops command. Validation of paths and names happens in
@@ -150,4 +205,4 @@ declare class OpsRouterError extends Error {
150
205
  */
151
206
  declare function collectOpsCommands(routes: Record<string, RouteDef<any> | Router<any>>): OpsCommand[];
152
207
 
153
- export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, type OpsCommand, type OpsManifest, OpsRouterError, type OpsRouterOptions, collectOpsCommands, createOpsRouter, opsRoute };
208
+ export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, type OpsCommand, type OpsEffect, type OpsManifest, type OpsModule, type OpsModuleCommand, type OpsModuleDescriptor, OpsRouterError, type OpsRouterOptions, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
package/dist/ops/index.js CHANGED
@@ -319,13 +319,15 @@ function defineRouter(routes) {
319
319
  return createRouterInstance(routes);
320
320
  }
321
321
 
322
- // src/ops/manifest.ts
322
+ // src/ops/error.ts
323
323
  var OpsRouterError = class extends Error {
324
324
  constructor(message) {
325
325
  super(message);
326
326
  this.name = "OpsRouterError";
327
327
  }
328
328
  };
329
+
330
+ // src/ops/manifest.ts
329
331
  function isRouter(value) {
330
332
  return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
331
333
  }
@@ -383,6 +385,242 @@ function assertUnclaimedName(name, path, claimed) {
383
385
  claimed.set(name, path);
384
386
  }
385
387
 
388
+ // src/ops/ops-route.ts
389
+ var OPS_PATH_ROOT = "/_ops";
390
+ function toOpsPath(path) {
391
+ if (!path.startsWith("/")) {
392
+ throw new OpsRouterError(
393
+ `Ops route path "${path}" must start with "/". It is appended to "${OPS_PATH_ROOT}", so "${path}" would read as "${OPS_PATH_ROOT}${path}".`
394
+ );
395
+ }
396
+ if (path === "/") {
397
+ throw new OpsRouterError(
398
+ `Ops route path "/" names no command \u2014 "${OPS_PATH_ROOT}" itself is not a command.`
399
+ );
400
+ }
401
+ return OPS_PATH_ROOT + path;
402
+ }
403
+ function opsMethod(method) {
404
+ return (path) => route[method](toOpsPath(path));
405
+ }
406
+ var opsRoute = {
407
+ get: opsMethod("get"),
408
+ post: opsMethod("post"),
409
+ put: opsMethod("put"),
410
+ patch: opsMethod("patch"),
411
+ delete: opsMethod("delete")
412
+ };
413
+
414
+ // src/ops/route-overlap.ts
415
+ function routeSegments(path) {
416
+ return path.split("/").slice(1).map((raw) => {
417
+ if (raw === "*" || raw.endsWith("*")) {
418
+ return { kind: "wildcard", value: raw, optional: true };
419
+ }
420
+ if (raw.startsWith(":")) {
421
+ return {
422
+ kind: "parameter",
423
+ value: raw.slice(1).replace(/\?$/, ""),
424
+ optional: raw.endsWith("?")
425
+ };
426
+ }
427
+ return { kind: "static", value: raw, optional: false };
428
+ });
429
+ }
430
+ function customPattern(parameter) {
431
+ const openingBrace = parameter.indexOf("{");
432
+ return openingBrace >= 0 && parameter.endsWith("}") ? parameter.slice(openingBrace + 1, -1) : null;
433
+ }
434
+ function parameterAccepts(parameter, value) {
435
+ const pattern = customPattern(parameter);
436
+ if (pattern === null) {
437
+ return value.length > 0 && !value.includes("/");
438
+ }
439
+ try {
440
+ return new RegExp(`^(?:${pattern})$`).test(value);
441
+ } catch {
442
+ return true;
443
+ }
444
+ }
445
+ function remainingStaticPath(segments, from) {
446
+ const remaining = segments.slice(from);
447
+ return remaining.every((segment) => segment.kind === "static") ? remaining.map((segment) => segment.value).join("/") : null;
448
+ }
449
+ function opsRoutePatternsOverlap(firstPath, secondPath) {
450
+ const first = routeSegments(firstPath);
451
+ const second = routeSegments(secondPath);
452
+ const length = Math.max(first.length, second.length);
453
+ for (let index = 0; index < length; index++) {
454
+ const left = first[index];
455
+ const right = second[index];
456
+ if (!left || !right) {
457
+ const remaining = left ? first.slice(index) : second.slice(index);
458
+ return remaining.every((segment) => segment.optional || segment.kind === "wildcard");
459
+ }
460
+ if (left.kind === "wildcard" || right.kind === "wildcard") {
461
+ return true;
462
+ }
463
+ if (left.kind === "static" && right.kind === "static") {
464
+ if (left.value !== right.value) {
465
+ return false;
466
+ }
467
+ continue;
468
+ }
469
+ if (left.kind === "parameter" && right.kind === "static") {
470
+ const remaining = remainingStaticPath(second, index);
471
+ if (customPattern(left.value) !== null && remaining !== null && parameterAccepts(left.value, remaining)) {
472
+ return true;
473
+ }
474
+ if (!parameterAccepts(left.value, right.value)) {
475
+ return customPattern(left.value) !== null && remaining === null;
476
+ }
477
+ if (customPattern(left.value) !== null && first.length !== second.length) {
478
+ return true;
479
+ }
480
+ continue;
481
+ }
482
+ if (left.kind === "static" && right.kind === "parameter") {
483
+ const remaining = remainingStaticPath(first, index);
484
+ if (customPattern(right.value) !== null && remaining !== null && parameterAccepts(right.value, remaining)) {
485
+ return true;
486
+ }
487
+ if (!parameterAccepts(right.value, left.value)) {
488
+ return customPattern(right.value) !== null && remaining === null;
489
+ }
490
+ if (customPattern(right.value) !== null && first.length !== second.length) {
491
+ return true;
492
+ }
493
+ continue;
494
+ }
495
+ return true;
496
+ }
497
+ return true;
498
+ }
499
+
500
+ // src/ops/module.ts
501
+ var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
502
+ var COMMAND_NAME = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/;
503
+ var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
504
+ var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
505
+ var OPS_PATH_BASE = "https://spfn.invalid";
506
+ function assertText(label, value) {
507
+ if (typeof value !== "string" || value.trim().length === 0) {
508
+ throw new OpsRouterError(`${label} must be a non-empty string.`);
509
+ }
510
+ }
511
+ function assertStableModulePath(moduleId, commandName, path) {
512
+ const label = `Ops command "${moduleId}.${commandName}" path "${path}"`;
513
+ const transportPath = path.replace(/[{}?#]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
514
+ let url;
515
+ try {
516
+ url = new URL(transportPath, OPS_PATH_BASE);
517
+ } catch {
518
+ throw new OpsRouterError(`${label} is not a valid URL path.`);
519
+ }
520
+ if (url.origin !== OPS_PATH_BASE || url.search !== "" || url.hash !== "" || url.pathname !== transportPath) {
521
+ throw new OpsRouterError(`${label} is not a stable plain absolute path.`);
522
+ }
523
+ const slashCount = (path.match(/\//g) ?? []).length;
524
+ let decoded = path;
525
+ for (let depth = 0; depth < 8; depth++) {
526
+ let next;
527
+ try {
528
+ next = decodeURIComponent(decoded);
529
+ } catch {
530
+ throw new OpsRouterError(`${label} contains malformed percent encoding.`);
531
+ }
532
+ if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
533
+ throw new OpsRouterError(`${label} contains encoded path separators or dot segments.`);
534
+ }
535
+ if (next === decoded) {
536
+ return;
537
+ }
538
+ decoded = next;
539
+ }
540
+ throw new OpsRouterError(`${label} uses too many percent-encoding layers.`);
541
+ }
542
+ function assertCliCallableModulePath(moduleId, commandName, path) {
543
+ 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)));
544
+ if (unsupported !== void 0) {
545
+ throw new OpsRouterError(
546
+ `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.`
547
+ );
548
+ }
549
+ }
550
+ function assertCommand(moduleId, name, command) {
551
+ if (!COMMAND_NAME.test(name)) {
552
+ throw new OpsRouterError(
553
+ `Ops module "${moduleId}" has invalid command name "${name}". Use dot-separated alphanumeric names.`
554
+ );
555
+ }
556
+ assertText(`Ops command "${moduleId}.${name}" summary`, command?.summary);
557
+ if (!EFFECTS.has(command?.effect)) {
558
+ throw new OpsRouterError(
559
+ `Ops command "${moduleId}.${name}" has invalid effect ${JSON.stringify(command?.effect)}.`
560
+ );
561
+ }
562
+ if (!Array.isArray(command?.scopes) || command.scopes.length === 0 || command.scopes.some((scope) => typeof scope !== "string" || scope.trim().length === 0)) {
563
+ throw new OpsRouterError(`Ops command "${moduleId}.${name}" must declare at least one non-empty scope.`);
564
+ }
565
+ const route2 = command?.route;
566
+ if (!route2 || typeof route2.handler !== "function" || !route2.method || !route2.path) {
567
+ throw new OpsRouterError(`Ops command "${moduleId}.${name}" must carry a complete ops route.`);
568
+ }
569
+ assertStableModulePath(moduleId, name, route2.path);
570
+ assertCliCallableModulePath(moduleId, name, route2.path);
571
+ const modulePath = `${OPS_PATH_ROOT}/${moduleId}/`;
572
+ if (!route2.path.startsWith(modulePath)) {
573
+ throw new OpsRouterError(
574
+ `Ops command "${moduleId}.${name}" is at "${route2.path}", outside "${modulePath}".`
575
+ );
576
+ }
577
+ if (route2.path.split("/").includes("..")) {
578
+ throw new OpsRouterError(`Ops command "${moduleId}.${name}" path climbs out of its module namespace.`);
579
+ }
580
+ }
581
+ function defineOpsModule(module) {
582
+ if (!MODULE_ID.test(module?.id ?? "")) {
583
+ throw new OpsRouterError(
584
+ `Ops module id ${JSON.stringify(module?.id)} is invalid. Use lower-kebab-case.`
585
+ );
586
+ }
587
+ assertText(`Ops module "${module.id}" source`, module.source);
588
+ assertText(`Ops module "${module.id}" summary`, module.summary);
589
+ if (!CONTRACT_VERSION.test(module.contractVersion)) {
590
+ throw new OpsRouterError(
591
+ `Ops module "${module.id}" contractVersion must be a semantic version.`
592
+ );
593
+ }
594
+ if (!module.commands || typeof module.commands !== "object" || Array.isArray(module.commands)) {
595
+ throw new OpsRouterError(`Ops module "${module.id}" commands must be an object.`);
596
+ }
597
+ const signatures = /* @__PURE__ */ new Map();
598
+ const claimedRoutes = [];
599
+ for (const [name, command] of Object.entries(module.commands)) {
600
+ assertCommand(module.id, name, command);
601
+ const signature = `${command.route.method} ${command.route.path}`;
602
+ const existing = signatures.get(signature);
603
+ if (existing) {
604
+ throw new OpsRouterError(
605
+ `Ops module "${module.id}" commands "${existing}" and "${name}" both use ${signature}.`
606
+ );
607
+ }
608
+ signatures.set(signature, name);
609
+ const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method && opsRoutePatternsOverlap(claim.path, command.route.path));
610
+ if (overlapping) {
611
+ throw new OpsRouterError(
612
+ `Ops module "${module.id}" commands "${overlapping.name}" and "${name}" have overlapping ${command.route.method} routes ("${overlapping.path}" and "${command.route.path}").`
613
+ );
614
+ }
615
+ claimedRoutes.push({
616
+ name,
617
+ method: command.route.method,
618
+ path: command.route.path
619
+ });
620
+ }
621
+ return module;
622
+ }
623
+
386
624
  // src/ops/create-ops-router.ts
387
625
  var OPS_PATH_PREFIX = "/_ops/";
388
626
  var OPS_MANIFEST_PATH = "/_ops/_manifest";
@@ -451,50 +689,112 @@ function secureRoutes(routes, auth, inherited = []) {
451
689
  }
452
690
  return secured;
453
691
  }
692
+ function compileModules(modules, appCommands) {
693
+ const descriptors = [];
694
+ const commands = [];
695
+ const routes = {};
696
+ const moduleIds = /* @__PURE__ */ new Set();
697
+ const commandNames = new Set(appCommands.map((command) => command.name));
698
+ const routeSignatures = new Map(
699
+ appCommands.map((command) => [`${command.method} ${command.path}`, command.name])
700
+ );
701
+ for (const rawModule of modules) {
702
+ const module = defineOpsModule(rawModule);
703
+ if (moduleIds.has(module.id)) {
704
+ throw new OpsRouterError(`Two ops modules use id "${module.id}".`);
705
+ }
706
+ moduleIds.add(module.id);
707
+ descriptors.push({
708
+ id: module.id,
709
+ source: module.source,
710
+ contractVersion: module.contractVersion,
711
+ summary: module.summary
712
+ });
713
+ for (const [localName, definition] of Object.entries(module.commands)) {
714
+ const name = `${module.id}.${localName}`;
715
+ if (commandNames.has(name)) {
716
+ throw new OpsRouterError(`Two ops commands are named "${name}".`);
717
+ }
718
+ commandNames.add(name);
719
+ const method = definition.route.method;
720
+ const path = definition.route.path;
721
+ const signature = `${method} ${path}`;
722
+ const existing = routeSignatures.get(signature);
723
+ if (existing) {
724
+ throw new OpsRouterError(
725
+ `Ops commands "${existing}" and "${name}" both use ${signature}.`
726
+ );
727
+ }
728
+ const overlappingAppCommand = appCommands.find((command) => command.method === method && opsRoutePatternsOverlap(command.path, path));
729
+ if (overlappingAppCommand) {
730
+ throw new OpsRouterError(
731
+ `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.`
732
+ );
733
+ }
734
+ routeSignatures.set(signature, name);
735
+ commands.push({
736
+ name,
737
+ module: module.id,
738
+ summary: definition.summary,
739
+ effect: definition.effect,
740
+ scopes: [...definition.scopes],
741
+ method,
742
+ path,
743
+ input: collectOpsCommands({ [localName]: definition.route })[0].input
744
+ });
745
+ routes[name] = { route: definition.route, scopes: definition.scopes };
746
+ }
747
+ }
748
+ descriptors.sort((a, b) => a.id.localeCompare(b.id));
749
+ commands.sort((a, b) => a.name.localeCompare(b.name));
750
+ return { descriptors, commands, routes };
751
+ }
752
+ function secureModuleRoutes(routes, auth, authorize) {
753
+ const secured = {};
754
+ for (const [name, definition] of Object.entries(routes)) {
755
+ assertOpsName(name);
756
+ assertOpsRoute(name, definition.route);
757
+ secured[name] = {
758
+ ...definition.route,
759
+ middlewares: [
760
+ auth,
761
+ authorize(...definition.scopes),
762
+ ...definition.route.middlewares ?? []
763
+ ]
764
+ };
765
+ }
766
+ return secured;
767
+ }
454
768
  function createOpsRouter(routes, options) {
455
769
  if (!options?.auth) {
456
770
  throw new OpsRouterError(
457
771
  "createOpsRouter requires an auth middleware ({ auth: ... }). An ops surface reachable without authentication cannot be created."
458
772
  );
459
773
  }
774
+ const modules = options.modules ?? [];
775
+ if (modules.length > 0 && !options.authorize) {
776
+ throw new OpsRouterError(
777
+ "createOpsRouter requires an authorize scope factory when modules are mounted."
778
+ );
779
+ }
780
+ const appCommands = collectOpsCommands(routes);
781
+ const moduleSurface = compileModules(modules, appCommands);
782
+ const commands = [...appCommands, ...moduleSurface.commands].sort((a, b) => a.name.localeCompare(b.name));
460
783
  const manifest = {
461
784
  manifestVersion: 1,
462
- commands: collectOpsCommands(routes)
785
+ ...moduleSurface.descriptors.length > 0 ? { modules: moduleSurface.descriptors } : {},
786
+ commands
463
787
  };
464
788
  const secured = secureRoutes(routes, options.auth);
789
+ const securedModules = moduleSurface.descriptors.length > 0 ? secureModuleRoutes(moduleSurface.routes, options.auth, options.authorize) : {};
465
790
  const manifestRoute = route.get(OPS_MANIFEST_PATH).use([options.auth]).handler(async () => manifest);
466
791
  return defineRouter({
467
792
  [OPS_MANIFEST_NAME]: manifestRoute,
468
- ...secured
793
+ ...secured,
794
+ ...securedModules
469
795
  });
470
796
  }
471
797
 
472
- // src/ops/ops-route.ts
473
- var OPS_PATH_ROOT = "/_ops";
474
- function toOpsPath(path) {
475
- if (!path.startsWith("/")) {
476
- throw new OpsRouterError(
477
- `Ops route path "${path}" must start with "/". It is appended to "${OPS_PATH_ROOT}", so "${path}" would read as "${OPS_PATH_ROOT}${path}".`
478
- );
479
- }
480
- if (path === "/") {
481
- throw new OpsRouterError(
482
- `Ops route path "/" names no command \u2014 "${OPS_PATH_ROOT}" itself is not a command.`
483
- );
484
- }
485
- return OPS_PATH_ROOT + path;
486
- }
487
- function opsMethod(method) {
488
- return (path) => route[method](toOpsPath(path));
489
- }
490
- var opsRoute = {
491
- get: opsMethod("get"),
492
- post: opsMethod("post"),
493
- put: opsMethod("put"),
494
- patch: opsMethod("patch"),
495
- delete: opsMethod("delete")
496
- };
497
-
498
- export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, OpsRouterError, collectOpsCommands, createOpsRouter, opsRoute };
798
+ export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, OpsRouterError, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
499
799
  //# sourceMappingURL=index.js.map
500
800
  //# sourceMappingURL=index.js.map