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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -356,6 +356,7 @@ Each module has its own README with the API detail.
356
356
  | `@spfn/core/codegen` | The codegen orchestrator and the built-in generators: `@spfn/core:route-map` for the proxy's route map, `@spfn/core:contract` for the client contract. | [src/codegen](./src/codegen/README.md) |
357
357
  | `@spfn/core/contract` | Route contracts for clients that ship separately: collect, snapshot, and the build gate that refuses a breaking change. | [src/contract](./src/contract/README.md) |
358
358
  | `@spfn/core/ops` | The operations surface `spfn ops` drives: `opsRoute`, `createOpsRouter`, `defineOpsModule`, and the manifest the CLI discovers commands from. Structure only — the router is always authenticated, and token verification lives in `@spfn/auth`. | [How do I operate the app from the terminal?](#how-do-i-operate-the-app-from-the-terminal) |
359
+ | `@spfn/core/surface` | `defineSurface`: a command surface on any namespace — the same authenticated router, manifest and modules `@spfn/core/ops` is on `/_ops`, for a caller other than an operator. | [Can a second surface sit beside `/_ops`?](#can-a-second-surface-sit-beside-_ops) |
359
360
 
360
361
  `db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
361
362
  They are internal modules re-exported by `@spfn/core/db` — import their symbols from
@@ -703,6 +704,66 @@ CLI keeps the token in the keychain (`spfn ops token store`), and resolution ord
703
704
  `--token` → `SPFN_OPS_TOKEN` → keychain. The `--app` URL must be https, since every command
704
705
  carries a secret; `http` is accepted only against a loopback host.
705
706
 
707
+ ### Can a second surface sit beside `/_ops`?
708
+
709
+ Yes. `/_ops` is one command surface; `defineSurface` from `@spfn/core/surface` builds
710
+ another on a namespace of your choosing, for a caller that is not an operator — a service
711
+ holding a token some issuer signed, say. It is the same structure: a `route` with the
712
+ namespace applied, auth injected into every command, a manifest at `<namespace>/_manifest`
713
+ registered first, and modules mounted only when you name them. `createOpsRouter` is
714
+ `defineSurface({ namespace: '/_ops', ... }).router(...)`.
715
+
716
+ ```typescript
717
+ // src/server/app-surface.ts
718
+ import { Type } from '@sinclair/typebox';
719
+ import { defineSurface } from '@spfn/core/surface';
720
+ import { signedTokenAuth } from '@spfn/auth/server';
721
+
722
+ export const app = defineSurface({
723
+ namespace: '/_app', // required
724
+ auth: signedTokenAuth({ // required
725
+ keysUrl: 'https://issuer.example/.well-known/keys',
726
+ iss: 'https://issuer.example',
727
+ aud: 'my-app',
728
+ }),
729
+ manifest: 'public', // 'authenticated' (default) | 'public'
730
+ });
731
+
732
+ export const pageAdd = app.route.post('/pages') // POST /_app/pages
733
+ .input({ body: Type.Object({ title: Type.String() }) })
734
+ .handler(async (c) => pagesRepository.add((await c.data()).body.title));
735
+
736
+ // src/server/router.ts — both surfaces, side by side
737
+ export const appRouter = defineRouter({ ... }).packages([app.router({ pageAdd }), opsRouter]);
738
+ ```
739
+
740
+ - **Namespace.** An absolute path of `[A-Za-z0-9_-]` segments with no trailing slash —
741
+ `/_app`, `/machine/v1`. Anything else (`app`, `/_app/`, `/`, a `%`, a `:param`) is refused
742
+ at `defineSurface`.
743
+ - **Manifest.** Served at `<namespace>/_manifest` under the reserved route name
744
+ `get<Namespace>Manifest` — `getAppManifest` for `/_app`, `getOpsManifest` for `/_ops`,
745
+ `getappManifest` for `/app`. The namespace is encoded one-to-one (`_x` → `X`; any other
746
+ `_`, `-`, `/` or uppercase letter is escaped with `_`), so two surfaces never register one
747
+ name. Two surfaces on one namespace are refused by `.packages()`. `manifest: 'public'`
748
+ drops `auth` from that one route and skips a server-level `auth` middleware on it, so a
749
+ caller can discover commands before it holds a token; every command stays behind `auth`
750
+ regardless.
751
+ - **`authorize`.** Optional, `(command) => middleware | undefined`, called once per command
752
+ (module commands included) when the router is built. The middleware runs after `auth` and
753
+ before the command's own: `[auth, authorize?, ...group, ...own]`. Required as soon as a
754
+ module is mounted. A guard named like `auth` or like one of the command's group or own
755
+ middlewares is refused: registration keeps one middleware per name, so the other one would
756
+ never run.
757
+ - **Modules.** A module is validated against the namespace of the surface that mounts it —
758
+ its commands must live under `<namespace>/<id>/`. Write one for a surface with
759
+ `defineSurfaceModule(module, '/_app')`; a module written for `/_ops` mounted on `/_app` is
760
+ refused, and the message names the namespace.
761
+ - **Errors.** Every refusal is a `SurfaceError`. `@spfn/core/ops` exports the same class as
762
+ `OpsRouterError`, so `instanceof` holds under either name.
763
+
764
+ The client for any surface is the CLI's library export,
765
+ [`spfn/surface`](../cli/README.md#can-i-call-a-surface-from-my-own-code).
766
+
706
767
  ---
707
768
 
708
769
  ## How is this different from NestJS or tRPC?
@@ -1,11 +1,11 @@
1
1
  import { E as EventRouterDef } from '../router-CysBmgv3.js';
2
- import { R as Router } from '../router-BqyEARoY.js';
3
- import { C as ContractDocument, a as ContractViolation, b as ContractOperation, c as ContractSnapshot } from '../types-B4DuGCyM.js';
4
- export { d as CompatibilityPolicy, e as ContractEvent, f as ContractEvents, g as ContractRequest, h as ContractViolationKind, J as JsonSchema } from '../types-B4DuGCyM.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';
5
5
  import '@sinclair/typebox';
6
6
  import '../define-middleware-CVKgqo8S.js';
7
7
  import 'hono';
8
- import '../route-builder-2ani2jEI.js';
8
+ import '../route-builder-BHKgGkwI.js';
9
9
  import 'hono/utils/http-status';
10
10
  import '../route/types.js';
11
11
 
@@ -0,0 +1,150 @@
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';
5
+
6
+ /**
7
+ * How refusals name the surface they come from.
8
+ *
9
+ * A command surface is generic, but the ops surface predates it and its
10
+ * refusals already say "ops route" and point at `opsRoute`. The ops layer
11
+ * passes its own wording so an operator reading an ops refusal sees the same
12
+ * text as before; every other surface reads the neutral one.
13
+ */
14
+ interface SurfaceWording {
15
+ /** What a route on this surface is called: "ops route", "surface route". */
16
+ noun: string;
17
+ /** The builder that applies the namespace, as named in a refusal. */
18
+ builder: string;
19
+ /** The function that builds the surface, as named in a refusal. */
20
+ factory: string;
21
+ }
22
+
23
+ /**
24
+ * Capability module contract
25
+ *
26
+ * A package describes commands here, but an adopter application decides
27
+ * whether to mount the resulting module, and on which surface. Validation
28
+ * keeps an installed package from becoming a command surface merely by
29
+ * existing, and gives the surface enough metadata to enforce scopes and
30
+ * describe the module to a client from one declaration.
31
+ *
32
+ * A module's paths are written for one namespace — `/_ops/<id>/...` for an
33
+ * ops module — so it is validated against the namespace of the surface that
34
+ * mounts it. A module written for `/_ops` mounted on `/_app` is refused rather
35
+ * than registered at paths outside the surface that is supposed to guard them.
36
+ */
37
+
38
+ type SurfaceEffect = 'read' | 'write' | 'destructive';
39
+ interface SurfaceModuleCommand<TRoute extends RouteDef<any> = RouteDef<any>> {
40
+ summary: string;
41
+ effect: SurfaceEffect;
42
+ scopes: readonly string[];
43
+ route: TRoute;
44
+ }
45
+ interface SurfaceModule<TCommands extends Record<string, SurfaceModuleCommand<any>> = Record<string, SurfaceModuleCommand<any>>> {
46
+ id: string;
47
+ source: string;
48
+ contractVersion: string;
49
+ summary: string;
50
+ commands: TCommands;
51
+ }
52
+ /**
53
+ * Validate and return a capability module written for a surface on
54
+ * `namespace` — every command under `<namespace>/<id>/`. The surface checks
55
+ * it again against its own namespace when it mounts it, so a module defined
56
+ * for one namespace and mounted on another is still refused.
57
+ */
58
+ declare function defineSurfaceModule<TCommands extends Record<string, SurfaceModuleCommand<any>>>(module: SurfaceModule<TCommands>, namespace: string): SurfaceModule<TCommands>;
59
+
60
+ /**
61
+ * Surface namespace and route builder
62
+ *
63
+ * A namespace is the path every command on a surface lives under — `/_ops`
64
+ * for operators, `/_app` or anything else an app chooses for another caller.
65
+ * It is refused at definition time unless it is a plain absolute path of
66
+ * URL-safe segments: no trailing slash, no dots, no percent signs, no
67
+ * parameters. Every check downstream — the prefix a route must carry, the
68
+ * module paths, the manifest URL, the CLI's own path checks — is a string
69
+ * comparison against it, and only a literal path keeps those comparisons
70
+ * honest.
71
+ *
72
+ * The route builder is `route` with the namespace applied, so a definition
73
+ * carries only the part of the path the app owns:
74
+ *
75
+ * ```ts
76
+ * const pageAdd = app.route.post('/pages') // POST /_app/pages
77
+ * .handler(async () => ({ ok: true }));
78
+ * ```
79
+ */
80
+
81
+ /** `route`, with a surface's namespace applied. */
82
+ interface SurfaceRouteBuilder {
83
+ get: (path: string) => RouteBuilder;
84
+ post: (path: string) => RouteBuilder;
85
+ put: (path: string) => RouteBuilder;
86
+ patch: (path: string) => RouteBuilder;
87
+ delete: (path: string) => RouteBuilder;
88
+ }
89
+
90
+ /**
91
+ * Surface Manifest
92
+ *
93
+ * The server's self-description of a command surface: every command an app
94
+ * exposes under the surface's namespace, with each command's input schemas as
95
+ * plain JSON Schema. A client fetches this from the running server, so command
96
+ * discovery needs neither the app's source nor a generated artifact on the
97
+ * caller's machine.
98
+ *
99
+ * The router is loaded and walked (the contract collector's approach) rather
100
+ * than parsed from source: route schemas built from imported values resolve,
101
+ * and TypeBox schemas serialize to JSON Schema by construction.
102
+ */
103
+
104
+ /** One explicitly mounted capability module, as a client sees it. */
105
+ interface SurfaceModuleDescriptor {
106
+ id: string;
107
+ source: string;
108
+ contractVersion: string;
109
+ summary: string;
110
+ }
111
+ /** One invokable command, as a client sees it. */
112
+ interface SurfaceCommand {
113
+ name: string;
114
+ method: HttpMethod;
115
+ path: string;
116
+ /** Present for commands contributed by an explicitly mounted module. */
117
+ module?: string;
118
+ summary?: string;
119
+ effect?: SurfaceEffect;
120
+ scopes?: string[];
121
+ /** Input sections the route declares, each as JSON Schema. */
122
+ input: {
123
+ params?: JsonSchema;
124
+ query?: JsonSchema;
125
+ body?: JsonSchema;
126
+ };
127
+ }
128
+ /** What `GET <namespace>/_manifest` answers. */
129
+ interface SurfaceManifest {
130
+ manifestVersion: 1;
131
+ /** Additive module metadata. Omitted for an app-only v1 surface. */
132
+ modules?: SurfaceModuleDescriptor[];
133
+ commands: SurfaceCommand[];
134
+ }
135
+ /**
136
+ * 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.
139
+ */
140
+ declare function collectSurfaceCommands(routes: Record<string, RouteDef<any> | Router<any>>, wording?: SurfaceWording): SurfaceCommand[];
141
+
142
+ /**
143
+ * Thrown when a namespace, route, or module cannot be part of a command
144
+ * surface. `@spfn/core/ops` exports the same class as `OpsRouterError`.
145
+ */
146
+ declare class SurfaceError extends Error {
147
+ constructor(message: string);
148
+ }
149
+
150
+ export { type SurfaceEffect as S, type SurfaceModuleCommand as a, type SurfaceModule as b, type SurfaceRouteBuilder as c, type SurfaceCommand as d, type SurfaceManifest as e, type SurfaceModuleDescriptor as f, SurfaceError as g, collectSurfaceCommands as h, defineSurfaceModule as i };
@@ -1,11 +1,13 @@
1
1
  import { MiddlewareHandler } from 'hono';
2
2
  import { a as NamedMiddleware } from '../define-middleware-CVKgqo8S.js';
3
- import { R as RouteDef, a as RouteBuilder } from '../route-builder-2ani2jEI.js';
4
- import { R as Router } from '../router-BqyEARoY.js';
5
- import { J as JsonSchema } from '../types-B4DuGCyM.js';
6
- import { HttpMethod } from '../route/types.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';
7
7
  import '@sinclair/typebox';
8
8
  import 'hono/utils/http-status';
9
+ import '../route/types.js';
10
+ import '../types-D52SokSM.js';
9
11
 
10
12
  /**
11
13
  * Capability ops module contract
@@ -15,24 +17,17 @@ import 'hono/utils/http-status';
15
17
  * installed package from becoming an ops surface merely by existing, and
16
18
  * gives `createOpsRouter` enough metadata to enforce scopes and describe the
17
19
  * module to the CLI from one declaration.
20
+ *
21
+ * The contract is the command surface's (`@spfn/core/surface`);
22
+ * `defineOpsModule` is its validator fixed to the `/_ops` namespace.
18
23
  */
19
24
 
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
- }
25
+ type OpsEffect = SurfaceEffect;
26
+ type OpsModuleCommand<TRoute extends RouteDef<any> = RouteDef<any>> = SurfaceModuleCommand<TRoute>;
27
+ type OpsModule<TCommands extends Record<string, OpsModuleCommand<any>> = Record<string, OpsModuleCommand<any>>> = SurfaceModule<TCommands>;
34
28
  /**
35
- * Validate and return a capability ops module.
29
+ * Validate and return a capability ops module — every command under
30
+ * `/_ops/<id>/`.
36
31
  *
37
32
  * This function intentionally does not register anything. Only an adopter's
38
33
  * explicit `createOpsRouter({ modules: [...] })` composition exposes it.
@@ -62,6 +57,11 @@ declare function defineOpsModule<TCommands extends Record<string, OpsModuleComma
62
57
  * ships `opsTokenAuth`); core owns only the structure, so the ops surface has
63
58
  * no opinion about how a token is stored or verified.
64
59
  *
60
+ * It is the command surface (`@spfn/core/surface`) fixed to `/_ops`, with the
61
+ * scope-factory `authorize` this factory has always taken adapted to the
62
+ * surface's per-command one: applied to module commands only, so every
63
+ * middleware chain is what it was before surfaces existed.
64
+ *
65
65
  * @example
66
66
  * ```ts
67
67
  * import { createOpsRouter, opsRoute } from '@spfn/core/ops';
@@ -124,9 +124,9 @@ declare function createOpsRouter<TRoutes extends Record<string, RouteDef<any, an
124
124
  * decisions, made when the ops route is written.
125
125
  *
126
126
  * The builder returned is an ordinary `RouteBuilder`, so `.use()`, `.input()`
127
- * and `.handler()` work exactly as they do elsewhere.
127
+ * and `.handler()` work exactly as they do elsewhere. It is the `/_ops`
128
+ * surface's `route` (`@spfn/core/surface`).
128
129
  */
129
-
130
130
  /** The ops namespace, without the trailing slash. */
131
131
  declare const OPS_PATH_ROOT = "/_ops";
132
132
  /**
@@ -140,64 +140,24 @@ declare const OPS_PATH_ROOT = "/_ops";
140
140
  * .handler(async (c) => ({ items: await repo.findAll((await c.data()).query.limit ?? 10, 0) }));
141
141
  * ```
142
142
  */
143
- declare const opsRoute: {
144
- get: (path: string) => RouteBuilder;
145
- post: (path: string) => RouteBuilder;
146
- put: (path: string) => RouteBuilder;
147
- patch: (path: string) => RouteBuilder;
148
- delete: (path: string) => RouteBuilder;
149
- };
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
- }
143
+ declare const opsRoute: SurfaceRouteBuilder;
155
144
 
156
145
  /**
157
146
  * Ops Manifest
158
147
  *
159
- * The server's self-description of its ops surface: every command an app
160
- * exposes under `/_ops`, with each command's input schemas as plain JSON
161
- * Schema. The ops CLI fetches this from the running server, so command
162
- * discovery needs neither the app's source nor a generated artifact on the
163
- * operator's machine.
164
- *
165
- * The router is loaded and walked (the contract collector's approach) rather
166
- * than parsed from source: route schemas built from imported values resolve,
167
- * and TypeBox schemas serialize to JSON Schema by construction.
148
+ * The ops surface's self-description: every command an app exposes under
149
+ * `/_ops`, with each command's input schemas as plain JSON Schema. The
150
+ * `spfn ops` CLI fetches it from the running server. The collector and the
151
+ * shapes are the command surface's (`@spfn/core/surface`); these are their
152
+ * ops names, kept so existing imports read as they always did.
168
153
  */
169
154
 
170
155
  /** 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
- }
156
+ type OpsModuleDescriptor = SurfaceModuleDescriptor;
177
157
  /** One invokable ops command, as the CLI sees it. */
178
- interface OpsCommand {
179
- name: string;
180
- method: HttpMethod;
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[];
187
- /** Input sections the route declares, each as JSON Schema. */
188
- input: {
189
- params?: JsonSchema;
190
- query?: JsonSchema;
191
- body?: JsonSchema;
192
- };
193
- }
158
+ type OpsCommand = SurfaceCommand;
194
159
  /** What `GET /_ops/_manifest` answers. */
195
- interface OpsManifest {
196
- manifestVersion: 1;
197
- /** Additive module metadata. Omitted for an app-only v1 surface. */
198
- modules?: OpsModuleDescriptor[];
199
- commands: OpsCommand[];
200
- }
160
+ type OpsManifest = SurfaceManifest;
201
161
  /**
202
162
  * Walk a routes record (nested routers included) and collect every RouteDef
203
163
  * as an ops command. Validation of paths and names happens in
@@ -205,4 +165,4 @@ interface OpsManifest {
205
165
  */
206
166
  declare function collectOpsCommands(routes: Record<string, RouteDef<any> | Router<any>>): OpsCommand[];
207
167
 
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 };
168
+ export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, type OpsCommand, type OpsEffect, type OpsManifest, type OpsModule, type OpsModuleCommand, type OpsModuleDescriptor, type OpsRouterOptions, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };