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

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 CHANGED
@@ -43,7 +43,7 @@ Next.js owns the frontend, Drizzle owns the SQL, and your services own the rules
43
43
 
44
44
  ```bash
45
45
  pnpm add @spfn/core@beta drizzle-orm@1.0.0-rc.4 postgres pg
46
- # optional peer: next ^16.3.3 (only for the Next.js bridge)
46
+ # optional peer: next ^16.3.8 (only for the Next.js bridge)
47
47
  ```
48
48
 
49
49
  Node `>=20.0.0`. ESM only.
@@ -691,6 +691,10 @@ The manifest gains a `modules` array and per-command `module`, `summary`, `effec
691
691
  `scopes`. All of it is additive — an app that mounts no modules serves exactly the v1
692
692
  manifest it served before.
693
693
 
694
+ An app's own ops routes may declare `.effect()` and `.summary()` too, and the manifest
695
+ carries both for that command. On `/_ops` they are optional: a route declaring neither is
696
+ announced exactly as before.
697
+
694
698
  ```bash
695
699
  spfn ops modules # what is mounted, and from where
696
700
  spfn ops list --module ledger # just that module's commands
@@ -731,6 +735,8 @@ export const app = defineSurface({
731
735
 
732
736
  export const pageAdd = app.route.post('/pages') // POST /_app/pages
733
737
  .input({ body: Type.Object({ title: Type.String() }) })
738
+ .effect('write') // required: read | write | destructive
739
+ .summary('Add a page to the blueprint') // optional, at most 500 characters
734
740
  .handler(async (c) => pagesRepository.add((await c.data()).body.title));
735
741
 
736
742
  // src/server/router.ts — both surfaces, side by side
@@ -754,6 +760,12 @@ export const appRouter = defineRouter({ ... }).packages([app.router({ pageAdd })
754
760
  module is mounted. A guard named like `auth` or like one of the command's group or own
755
761
  middlewares is refused: registration keeps one middleware per name, so the other one would
756
762
  never run.
763
+ - **Effect.** Every app command on a `defineSurface` surface declares `.effect()` on its
764
+ route; `router()` refuses one that does not, naming the command, so a client never
765
+ receives a command whose effect it cannot know. `/_ops` routes may declare it and need
766
+ not. The manifest announces `effect` and `summary` beside the command, and `authorize`
767
+ receives them on the command it is given. A module command's own declaration wins over its
768
+ route's: a route `.effect()` that disagrees with it is refused.
757
769
  - **Modules.** A module is validated against the namespace of the surface that mounts it —
758
770
  its commands must live under `<namespace>/<id>/`. Write one for a surface with
759
771
  `defineSurfaceModule(module, '/_app')`; a module written for `/_ops` mounted on `/_app` is
@@ -1,11 +1,11 @@
1
1
  import { E as EventRouterDef } from '../router-CysBmgv3.js';
2
- import { R as Router } from '../router-DaDrprJd.js';
3
- import { C as ContractDocument, a as ContractViolation, b as ContractOperation, c as ContractSnapshot } from '../types-D52SokSM.js';
4
- export { d as CompatibilityPolicy, e as ContractEvent, f as ContractEvents, g as ContractRequest, h as ContractViolationKind, J as JsonSchema } from '../types-D52SokSM.js';
2
+ import { R as Router } from '../router-4YoIy5fN.js';
3
+ import { C as ContractDocument, a as ContractViolation, b as ContractOperation, c as ContractSnapshot } from '../types-5Rl11_Nb.js';
4
+ export { d as CompatibilityPolicy, e as ContractEvent, f as ContractEvents, g as ContractRequest, h as ContractViolationKind, J as JsonSchema } from '../types-5Rl11_Nb.js';
5
5
  import '@sinclair/typebox';
6
6
  import '../define-middleware-CVKgqo8S.js';
7
7
  import 'hono';
8
- import '../route-builder-BHKgGkwI.js';
8
+ import '../route-builder-DRW-MSCJ.js';
9
9
  import 'hono/utils/http-status';
10
10
  import '../route/types.js';
11
11
 
@@ -1,7 +1,7 @@
1
- import { R as RouteDef, f as RouteBuilder } from './route-builder-BHKgGkwI.js';
2
- import { J as JsonSchema } from './types-D52SokSM.js';
3
- import { R as Router } from './router-DaDrprJd.js';
4
- import { HttpMethod } from './route/types.js';
1
+ import { R as RouteDef, f as RouteBuilder } from './route-builder-DRW-MSCJ.js';
2
+ import { J as JsonSchema } from './types-5Rl11_Nb.js';
3
+ import { R as Router } from './router-4YoIy5fN.js';
4
+ import { RouteEffect, HttpMethod } from './route/types.js';
5
5
 
6
6
  /**
7
7
  * How refusals name the surface they come from.
@@ -35,7 +35,8 @@ interface SurfaceWording {
35
35
  * than registered at paths outside the surface that is supposed to guard them.
36
36
  */
37
37
 
38
- type SurfaceEffect = 'read' | 'write' | 'destructive';
38
+ /** One vocabulary for module commands and an app's own: the route's `.effect()`. */
39
+ type SurfaceEffect = RouteEffect;
39
40
  interface SurfaceModuleCommand<TRoute extends RouteDef<any> = RouteDef<any>> {
40
41
  summary: string;
41
42
  effect: SurfaceEffect;
@@ -115,8 +116,13 @@ interface SurfaceCommand {
115
116
  path: string;
116
117
  /** Present for commands contributed by an explicitly mounted module. */
117
118
  module?: string;
119
+ /**
120
+ * A module command's from its declaration; an app command's from the
121
+ * route's `.summary()` and `.effect()`, present only when declared.
122
+ */
118
123
  summary?: string;
119
124
  effect?: SurfaceEffect;
125
+ /** Present for module commands only. */
120
126
  scopes?: string[];
121
127
  /** Input sections the route declares, each as JSON Schema. */
122
128
  input: {
@@ -134,8 +140,9 @@ interface SurfaceManifest {
134
140
  }
135
141
  /**
136
142
  * Walk a routes record (nested routers included) and collect every RouteDef
137
- * as a command. Validation of paths happens in the surface's `router()`, so
138
- * nothing here is checked but the uniqueness of names.
143
+ * as a command, with the summary and effect its route declared. Validation of
144
+ * paths and of that metadata happens in the surface's `router()`, so nothing
145
+ * here is checked but the uniqueness of names.
139
146
  */
140
147
  declare function collectSurfaceCommands(routes: Record<string, RouteDef<any> | Router<any>>, wording?: SurfaceWording): SurfaceCommand[];
141
148
 
@@ -1,13 +1,13 @@
1
1
  import { MiddlewareHandler } from 'hono';
2
2
  import { a as NamedMiddleware } from '../define-middleware-CVKgqo8S.js';
3
- import { R as RouteDef } from '../route-builder-BHKgGkwI.js';
4
- import { R as Router } from '../router-DaDrprJd.js';
5
- import { S as SurfaceEffect, a as SurfaceModuleCommand, b as SurfaceModule, c as SurfaceRouteBuilder, d as SurfaceCommand, e as SurfaceManifest, f as SurfaceModuleDescriptor } from '../error-BcdBVfRA.js';
6
- export { g as OpsRouterError } from '../error-BcdBVfRA.js';
3
+ import { R as RouteDef } from '../route-builder-DRW-MSCJ.js';
4
+ import { R as Router } from '../router-4YoIy5fN.js';
5
+ import { S as SurfaceEffect, a as SurfaceModuleCommand, b as SurfaceModule, c as SurfaceRouteBuilder, d as SurfaceCommand, e as SurfaceManifest, f as SurfaceModuleDescriptor } from '../error-TsUIjp_x.js';
6
+ export { g as OpsRouterError } from '../error-TsUIjp_x.js';
7
7
  import '@sinclair/typebox';
8
8
  import 'hono/utils/http-status';
9
9
  import '../route/types.js';
10
- import '../types-D52SokSM.js';
10
+ import '../types-5Rl11_Nb.js';
11
11
 
12
12
  /**
13
13
  * Capability ops module contract
@@ -60,7 +60,10 @@ declare function defineOpsModule<TCommands extends Record<string, OpsModuleComma
60
60
  * It is the command surface (`@spfn/core/surface`) fixed to `/_ops`, with the
61
61
  * scope-factory `authorize` this factory has always taken adapted to the
62
62
  * surface's per-command one: applied to module commands only, so every
63
- * middleware chain is what it was before surfaces existed.
63
+ * middleware chain is what it was before surfaces existed. An ops route may
64
+ * declare `.effect()` and `.summary()` and the manifest carries them; unlike
65
+ * `defineSurface`, a route declaring neither is not refused, so an ops
66
+ * surface written before they existed boots and announces what it always did.
64
67
  *
65
68
  * @example
66
69
  * ```ts
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: {},
@@ -392,6 +441,8 @@ function visit(routes, commands, claimed, wording) {
392
441
  name,
393
442
  method: entry.method,
394
443
  path: entry.path,
444
+ ...entry.summary !== void 0 ? { summary: entry.summary } : {},
445
+ ...entry.effect !== void 0 ? { effect: entry.effect } : {},
395
446
  input: toCommandInput(entry.input)
396
447
  });
397
448
  }
@@ -498,6 +549,9 @@ var COMMAND_NAME = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/;
498
549
  var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
499
550
  var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
500
551
  var PATH_BASE = "https://spfn.invalid";
552
+ function isSurfaceEffect(value) {
553
+ return EFFECTS.has(value);
554
+ }
501
555
  function assertText(label, value) {
502
556
  if (typeof value !== "string" || value.trim().length === 0) {
503
557
  throw new SurfaceError(`${label} must be a non-empty string.`);
@@ -665,6 +719,8 @@ function surfaceRouteBuilder(namespace, wording) {
665
719
  }
666
720
 
667
721
  // src/surface/define-surface.ts
722
+ var MAX_COMMAND_SUMMARY_LENGTH = 500;
723
+ var EFFECT_METHOD = ".effect('read' | 'write' | 'destructive')";
668
724
  function isRouter2(value) {
669
725
  return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
670
726
  }
@@ -689,6 +745,43 @@ function assertSurfaceRoute(plan, name, def) {
689
745
  );
690
746
  }
691
747
  }
748
+ function assertCommandMetadata(plan, name, def) {
749
+ const label = `${capitalized(plan.wording.noun)} command "${name}"`;
750
+ if (def.effect === void 0 && plan.requireEffect) {
751
+ throw new SurfaceError(
752
+ `${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}.`
753
+ );
754
+ }
755
+ if (def.effect !== void 0 && !isSurfaceEffect(def.effect)) {
756
+ throw new SurfaceError(
757
+ `${label} has invalid effect ${JSON.stringify(def.effect)}. A client knows no other, so declare it with ${EFFECT_METHOD}.`
758
+ );
759
+ }
760
+ assertCommandSummary(label, def.summary);
761
+ }
762
+ function assertCommandSummary(label, summary) {
763
+ if (summary === void 0) {
764
+ return;
765
+ }
766
+ if (typeof summary !== "string" || summary.trim().length === 0) {
767
+ throw new SurfaceError(
768
+ `${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.`
769
+ );
770
+ }
771
+ if (summary.length > MAX_COMMAND_SUMMARY_LENGTH) {
772
+ throw new SurfaceError(
773
+ `${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.`
774
+ );
775
+ }
776
+ }
777
+ function assertModuleRouteEffect(plan, name, definition) {
778
+ const routeEffect = definition.route.effect;
779
+ if (routeEffect !== void 0 && routeEffect !== definition.effect) {
780
+ throw new SurfaceError(
781
+ `${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.`
782
+ );
783
+ }
784
+ }
692
785
  function assertSurfaceName(plan, name) {
693
786
  if (name === plan.manifestName) {
694
787
  throw new SurfaceError(
@@ -696,7 +789,7 @@ function assertSurfaceName(plan, name) {
696
789
  );
697
790
  }
698
791
  }
699
- function rebuildNestedRouter(plan, name, router, guards, inherited) {
792
+ function rebuildNestedRouter(plan, name, router, commandsByName, inherited) {
700
793
  if (router._packageRouters?.length > 0) {
701
794
  throw new SurfaceError(
702
795
  `${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.`
@@ -704,19 +797,19 @@ function rebuildNestedRouter(plan, name, router, guards, inherited) {
704
797
  }
705
798
  const handedDown = [...inherited, ...router._globalMiddlewares ?? []];
706
799
  let rebuilt = defineUnmappedRouter(
707
- secureRoutes(plan, router.routes, guards, handedDown)
800
+ secureRoutes(plan, router.routes, commandsByName, handedDown)
708
801
  );
709
802
  if (router._contractVersion) {
710
803
  rebuilt = rebuilt.contractVersion(router._contractVersion);
711
804
  }
712
805
  return rebuilt;
713
806
  }
714
- function secureRoutes(plan, routes, guards, inherited = []) {
807
+ function secureRoutes(plan, routes, commandsByName, inherited = []) {
715
808
  const secured = {};
716
809
  for (const [name, entry] of Object.entries(routes)) {
717
810
  assertSurfaceName(plan, name);
718
811
  if (isRouter2(entry)) {
719
- secured[name] = rebuildNestedRouter(plan, name, entry, guards, inherited);
812
+ secured[name] = rebuildNestedRouter(plan, name, entry, commandsByName, inherited);
720
813
  continue;
721
814
  }
722
815
  if (!isRouteDef2(entry)) {
@@ -725,9 +818,11 @@ function secureRoutes(plan, routes, guards, inherited = []) {
725
818
  );
726
819
  }
727
820
  assertSurfaceRoute(plan, name, entry);
821
+ assertCommandMetadata(plan, name, entry);
822
+ const guard = plan.authorize?.(commandsByName.get(name));
728
823
  secured[name] = {
729
824
  ...entry,
730
- middlewares: commandChain(plan, name, guards.get(name), [...inherited, ...entry.middlewares ?? []])
825
+ middlewares: commandChain(plan, name, guard, [...inherited, ...entry.middlewares ?? []])
731
826
  };
732
827
  }
733
828
  return secured;
@@ -758,9 +853,6 @@ function refuseGuardCollision(plan, name, guardName, declared) {
758
853
  function middlewareNameOf(middleware) {
759
854
  return typeof middleware === "function" ? void 0 : middleware.name;
760
855
  }
761
- function guardsFor(plan, commands) {
762
- return new Map(commands.map((command) => [command.name, plan.authorize?.(command)]));
763
- }
764
856
  function assertModuleCommandFree(plan, command, appCommands, claimed) {
765
857
  const noun = plan.wording.noun;
766
858
  if (claimed.names.has(command.name)) {
@@ -812,6 +904,7 @@ function compileModules(plan, appCommands) {
812
904
  path: definition.route.path,
813
905
  input: collectSurfaceCommands({ [localName]: definition.route }, plan.wording)[0].input
814
906
  };
907
+ assertModuleRouteEffect(plan, command.name, definition);
815
908
  assertModuleCommandFree(plan, command, appCommands, claimed);
816
909
  compiled.commands.push(command);
817
910
  compiled.routes[command.name] = { route: definition.route, command };
@@ -851,7 +944,8 @@ function buildRouter(plan, routes) {
851
944
  ...moduleSurface.descriptors.length > 0 ? { modules: moduleSurface.descriptors } : {},
852
945
  commands
853
946
  };
854
- const secured = secureRoutes(plan, routes, guardsFor(plan, appCommands));
947
+ const commandsByName = new Map(appCommands.map((command) => [command.name, command]));
948
+ const secured = secureRoutes(plan, routes, commandsByName);
855
949
  const securedModules = secureModuleRoutes(plan, moduleSurface.routes);
856
950
  return Object.defineProperty(defineUnmappedRouter({
857
951
  [plan.manifestName]: manifestRoute(plan, manifest),
@@ -859,7 +953,7 @@ function buildRouter(plan, routes) {
859
953
  ...securedModules
860
954
  }), "_surfaceNamespace", { value: plan.namespace, enumerable: false });
861
955
  }
862
- function planOf(options, wording) {
956
+ function planOf(options, wording, rules) {
863
957
  assertNamespace(options?.namespace);
864
958
  if (!options.auth) {
865
959
  throw new SurfaceError(
@@ -885,11 +979,12 @@ function planOf(options, wording) {
885
979
  authorize: options.authorize,
886
980
  manifest,
887
981
  modules,
888
- wording
982
+ wording,
983
+ requireEffect: rules.requireEffect
889
984
  };
890
985
  }
891
- function createSurface(options, wording) {
892
- const plan = planOf(options, wording);
986
+ function createSurface(options, wording, rules) {
987
+ const plan = planOf(options, wording, rules);
893
988
  return {
894
989
  namespace: plan.namespace,
895
990
  route: surfaceRouteBuilder(plan.namespace, wording),
@@ -928,7 +1023,7 @@ function createOpsRouter(routes, options) {
928
1023
  auth: options?.auth,
929
1024
  authorize: moduleScopeGuard(options?.authorize),
930
1025
  modules: options?.modules
931
- }, OPS_WORDING).router(routes);
1026
+ }, OPS_WORDING, { requireEffect: false }).router(routes);
932
1027
  }
933
1028
 
934
1029
  // src/ops/manifest.ts