@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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.
Files changed (104) hide show
  1. package/README.md +304 -12
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +41 -11
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
package/dist/ops/index.js CHANGED
@@ -7,6 +7,8 @@ var RouteBuilder = class _RouteBuilder {
7
7
  _middlewares;
8
8
  _skipMiddlewares;
9
9
  _contract;
10
+ _effect;
11
+ _summary;
10
12
  /**
11
13
  * Create a new RouteBuilder with copied properties and optional overrides
12
14
  */
@@ -19,6 +21,8 @@ var RouteBuilder = class _RouteBuilder {
19
21
  builder._middlewares = overrides?.middlewares ?? this._middlewares;
20
22
  builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
21
23
  builder._contract = overrides?.contract ?? this._contract;
24
+ builder._effect = overrides?.effect ?? this._effect;
25
+ builder._summary = overrides?.summary ?? this._summary;
22
26
  return builder;
23
27
  }
24
28
  /**
@@ -189,6 +193,49 @@ var RouteBuilder = class _RouteBuilder {
189
193
  contract(contract) {
190
194
  return this.clone({ contract });
191
195
  }
196
+ /**
197
+ * Declare what invoking this route does
198
+ *
199
+ * Read by a command surface (`@spfn/core/surface`, `@spfn/core/ops`),
200
+ * which announces it in its manifest so a machine caller can tell a read
201
+ * from a write before it calls — `spfn ops call` asks for `--yes` before a
202
+ * `destructive` command. A surface built with `defineSurface` refuses an
203
+ * app command that does not declare one; on `/_ops` it is optional.
204
+ *
205
+ * Nothing else reads it: codegen, the route map, and the contract are
206
+ * unaffected, and a route outside any surface simply carries it.
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * export const pageAdd = app.route.post('/pages')
211
+ * .input({ body: PageAddBody })
212
+ * .effect('write')
213
+ * .summary('Add a page to the blueprint')
214
+ * .handler(async (c) => { ... });
215
+ * ```
216
+ */
217
+ effect(effect) {
218
+ return this.clone({ effect });
219
+ }
220
+ /**
221
+ * Describe this route in one line for a command surface's manifest
222
+ *
223
+ * A surface announces it beside the command in `spfn ops list` and
224
+ * `--describe`, and refuses one that is empty or longer than 500
225
+ * characters. Codegen, the route map, and the contract ignore it. On a
226
+ * module command the module's own summary wins.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * export const pageAdd = app.route.post('/pages')
231
+ * .effect('write')
232
+ * .summary('Add a page to the blueprint')
233
+ * .handler(async (c) => { ... });
234
+ * ```
235
+ */
236
+ summary(summary) {
237
+ return this.clone({ summary });
238
+ }
192
239
  /**
193
240
  * Define handler function
194
241
  *
@@ -244,6 +291,8 @@ var RouteBuilder = class _RouteBuilder {
244
291
  middlewares: this._middlewares,
245
292
  skipMiddlewares: this._skipMiddlewares,
246
293
  contract: this._contract,
294
+ ...this._effect !== void 0 ? { effect: this._effect } : {},
295
+ ...this._summary !== void 0 ? { summary: this._summary } : {},
247
296
  handler: fn,
248
297
  _input: {},
249
298
  _interceptor: {},
@@ -268,45 +317,45 @@ var route = {
268
317
  };
269
318
 
270
319
  // src/route/router.ts
271
- function createRouterInstance(routes, packageRouters = [], globalMiddlewares = [], contractVersion = null) {
272
- return {
273
- routes,
274
- _routes: routes,
275
- _packageRouters: packageRouters,
276
- _globalMiddlewares: globalMiddlewares,
277
- _contractVersion: contractVersion,
320
+ function createRouterInstance(state) {
321
+ const router = {
322
+ routes: state.routes,
323
+ _routes: state.routes,
324
+ _packageRouters: state.packageRouters,
325
+ _globalMiddlewares: state.globalMiddlewares,
326
+ _contractVersion: state.contractVersion,
327
+ _publishesRouteMap: state.publishesRouteMap,
278
328
  packages(routers) {
279
- const newPackageRouters = [...this._packageRouters, ...routers];
329
+ const newPackageRouters = [...state.packageRouters, ...routers];
280
330
  for (const pkgRouter of routers) {
281
331
  if (pkgRouter._packageRouters?.length > 0) {
282
332
  newPackageRouters.push(...pkgRouter._packageRouters);
283
333
  }
284
334
  }
285
- return createRouterInstance(
286
- this.routes,
287
- newPackageRouters,
288
- this._globalMiddlewares,
289
- this._contractVersion
290
- );
335
+ assertDistinctSurfaces(newPackageRouters);
336
+ return carryMarkers(router, createRouterInstance({ ...state, packageRouters: newPackageRouters }));
291
337
  },
292
338
  use(middlewares) {
293
- return createRouterInstance(
294
- this.routes,
295
- this._packageRouters,
296
- [...this._globalMiddlewares, ...middlewares],
297
- this._contractVersion
298
- );
339
+ return carryMarkers(router, createRouterInstance({
340
+ ...state,
341
+ globalMiddlewares: [...state.globalMiddlewares, ...middlewares]
342
+ }));
299
343
  },
300
344
  contractVersion(version) {
301
345
  assertContractVersion(version);
302
- return createRouterInstance(
303
- this.routes,
304
- this._packageRouters,
305
- this._globalMiddlewares,
306
- version
307
- );
346
+ return carryMarkers(router, createRouterInstance({ ...state, contractVersion: version }));
308
347
  }
309
348
  };
349
+ return router;
350
+ }
351
+ var MARKERS = ["_surfaceNamespace", "_stateManifest"];
352
+ function carryMarkers(from, to) {
353
+ for (const marker of MARKERS) {
354
+ if (from[marker] !== void 0) {
355
+ Object.defineProperty(to, marker, { value: from[marker], enumerable: false });
356
+ }
357
+ }
358
+ return to;
310
359
  }
311
360
  function assertContractVersion(version) {
312
361
  if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
@@ -315,17 +364,50 @@ function assertContractVersion(version) {
315
364
  );
316
365
  }
317
366
  }
318
- function defineRouter(routes) {
319
- return createRouterInstance(routes);
367
+ function assertDistinctSurfaces(packageRouters) {
368
+ const namespaces = /* @__PURE__ */ new Set();
369
+ for (const router of packageRouters) {
370
+ const namespace = router._surfaceNamespace;
371
+ if (namespace === void 0) {
372
+ continue;
373
+ }
374
+ if (namespaces.has(namespace)) {
375
+ throw new Error(
376
+ `.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.`
377
+ );
378
+ }
379
+ namespaces.add(namespace);
380
+ }
381
+ }
382
+ function defineUnmappedRouter(routes) {
383
+ return createRouterInstance({
384
+ routes,
385
+ packageRouters: [],
386
+ globalMiddlewares: [],
387
+ contractVersion: null,
388
+ publishesRouteMap: false
389
+ });
320
390
  }
321
391
 
322
- // src/ops/manifest.ts
323
- var OpsRouterError = class extends Error {
392
+ // src/surface/error.ts
393
+ var SurfaceError = class extends Error {
324
394
  constructor(message) {
325
395
  super(message);
326
- this.name = "OpsRouterError";
396
+ this.name = "SurfaceError";
327
397
  }
328
398
  };
399
+
400
+ // src/surface/wording.ts
401
+ var SURFACE_WORDING = {
402
+ noun: "surface",
403
+ builder: "`<surface>.route`",
404
+ factory: "defineSurface"
405
+ };
406
+ function capitalized(noun) {
407
+ return noun.charAt(0).toUpperCase() + noun.slice(1);
408
+ }
409
+
410
+ // src/surface/manifest.ts
329
411
  function isRouter(value) {
330
412
  return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
331
413
  }
@@ -349,152 +431,621 @@ function toCommandInput(input) {
349
431
  }
350
432
  return sections;
351
433
  }
352
- function collectOpsCommands(routes) {
434
+ function collectSurfaceCommands(routes, wording = SURFACE_WORDING) {
353
435
  const commands = [];
354
- visit(routes, commands, /* @__PURE__ */ new Map());
436
+ visit(routes, commands, /* @__PURE__ */ new Map(), wording);
355
437
  commands.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
356
438
  return commands;
357
439
  }
358
- function visit(routes, commands, claimed) {
440
+ function visit(routes, commands, claimed, wording) {
359
441
  for (const [name, entry] of Object.entries(routes)) {
360
442
  if (isRouter(entry)) {
361
- visit(entry.routes, commands, claimed);
443
+ visit(entry.routes, commands, claimed, wording);
362
444
  continue;
363
445
  }
364
446
  if (!isRouteDef(entry) || !entry.method || !entry.path) {
365
447
  continue;
366
448
  }
367
- assertUnclaimedName(name, entry.path, claimed);
449
+ assertUnclaimedName(name, entry.path, claimed, wording);
368
450
  commands.push({
369
451
  name,
370
452
  method: entry.method,
371
453
  path: entry.path,
454
+ ...entry.summary !== void 0 ? { summary: entry.summary } : {},
455
+ ...entry.effect !== void 0 ? { effect: entry.effect } : {},
372
456
  input: toCommandInput(entry.input)
373
457
  });
374
458
  }
375
459
  }
376
- function assertUnclaimedName(name, path, claimed) {
460
+ function assertUnclaimedName(name, path, claimed, wording) {
377
461
  const existing = claimed.get(name);
378
462
  if (existing !== void 0) {
379
- throw new OpsRouterError(
380
- `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.`
463
+ throw new SurfaceError(
464
+ `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.`
381
465
  );
382
466
  }
383
467
  claimed.set(name, path);
384
468
  }
385
469
 
386
- // src/ops/create-ops-router.ts
387
- var OPS_PATH_PREFIX = "/_ops/";
388
- var OPS_MANIFEST_PATH = "/_ops/_manifest";
389
- var OPS_MANIFEST_NAME = "getOpsManifest";
470
+ // src/surface/route-overlap.ts
471
+ function routeSegments(path) {
472
+ return path.split("/").slice(1).map((raw) => {
473
+ if (raw === "*" || raw.endsWith("*")) {
474
+ return { kind: "wildcard", value: raw, optional: true };
475
+ }
476
+ if (raw.startsWith(":")) {
477
+ return {
478
+ kind: "parameter",
479
+ value: raw.slice(1).replace(/\?$/, ""),
480
+ optional: raw.endsWith("?")
481
+ };
482
+ }
483
+ return { kind: "static", value: raw, optional: false };
484
+ });
485
+ }
486
+ function customPattern(parameter) {
487
+ const openingBrace = parameter.indexOf("{");
488
+ return openingBrace >= 0 && parameter.endsWith("}") ? parameter.slice(openingBrace + 1, -1) : null;
489
+ }
490
+ function parameterAccepts(parameter, value) {
491
+ const pattern = customPattern(parameter);
492
+ if (pattern === null) {
493
+ return value.length > 0 && !value.includes("/");
494
+ }
495
+ try {
496
+ return new RegExp(`^(?:${pattern})$`).test(value);
497
+ } catch {
498
+ return true;
499
+ }
500
+ }
501
+ function remainingStaticPath(segments, from) {
502
+ const remaining = segments.slice(from);
503
+ return remaining.every((segment) => segment.kind === "static") ? remaining.map((segment) => segment.value).join("/") : null;
504
+ }
505
+ function routePatternsOverlap(firstPath, secondPath) {
506
+ const first = routeSegments(firstPath);
507
+ const second = routeSegments(secondPath);
508
+ const length = Math.max(first.length, second.length);
509
+ for (let index = 0; index < length; index++) {
510
+ const left = first[index];
511
+ const right = second[index];
512
+ if (!left || !right) {
513
+ const remaining = left ? first.slice(index) : second.slice(index);
514
+ return remaining.every((segment) => segment.optional || segment.kind === "wildcard");
515
+ }
516
+ if (left.kind === "wildcard" || right.kind === "wildcard") {
517
+ return true;
518
+ }
519
+ if (left.kind === "static" && right.kind === "static") {
520
+ if (left.value !== right.value) {
521
+ return false;
522
+ }
523
+ continue;
524
+ }
525
+ if (left.kind === "parameter" && right.kind === "static") {
526
+ const remaining = remainingStaticPath(second, index);
527
+ if (customPattern(left.value) !== null && remaining !== null && parameterAccepts(left.value, remaining)) {
528
+ return true;
529
+ }
530
+ if (!parameterAccepts(left.value, right.value)) {
531
+ return customPattern(left.value) !== null && remaining === null;
532
+ }
533
+ if (customPattern(left.value) !== null && first.length !== second.length) {
534
+ return true;
535
+ }
536
+ continue;
537
+ }
538
+ if (left.kind === "static" && right.kind === "parameter") {
539
+ const remaining = remainingStaticPath(first, index);
540
+ if (customPattern(right.value) !== null && remaining !== null && parameterAccepts(right.value, remaining)) {
541
+ return true;
542
+ }
543
+ if (!parameterAccepts(right.value, left.value)) {
544
+ return customPattern(right.value) !== null && remaining === null;
545
+ }
546
+ if (customPattern(right.value) !== null && first.length !== second.length) {
547
+ return true;
548
+ }
549
+ continue;
550
+ }
551
+ return true;
552
+ }
553
+ return true;
554
+ }
555
+
556
+ // src/surface/module.ts
557
+ var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
558
+ var COMMAND_NAME = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/;
559
+ var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
560
+ var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
561
+ var PATH_BASE = "https://spfn.invalid";
562
+ function isSurfaceEffect(value) {
563
+ return EFFECTS.has(value);
564
+ }
565
+ function assertText(label, value) {
566
+ if (typeof value !== "string" || value.trim().length === 0) {
567
+ throw new SurfaceError(`${label} must be a non-empty string.`);
568
+ }
569
+ }
570
+ function assertStableModulePath(at, moduleId, commandName, path) {
571
+ const label = `${at.label} command "${moduleId}.${commandName}" path "${path}"`;
572
+ const transportPath = path.replace(/[{}?#]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
573
+ let url;
574
+ try {
575
+ url = new URL(transportPath, PATH_BASE);
576
+ } catch {
577
+ throw new SurfaceError(`${label} is not a valid URL path.`);
578
+ }
579
+ if (url.origin !== PATH_BASE || url.search !== "" || url.hash !== "" || url.pathname !== transportPath) {
580
+ throw new SurfaceError(`${label} is not a stable plain absolute path.`);
581
+ }
582
+ const slashCount = (path.match(/\//g) ?? []).length;
583
+ let decoded = path;
584
+ for (let depth = 0; depth < 8; depth++) {
585
+ let next;
586
+ try {
587
+ next = decodeURIComponent(decoded);
588
+ } catch {
589
+ throw new SurfaceError(`${label} contains malformed percent encoding.`);
590
+ }
591
+ if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
592
+ throw new SurfaceError(`${label} contains encoded path separators or dot segments.`);
593
+ }
594
+ if (next === decoded) {
595
+ return;
596
+ }
597
+ decoded = next;
598
+ }
599
+ throw new SurfaceError(`${label} uses too many percent-encoding layers.`);
600
+ }
601
+ function assertCliCallableModulePath(at, moduleId, commandName, path) {
602
+ 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)));
603
+ if (unsupported !== void 0) {
604
+ throw new SurfaceError(
605
+ `${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.`
606
+ );
607
+ }
608
+ }
609
+ function assertCommand(at, moduleId, name, command) {
610
+ if (!COMMAND_NAME.test(name)) {
611
+ throw new SurfaceError(
612
+ `${at.label} module "${moduleId}" has invalid command name "${name}". Use dot-separated alphanumeric names.`
613
+ );
614
+ }
615
+ assertText(`${at.label} command "${moduleId}.${name}" summary`, command?.summary);
616
+ if (!EFFECTS.has(command?.effect)) {
617
+ throw new SurfaceError(
618
+ `${at.label} command "${moduleId}.${name}" has invalid effect ${JSON.stringify(command?.effect)}.`
619
+ );
620
+ }
621
+ if (!Array.isArray(command?.scopes) || command.scopes.length === 0 || command.scopes.some((scope) => typeof scope !== "string" || scope.trim().length === 0)) {
622
+ throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must declare at least one non-empty scope.`);
623
+ }
624
+ const route2 = command?.route;
625
+ if (!route2 || typeof route2.handler !== "function" || !route2.method || !route2.path) {
626
+ throw new SurfaceError(`${at.label} command "${moduleId}.${name}" must carry a complete route.`);
627
+ }
628
+ assertStableModulePath(at, moduleId, name, route2.path);
629
+ assertCliCallableModulePath(at, moduleId, name, route2.path);
630
+ const modulePath = `${at.namespace}/${moduleId}/`;
631
+ if (!route2.path.startsWith(modulePath)) {
632
+ throw new SurfaceError(
633
+ `${at.label} command "${moduleId}.${name}" is at "${route2.path}", outside "${modulePath}". A module mounted on "${at.namespace}" keeps every command under "${modulePath}".`
634
+ );
635
+ }
636
+ if (route2.path.split("/").includes("..")) {
637
+ throw new SurfaceError(`${at.label} command "${moduleId}.${name}" path climbs out of its module namespace.`);
638
+ }
639
+ }
640
+ function validateSurfaceModule(module, namespace, wording = SURFACE_WORDING) {
641
+ const at = { namespace, label: capitalized(wording.noun) };
642
+ if (!MODULE_ID.test(module?.id ?? "")) {
643
+ throw new SurfaceError(
644
+ `${at.label} module id ${JSON.stringify(module?.id)} is invalid. Use lower-kebab-case.`
645
+ );
646
+ }
647
+ assertText(`${at.label} module "${module.id}" source`, module.source);
648
+ assertText(`${at.label} module "${module.id}" summary`, module.summary);
649
+ if (!CONTRACT_VERSION.test(module.contractVersion)) {
650
+ throw new SurfaceError(
651
+ `${at.label} module "${module.id}" contractVersion must be a semantic version.`
652
+ );
653
+ }
654
+ if (!module.commands || typeof module.commands !== "object" || Array.isArray(module.commands)) {
655
+ throw new SurfaceError(`${at.label} module "${module.id}" commands must be an object.`);
656
+ }
657
+ const signatures = /* @__PURE__ */ new Map();
658
+ const claimedRoutes = [];
659
+ for (const [name, command] of Object.entries(module.commands)) {
660
+ assertCommand(at, module.id, name, command);
661
+ const signature = `${command.route.method} ${command.route.path}`;
662
+ const existing = signatures.get(signature);
663
+ if (existing) {
664
+ throw new SurfaceError(
665
+ `${at.label} module "${module.id}" commands "${existing}" and "${name}" both use ${signature}.`
666
+ );
667
+ }
668
+ signatures.set(signature, name);
669
+ const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method && routePatternsOverlap(claim.path, command.route.path));
670
+ if (overlapping) {
671
+ throw new SurfaceError(
672
+ `${at.label} module "${module.id}" commands "${overlapping.name}" and "${name}" have overlapping ${command.route.method} routes ("${overlapping.path}" and "${command.route.path}").`
673
+ );
674
+ }
675
+ claimedRoutes.push({
676
+ name,
677
+ method: command.route.method,
678
+ path: command.route.path
679
+ });
680
+ }
681
+ return module;
682
+ }
683
+
684
+ // src/surface/namespace.ts
685
+ var NAMESPACE = /^(?:\/[A-Za-z0-9_-]+)+$/;
686
+ function assertNamespace(namespace) {
687
+ if (typeof namespace !== "string" || !NAMESPACE.test(namespace)) {
688
+ throw new SurfaceError(
689
+ `Surface namespace ${JSON.stringify(namespace)} is invalid. Use an absolute path of [A-Za-z0-9_-] segments without a trailing slash, such as "/_app".`
690
+ );
691
+ }
692
+ }
693
+ function manifestPathOf(namespace) {
694
+ return `${namespace}/_manifest`;
695
+ }
696
+ function manifestNameOf(namespace) {
697
+ return `get${namespace.slice(1).replace(/_[a-z]|[A-Z_/-]/g, encodedCharacter)}Manifest`;
698
+ }
699
+ var ESCAPES = { "_": "_", "-": "d", "/": "s" };
700
+ function encodedCharacter(match) {
701
+ if (match.length === 2) {
702
+ return match.charAt(1).toUpperCase();
703
+ }
704
+ return `_${ESCAPES[match] ?? match}`;
705
+ }
706
+ function toSurfacePath(namespace, path, wording) {
707
+ const label = capitalized(wording.noun);
708
+ if (!path.startsWith("/")) {
709
+ throw new SurfaceError(
710
+ `${label} route path "${path}" must start with "/". It is appended to "${namespace}", so "${path}" would read as "${namespace}${path}".`
711
+ );
712
+ }
713
+ if (path === "/") {
714
+ throw new SurfaceError(
715
+ `${label} route path "/" names no command \u2014 "${namespace}" itself is not a command.`
716
+ );
717
+ }
718
+ return namespace + path;
719
+ }
720
+ function surfaceRouteBuilder(namespace, wording) {
721
+ const method = (name) => (path) => route[name](toSurfacePath(namespace, path, wording));
722
+ return {
723
+ get: method("get"),
724
+ post: method("post"),
725
+ put: method("put"),
726
+ patch: method("patch"),
727
+ delete: method("delete")
728
+ };
729
+ }
730
+
731
+ // src/surface/define-surface.ts
732
+ var MAX_COMMAND_SUMMARY_LENGTH = 500;
733
+ var EFFECT_METHOD = ".effect('read' | 'write' | 'destructive')";
390
734
  function isRouter2(value) {
391
735
  return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
392
736
  }
393
737
  function isRouteDef2(value) {
394
738
  return value !== null && typeof value === "object" && "handler" in value;
395
739
  }
396
- function assertOpsRoute(name, def) {
740
+ function assertSurfaceRoute(plan, name, def) {
741
+ const label = capitalized(plan.wording.noun);
397
742
  if (!def.method || !def.path) {
398
- throw new OpsRouterError(
399
- `Ops route "${name}" has no method or path. An ops command is invoked on the wire, so both are required.`
743
+ throw new SurfaceError(
744
+ `${label} route "${name}" has no method or path. A command is invoked on the wire, so both are required.`
745
+ );
746
+ }
747
+ if (!def.path.startsWith(plan.prefix)) {
748
+ throw new SurfaceError(
749
+ `${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.`
750
+ );
751
+ }
752
+ if (def.path === plan.manifestPath) {
753
+ throw new SurfaceError(
754
+ `${label} route "${name}" claims "${plan.manifestPath}", which is reserved for the manifest. The manifest is registered first, so this route would never answer.`
755
+ );
756
+ }
757
+ }
758
+ function assertCommandMetadata(plan, name, def) {
759
+ const label = `${capitalized(plan.wording.noun)} command "${name}"`;
760
+ if (def.effect === void 0 && plan.requireEffect) {
761
+ throw new SurfaceError(
762
+ `${label} declares no effect. A client reads it to decide whether to call, retry, or ask for confirmation, so ${plan.wording.factory} refuses a command without one. Declare it on the route with ${EFFECT_METHOD}.`
763
+ );
764
+ }
765
+ if (def.effect !== void 0 && !isSurfaceEffect(def.effect)) {
766
+ throw new SurfaceError(
767
+ `${label} has invalid effect ${JSON.stringify(def.effect)}. A client knows no other, so declare it with ${EFFECT_METHOD}.`
768
+ );
769
+ }
770
+ assertCommandSummary(label, def.summary);
771
+ }
772
+ function assertCommandSummary(label, summary) {
773
+ if (summary === void 0) {
774
+ return;
775
+ }
776
+ if (typeof summary !== "string" || summary.trim().length === 0) {
777
+ throw new SurfaceError(
778
+ `${label} has an empty summary. A client would list it as a command described by nothing; give .summary() one line of text, or leave it out.`
400
779
  );
401
780
  }
402
- if (!def.path.startsWith(OPS_PATH_PREFIX)) {
403
- throw new OpsRouterError(
404
- `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.`
781
+ if (summary.length > MAX_COMMAND_SUMMARY_LENGTH) {
782
+ throw new SurfaceError(
783
+ `${label} has a summary of ${summary.length} characters, over the ${MAX_COMMAND_SUMMARY_LENGTH} a client accepts \u2014 it would drop the command's metadata and treat it as destructive. Shorten it.`
405
784
  );
406
785
  }
407
- if (def.path === OPS_MANIFEST_PATH) {
408
- throw new OpsRouterError(
409
- `Ops route "${name}" claims "${OPS_MANIFEST_PATH}", which is reserved for the manifest. The manifest is registered first, so this route would never answer.`
786
+ }
787
+ function assertModuleRouteEffect(plan, name, definition) {
788
+ const routeEffect = definition.route.effect;
789
+ if (routeEffect !== void 0 && routeEffect !== definition.effect) {
790
+ throw new SurfaceError(
791
+ `${capitalized(plan.wording.noun)} command "${name}" is declared ${JSON.stringify(definition.effect)} by its module, but its route says .effect(${JSON.stringify(routeEffect)}). The manifest announces the module's declaration, so the two cannot disagree. Drop the route's .effect() or make it match.`
410
792
  );
411
793
  }
412
794
  }
413
- function assertOpsName(name) {
414
- if (name === OPS_MANIFEST_NAME) {
415
- throw new OpsRouterError(
416
- `Ops route name "${OPS_MANIFEST_NAME}" is reserved for the manifest route.`
795
+ function assertSurfaceName(plan, name) {
796
+ if (name === plan.manifestName) {
797
+ throw new SurfaceError(
798
+ `${capitalized(plan.wording.noun)} route name "${plan.manifestName}" is reserved for the manifest route.`
417
799
  );
418
800
  }
419
801
  }
420
- function rebuildNestedRouter(name, router, auth, inherited) {
802
+ function rebuildNestedRouter(plan, name, router, commandsByName, inherited) {
421
803
  if (router._packageRouters?.length > 0) {
422
- throw new OpsRouterError(
423
- `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.`
804
+ throw new SurfaceError(
805
+ `${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.`
424
806
  );
425
807
  }
426
808
  const handedDown = [...inherited, ...router._globalMiddlewares ?? []];
427
- let rebuilt = defineRouter(
428
- secureRoutes(router.routes, auth, handedDown)
809
+ let rebuilt = defineUnmappedRouter(
810
+ secureRoutes(plan, router.routes, commandsByName, handedDown)
429
811
  );
430
812
  if (router._contractVersion) {
431
813
  rebuilt = rebuilt.contractVersion(router._contractVersion);
432
814
  }
433
815
  return rebuilt;
434
816
  }
435
- function secureRoutes(routes, auth, inherited = []) {
817
+ function secureRoutes(plan, routes, commandsByName, inherited = []) {
436
818
  const secured = {};
437
819
  for (const [name, entry] of Object.entries(routes)) {
438
- assertOpsName(name);
820
+ assertSurfaceName(plan, name);
439
821
  if (isRouter2(entry)) {
440
- secured[name] = rebuildNestedRouter(name, entry, auth, inherited);
822
+ secured[name] = rebuildNestedRouter(plan, name, entry, commandsByName, inherited);
441
823
  continue;
442
824
  }
443
825
  if (!isRouteDef2(entry)) {
444
- throw new OpsRouterError(`Ops router entry "${name}" is neither a route nor a router.`);
826
+ throw new SurfaceError(
827
+ `${capitalized(plan.wording.noun)} router entry "${name}" is neither a route nor a router.`
828
+ );
445
829
  }
446
- assertOpsRoute(name, entry);
830
+ assertSurfaceRoute(plan, name, entry);
831
+ assertCommandMetadata(plan, name, entry);
832
+ const guard = plan.authorize?.(commandsByName.get(name));
447
833
  secured[name] = {
448
834
  ...entry,
449
- middlewares: [auth, ...inherited, ...entry.middlewares ?? []]
835
+ middlewares: commandChain(plan, name, guard, [...inherited, ...entry.middlewares ?? []])
450
836
  };
451
837
  }
452
838
  return secured;
453
839
  }
454
- function createOpsRouter(routes, options) {
455
- if (!options?.auth) {
456
- throw new OpsRouterError(
457
- "createOpsRouter requires an auth middleware ({ auth: ... }). An ops surface reachable without authentication cannot be created."
840
+ function commandChain(plan, name, guard, declared) {
841
+ if (guard === void 0) {
842
+ return [plan.auth, ...declared];
843
+ }
844
+ const guardName = middlewareNameOf(guard);
845
+ if (guardName !== void 0) {
846
+ refuseGuardCollision(plan, name, guardName, declared);
847
+ }
848
+ return [plan.auth, guard, ...declared];
849
+ }
850
+ function refuseGuardCollision(plan, name, guardName, declared) {
851
+ const noun = capitalized(plan.wording.noun);
852
+ if (middlewareNameOf(plan.auth) === guardName) {
853
+ throw new SurfaceError(
854
+ `${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.`
855
+ );
856
+ }
857
+ if (declared.some((middleware) => middlewareNameOf(middleware) === guardName)) {
858
+ throw new SurfaceError(
859
+ `${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.`
860
+ );
861
+ }
862
+ }
863
+ function middlewareNameOf(middleware) {
864
+ return typeof middleware === "function" ? void 0 : middleware.name;
865
+ }
866
+ function assertModuleCommandFree(plan, command, appCommands, claimed) {
867
+ const noun = plan.wording.noun;
868
+ if (claimed.names.has(command.name)) {
869
+ throw new SurfaceError(`Two ${noun} commands are named "${command.name}".`);
870
+ }
871
+ const signature = `${command.method} ${command.path}`;
872
+ const existing = claimed.signatures.get(signature);
873
+ if (existing) {
874
+ throw new SurfaceError(
875
+ `${capitalized(noun)} commands "${existing}" and "${command.name}" both use ${signature}.`
458
876
  );
459
877
  }
878
+ const overlapping = appCommands.find((app) => app.method === command.method && routePatternsOverlap(app.path, command.path));
879
+ if (overlapping) {
880
+ throw new SurfaceError(
881
+ `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.`
882
+ );
883
+ }
884
+ claimed.names.add(command.name);
885
+ claimed.signatures.set(signature, command.name);
886
+ }
887
+ function compileModules(plan, appCommands) {
888
+ const compiled = { descriptors: [], commands: [], routes: {} };
889
+ const moduleIds = /* @__PURE__ */ new Set();
890
+ const claimed = {
891
+ names: new Set(appCommands.map((command) => command.name)),
892
+ signatures: new Map(appCommands.map((command) => [`${command.method} ${command.path}`, command.name]))
893
+ };
894
+ for (const rawModule of plan.modules) {
895
+ const module = validateSurfaceModule(rawModule, plan.namespace, plan.wording);
896
+ if (moduleIds.has(module.id)) {
897
+ throw new SurfaceError(`Two ${plan.wording.noun} modules use id "${module.id}".`);
898
+ }
899
+ moduleIds.add(module.id);
900
+ compiled.descriptors.push({
901
+ id: module.id,
902
+ source: module.source,
903
+ contractVersion: module.contractVersion,
904
+ summary: module.summary
905
+ });
906
+ for (const [localName, definition] of Object.entries(module.commands)) {
907
+ const command = {
908
+ name: `${module.id}.${localName}`,
909
+ module: module.id,
910
+ summary: definition.summary,
911
+ effect: definition.effect,
912
+ scopes: [...definition.scopes],
913
+ method: definition.route.method,
914
+ path: definition.route.path,
915
+ input: collectSurfaceCommands({ [localName]: definition.route }, plan.wording)[0].input
916
+ };
917
+ assertModuleRouteEffect(plan, command.name, definition);
918
+ assertModuleCommandFree(plan, command, appCommands, claimed);
919
+ compiled.commands.push(command);
920
+ compiled.routes[command.name] = { route: definition.route, command };
921
+ }
922
+ }
923
+ compiled.descriptors.sort((a, b) => a.id.localeCompare(b.id));
924
+ compiled.commands.sort((a, b) => a.name.localeCompare(b.name));
925
+ return compiled;
926
+ }
927
+ function secureModuleRoutes(plan, routes) {
928
+ const secured = {};
929
+ for (const [name, definition] of Object.entries(routes)) {
930
+ assertSurfaceName(plan, name);
931
+ assertSurfaceRoute(plan, name, definition.route);
932
+ secured[name] = {
933
+ ...definition.route,
934
+ middlewares: commandChain(
935
+ plan,
936
+ name,
937
+ plan.authorize?.(definition.command),
938
+ definition.route.middlewares ?? []
939
+ )
940
+ };
941
+ }
942
+ return secured;
943
+ }
944
+ function manifestRoute(plan, manifest) {
945
+ const builder = route.get(plan.manifestPath);
946
+ return (plan.manifest === "public" ? builder.skip(["auth"]) : builder.use([plan.auth])).handler(async () => manifest);
947
+ }
948
+ function buildRouter(plan, routes) {
949
+ const appCommands = collectSurfaceCommands(routes, plan.wording);
950
+ const moduleSurface = compileModules(plan, appCommands);
951
+ const commands = [...appCommands, ...moduleSurface.commands].sort((a, b) => a.name.localeCompare(b.name));
460
952
  const manifest = {
461
953
  manifestVersion: 1,
462
- commands: collectOpsCommands(routes)
954
+ ...moduleSurface.descriptors.length > 0 ? { modules: moduleSurface.descriptors } : {},
955
+ commands
463
956
  };
464
- const secured = secureRoutes(routes, options.auth);
465
- const manifestRoute = route.get(OPS_MANIFEST_PATH).use([options.auth]).handler(async () => manifest);
466
- return defineRouter({
467
- [OPS_MANIFEST_NAME]: manifestRoute,
468
- ...secured
469
- });
957
+ const commandsByName = new Map(appCommands.map((command) => [command.name, command]));
958
+ const secured = secureRoutes(plan, routes, commandsByName);
959
+ const securedModules = secureModuleRoutes(plan, moduleSurface.routes);
960
+ return Object.defineProperty(defineUnmappedRouter({
961
+ [plan.manifestName]: manifestRoute(plan, manifest),
962
+ ...secured,
963
+ ...securedModules
964
+ }), "_surfaceNamespace", { value: plan.namespace, enumerable: false });
470
965
  }
966
+ function planOf(options, wording, rules) {
967
+ assertNamespace(options?.namespace);
968
+ if (!options.auth) {
969
+ throw new SurfaceError(
970
+ `${wording.factory} requires an auth middleware ({ auth: ... }). A surface reachable without authentication cannot be created.`
971
+ );
972
+ }
973
+ const modules = options.modules ?? [];
974
+ if (modules.length > 0 && !options.authorize) {
975
+ throw new SurfaceError(`${wording.factory} requires an authorize function when modules are mounted.`);
976
+ }
977
+ const manifest = options.manifest ?? "authenticated";
978
+ if (manifest !== "authenticated" && manifest !== "public") {
979
+ throw new SurfaceError(
980
+ `${wording.factory} manifest must be 'authenticated' or 'public', got ${JSON.stringify(manifest)}.`
981
+ );
982
+ }
983
+ return {
984
+ namespace: options.namespace,
985
+ prefix: `${options.namespace}/`,
986
+ manifestPath: manifestPathOf(options.namespace),
987
+ manifestName: manifestNameOf(options.namespace),
988
+ auth: options.auth,
989
+ authorize: options.authorize,
990
+ manifest,
991
+ modules,
992
+ wording,
993
+ requireEffect: rules.requireEffect
994
+ };
995
+ }
996
+ function createSurface(options, wording, rules) {
997
+ const plan = planOf(options, wording, rules);
998
+ return {
999
+ namespace: plan.namespace,
1000
+ route: surfaceRouteBuilder(plan.namespace, wording),
1001
+ router: (routes) => buildRouter(plan, routes)
1002
+ };
1003
+ }
1004
+
1005
+ // src/ops/wording.ts
1006
+ var OPS_WORDING = {
1007
+ noun: "ops",
1008
+ builder: "`opsRoute`",
1009
+ factory: "createOpsRouter"
1010
+ };
471
1011
 
472
1012
  // src/ops/ops-route.ts
473
1013
  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
- );
1014
+ var opsRoute = surfaceRouteBuilder(OPS_PATH_ROOT, OPS_WORDING);
1015
+
1016
+ // src/ops/create-ops-router.ts
1017
+ var OPS_PATH_PREFIX = "/_ops/";
1018
+ var OPS_MANIFEST_PATH = "/_ops/_manifest";
1019
+ function moduleScopeGuard(authorize) {
1020
+ if (!authorize) {
1021
+ return void 0;
479
1022
  }
480
- if (path === "/") {
481
- throw new OpsRouterError(
482
- `Ops route path "/" names no command \u2014 "${OPS_PATH_ROOT}" itself is not a command.`
1023
+ return (command) => command.module === void 0 ? void 0 : authorize(...command.scopes ?? []);
1024
+ }
1025
+ function createOpsRouter(routes, options) {
1026
+ if ((options?.modules ?? []).length > 0 && !options.authorize) {
1027
+ throw new SurfaceError(
1028
+ "createOpsRouter requires an authorize scope factory when modules are mounted."
483
1029
  );
484
1030
  }
485
- return OPS_PATH_ROOT + path;
1031
+ return createSurface({
1032
+ namespace: OPS_PATH_ROOT,
1033
+ auth: options?.auth,
1034
+ authorize: moduleScopeGuard(options?.authorize),
1035
+ modules: options?.modules
1036
+ }, OPS_WORDING, { requireEffect: false }).router(routes);
486
1037
  }
487
- function opsMethod(method) {
488
- return (path) => route[method](toOpsPath(path));
1038
+
1039
+ // src/ops/manifest.ts
1040
+ function collectOpsCommands(routes) {
1041
+ return collectSurfaceCommands(routes, OPS_WORDING);
1042
+ }
1043
+
1044
+ // src/ops/module.ts
1045
+ function defineOpsModule(module) {
1046
+ return validateSurfaceModule(module, OPS_PATH_ROOT, OPS_WORDING);
489
1047
  }
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
1048
 
498
- export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, OpsRouterError, collectOpsCommands, createOpsRouter, opsRoute };
1049
+ export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, SurfaceError as OpsRouterError, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
499
1050
  //# sourceMappingURL=index.js.map
500
1051
  //# sourceMappingURL=index.js.map