@spfn/core 0.3.0-beta.1 → 0.3.0-beta.11

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 (69) hide show
  1. package/README.md +301 -26
  2. package/dist/app-config/index.d.ts +96 -0
  3. package/dist/app-config/index.js +58 -0
  4. package/dist/app-config/index.js.map +1 -0
  5. package/dist/authz/index.js +1 -381
  6. package/dist/authz/index.js.map +1 -1
  7. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  8. package/dist/config/index.d.ts +0 -63
  9. package/dist/config/index.js +12 -12
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/contract/index.d.ts +5 -4
  12. package/dist/db/index.d.ts +201 -34
  13. package/dist/db/index.js +294 -158
  14. package/dist/db/index.js.map +1 -1
  15. package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
  16. package/dist/env/loader.js +24 -1
  17. package/dist/env/loader.js.map +1 -1
  18. package/dist/errors/index.js +1 -381
  19. package/dist/errors/index.js.map +1 -1
  20. package/dist/event/sse/client.d.ts +15 -2
  21. package/dist/event/sse/client.js +38 -2
  22. package/dist/event/sse/client.js.map +1 -1
  23. package/dist/job/index.d.ts +48 -3
  24. package/dist/job/index.js +118 -40
  25. package/dist/job/index.js.map +1 -1
  26. package/dist/logger/index.js +0 -12
  27. package/dist/logger/index.js.map +1 -1
  28. package/dist/middleware/index.d.ts +6 -2
  29. package/dist/middleware/index.js +7 -388
  30. package/dist/middleware/index.js.map +1 -1
  31. package/dist/nextjs/index.d.ts +73 -6
  32. package/dist/nextjs/index.js +40 -1
  33. package/dist/nextjs/index.js.map +1 -1
  34. package/dist/nextjs/server.d.ts +35 -2
  35. package/dist/nextjs/server.js +14 -0
  36. package/dist/nextjs/server.js.map +1 -1
  37. package/dist/ops/index.d.ts +115 -14
  38. package/dist/ops/index.js +339 -15
  39. package/dist/ops/index.js.map +1 -1
  40. package/dist/route/index.d.ts +5 -3
  41. package/dist/route/index.js +1 -1
  42. package/dist/route/index.js.map +1 -1
  43. package/dist/{router-ukNdAZcN.d.ts → route-builder-2ani2jEI.d.ts} +3 -126
  44. package/dist/router-DJdpwuB6.d.ts +127 -0
  45. package/dist/server/index.d.ts +130 -11
  46. package/dist/server/index.js +714 -70
  47. package/dist/server/index.js.map +1 -1
  48. package/dist/{types-Bvvig_tT.d.ts → types-ClQVomgV.d.ts} +1 -1
  49. package/dist/{types-9oszaJqp.d.ts → types-yv6z_mK7.d.ts} +122 -3
  50. package/docs/file-upload.md +195 -333
  51. package/package.json +14 -8
  52. package/src/cache/README.md +330 -0
  53. package/src/codegen/README.md +516 -0
  54. package/src/config/README.md +326 -0
  55. package/src/contract/README.md +326 -0
  56. package/src/db/README.md +589 -0
  57. package/src/db/manager/README.md +500 -0
  58. package/src/db/schema/README.md +354 -0
  59. package/src/db/transaction/README.md +822 -0
  60. package/src/env/README.md +651 -0
  61. package/src/errors/README.md +429 -0
  62. package/src/event/README.md +741 -0
  63. package/src/job/README.md +613 -0
  64. package/src/logger/README.md +321 -0
  65. package/src/middleware/README.md +634 -0
  66. package/src/nextjs/README.md +649 -0
  67. package/src/route/README.md +744 -0
  68. package/src/security/README.md +100 -0
  69. package/src/server/README.md +704 -0
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.2.11 (only for the Next.js bridge)
46
+ # optional peer: next ^16.3.3 (only for the Next.js bridge)
47
47
  ```
48
48
 
49
49
  Node `>=20.0.0`. ESM only.
@@ -185,18 +185,144 @@ import type { AppRouter } from '@/server/router';
185
185
 
186
186
  export const api = createApi<AppRouter>();
187
187
 
188
- // anywhere — server component, client component, or server action:
188
+ // the same client in a Server Component, a Client Component or a Server Action:
189
189
  const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
190
190
  const made = await api.createUser.call({ body: { name: 'A' } });
191
191
  ```
192
192
 
193
+ That the client is isomorphic does not make the three callers interchangeable. **A page's
194
+ initial data is awaited in the Server Component**, where
195
+ `api.getUser.fetchOptions({ next: { revalidate, tags } }).call(…)` participates in
196
+ Next.js caching and reaches the backend without a browser round trip. Fetching that same
197
+ first paint from a `'use client'` component inside a `useEffect` is the anti-pattern: it
198
+ ships a loading state and a second network hop for data the server already had. Client
199
+ Components and Server Actions are for what happens after the first paint — interaction
200
+ and mutation. Cache tags, revalidation and SSR cookie forwarding are in
201
+ [the Next.js bridge docs](https://superfunction.xyz/docs/packages/core/nextjs).
202
+
203
+ ---
204
+
205
+ ## How does a repository talk to the database?
206
+
207
+ Through `BaseRepository`. Extending it gives a repository two transaction-aware
208
+ connections — `this.db` (write/primary) and `this.readDb` (the replica, when one is
209
+ configured) — plus the CRUD set as protected methods.
210
+
211
+ ```typescript
212
+ // server/repositories/order.ts
213
+ import { BaseRepository } from '@spfn/core/db';
214
+ import { desc } from 'drizzle-orm';
215
+ import { orders } from '../entities/order';
216
+
217
+ export class OrderRepository extends BaseRepository
218
+ {
219
+ findRecentFor(userId: string)
220
+ {
221
+ return this._findMany(orders, { where: { userId }, orderBy: desc(orders.createdAt) });
222
+ }
223
+
224
+ place(data: { userId: string; total: number })
225
+ {
226
+ return this._create(orders, data);
227
+ }
228
+ }
229
+
230
+ export const orderRepo = new OrderRepository();
231
+ ```
232
+
233
+ Handlers never import drizzle query builders; repositories do. `_findMany` reads through
234
+ `this.readDb`, `_create` writes through `this.db`, and both getters resolve to the active
235
+ transaction's connection when there is one — so the same method is correct inside a
236
+ transaction and outside it. When a helper cannot express a query, drop to
237
+ `this.readDb.select()…` inside the repository rather than in the handler. The full
238
+ protected CRUD set is in [src/db](./src/db/README.md).
239
+
240
+ ---
241
+
242
+ ## Where do transactions and their side effects go?
243
+
244
+ `Transactional()` covers the route case — commit on return, rollback on throw. Two rules
245
+ decide the rest.
246
+
247
+ **Nothing takes a `tx` parameter.** The transaction travels in AsyncLocalStorage, so
248
+ `this.db` inside a repository already resolves to it. A service that accepts `tx` and
249
+ threads it downward re-implements propagation that already happened, and the first caller
250
+ that forgets to pass it writes outside the transaction. For a service, script or job with
251
+ no route around it, open one with `runInTransaction(fn, options?)`;
252
+ `runWithTransaction(tx, txId, fn)` is the lower-level primitive that binds an existing
253
+ Drizzle transaction into the context.
254
+
255
+ **Side effects go on the commit hooks, not inline.** An event emitted or a mail sent from
256
+ inside the transaction still went out when the transaction later rolls back.
257
+
258
+ | Hook | When it runs | What it is for |
259
+ |---|---|---|
260
+ | `onBeforeCommit(fn)` | Inside the still-open transaction, just before commit — a throw aborts and rolls back | Last-moment invariant checks, and statements that must land in the same commit |
261
+ | `onAfterCommit(fn)` | After the root transaction commits, outside the transaction context; errors are logged, never thrown | Events, mail, cache invalidation — anything the outside world observes |
262
+ | `onAfterRollback(fn)` | After the root transaction rolls back, before the causing error propagates; errors are logged, never thrown | Undoing external work that cannot roll itself back, such as an object already uploaded |
263
+
264
+ All three import from `@spfn/core/db`, can be registered anywhere inside the transaction,
265
+ and bubble to the **root** transaction — a nested block's callbacks fire on the outermost
266
+ outcome, not on a savepoint's.
267
+
268
+ ```typescript
269
+ import { runInTransaction, onAfterCommit } from '@spfn/core/db';
270
+
271
+ export async function placeOrder(input: { userId: string; total: number })
272
+ {
273
+ return runInTransaction(async () =>
274
+ {
275
+ const order = await orderRepo.place(input); // no tx argument, at any depth
276
+ onAfterCommit(() => orderPlacedEvent.emit({ orderId: order.id }));
277
+
278
+ return order;
279
+ });
280
+ }
281
+ ```
282
+
283
+ ---
284
+
285
+ ## What else can defineServerConfig configure?
286
+
287
+ `.port()` and `.routes()` are the two every app calls. The rest of the builder is how an
288
+ app wires its infrastructure without touching the server's boot sequence. Every method
289
+ returns the builder; `.build()` ends the chain.
290
+
291
+ | Method | What it configures |
292
+ |---|---|
293
+ | `.port(n)` / `.host(s)` | Where the server listens |
294
+ | `.routes(router)` | The `defineRouter` router to mount, with its own `.use()` and `.packages()` |
295
+ | `.jobs(router, config?)` | Background jobs — a `defineJobRouter`, plus pg-boss options |
296
+ | `.events(router, config?)` | SSE streaming — a `defineEventRouter`, served at `GET /events/stream` |
297
+ | `.websockets(router, config?)` | Bidirectional WebSockets — a `defineWSRouter`, served at `WS /ws` |
298
+ | `.workflows(router, config?)` | `@spfn/workflow` orchestration; the engine starts once the database is ready |
299
+ | `.lifecycle(hooks)` | Boot and shutdown hooks. Callable more than once; hooks run in registration order |
300
+ | `.migrations(opts)` | The migration boot gate — `{ allowPending: true }` lets a server start behind its migrations |
301
+ | `.database(opts)` | Connection and pool settings |
302
+ | `.infrastructure(opts)` | Which infrastructure is initialized at boot |
303
+ | `.healthCheck(opts)` | The health endpoint |
304
+ | `.serverTime(clock)` | The clock behind `GET /_core/time`; normally left at the default |
305
+ | `.middleware(opts)` | The built-in middleware — `ErrorHandler`, `RequestLogger` |
306
+ | `.use(handlers)` | Additional global Hono middleware |
307
+ | `.middlewares(named)` | Global middleware under names, so a route can `.skip([...])` it |
308
+ | `.cors(opts)` | CORS |
309
+ | `.rateLimit(opts)` | A global default limiter plus the named policies routes resolve against |
310
+ | `.proxyGuard(opts)` | Trusted-proxy signature and origin verification, resolved to a `clientType` |
311
+ | `.outboundFetch(opts)` | The SSRF policy `safeFetch` applies to outbound calls |
312
+ | `.timeout(opts)` / `.shutdown(opts)` | Request timeouts and graceful shutdown |
313
+ | `.debug(bool)` | Debug logging |
314
+
315
+ The options each one takes are in [src/server](./src/server/README.md).
316
+
193
317
  ---
194
318
 
195
319
  ## Which import path do I use for what?
196
320
 
197
321
  There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
198
322
  comes from a subpath, and the table below is the complete public surface — one row per
199
- entry in `package.json` `exports`. Each module has its own README with the API detail.
323
+ entry in `package.json` `exports`, with a single exclusion: `./client` is still listed in
324
+ `exports` but the build no longer emits it, so it has no row (see [Pitfalls](#pitfalls)).
325
+ Each module has its own README with the API detail.
200
326
 
201
327
  | Import path | Purpose | Doc |
202
328
  |-------------|---------|-----|
@@ -207,16 +333,17 @@ entry in `package.json` `exports`. Each module has its own README with the API d
207
333
  | `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
208
334
  | `@spfn/core/db` | PostgreSQL through Drizzle: CRUD helpers, `BaseRepository`, schema helpers, transactions, Postgres error mapping. One entry point for all of it. | [src/db](./src/db/README.md) |
209
335
  | `@spfn/core/db` → manager | Connection lifecycle, pool, primary/replica, health check, reconnect (`initDatabase`, `getDatabase`). Re-exported from `@spfn/core/db`. | [src/db/manager](./src/db/manager/README.md) |
210
- | `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](./src/db/migrations/index.ts) |
336
+ | `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](https://github.com/fxylabs/spfn/blob/main/packages/core/src/db/migrations/index.ts) |
211
337
  | `@spfn/core/db` → schema | Drizzle column helpers (`id`, `uuid`, `timestamps`, `foreignKey`, `enumText`, `typedJsonb`, `softDelete`, …). Re-exported from `@spfn/core/db`. | [src/db/schema](./src/db/schema/README.md) |
212
338
  | `@spfn/core/db` → transaction | `Transactional()` middleware and `runInTransaction`; the transaction reaches every repository through AsyncLocalStorage. Re-exported from `@spfn/core/db`. | [src/db/transaction](./src/db/transaction/README.md) |
213
339
  | `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
214
340
  | `@spfn/core/errors` | Serializable HTTP and database error classes, plus `ErrorRegistry` so an error survives the trip to the client as its own class. | [src/errors](./src/errors/README.md) |
215
341
  | `@spfn/core/security` | `safeFetch` — a drop-in `fetch` hardened against SSRF, including DNS rebinding, by pinning the connection to a validated IP. | [src/security](./src/security/README.md) |
216
- | `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](./src/authz/index.ts) |
342
+ | `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/authz/index.ts) |
217
343
  | `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
218
344
  | `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
219
345
  | `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
346
+ | `@spfn/core/app-config` | Reads `spfn.config.js` — the one committed place that says which ports and host the app is served on (`loadAppConfig`, `resolvePorts`, `resolveHost`, `PORT_DEFAULTS`). Deliberately side-effect free, so the CLI can import it before an app's environment exists. | [src/app-config/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/app-config/index.ts) |
220
347
  | `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
221
348
  | `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
222
349
  | `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
@@ -227,6 +354,7 @@ entry in `package.json` `exports`. Each module has its own README with the API d
227
354
  | `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
228
355
  | `@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) |
229
356
  | `@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) |
357
+ | `@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) |
230
358
 
231
359
  `db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
232
360
  They are internal modules re-exported by `@spfn/core/db` — import their symbols from
@@ -292,8 +420,8 @@ export default defineServerConfig()
292
420
 
293
421
  All three log the pending list as a warning rather than silently continuing.
294
422
 
295
- **A readiness probe sees the same thing.** When detailed health is on, `GET /health`
296
- carries a `migrations` object beside `services`:
423
+ **A readiness probe sees the same thing.** When detailed health is on,
424
+ `GET /_core/health` carries a `migrations` object beside `services`:
297
425
 
298
426
  ```json
299
427
  {
@@ -320,6 +448,78 @@ The serverless path (`createServerlessApp`) has no boot to gate — run
320
448
 
321
449
  ---
322
450
 
451
+ ## Which path does the health endpoint answer on?
452
+
453
+ `/_core/health`. That is the whole answer — `@spfn/core` registers nothing on `/health`.
454
+
455
+ `/_core/` belongs to `@spfn/core` the way `/_auth/` belongs to `@spfn/auth`: the endpoints
456
+ in it are registered before your routes, so no route you declare can take one. That is
457
+ what makes `/_core/health` the right target for a readiness probe, a Dockerfile
458
+ `HEALTHCHECK` or an uptime monitor — the answer does not depend on what your app defines.
459
+
460
+ ```typescript
461
+ // the endpoint answers at /_core/health, and nowhere else
462
+ export default defineServerConfig().build();
463
+
464
+ // an additional address, for a probe path you cannot change
465
+ export default defineServerConfig().healthCheck({ path: '/health' }).build();
466
+
467
+ // off → nothing answers anywhere
468
+ export default defineServerConfig().healthCheck({ enabled: false }).build();
469
+ ```
470
+
471
+ `healthCheck.path` adds a second address. It never moves `/_core/health`, and it is
472
+ registered before your routes like the canonical one — so an app route on the same path
473
+ will not run, and the server says so at boot.
474
+
475
+ **`/health` is yours now.** Declare `GET /health` and it behaves like any other route of
476
+ yours. Declare nothing there and, for one release, a `GET` answers **410** naming
477
+ `/_core/health`, and the server warns once the first time it is hit — a readiness probe
478
+ failure shows an operator neither a response body nor a status text, so a bare 404 would
479
+ leave them nothing to search for. Upgrading an existing deployment?
480
+ [docs/guides/migration/health-endpoint.md](../../docs/guides/migration/health-endpoint.md).
481
+
482
+ Every built-in health address is registered before the `lifecycle.beforeRoutes` hook, and
483
+ a Hono middleware only wraps handlers registered after it. So a global guard your app adds
484
+ in that hook — which is what the hook is documented for — cannot close the endpoint a
485
+ probe depends on, and a probe reaches it unauthenticated. An app with `src/server/app.ts`
486
+ (level 3) wires its own server and gets no health endpoint at all.
487
+
488
+ ---
489
+
490
+ ## How does a client synchronize with the server clock?
491
+
492
+ Call `GET /_core/time` before minting a timestamped proof. It is a built-in,
493
+ unauthenticated and session-free operation registered before application middleware and
494
+ routes:
495
+
496
+ ```json
497
+ { "serverTimeMillis": 1750000000123 }
498
+ ```
499
+
500
+ The response is a closed contract: `serverTimeMillis` is an integer Unix epoch in
501
+ milliseconds and no additional fields are declared. It always carries
502
+ `Cache-Control: no-store`; a cached clock reading is not a synchronization point.
503
+
504
+ The value is trustworthy only when the transport is trustworthy. Production clients must
505
+ call the endpoint over HTTPS and validate the server certificate. The endpoint does not
506
+ sign its response, compensate for network latency, choose an authentication skew margin,
507
+ or define client retry and persistence policy — those belong to the consuming protocol.
508
+
509
+ Tests can inject an exact clock without replacing global time:
510
+
511
+ ```typescript
512
+ const app = await createServer(defineServerConfig()
513
+ .serverTime({ clock: { now: () => 1750000000123 } })
514
+ .build());
515
+ ```
516
+
517
+ `CORE_TIME_ROUTE`, `CORE_TIME_PATH`, `ServerTimeResponseSchema` and the
518
+ `ServerTimeResponse` type are exported from `@spfn/core/server` for separately deployed
519
+ client-contract exporters to consume from the same wire definition.
520
+
521
+ ---
522
+
323
523
  ## Can I deploy this to Vercel?
324
524
 
325
525
  Yes, and it is a first-class target rather than a workaround. From your app:
@@ -369,11 +569,10 @@ path: `spfn build && spfn start`, or the generated Docker files.
369
569
  Put the contract in a file and let the agent read it, instead of describing the
370
570
  architecture again in every prompt.
371
571
 
372
- An SPFN repository ships an `AGENTS.md` stating what the repo is, the commands, the
572
+ The SPFN repository states it in `CONTRIBUTING.md`: what the repo is, the commands, the
373
573
  vertical-slice pattern, and the rules that are not negotiable — never hand-edit generated
374
- files, migrations come from the schema. Tool-specific files (`CLAUDE.md`,
375
- `.cursorrules`) point at it in one line rather than duplicating it, so they cannot drift
376
- apart. Projects created by `spfn create` get the same arrangement.
574
+ files, migrations come from the schema. One file answers to people and agents alike, so
575
+ there is no second copy to drift apart from the first.
377
576
 
378
577
  Each module README under `src/` is written for the same reader. When an agent is working
379
578
  on database code, `src/db/README.md` is the page to give it.
@@ -389,12 +588,11 @@ vertical slice whose path lives under `/_ops/`.
389
588
  ```typescript
390
589
  // src/server/ops.ts
391
590
  import { Type } from '@sinclair/typebox';
392
- import { route } from '@spfn/core/route';
393
- import { createOpsRouter } from '@spfn/core/ops';
591
+ import { createOpsRouter, opsRoute } from '@spfn/core/ops';
394
592
  import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
395
593
 
396
594
  export const opsRouter = createOpsRouter({
397
- listSignups: route.get('/_ops/signups')
595
+ listSignups: opsRoute.get('/signups') // GET /_ops/signups
398
596
  .use([requireOpsScope('waitlist:read')])
399
597
  .input({ query: Type.Object({ limit: Type.Optional(Type.Number()) }) })
400
598
  .handler(async (c) => signupsRepository.list((await c.data()).query.limit)),
@@ -404,16 +602,41 @@ export const opsRouter = createOpsRouter({
404
602
  export const appRouter = defineRouter({ ... }).packages([opsRouter]);
405
603
  ```
406
604
 
407
- `createOpsRouter` refuses a route outside `/_ops/`, injects the auth middleware into every
408
- route (there is no unauthenticated variant), and serves `GET /_ops/_manifest` — the
409
- self-description the CLI reads, with each command's TypeBox schemas as JSON Schema:
605
+ `opsRoute` is `route` with the `/_ops` namespace applied, so a definition carries only the
606
+ path this app owns — what that path looks like, how it nests, which segments are
607
+ parameters are the app's decisions. It exists from `@spfn/core` **0.3.0-beta.2**.
608
+ `createOpsRouter` injects the auth middleware into every route (there is no
609
+ unauthenticated variant) and serves `GET /_ops/_manifest` — the self-description the CLI
610
+ reads, with each command's TypeBox schemas as JSON Schema.
611
+
612
+ The manifest is registered ahead of the ops routes, so none of them can take its URL even
613
+ when one is a pattern like `/_ops/:name`. That ordering does not reach outside the ops
614
+ router: routes an app declares in its own router are registered before any package router,
615
+ so a pattern there that covers `/_ops/_manifest` — a `/*` catch-all, say — shadows the
616
+ manifest, exactly as it shadows every other package route.
410
617
 
411
618
  Routes may be grouped in nested `defineRouter`s, and a group's own `.use()` middlewares
412
- apply to its routes as usual. Three things are refused when the surface is defined, rather
413
- than discovered in production: two routes sharing a command name (the manifest flattens
414
- nested groups into one list, so the CLI could not tell them apart), the reserved name
415
- `getOpsManifest`, and a group mounting `.packages()` (those routes register without the
416
- prefix check or the auth injection).
619
+ apply to its routes — always after the injected auth, so a group-wide guard reads a
620
+ request that has already been authenticated. A group-level middleware must be a named one;
621
+ wrap a factory to give it a name:
622
+
623
+ ```typescript
624
+ const requireAdmin = defineMiddleware('opsAdminScope', requireOpsScope('admin:read'));
625
+
626
+ export const opsRouter = createOpsRouter({
627
+ admin: defineRouter({ getStats, reindex }).use([requireAdmin]),
628
+ }, { auth: opsTokenAuth });
629
+ ```
630
+
631
+ Note that a group's `.use()` middleware carries its `skips` into every route in the group,
632
+ suppressing that server-level middleware there.
633
+
634
+ Three things are refused when the surface is defined, rather than discovered in
635
+ production: a route built with `route` instead of `opsRoute` (it would carry no
636
+ namespace), two routes sharing a command name (the manifest flattens nested groups into
637
+ one list, so the CLI could not tell them apart), and a group mounting `.packages()` (those
638
+ routes register with neither the namespace nor the auth injection). The command name
639
+ `getOpsManifest` and the path `/_ops/_manifest` are reserved.
417
640
 
418
641
  ```bash
419
642
  spfn ops list --app https://api.example.com # discover commands
@@ -421,11 +644,63 @@ spfn ops call listSignups --query limit=50 # invoke one
421
644
  spfn ops call listSignups --describe # print its usage (--json for raw schemas)
422
645
  ```
423
646
 
647
+ ### Can a package ship ops commands?
648
+
649
+ It can describe them. Whether they are reachable is your application's decision, made in
650
+ the `createOpsRouter` call — installing a package never adds anything to your ops surface.
651
+ Available from **0.3.0-beta.5**.
652
+
653
+ ```typescript
654
+ // in the package
655
+ export const ledgerOpsModule = defineOpsModule({
656
+ id: 'ledger',
657
+ source: '@acme/ledger',
658
+ contractVersion: '1.0.0',
659
+ summary: 'Ledger diagnostics',
660
+ commands: {
661
+ verify: {
662
+ summary: 'Verify ledger invariants',
663
+ effect: 'read', // read | write | destructive
664
+ scopes: ['ledger:read'],
665
+ route: opsRoute.get('/ledger/verify').handler(verifyLedger),
666
+ },
667
+ },
668
+ });
669
+
670
+ // in the application — nothing is mounted until this line names it
671
+ export const opsRouter = createOpsRouter({ listSignups }, {
672
+ auth: opsTokenAuth,
673
+ authorize: requireOpsScope,
674
+ modules: [ledgerOpsModule],
675
+ });
676
+ ```
677
+
678
+ A module command is named `<module>.<command>` (`ledger.verify`) and its route must live
679
+ under `/_ops/<module>/`. The scopes it declares become a server-side guard, run after
680
+ authentication — `authorize` is required as soon as any module is mounted, and it is passed
681
+ in rather than imported so core stays independent of `@spfn/auth`.
682
+
683
+ What is refused at definition time: a path that could decode its way out of the module's
684
+ namespace, two commands in one module that could answer the same URL, and an app route that
685
+ overlaps a mounted module's command. That last one matters because the alternative is a
686
+ surface where which command answers depends on route registration order.
687
+
688
+ The manifest gains a `modules` array and per-command `module`, `summary`, `effect` and
689
+ `scopes`. All of it is additive — an app that mounts no modules serves exactly the v1
690
+ manifest it served before.
691
+
692
+ ```bash
693
+ spfn ops modules # what is mounted, and from where
694
+ spfn ops list --module ledger # just that module's commands
695
+ spfn ops call ledger.compact --yes # effect=destructive needs this
696
+ ```
697
+
424
698
  Authentication is an ops token from [`@spfn/auth`](../auth/README.md#ops-tokens-spfn-ops):
425
- scoped, revocable, hash-stored, issued with `spfn ops token issue` where database access
426
- already exists — a deployed app has no token-creation endpoint. On macOS the CLI keeps the
427
- token in the keychain (`spfn ops token store`), and resolution order is `--token` →
428
- `SPFN_OPS_TOKEN` → keychain.
699
+ scoped, revocable, hash-stored, issued with `spfn ops token issue` against the running app
700
+ — the CLI signs in as an administrator, so issuance needs no database access. On macOS the
701
+ CLI keeps the token in the keychain (`spfn ops token store`), and resolution order is
702
+ `--token` → `SPFN_OPS_TOKEN` → keychain. The `--app` URL must be https, since every command
703
+ carries a secret; `http` is accepted only against a loopback host.
429
704
 
430
705
  ---
431
706
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `spfn.config.js` — the app's serving shape, read by both the CLI and the server.
3
+ *
4
+ * The file has existed since `spfn init` started writing it, and until now
5
+ * nothing read it back. It is the one committed place that describes how an app
6
+ * is served, which makes it the right home for the ports: an app that wants a
7
+ * different port says so once here instead of repeating the number across its
8
+ * `server.config.ts`, Dockerfile, compose file, `next.config.ts` and env
9
+ * example, where nothing checks that the copies still agree.
10
+ *
11
+ * This module is deliberately free of side effects. `@spfn/core/config`
12
+ * validates the whole environment schema the moment it is imported, which is
13
+ * fine inside a server and wrong inside a CLI that has not been handed an
14
+ * app's environment yet.
15
+ *
16
+ * @module app-config
17
+ */
18
+ /**
19
+ * Ports the app is served on.
20
+ *
21
+ * Two names because two processes are started. Next.js reads `PORT` as its own,
22
+ * so the SPFN server cannot claim that name without the two colliding whenever
23
+ * they run together.
24
+ */
25
+ interface SpfnPorts {
26
+ /** Next.js frontend. Passed to `next` as `-p`. */
27
+ next?: number;
28
+ /** SPFN API server. */
29
+ server?: number;
30
+ }
31
+ interface SpfnAppConfig {
32
+ ports?: SpfnPorts;
33
+ /** Host the SPFN API server binds. */
34
+ host?: string;
35
+ /** Deployment settings — untouched by this module, kept so the type is whole. */
36
+ [key: string]: unknown;
37
+ }
38
+ /**
39
+ * The single place each default exists.
40
+ *
41
+ * Defaults live at the bottom of the chain and nowhere else. A default declared
42
+ * at an input layer — a commander option, a generated entry file, an env schema
43
+ * — cannot be told apart from a value someone supplied, so it silently outranks
44
+ * whatever is below it. Three separate defects in this repository came from
45
+ * exactly that, including an env schema default that forced the environment to
46
+ * be consulted last and made an injected port unreachable.
47
+ */
48
+ declare const PORT_DEFAULTS: {
49
+ readonly next: 3790;
50
+ readonly server: 8790;
51
+ };
52
+ declare const HOST_DEFAULT = "localhost";
53
+ /**
54
+ * Read `spfn.config.js` from an app directory.
55
+ *
56
+ * Returns an empty object when there is no file, and also when the file cannot
57
+ * be imported — a malformed deployment config must not stop a server from
58
+ * booting. The caller decides whether the absence is worth reporting.
59
+ */
60
+ declare function loadAppConfig(cwd?: string): Promise<SpfnAppConfig>;
61
+ /**
62
+ * The port each process binds, given a loaded config and the environment.
63
+ *
64
+ * Three layers, in this order, and no more: environment variable, then
65
+ * `spfn.config.js`, then the default.
66
+ */
67
+ declare function resolvePorts(config: SpfnAppConfig, env?: NodeJS.ProcessEnv): {
68
+ next: number;
69
+ server: number;
70
+ };
71
+ /**
72
+ * The host the SPFN server binds, on the same three layers.
73
+ *
74
+ * `localhost` by default: a container states `SPFN_HOST=0.0.0.0` in its own
75
+ * files, where that decision belongs, rather than every developer machine
76
+ * publishing a dev server to its network.
77
+ */
78
+ declare function resolveHost(config: SpfnAppConfig, env?: NodeJS.ProcessEnv): string;
79
+ /**
80
+ * Where the SPFN server binds, including the address a server config may still
81
+ * carry through `.port()` / `.host()`.
82
+ *
83
+ * Those two are deprecated and sit between `spfn.config.js` and the default for
84
+ * one release, so an app that has not moved yet keeps the address it had. Once
85
+ * they are removed this collapses back to {@link resolvePorts} and
86
+ * {@link resolveHost}.
87
+ */
88
+ declare function resolveServerAddress(config: SpfnAppConfig, deprecated?: {
89
+ port?: number;
90
+ host?: string;
91
+ }, env?: NodeJS.ProcessEnv): {
92
+ port: number;
93
+ host: string;
94
+ };
95
+
96
+ export { HOST_DEFAULT, PORT_DEFAULTS, type SpfnAppConfig, type SpfnPorts, loadAppConfig, resolveHost, resolvePorts, resolveServerAddress };
@@ -0,0 +1,58 @@
1
+ import { existsSync } from 'fs';
2
+ import { pathToFileURL } from 'url';
3
+ import { join } from 'path';
4
+
5
+ // src/app-config/index.ts
6
+ var PORT_DEFAULTS = {
7
+ next: 3790,
8
+ server: 8790
9
+ };
10
+ var HOST_DEFAULT = "localhost";
11
+ var CONFIG_FILE_NAMES = [
12
+ "spfn.config.js",
13
+ "spfn.config.mjs"
14
+ ];
15
+ async function loadAppConfig(cwd = process.cwd()) {
16
+ for (const fileName of CONFIG_FILE_NAMES) {
17
+ const fullPath = join(cwd, fileName);
18
+ if (!existsSync(fullPath)) {
19
+ continue;
20
+ }
21
+ try {
22
+ const module = await import(pathToFileURL(fullPath).href);
23
+ return module.default ?? {};
24
+ } catch (error) {
25
+ console.warn(
26
+ `\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
27
+ );
28
+ return {};
29
+ }
30
+ }
31
+ return {};
32
+ }
33
+ function resolvePorts(config, env = process.env) {
34
+ return {
35
+ next: readPort(env.NEXT_PORT) ?? config.ports?.next ?? PORT_DEFAULTS.next,
36
+ server: readPort(env.SPFN_PORT) ?? config.ports?.server ?? PORT_DEFAULTS.server
37
+ };
38
+ }
39
+ function resolveHost(config, env = process.env) {
40
+ return env.SPFN_HOST || config.host || HOST_DEFAULT;
41
+ }
42
+ function resolveServerAddress(config, deprecated = {}, env = process.env) {
43
+ return {
44
+ port: readPort(env.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
45
+ host: env.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
46
+ };
47
+ }
48
+ function readPort(value) {
49
+ if (!value) {
50
+ return void 0;
51
+ }
52
+ const port = Number(value);
53
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
54
+ }
55
+
56
+ export { HOST_DEFAULT, PORT_DEFAULTS, loadAppConfig, resolveHost, resolvePorts, resolveServerAddress };
57
+ //# sourceMappingURL=index.js.map
58
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/app-config/index.ts"],"names":[],"mappings":";;;;;AA2DO,IAAM,aAAA,GAAgB;AAAA,EACzB,IAAA,EAAM,IAAA;AAAA,EACN,MAAA,EAAQ;AACZ;AAEO,IAAM,YAAA,GAAe;AAE5B,IAAM,iBAAA,GAAoB;AAAA,EACtB,gBAAA;AAAA,EACA;AACJ,CAAA;AASA,eAAsB,aAAA,CAAc,GAAA,GAAc,OAAA,CAAQ,GAAA,EAAI,EAC9D;AACI,EAAA,KAAA,MAAW,YAAY,iBAAA,EACvB;AACI,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,EAAK,QAAQ,CAAA;AAEnC,IAAA,IAAI,CAAC,UAAA,CAAW,QAAQ,CAAA,EACxB;AACI,MAAA;AAAA,IACJ;AAEA,IAAA,IACA;AAGI,MAAA,MAAM,MAAA,GAAS,MAAM,OAAO,aAAA,CAAc,QAAQ,CAAA,CAAE,IAAA,CAAA;AAEpD,MAAA,OAAQ,MAAA,CAAO,WAAW,EAAC;AAAA,IAC/B,SACO,KAAA,EACP;AAQI,MAAA,OAAA,CAAQ,IAAA;AAAA,QACJ,CAAA,cAAA,EAAO,QAAQ,CAAA,2EAAA,EACU,KAAA,YAAiB,QAAQ,KAAA,CAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,OACnF;AAEA,MAAA,OAAO,EAAC;AAAA,IACZ;AAAA,EACJ;AAEA,EAAA,OAAO,EAAC;AACZ;AAQO,SAAS,YAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,QAAQ,aAAA,CAAc,IAAA;AAAA,IACrE,MAAA,EAAQ,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,UAAU,aAAA,CAAc;AAAA,GAC7E;AACJ;AASO,SAAS,WAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO,GAAA,CAAI,SAAA,IAAa,MAAA,CAAO,IAAA,IAAQ,YAAA;AAC3C;AAWO,SAAS,qBACZ,MAAA,EACA,UAAA,GAA+C,EAAC,EAChD,GAAA,GAAyB,QAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,QAAA,CAAS,GAAA,CAAI,SAAS,CAAA,IACrB,OAAO,KAAA,EAAO,MAAA,IACd,UAAA,CAAW,IAAA,IACX,aAAA,CAAc,MAAA;AAAA,IACrB,MAAM,GAAA,CAAI,SAAA,IACH,MAAA,CAAO,IAAA,IACP,WAAW,IAAA,IACX;AAAA,GACX;AACJ;AAEA,SAAS,SAAS,KAAA,EAClB;AACI,EAAA,IAAI,CAAC,KAAA,EACL;AACI,IAAA,OAAO,MAAA;AAAA,EACX;AAEA,EAAA,MAAM,IAAA,GAAO,OAAO,KAAK,CAAA;AAEzB,EAAA,OAAO,MAAA,CAAO,UAAU,IAAI,CAAA,IAAK,OAAO,CAAA,IAAK,IAAA,GAAO,QAAQ,IAAA,GAAO,MAAA;AACvE","file":"index.js","sourcesContent":["/**\n * `spfn.config.js` — the app's serving shape, read by both the CLI and the server.\n *\n * The file has existed since `spfn init` started writing it, and until now\n * nothing read it back. It is the one committed place that describes how an app\n * is served, which makes it the right home for the ports: an app that wants a\n * different port says so once here instead of repeating the number across its\n * `server.config.ts`, Dockerfile, compose file, `next.config.ts` and env\n * example, where nothing checks that the copies still agree.\n *\n * This module is deliberately free of side effects. `@spfn/core/config`\n * validates the whole environment schema the moment it is imported, which is\n * fine inside a server and wrong inside a CLI that has not been handed an\n * app's environment yet.\n *\n * @module app-config\n */\n\nimport { existsSync } from 'fs';\nimport { pathToFileURL } from 'url';\nimport { join } from 'path';\n\n/**\n * Ports the app is served on.\n *\n * Two names because two processes are started. Next.js reads `PORT` as its own,\n * so the SPFN server cannot claim that name without the two colliding whenever\n * they run together.\n */\nexport interface SpfnPorts\n{\n /** Next.js frontend. Passed to `next` as `-p`. */\n next?: number;\n\n /** SPFN API server. */\n server?: number;\n}\n\nexport interface SpfnAppConfig\n{\n ports?: SpfnPorts;\n\n /** Host the SPFN API server binds. */\n host?: string;\n\n /** Deployment settings — untouched by this module, kept so the type is whole. */\n [key: string]: unknown;\n}\n\n/**\n * The single place each default exists.\n *\n * Defaults live at the bottom of the chain and nowhere else. A default declared\n * at an input layer — a commander option, a generated entry file, an env schema\n * — cannot be told apart from a value someone supplied, so it silently outranks\n * whatever is below it. Three separate defects in this repository came from\n * exactly that, including an env schema default that forced the environment to\n * be consulted last and made an injected port unreachable.\n */\nexport const PORT_DEFAULTS = {\n next: 3790,\n server: 8790,\n} as const;\n\nexport const HOST_DEFAULT = 'localhost';\n\nconst CONFIG_FILE_NAMES = [\n 'spfn.config.js',\n 'spfn.config.mjs',\n] as const;\n\n/**\n * Read `spfn.config.js` from an app directory.\n *\n * Returns an empty object when there is no file, and also when the file cannot\n * be imported — a malformed deployment config must not stop a server from\n * booting. The caller decides whether the absence is worth reporting.\n */\nexport async function loadAppConfig(cwd: string = process.cwd()): Promise<SpfnAppConfig>\n{\n for (const fileName of CONFIG_FILE_NAMES)\n {\n const fullPath = join(cwd, fileName);\n\n if (!existsSync(fullPath))\n {\n continue;\n }\n\n try\n {\n // A file URL, not a path: on Windows an absolute path is not a\n // valid ESM specifier, and this file is imported at runtime.\n const module = await import(pathToFileURL(fullPath).href);\n\n return (module.default ?? {}) as SpfnAppConfig;\n }\n catch (error)\n {\n // Falling back to defaults, and saying so. A file that exists and\n // cannot be imported is a typo, not an absence — silence would send\n // the server to port 8790 while its own config named another, and\n // the only symptom would be a port nobody chose.\n //\n // console, not the server logger: this runs inside the CLI too,\n // before any logger is configured.\n console.warn(\n `⚠️ ${fileName} could not be imported — falling back to defaults `\n + `for ports and host. ${error instanceof Error ? error.message : String(error)}`,\n );\n\n return {};\n }\n }\n\n return {};\n}\n\n/**\n * The port each process binds, given a loaded config and the environment.\n *\n * Three layers, in this order, and no more: environment variable, then\n * `spfn.config.js`, then the default.\n */\nexport function resolvePorts(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): { next: number; server: number }\n{\n return {\n next: readPort(env.NEXT_PORT) ?? config.ports?.next ?? PORT_DEFAULTS.next,\n server: readPort(env.SPFN_PORT) ?? config.ports?.server ?? PORT_DEFAULTS.server,\n };\n}\n\n/**\n * The host the SPFN server binds, on the same three layers.\n *\n * `localhost` by default: a container states `SPFN_HOST=0.0.0.0` in its own\n * files, where that decision belongs, rather than every developer machine\n * publishing a dev server to its network.\n */\nexport function resolveHost(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): string\n{\n return env.SPFN_HOST || config.host || HOST_DEFAULT;\n}\n\n/**\n * Where the SPFN server binds, including the address a server config may still\n * carry through `.port()` / `.host()`.\n *\n * Those two are deprecated and sit between `spfn.config.js` and the default for\n * one release, so an app that has not moved yet keeps the address it had. Once\n * they are removed this collapses back to {@link resolvePorts} and\n * {@link resolveHost}.\n */\nexport function resolveServerAddress(\n config: SpfnAppConfig,\n deprecated: { port?: number; host?: string } = {},\n env: NodeJS.ProcessEnv = process.env,\n): { port: number; host: string }\n{\n return {\n port: readPort(env.SPFN_PORT)\n ?? config.ports?.server\n ?? deprecated.port\n ?? PORT_DEFAULTS.server,\n host: env.SPFN_HOST\n || config.host\n || deprecated.host\n || HOST_DEFAULT,\n };\n}\n\nfunction readPort(value: string | undefined): number | undefined\n{\n if (!value)\n {\n return undefined;\n }\n\n const port = Number(value);\n\n return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;\n}\n"]}