spfn 0.3.0-beta.2 → 0.3.0-beta.4
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 +37 -23
- package/dist/index.js +89 -56
- package/dist/templates/Dockerfile +6 -3
- package/dist/templates/README.md +22 -7
- package/dist/templates/docker-compose.production.yml +9 -2
- package/dist/templates/modes/full/src/server/router.ts +3 -5
- package/dist/templates/modes/full/src/server/routes/ops.ts +52 -0
- package/dist/templates/modes/full/src/server/routes/root.ts +2 -2
- package/dist/templates/modes/full/src/server/server.config.ts +0 -2
- package/dist/templates/server/router.ts +0 -2
- package/dist/templates/server/routes/root.ts +1 -1
- package/dist/templates/server/server.config.ts +0 -2
- package/package.json +2 -2
- package/dist/templates/modes/full/src/server/config/env.config.ts +0 -23
- package/dist/templates/modes/full/src/server/mcp.ts +0 -75
- package/dist/templates/modes/full/src/server/routes/health.ts +0 -9
- package/dist/templates/server/routes/health.ts +0 -18
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`spfn` takes a Next.js idea from prototype to production with a consistent full-stack
|
|
4
4
|
architecture. It can scaffold either a core-only backend or a production baseline with
|
|
5
|
-
authentication, internationalization, and
|
|
5
|
+
authentication, internationalization, and a terminal operations surface, then runs the
|
|
6
6
|
dev/build/start lifecycle, database tooling, RPC codegen, and environment validation.
|
|
7
7
|
|
|
8
8
|
Consistent is the point rather than a nicety: what it scaffolds is one fixed shape per
|
|
@@ -31,7 +31,7 @@ is not supported — see [the root README](../../README.md#what-do-i-need-instal
|
|
|
31
31
|
## Usage
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
# Prototype-to-Production baseline: core + auth + i18n +
|
|
34
|
+
# Prototype-to-Production baseline: core + auth + i18n + ops
|
|
35
35
|
npx spfn@beta create my-app --mode full
|
|
36
36
|
cd my-app
|
|
37
37
|
docker compose up -d # Postgres + Redis
|
|
@@ -71,7 +71,7 @@ pass `--mode full`.
|
|
|
71
71
|
|--------|-------------|
|
|
72
72
|
| `--pm <manager>` | Force package manager: `npm` \| `pnpm` \| `yarn` \| `bun` |
|
|
73
73
|
| `--shadcn` | Also run `shadcn init` |
|
|
74
|
-
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n,
|
|
74
|
+
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
|
|
75
75
|
| `--skip-install` | Skip dependency install |
|
|
76
76
|
| `--skip-git` | Skip `git init` |
|
|
77
77
|
| `-y, --yes` | Skip prompts, use defaults |
|
|
@@ -86,7 +86,7 @@ already exists). See [Scaffold structure](#scaffold-structure) for what lands on
|
|
|
86
86
|
|
|
87
87
|
| Option | Description |
|
|
88
88
|
|--------|-------------|
|
|
89
|
-
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n,
|
|
89
|
+
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
|
|
90
90
|
| `-y, --yes` | Skip prompts, use defaults |
|
|
91
91
|
|
|
92
92
|
Generated projects pin `drizzle-orm` and `drizzle-kit` to `1.0.0-rc.4`, matching
|
|
@@ -134,9 +134,8 @@ no pre-build needed.
|
|
|
134
134
|
|--------|-------------|---------|
|
|
135
135
|
| `--server-only` | Run only the SPFN/Hono server (also auto-selected if Next.js isn't a dependency) | off |
|
|
136
136
|
| `--watch` | Restart the server on `src/server` changes (chokidar) | off |
|
|
137
|
-
| `-p, --port <port>` |
|
|
138
|
-
| `-H, --host <host>` |
|
|
139
|
-
| `--routes <path>` | Routes directory path | server default |
|
|
137
|
+
| `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `spfn.config.js` `ports.server`, then `8790` |
|
|
138
|
+
| `-H, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `spfn.config.js` `host`, then `localhost` |
|
|
140
139
|
| `--allow-pending-migrations` | Start even when migrations are pending (they are listed as a warning) | off |
|
|
141
140
|
|
|
142
141
|
Note: hot reload is **off by default** — pass `--watch` to restart on file changes.
|
|
@@ -165,11 +164,19 @@ if `.spfn/server`, `.spfn/prod-server.mjs`, or `.next` are missing.
|
|
|
165
164
|
|--------|-------------|---------|
|
|
166
165
|
| `--server-only` | Run only the SPFN server | off |
|
|
167
166
|
| `--next-only` | Run only Next.js | off |
|
|
168
|
-
| `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `8790` |
|
|
169
|
-
| `-h, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `
|
|
167
|
+
| `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `spfn.config.js` `ports.server`, then `8790` |
|
|
168
|
+
| `-h, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `spfn.config.js` `host`, then `localhost` |
|
|
170
169
|
| `--allow-pending-migrations` | Start even when migrations are pending (they are listed as a warning) | off |
|
|
171
170
|
|
|
172
|
-
|
|
171
|
+
Both run together via `concurrently --kill-others`.
|
|
172
|
+
|
|
173
|
+
Neither flag has a default value, deliberately. A default is indistinguishable
|
|
174
|
+
from a value the operator typed, and it was forwarded as `SPFN_PORT` either way —
|
|
175
|
+
which overrode the app's own configuration. Pass nothing and `spfn.config.js`
|
|
176
|
+
decides; pass a flag and it wins.
|
|
177
|
+
|
|
178
|
+
Next.js is started on the port `spfn.config.js` gives as `ports.next` (`3790` by
|
|
179
|
+
default), overridable with `NEXT_PORT`.
|
|
173
180
|
|
|
174
181
|
Pending migrations stop the boot unless `--allow-pending-migrations` or
|
|
175
182
|
`SPFN_ALLOW_PENDING_MIGRATIONS=true` is set — see [Database](#spfn-db). `--next-only`
|
|
@@ -491,24 +498,30 @@ src/
|
|
|
491
498
|
app/auth/callback/page.tsx # OAuth session handoff
|
|
492
499
|
i18n/catalogs.ts # application-owned en/ko starter messages
|
|
493
500
|
i18n/server.ts # configured server-side i18n registry
|
|
494
|
-
server/
|
|
495
|
-
server/router.ts # authRouter +
|
|
501
|
+
server/routes/ops.ts # ops routes under /_ops + the manifest `spfn ops` reads
|
|
502
|
+
server/router.ts # authRouter + opsRouter + global authenticate
|
|
496
503
|
server/server.config.ts # createAuthLifecycle + i18n startup
|
|
497
504
|
next.config.ts # /_auth/* callback rewrite
|
|
498
505
|
.env.local # generated auth session secret (gitignored)
|
|
499
|
-
.env.server # auth keyring
|
|
506
|
+
.env.server # auth keyring (gitignored)
|
|
500
507
|
```
|
|
501
508
|
|
|
502
509
|
The full RPC proxy imports the auth interceptor and merges `authRouteMap`. Internal auth
|
|
503
|
-
|
|
510
|
+
keys are generated with cryptographic randomness in ignored local env files;
|
|
504
511
|
`.env.example` contains placeholders only. Add only the provider keys you use, then run
|
|
505
|
-
`pnpm spfn db migrate`.
|
|
506
|
-
|
|
512
|
+
`pnpm spfn db migrate`.
|
|
513
|
+
|
|
514
|
+
Operating the app is [`spfn ops`](#spfn-ops), not a dashboard: the starter
|
|
515
|
+
`src/server/routes/ops.ts` exposes two read commands, and `spfn ops` discovers them from
|
|
516
|
+
the running server's manifest. Issuing the first token signs in as an administrator, so
|
|
517
|
+
uncomment `SPFN_AUTH_ADMIN_ACCOUNTS` in `.env.server` and restart before
|
|
518
|
+
`spfn ops token issue`. The ops surface adds no dependency — the router comes from
|
|
519
|
+
`@spfn/core/ops` and the tokens from `@spfn/auth`.
|
|
507
520
|
|
|
508
521
|
`init` also patches `package.json` (scripts: `spfn:dev`, `spfn:server`, `spfn:next`,
|
|
509
522
|
`spfn:build`, `spfn:start`, `codegen`; deps: `@spfn/core`, `spfn`, `drizzle-orm`,
|
|
510
523
|
`@sinclair/typebox`, `concurrently`, etc.; full also adds `@spfn/auth`, `@spfn/i18n`,
|
|
511
|
-
|
|
524
|
+
auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
|
|
512
525
|
existing range still permits older Node versions), excludes `src/server` from the root
|
|
513
526
|
`tsconfig.json` (Vercel compat), and adds `.spfn/`, `.env.local`, `.env.server` to
|
|
514
527
|
`.gitignore`.
|
|
@@ -580,7 +593,7 @@ docker compose -f docker-compose.production.yml up --build -d
|
|
|
580
593
|
|
|
581
594
|
The Dockerfile (`node:22-alpine`) installs with `pnpm --frozen-lockfile`, runs
|
|
582
595
|
`pnpm run spfn:build`, prunes dev deps, exposes `3790`/`8790`, health-checks
|
|
583
|
-
`http://localhost:8790/health`, and starts via `pnpm run spfn:start`.
|
|
596
|
+
`http://localhost:8790/_core/health`, and starts via `pnpm run spfn:start`.
|
|
584
597
|
|
|
585
598
|
Run migrations against the target DB before/with deploy:
|
|
586
599
|
|
|
@@ -595,7 +608,7 @@ the gate is one that never served the 500s. If a rollout has to proceed anyway,
|
|
|
595
608
|
logged as a warning instead.
|
|
596
609
|
|
|
597
610
|
A readiness probe can catch the same drift on a cluster the local gate never sees. When
|
|
598
|
-
detailed health is on, `GET /health` carries a `migrations` object with per-package
|
|
611
|
+
detailed health is on, `GET /_core/health` carries a `migrations` object with per-package
|
|
599
612
|
applied/pending counts — assert `migrations.pending === 0` in the probe to hold a
|
|
600
613
|
drifted pod out of rotation. Reporting drift does not, by itself, change the overall
|
|
601
614
|
health `status`.
|
|
@@ -614,8 +627,8 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
|
|
|
614
627
|
something with an AI coding agent and now want a real backend under it, `init` is the one.
|
|
615
628
|
|
|
616
629
|
**`bare` or `full`?**
|
|
617
|
-
`full` is the recommended baseline: core, auth, i18n and
|
|
618
|
-
working authenticated app on day one. `bare` is core only — the architecture with nothing
|
|
630
|
+
`full` is the recommended baseline: core, auth, i18n and the ops surface wired together, so
|
|
631
|
+
you get a working authenticated app on day one, operable from the terminal. `bare` is core only — the architecture with nothing
|
|
619
632
|
else decided. Automation should always pass `--mode` explicitly, because a `--yes` run
|
|
620
633
|
without one still produces `bare` for backward compatibility.
|
|
621
634
|
|
|
@@ -630,7 +643,7 @@ pointing at your own PostgreSQL works too. PostgreSQL itself is not optional.
|
|
|
630
643
|
|
|
631
644
|
**Which Node version do I need?**
|
|
632
645
|
20 or later, in both modes. `@spfn/core` runs on `@hono/node-server` 2, which declares
|
|
633
|
-
that floor, and full mode's
|
|
646
|
+
that floor, and full mode's `@spfn/auth` needs the same.
|
|
634
647
|
|
|
635
648
|
**When do I have to run codegen by hand?**
|
|
636
649
|
Whenever routes change outside `spfn dev`, which runs a codegen watcher for you. A stale or
|
|
@@ -678,5 +691,6 @@ committed.
|
|
|
678
691
|
|
|
679
692
|
- [`@spfn/core`](../core/README.md) — server, route DSL, codegen, db, client runtime.
|
|
680
693
|
- [`@spfn/auth`](../auth/README.md) — what `--mode full` wires in for accounts and roles.
|
|
681
|
-
- [`@spfn/mcp`](../mcp/README.md) —
|
|
694
|
+
- [`@spfn/mcp`](../mcp/README.md) — an agent-facing MCP endpoint, added on demand with
|
|
695
|
+
`spfn add @spfn/mcp`.
|
|
682
696
|
- Project root README — framework overview and getting started.
|
package/dist/index.js
CHANGED
|
@@ -103,7 +103,7 @@ async function selectScaffoldMode(options) {
|
|
|
103
103
|
choices: [
|
|
104
104
|
{
|
|
105
105
|
title: "full (recommended)",
|
|
106
|
-
description: "Core, auth, i18n, and
|
|
106
|
+
description: "Core, auth, i18n, and ops CLI \u2014 ready for Prototype to Production",
|
|
107
107
|
value: "full"
|
|
108
108
|
},
|
|
109
109
|
{
|
|
@@ -808,11 +808,33 @@ async function setupDeploymentConfig(cwd, packageJson, packageManager) {
|
|
|
808
808
|
const configContent = `/**
|
|
809
809
|
* SPFN Configuration
|
|
810
810
|
*
|
|
811
|
-
* This file
|
|
811
|
+
* This file describes how your app is served and deployed. It is committed, so
|
|
812
|
+
* keep secrets out of it.
|
|
812
813
|
*
|
|
813
814
|
* @type {import('spfn').SpfnConfig}
|
|
814
815
|
*/
|
|
815
816
|
export default {
|
|
817
|
+
/**
|
|
818
|
+
* Ports the two processes bind.
|
|
819
|
+
*
|
|
820
|
+
* This is the only place either number is written. The Dockerfile, the
|
|
821
|
+
* compose file and \`spfn dev\` / \`spfn start\` all read it, so changing a
|
|
822
|
+
* port here changes it everywhere.
|
|
823
|
+
*
|
|
824
|
+
* Overridable per environment with NEXT_PORT and SPFN_PORT \u2014 a container
|
|
825
|
+
* setting one of those wins over what is written here.
|
|
826
|
+
*/
|
|
827
|
+
ports: {
|
|
828
|
+
next: 3790,
|
|
829
|
+
server: 8790,
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Host the SPFN API server binds. A container sets SPFN_HOST=0.0.0.0; a
|
|
834
|
+
* developer machine has no reason to publish its dev server to the network.
|
|
835
|
+
*/
|
|
836
|
+
host: 'localhost',
|
|
837
|
+
|
|
816
838
|
/**
|
|
817
839
|
* Package manager to use for dependency installation
|
|
818
840
|
* Options: 'npm' | 'yarn' | 'pnpm' | 'bun'
|
|
@@ -922,7 +944,7 @@ var init_deployment_config = __esm({
|
|
|
922
944
|
|
|
923
945
|
// src/utils/version.ts
|
|
924
946
|
function getCliVersion() {
|
|
925
|
-
return "0.3.0-beta.
|
|
947
|
+
return "0.3.0-beta.4";
|
|
926
948
|
}
|
|
927
949
|
function getTagFromVersion(version) {
|
|
928
950
|
const match = version.match(/-([a-z]+)\./i);
|
|
@@ -966,7 +988,6 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
|
|
|
966
988
|
if (mode === "full") {
|
|
967
989
|
packageJson.dependencies["@spfn/auth"] = spfnTag;
|
|
968
990
|
packageJson.dependencies["@spfn/i18n"] = spfnTag;
|
|
969
|
-
packageJson.dependencies["@spfn/mcp"] = spfnTag;
|
|
970
991
|
packageJson.dependencies["@spfn/notification"] = spfnTag;
|
|
971
992
|
}
|
|
972
993
|
packageJson.devDependencies["@types/node"] = "^20.11.0";
|
|
@@ -982,7 +1003,7 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
|
|
|
982
1003
|
}
|
|
983
1004
|
packageJson.scripts["spfn:dev"] = "spfn dev";
|
|
984
1005
|
packageJson.scripts["spfn:server"] = "spfn dev --server-only";
|
|
985
|
-
packageJson.scripts["spfn:next"] = "next dev --turbo
|
|
1006
|
+
packageJson.scripts["spfn:next"] = "next dev --turbo";
|
|
986
1007
|
packageJson.scripts["spfn:start"] = "spfn start";
|
|
987
1008
|
packageJson.scripts["spfn:build"] = "spfn build";
|
|
988
1009
|
packageJson.scripts["codegen"] = "spfn codegen run";
|
|
@@ -1165,9 +1186,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:${randomSecret("base64")}
|
|
|
1165
1186
|
# SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
|
|
1166
1187
|
# SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
|
|
1167
1188
|
|
|
1168
|
-
# Full scaffold:
|
|
1169
|
-
|
|
1170
|
-
|
|
1189
|
+
# Full scaffold: ops CLI (spfn ops)
|
|
1190
|
+
# Ops commands need no variable of their own \u2014 they authenticate with a token
|
|
1191
|
+
# issued by \`spfn ops token issue\`, and issuing one signs in as an administrator.
|
|
1192
|
+
# Uncomment to seed that administrator on the next server start, then remove the
|
|
1193
|
+
# password once the account exists.
|
|
1194
|
+
# SPFN_AUTH_ADMIN_ACCOUNTS=[{"email":"admin@example.com","password":"replace-with-a-strong-password","role":"admin"}]
|
|
1171
1195
|
`;
|
|
1172
1196
|
}
|
|
1173
1197
|
function randomSecret(encoding) {
|
|
@@ -1397,9 +1421,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:replace-with-a-base64-encoded-32-byte-key
|
|
|
1397
1421
|
# SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
|
|
1398
1422
|
# SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
|
|
1399
1423
|
|
|
1400
|
-
# Full scaffold:
|
|
1401
|
-
|
|
1402
|
-
|
|
1424
|
+
# Full scaffold: ops CLI (spfn ops)
|
|
1425
|
+
# Ops commands need no variable of their own \u2014 they authenticate with a token
|
|
1426
|
+
# issued by \`spfn ops token issue\`, and issuing one signs in as an administrator.
|
|
1427
|
+
# Uncomment to seed that administrator on the next server start, then remove the
|
|
1428
|
+
# password once the account exists.
|
|
1429
|
+
# SPFN_AUTH_ADMIN_ACCOUNTS=[{"email":"admin@example.com","password":"replace-with-a-strong-password","role":"admin"}]
|
|
1403
1430
|
`;
|
|
1404
1431
|
}
|
|
1405
1432
|
});
|
|
@@ -1483,7 +1510,7 @@ async function initializeSpfn(options = {}) {
|
|
|
1483
1510
|
console.log(` ${mode === "full" ? "4" : "3"}. Run: ` + chalk4.cyan(`${getRunCommand(pm)} spfn:dev`));
|
|
1484
1511
|
console.log(` ${mode === "full" ? "5" : "4"}. Visit:`);
|
|
1485
1512
|
console.log(" - Next.js: " + chalk4.cyan("http://localhost:3790"));
|
|
1486
|
-
console.log(" - API: " + chalk4.cyan("http://localhost:8790/health"));
|
|
1513
|
+
console.log(" - API: " + chalk4.cyan("http://localhost:8790/_core/health"));
|
|
1487
1514
|
if (mode === "full") {
|
|
1488
1515
|
console.log(" - MCP: " + chalk4.cyan("http://localhost:8790/mcp"));
|
|
1489
1516
|
}
|
|
@@ -2122,6 +2149,7 @@ async function resolveKeychainEnv(cwd) {
|
|
|
2122
2149
|
}
|
|
2123
2150
|
|
|
2124
2151
|
// src/commands/dev.ts
|
|
2152
|
+
import { loadAppConfig, resolvePorts, resolveHost } from "@spfn/core/app-config";
|
|
2125
2153
|
function ignoreDotfilesUnder(root) {
|
|
2126
2154
|
return (watchedPath) => relative(root, watchedPath).split(sep2).some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
|
|
2127
2155
|
}
|
|
@@ -2145,13 +2173,22 @@ function waitForReadyFile(filePath, timeoutMs = 3e4) {
|
|
|
2145
2173
|
});
|
|
2146
2174
|
});
|
|
2147
2175
|
}
|
|
2148
|
-
var devCommand = new Command4("dev").description("Start SPFN development server (detects and runs Next.js + Hono)").option("-p, --port <port>", "Server port").option("-H, --host <host>", "Server host").option("--
|
|
2176
|
+
var devCommand = new Command4("dev").description("Start SPFN development server (detects and runs Next.js + Hono)").option("-p, --port <port>", "Server port").option("-H, --host <host>", "Server host").option("--server-only", "Run only Hono server (skip Next.js)").option("--watch", "Enable hot reload (watch mode)").option("--allow-pending-migrations", "Start even when migrations are pending (they are listed as a warning)").action(async (options) => {
|
|
2149
2177
|
process.setMaxListeners(20);
|
|
2150
2178
|
if (!process.env.NODE_ENV) {
|
|
2151
2179
|
process.env.NODE_ENV = "development";
|
|
2152
2180
|
}
|
|
2153
2181
|
const cwd = process.cwd();
|
|
2154
2182
|
const serverDir = join15(cwd, "src", "server");
|
|
2183
|
+
if (options.port) {
|
|
2184
|
+
process.env.SPFN_PORT = String(options.port);
|
|
2185
|
+
}
|
|
2186
|
+
if (options.host) {
|
|
2187
|
+
process.env.SPFN_HOST = String(options.host);
|
|
2188
|
+
}
|
|
2189
|
+
const appConfig = await loadAppConfig(cwd);
|
|
2190
|
+
const { next: nextPort, server: serverPort } = resolvePorts(appConfig);
|
|
2191
|
+
const serverHost = resolveHost(appConfig);
|
|
2155
2192
|
if (!existsSync14(serverDir)) {
|
|
2156
2193
|
logger.error("src/server directory not found.");
|
|
2157
2194
|
logger.info('Run "spfn init" first to initialize SPFN in your project.');
|
|
@@ -2197,11 +2234,7 @@ var devCommand = new Command4("dev").description("Start SPFN development server
|
|
|
2197
2234
|
if (existsSync14(readySignal)) {
|
|
2198
2235
|
unlinkSync(readySignal);
|
|
2199
2236
|
}
|
|
2200
|
-
const configParts = [];
|
|
2201
|
-
if (options.port) configParts.push(`port: ${options.port}`);
|
|
2202
|
-
if (options.host) configParts.push(`host: '${options.host}'`);
|
|
2203
|
-
if (options.routes) configParts.push(`routesPath: '${options.routes}'`);
|
|
2204
|
-
configParts.push("debug: true");
|
|
2237
|
+
const configParts = ["debug: true"];
|
|
2205
2238
|
const readyFile = join15(tempDir, "server-ready");
|
|
2206
2239
|
writeFileSync10(serverEntry, `
|
|
2207
2240
|
import { writeFileSync } from 'fs';
|
|
@@ -2274,9 +2307,7 @@ catch (error)
|
|
|
2274
2307
|
const pm = detectPackageManager(cwd);
|
|
2275
2308
|
if (options.serverOnly || !hasNext) {
|
|
2276
2309
|
const watchMode2 = options.watch === true;
|
|
2277
|
-
|
|
2278
|
-
const port = options.port ?? process.env.PORT ?? "4000";
|
|
2279
|
-
logger.info(`Starting SPFN Server on http://${host}:${port}${watchMode2 ? " (watch mode)" : ""}
|
|
2310
|
+
logger.info(`Starting SPFN Server on http://${serverHost}:${serverPort}${watchMode2 ? " (watch mode)" : ""}
|
|
2280
2311
|
`);
|
|
2281
2312
|
let serverProcess2 = null;
|
|
2282
2313
|
let watcherProcess2 = null;
|
|
@@ -2392,7 +2423,7 @@ catch (error)
|
|
|
2392
2423
|
};
|
|
2393
2424
|
const startNext = () => {
|
|
2394
2425
|
const nextCmd = pm === "npm" ? "npm" : pm;
|
|
2395
|
-
const nextArgs =
|
|
2426
|
+
const nextArgs = ["run", "spfn:next", "--", "--port", String(nextPort)];
|
|
2396
2427
|
nextProcess = execa6(nextCmd, nextArgs, {
|
|
2397
2428
|
cwd,
|
|
2398
2429
|
stdio: "inherit",
|
|
@@ -2498,6 +2529,19 @@ import { execa as execa7 } from "execa";
|
|
|
2498
2529
|
import ora5 from "ora";
|
|
2499
2530
|
import chalk7 from "chalk";
|
|
2500
2531
|
import { build } from "tsup";
|
|
2532
|
+
function renderProdServerEntry() {
|
|
2533
|
+
return `// Load environment variables FIRST (before any imports that depend on them)
|
|
2534
|
+
// Use centralized environment loader for standard dotenv priority
|
|
2535
|
+
await import('@spfn/core/config');
|
|
2536
|
+
|
|
2537
|
+
// Now import server (logger singleton will be created with correct NODE_ENV)
|
|
2538
|
+
const { startServer } = await import('@spfn/core/server');
|
|
2539
|
+
|
|
2540
|
+
// No address here on purpose: SPFN_PORT / SPFN_HOST and spfn.config.js decide,
|
|
2541
|
+
// and startServer reads both.
|
|
2542
|
+
await startServer({ debug: false });
|
|
2543
|
+
`;
|
|
2544
|
+
}
|
|
2501
2545
|
async function buildProject(options) {
|
|
2502
2546
|
if (!process.env.NODE_ENV) {
|
|
2503
2547
|
process.env.NODE_ENV = "production";
|
|
@@ -2571,6 +2615,12 @@ async function buildProject(options) {
|
|
|
2571
2615
|
entry: ["src/server/**/*.ts"],
|
|
2572
2616
|
format: ["esm"],
|
|
2573
2617
|
outDir: ".spfn/server",
|
|
2618
|
+
// Pin the extension. Left to tsup it follows the app's
|
|
2619
|
+
// package.json — `.mjs` normally, `.js` under
|
|
2620
|
+
// `"type": "module"` — and @spfn/core looks the compiled
|
|
2621
|
+
// server.config up by name, so an app that declared that field
|
|
2622
|
+
// shipped a production server running on defaults.
|
|
2623
|
+
outExtension: () => ({ js: ".mjs" }),
|
|
2574
2624
|
clean: true,
|
|
2575
2625
|
splitting: false,
|
|
2576
2626
|
tsconfig: "src/server/tsconfig.json",
|
|
@@ -2590,29 +2640,7 @@ async function buildProject(options) {
|
|
|
2590
2640
|
}
|
|
2591
2641
|
});
|
|
2592
2642
|
const prodServerPath = join16(cwd, ".spfn", "prod-server.mjs");
|
|
2593
|
-
const prodServerContent =
|
|
2594
|
-
// Use centralized environment loader for standard dotenv priority
|
|
2595
|
-
const { env } = await import('@spfn/core/config');
|
|
2596
|
-
|
|
2597
|
-
// Now import server (logger singleton will be created with correct NODE_ENV)
|
|
2598
|
-
const { startServer } = await import('@spfn/core/server');
|
|
2599
|
-
import { join } from 'path';
|
|
2600
|
-
import { fileURLToPath } from 'url';
|
|
2601
|
-
import { dirname } from 'path';
|
|
2602
|
-
|
|
2603
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
2604
|
-
|
|
2605
|
-
// Environment variables: from .env files OR injected by container/kubernetes
|
|
2606
|
-
const port = env.SPFN_PORT || '8790';
|
|
2607
|
-
const host = env.SPFN_HOST || '0.0.0.0';
|
|
2608
|
-
|
|
2609
|
-
await startServer({
|
|
2610
|
-
port: Number(port),
|
|
2611
|
-
host,
|
|
2612
|
-
routesPath: join(__dirname, 'server', 'routes'),
|
|
2613
|
-
debug: false
|
|
2614
|
-
});
|
|
2615
|
-
`;
|
|
2643
|
+
const prodServerContent = renderProdServerEntry();
|
|
2616
2644
|
writeFileSync11(prodServerPath, prodServerContent);
|
|
2617
2645
|
spinner.succeed(`SPFN server build completed \u2192 .spfn/server`);
|
|
2618
2646
|
const routesDir = join16(cwd, ".spfn", "server", "routes");
|
|
@@ -2676,7 +2704,8 @@ import { existsSync as existsSync16, readFileSync as readFileSync8 } from "fs";
|
|
|
2676
2704
|
import { join as join17 } from "path";
|
|
2677
2705
|
import { execa as execa8 } from "execa";
|
|
2678
2706
|
import chalk8 from "chalk";
|
|
2679
|
-
|
|
2707
|
+
import { loadAppConfig as loadAppConfig2, resolvePorts as resolvePorts2 } from "@spfn/core/app-config";
|
|
2708
|
+
var startCommand = new Command6("start").description("Start SPFN production server (Next.js + Hono)").option("--server-only", "Run only SPFN server (skip Next.js)").option("--next-only", "Run only Next.js (skip SPFN server)").option("-p, --port <port>", "SPFN server port (default: spfn.config.js ports.server, then 8790)").option("-h, --host <host>", "SPFN server host (default: spfn.config.js host, then localhost)").option("--allow-pending-migrations", "Start even when migrations are pending (they are listed as a warning)").action(async (options) => {
|
|
2680
2709
|
if (!process.env.NODE_ENV) {
|
|
2681
2710
|
process.env.NODE_ENV = "production";
|
|
2682
2711
|
}
|
|
@@ -2705,8 +2734,13 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2705
2734
|
logger.error('.spfn/prod-server.mjs not found. Please run "spfn build" first.');
|
|
2706
2735
|
process.exit(1);
|
|
2707
2736
|
}
|
|
2708
|
-
|
|
2709
|
-
|
|
2737
|
+
if (options.port) {
|
|
2738
|
+
process.env.SPFN_PORT = options.port;
|
|
2739
|
+
}
|
|
2740
|
+
if (options.host) {
|
|
2741
|
+
process.env.SPFN_HOST = options.host;
|
|
2742
|
+
}
|
|
2743
|
+
const nextPort = resolvePorts2(await loadAppConfig2(cwd)).next;
|
|
2710
2744
|
if (!options.nextOnly) {
|
|
2711
2745
|
const { checkPendingMigrationsBeforeStart: checkPendingMigrationsBeforeStart2 } = await Promise.resolve().then(() => (init_migration_status(), migration_status_exports));
|
|
2712
2746
|
const migrationCheck = await checkPendingMigrationsBeforeStart2(
|
|
@@ -2722,8 +2756,7 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2722
2756
|
}
|
|
2723
2757
|
}
|
|
2724
2758
|
if (options.serverOnly || !hasNext) {
|
|
2725
|
-
logger.info(
|
|
2726
|
-
`);
|
|
2759
|
+
logger.info("Starting SPFN Server (production)\n");
|
|
2727
2760
|
try {
|
|
2728
2761
|
await execa8("node", [serverEntry], {
|
|
2729
2762
|
stdio: "inherit",
|
|
@@ -2737,9 +2770,10 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2737
2770
|
return;
|
|
2738
2771
|
}
|
|
2739
2772
|
if (options.nextOnly) {
|
|
2740
|
-
logger.info(
|
|
2773
|
+
logger.info(`Starting Next.js (production) on http://0.0.0.0:${nextPort}
|
|
2774
|
+
`);
|
|
2741
2775
|
try {
|
|
2742
|
-
await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p",
|
|
2776
|
+
await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p", String(nextPort)], {
|
|
2743
2777
|
stdio: "inherit",
|
|
2744
2778
|
cwd
|
|
2745
2779
|
});
|
|
@@ -2749,12 +2783,11 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2749
2783
|
}
|
|
2750
2784
|
return;
|
|
2751
2785
|
}
|
|
2752
|
-
const nextCmd =
|
|
2786
|
+
const nextCmd = `next start -H 0.0.0.0 -p ${nextPort}`;
|
|
2753
2787
|
const serverCmd = `node "${serverEntry}"`;
|
|
2754
2788
|
console.log(chalk8.blue.bold("\n\u{1F680} Starting SPFN production server...\n"));
|
|
2755
|
-
logger.info(
|
|
2756
|
-
logger.info(
|
|
2757
|
-
`);
|
|
2789
|
+
logger.info(`Next.js: http://0.0.0.0:${nextPort}`);
|
|
2790
|
+
logger.info("SPFN API: announced by the server below\n");
|
|
2758
2791
|
try {
|
|
2759
2792
|
await execa8(
|
|
2760
2793
|
pm === "npm" ? "npx" : pm,
|
|
@@ -24,12 +24,15 @@ RUN pnpm prune --prod
|
|
|
24
24
|
# Environment
|
|
25
25
|
ENV NODE_ENV=production
|
|
26
26
|
|
|
27
|
-
# Expose ports
|
|
27
|
+
# Expose ports. Docker cannot read spfn.config.js, so these two numbers are the
|
|
28
|
+
# one place they are repeated — keep them in step with `ports` in that file, or
|
|
29
|
+
# set NEXT_PORT / SPFN_PORT here and in the compose file instead.
|
|
28
30
|
EXPOSE 3790 8790
|
|
29
31
|
|
|
30
|
-
# Health check
|
|
32
|
+
# Health check. Falls back to the same default the framework uses, so setting
|
|
33
|
+
# SPFN_PORT alone moves both the server and its probe.
|
|
31
34
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
32
|
-
CMD node -e "require('http').get('http://localhost:8790/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
|
35
|
+
CMD node -e "require('http').get('http://localhost:'+(process.env.SPFN_PORT||8790)+'/_core/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
|
33
36
|
|
|
34
37
|
# Start application
|
|
35
38
|
CMD ["pnpm", "run", "spfn:start"]
|
package/dist/templates/README.md
CHANGED
|
@@ -21,7 +21,7 @@ docker compose up -d
|
|
|
21
21
|
Then open:
|
|
22
22
|
|
|
23
23
|
- Next.js — http://localhost:3790
|
|
24
|
-
- API health — http://localhost:8790/health
|
|
24
|
+
- API health — http://localhost:8790/_core/health
|
|
25
25
|
|
|
26
26
|
## Project structure
|
|
27
27
|
|
|
@@ -71,13 +71,28 @@ Next.js interceptor, `/login` starter UI, OAuth callback, and route map are alre
|
|
|
71
71
|
Edit `src/i18n/catalogs.ts` to add application-owned messages. Server components
|
|
72
72
|
and handlers can import `getT` or `getClientMessages` from `@/i18n/server`.
|
|
73
73
|
|
|
74
|
-
##
|
|
74
|
+
## Operations from the terminal
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
Operating this app needs no admin dashboard. Ops routes live in
|
|
77
|
+
`src/server/routes/ops.ts`, are written like any other route, and the `spfn ops`
|
|
78
|
+
CLI discovers them from the running server:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# 1. Seed an administrator: uncomment SPFN_AUTH_ADMIN_ACCOUNTS in .env.server,
|
|
82
|
+
# then restart the server so the account is created.
|
|
83
|
+
|
|
84
|
+
# 2. Issue a token for this machine (stored in the OS keychain on macOS)
|
|
85
|
+
{{pmExec}} spfn ops token issue --name laptop --scopes 'example:read'
|
|
86
|
+
|
|
87
|
+
# 3. Run the app's own operations
|
|
88
|
+
{{pmExec}} spfn ops list --app http://localhost:8790
|
|
89
|
+
{{pmExec}} spfn ops call countExamples --app http://localhost:8790
|
|
90
|
+
{{pmExec}} spfn ops call listRecentExamples --describe # usage from the route's schema
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Tokens are scoped and revocable (`spfn ops token list` / `revoke`). Add your own
|
|
94
|
+
commands by exporting more `opsRoute` handlers and passing them to
|
|
95
|
+
`createOpsRouter` — no CLI change is needed, the manifest carries them.
|
|
81
96
|
<!-- {{/auth}} -->
|
|
82
97
|
|
|
83
98
|
## Deployment
|
|
@@ -22,13 +22,20 @@ services:
|
|
|
22
22
|
context: .
|
|
23
23
|
dockerfile: Dockerfile
|
|
24
24
|
ports:
|
|
25
|
-
- "3790
|
|
26
|
-
- "8790
|
|
25
|
+
- "${NEXT_PORT:-3790}:${NEXT_PORT:-3790}" # Next.js
|
|
26
|
+
- "${SPFN_PORT:-8790}:${SPFN_PORT:-8790}" # SPFN API
|
|
27
27
|
environment:
|
|
28
28
|
# Required: Set these via .env file or environment
|
|
29
29
|
- DATABASE_URL=${DATABASE_URL}
|
|
30
30
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
|
31
31
|
- NODE_ENV=production
|
|
32
|
+
# Ports: leave unset and spfn.config.js decides. Set either one to
|
|
33
|
+
# override it for this deployment — the mapping above follows.
|
|
34
|
+
- NEXT_PORT=${NEXT_PORT:-}
|
|
35
|
+
- SPFN_PORT=${SPFN_PORT:-}
|
|
36
|
+
# A container has to bind every interface; a developer machine does not,
|
|
37
|
+
# which is why localhost is the default and this line is here.
|
|
38
|
+
- SPFN_HOST=0.0.0.0
|
|
32
39
|
# Optional: Frontend API URL
|
|
33
40
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8790}
|
|
34
41
|
restart: unless-stopped
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
* Prototype-to-Production application router.
|
|
3
3
|
*
|
|
4
4
|
* Auth protects application routes by default. Public routes opt out with
|
|
5
|
-
* `.skip(['auth'])`; the
|
|
5
|
+
* `.skip(['auth'])`; the ops router owns its own ops-token boundary.
|
|
6
6
|
*/
|
|
7
7
|
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
8
8
|
import { defineRouter } from '@spfn/core/route';
|
|
9
|
-
import { mcpRouter } from './mcp';
|
|
10
9
|
import { getRoot } from './routes/root';
|
|
11
|
-
import {
|
|
10
|
+
import { opsRouter } from './routes/ops';
|
|
12
11
|
import {
|
|
13
12
|
listExamples,
|
|
14
13
|
getExample,
|
|
@@ -19,14 +18,13 @@ import {
|
|
|
19
18
|
|
|
20
19
|
export const appRouter = defineRouter({
|
|
21
20
|
getRoot,
|
|
22
|
-
getHealth,
|
|
23
21
|
listExamples,
|
|
24
22
|
getExample,
|
|
25
23
|
createExample,
|
|
26
24
|
updateExample,
|
|
27
25
|
deleteExample,
|
|
28
26
|
})
|
|
29
|
-
.packages([authRouter,
|
|
27
|
+
.packages([authRouter, opsRouter])
|
|
30
28
|
.use([authenticate]);
|
|
31
29
|
|
|
32
30
|
export type AppRouter = typeof appRouter;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops routes — operate this application from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Operations are written exactly like features: `opsRoute` puts them under the
|
|
5
|
+
* `/_ops` namespace, and `createOpsRouter` injects `opsTokenAuth` into every one
|
|
6
|
+
* of them plus serves `GET /_ops/_manifest`, which the `spfn ops` CLI reads to
|
|
7
|
+
* discover the commands this app owns.
|
|
8
|
+
*
|
|
9
|
+
* First run, against a running server:
|
|
10
|
+
*
|
|
11
|
+
* spfn ops token issue --name laptop --scopes 'example:read'
|
|
12
|
+
* spfn ops list --app http://localhost:8790
|
|
13
|
+
* spfn ops call countExamples --app http://localhost:8790
|
|
14
|
+
*
|
|
15
|
+
* Issuing a token signs in as an administrator, so seed one first with
|
|
16
|
+
* `SPFN_AUTH_ADMIN_ACCOUNTS` in `.env.server`.
|
|
17
|
+
*/
|
|
18
|
+
import { createOpsRouter, opsRoute } from '@spfn/core/ops';
|
|
19
|
+
import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
|
|
20
|
+
import { Type } from '@sinclair/typebox';
|
|
21
|
+
import { ExampleRepository } from '../repositories/example.repository';
|
|
22
|
+
|
|
23
|
+
const exampleRepo = new ExampleRepository();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* GET /_ops/examples/count — how many examples exist
|
|
27
|
+
*/
|
|
28
|
+
const countExamples = opsRoute.get('/examples/count')
|
|
29
|
+
.use([requireOpsScope('example:read')])
|
|
30
|
+
.handler(async () => ({ count: await exampleRepo.countAll() }));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GET /_ops/examples — recent examples, ops view
|
|
34
|
+
*/
|
|
35
|
+
const listRecentExamples = opsRoute.get('/examples')
|
|
36
|
+
.use([requireOpsScope('example:read')])
|
|
37
|
+
.input({
|
|
38
|
+
query: Type.Object({
|
|
39
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
|
|
40
|
+
}),
|
|
41
|
+
})
|
|
42
|
+
.handler(async (c) =>
|
|
43
|
+
{
|
|
44
|
+
const { query } = await c.data();
|
|
45
|
+
|
|
46
|
+
return { items: await exampleRepo.findAll(query.limit ?? 10, 0) };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const opsRouter = createOpsRouter({
|
|
50
|
+
countExamples,
|
|
51
|
+
listRecentExamples,
|
|
52
|
+
}, { auth: opsTokenAuth });
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import { defineRouter } from '@spfn/core/route';
|
|
8
8
|
import { getRoot } from './routes/root';
|
|
9
|
-
import { getHealth } from './routes/health';
|
|
10
9
|
import { listExamples, getExample, createExample, updateExample, deleteExample } from './routes/examples';
|
|
11
10
|
|
|
12
11
|
/**
|
|
@@ -14,7 +13,6 @@ import { listExamples, getExample, createExample, updateExample, deleteExample }
|
|
|
14
13
|
*/
|
|
15
14
|
export const appRouter = defineRouter({
|
|
16
15
|
getRoot,
|
|
17
|
-
getHealth,
|
|
18
16
|
listExamples,
|
|
19
17
|
getExample,
|
|
20
18
|
createExample,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spfn",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.4",
|
|
4
4
|
"description": "Scaffold a full-stack TypeScript backend onto a Next.js app built with an AI coding agent: auth, database, typed routes and codegen, one fixed vertical slice per feature",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"node": ">=20.0.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@spfn/core": ">=0.3.0-beta.
|
|
60
|
+
"@spfn/core": ">=0.3.0-beta.3 <0.4.0",
|
|
61
61
|
"typescript": "^5.3.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEnvRegistry,
|
|
3
|
-
defineEnvSchema,
|
|
4
|
-
envSecret,
|
|
5
|
-
envUrl,
|
|
6
|
-
} from '@spfn/core/env';
|
|
7
|
-
|
|
8
|
-
export const envSchema = defineEnvSchema({
|
|
9
|
-
SPFN_MCP_URL: envUrl({
|
|
10
|
-
description: 'Public base URL of the SPFN server that exposes /mcp',
|
|
11
|
-
default: 'http://localhost:8790',
|
|
12
|
-
required: false,
|
|
13
|
-
}),
|
|
14
|
-
SPFN_MCP_API_KEY: envSecret({
|
|
15
|
-
description: 'First-party Bearer token for the generated MCP operator endpoint',
|
|
16
|
-
required: true,
|
|
17
|
-
generate: 'base64url32',
|
|
18
|
-
}),
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
export const env = createEnvRegistry(envSchema).validate();
|
|
22
|
-
|
|
23
|
-
export default env;
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Remote MCP endpoint for operating this application with an agent.
|
|
3
|
-
*
|
|
4
|
-
* The generated operator key makes the endpoint usable immediately. Replace
|
|
5
|
-
* `validateToken` with your OAuth access-token validator before granting access
|
|
6
|
-
* to third-party clients, and expose only tools the resolved operator may use.
|
|
7
|
-
*/
|
|
8
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
9
|
-
import type { McpAuth, McpTool } from '@spfn/mcp';
|
|
10
|
-
import { createMcpRoute } from '@spfn/mcp/server';
|
|
11
|
-
import env from './config/env.config';
|
|
12
|
-
|
|
13
|
-
type OperatorAuth = McpAuth & {
|
|
14
|
-
operatorId: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
type OperatorContext = {
|
|
18
|
-
operatorId: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const tools: McpTool<OperatorContext>[] = [
|
|
22
|
-
{
|
|
23
|
-
name: 'app_status',
|
|
24
|
-
title: 'Application status',
|
|
25
|
-
description: 'Check that the deployed application MCP endpoint is available.',
|
|
26
|
-
inputSchema: {
|
|
27
|
-
type: 'object',
|
|
28
|
-
properties: {},
|
|
29
|
-
},
|
|
30
|
-
annotations: {
|
|
31
|
-
readOnlyHint: true,
|
|
32
|
-
destructiveHint: false,
|
|
33
|
-
},
|
|
34
|
-
handler: async (_args, context) => ({
|
|
35
|
-
status: 'ok',
|
|
36
|
-
operatorId: context.operatorId,
|
|
37
|
-
}),
|
|
38
|
-
},
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
export const mcpRouter = createMcpRoute<OperatorAuth, OperatorContext>({
|
|
42
|
-
appUrl: env.SPFN_MCP_URL,
|
|
43
|
-
serverInfo: {
|
|
44
|
-
name: 'spfn-app',
|
|
45
|
-
version: '1.0.0',
|
|
46
|
-
description: 'Operate this SPFN application with an agent.',
|
|
47
|
-
},
|
|
48
|
-
validateToken: async (token) =>
|
|
49
|
-
{
|
|
50
|
-
// The registry validates this required secret at startup. Its public
|
|
51
|
-
// proxy type remains optional so schemas can also describe values that
|
|
52
|
-
// are not required in every environment.
|
|
53
|
-
if (!secretsMatch(token, env.SPFN_MCP_API_KEY!))
|
|
54
|
-
{
|
|
55
|
-
throw new Error('Invalid MCP access token');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
clientId: 'operator-agent',
|
|
60
|
-
operatorId: 'operator',
|
|
61
|
-
scopes: ['operate'],
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
resolveContext: async auth => ({ operatorId: auth.operatorId }),
|
|
65
|
-
listTools: () => tools,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
function secretsMatch(actual: string, expected: string): boolean
|
|
69
|
-
{
|
|
70
|
-
const actualBytes = Buffer.from(actual);
|
|
71
|
-
const expectedBytes = Buffer.from(expected);
|
|
72
|
-
|
|
73
|
-
return actualBytes.length === expectedBytes.length
|
|
74
|
-
&& timingSafeEqual(actualBytes, expectedBytes);
|
|
75
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Health Check Route
|
|
3
|
-
*
|
|
4
|
-
* Minimal endpoint for monitoring systems, load balancers, and orchestrators.
|
|
5
|
-
* Used by Kubernetes probes, uptime monitors, etc.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { route } from '@spfn/core/route';
|
|
9
|
-
|
|
10
|
-
export const getHealth = route.get('/health')
|
|
11
|
-
.handler(async () =>
|
|
12
|
-
{
|
|
13
|
-
return {
|
|
14
|
-
status: 'ok',
|
|
15
|
-
timestamp: Date.now(),
|
|
16
|
-
uptime: process.uptime(),
|
|
17
|
-
};
|
|
18
|
-
});
|