@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.
- package/README.md +51 -0
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/ops/index.d.ts
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|