spfn 0.3.0-beta.1 → 0.3.0-beta.3

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
@@ -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>` | Server port | from `server.config.ts` / env (`4000` in server-only fallback) |
138
- | `-H, --host <host>` | Server host | `localhost` |
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`) | `0.0.0.0` |
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
- Next.js is started on `0.0.0.0:3790`. Both run together via `concurrently --kill-others`.
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`
@@ -411,8 +418,14 @@ listSignups GET /_ops/signups
411
418
  Add `--json` for the raw JSON Schema. The server still validates every call — `--describe`
412
419
  reports what it will accept, and the app's answer decides.
413
420
 
414
- The app URL comes from `--app` or `SPFN_OPS_APP`. The ops token resolves `--token`
415
- `SPFN_OPS_TOKEN` macOS keychain, and its lifecycle is managed with:
421
+ The app URL comes from `--app` or `SPFN_OPS_APP`, and it must be **https** — every one of
422
+ these commands carries a secret, and `token issue` carries an administrator's password.
423
+ `http` is accepted only against `localhost`, `127.0.0.1` and `::1`, where there is no
424
+ network to listen on. A URL with a base path (`https://example.com/api`) is kept whole:
425
+ both the ops calls and the administrator sign-in go through it.
426
+
427
+ The ops token resolves `--token` → `SPFN_OPS_TOKEN` → macOS keychain, and its lifecycle is
428
+ managed with:
416
429
 
417
430
  ```bash
418
431
  spfn ops token issue --name laptop --scopes 'waitlist:read' --app <url>
@@ -442,6 +455,10 @@ point, which that release added. The CLI does not depend on the package in any f
442
455
  loads it from the app at run time, and tells a missing package apart from one too old to
443
456
  carry the entry point, so the message names the thing to do.
444
457
 
458
+ Because the package is the app's, it is resolved **from the directory the command runs in**.
459
+ Run `spfn ops token` from the app's root; running it elsewhere reports the package as
460
+ missing, and the message names the directory it looked in.
461
+
445
462
  ---
446
463
 
447
464
  ## Scaffold structure
@@ -570,7 +587,7 @@ docker compose -f docker-compose.production.yml up --build -d
570
587
 
571
588
  The Dockerfile (`node:22-alpine`) installs with `pnpm --frozen-lockfile`, runs
572
589
  `pnpm run spfn:build`, prunes dev deps, exposes `3790`/`8790`, health-checks
573
- `http://localhost:8790/health`, and starts via `pnpm run spfn:start`.
590
+ `http://localhost:8790/_core/health`, and starts via `pnpm run spfn:start`.
574
591
 
575
592
  Run migrations against the target DB before/with deploy:
576
593
 
@@ -585,7 +602,7 @@ the gate is one that never served the 500s. If a rollout has to proceed anyway,
585
602
  logged as a warning instead.
586
603
 
587
604
  A readiness probe can catch the same drift on a cluster the local gate never sees. When
588
- detailed health is on, `GET /health` carries a `migrations` object with per-package
605
+ detailed health is on, `GET /_core/health` carries a `migrations` object with per-package
589
606
  applied/pending counts — assert `migrations.pending === 0` in the probe to hold a
590
607
  drifted pod out of rotation. Reporting drift does not, by itself, change the overall
591
608
  health `status`.
package/dist/index.js CHANGED
@@ -808,11 +808,33 @@ async function setupDeploymentConfig(cwd, packageJson, packageManager) {
808
808
  const configContent = `/**
809
809
  * SPFN Configuration
810
810
  *
811
- * This file configures your SPFN application deployment settings.
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.1";
947
+ return "0.3.0-beta.3";
926
948
  }
927
949
  function getTagFromVersion(version) {
928
950
  const match = version.match(/-([a-z]+)\./i);
@@ -982,7 +1004,7 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
982
1004
  }
983
1005
  packageJson.scripts["spfn:dev"] = "spfn dev";
984
1006
  packageJson.scripts["spfn:server"] = "spfn dev --server-only";
985
- packageJson.scripts["spfn:next"] = "next dev --turbo --port 3790";
1007
+ packageJson.scripts["spfn:next"] = "next dev --turbo";
986
1008
  packageJson.scripts["spfn:start"] = "spfn start";
987
1009
  packageJson.scripts["spfn:build"] = "spfn build";
988
1010
  packageJson.scripts["codegen"] = "spfn codegen run";
@@ -1483,7 +1505,7 @@ async function initializeSpfn(options = {}) {
1483
1505
  console.log(` ${mode === "full" ? "4" : "3"}. Run: ` + chalk4.cyan(`${getRunCommand(pm)} spfn:dev`));
1484
1506
  console.log(` ${mode === "full" ? "5" : "4"}. Visit:`);
1485
1507
  console.log(" - Next.js: " + chalk4.cyan("http://localhost:3790"));
1486
- console.log(" - API: " + chalk4.cyan("http://localhost:8790/health"));
1508
+ console.log(" - API: " + chalk4.cyan("http://localhost:8790/_core/health"));
1487
1509
  if (mode === "full") {
1488
1510
  console.log(" - MCP: " + chalk4.cyan("http://localhost:8790/mcp"));
1489
1511
  }
@@ -2122,6 +2144,7 @@ async function resolveKeychainEnv(cwd) {
2122
2144
  }
2123
2145
 
2124
2146
  // src/commands/dev.ts
2147
+ import { loadAppConfig, resolvePorts, resolveHost } from "@spfn/core/app-config";
2125
2148
  function ignoreDotfilesUnder(root) {
2126
2149
  return (watchedPath) => relative(root, watchedPath).split(sep2).some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
2127
2150
  }
@@ -2145,13 +2168,22 @@ function waitForReadyFile(filePath, timeoutMs = 3e4) {
2145
2168
  });
2146
2169
  });
2147
2170
  }
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("--routes <path>", "Routes directory path").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) => {
2171
+ 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
2172
  process.setMaxListeners(20);
2150
2173
  if (!process.env.NODE_ENV) {
2151
2174
  process.env.NODE_ENV = "development";
2152
2175
  }
2153
2176
  const cwd = process.cwd();
2154
2177
  const serverDir = join15(cwd, "src", "server");
2178
+ if (options.port) {
2179
+ process.env.SPFN_PORT = String(options.port);
2180
+ }
2181
+ if (options.host) {
2182
+ process.env.SPFN_HOST = String(options.host);
2183
+ }
2184
+ const appConfig = await loadAppConfig(cwd);
2185
+ const { next: nextPort, server: serverPort } = resolvePorts(appConfig);
2186
+ const serverHost = resolveHost(appConfig);
2155
2187
  if (!existsSync14(serverDir)) {
2156
2188
  logger.error("src/server directory not found.");
2157
2189
  logger.info('Run "spfn init" first to initialize SPFN in your project.');
@@ -2197,11 +2229,7 @@ var devCommand = new Command4("dev").description("Start SPFN development server
2197
2229
  if (existsSync14(readySignal)) {
2198
2230
  unlinkSync(readySignal);
2199
2231
  }
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");
2232
+ const configParts = ["debug: true"];
2205
2233
  const readyFile = join15(tempDir, "server-ready");
2206
2234
  writeFileSync10(serverEntry, `
2207
2235
  import { writeFileSync } from 'fs';
@@ -2274,9 +2302,7 @@ catch (error)
2274
2302
  const pm = detectPackageManager(cwd);
2275
2303
  if (options.serverOnly || !hasNext) {
2276
2304
  const watchMode2 = options.watch === true;
2277
- const host = options.host ?? process.env.HOST ?? "localhost";
2278
- const port = options.port ?? process.env.PORT ?? "4000";
2279
- logger.info(`Starting SPFN Server on http://${host}:${port}${watchMode2 ? " (watch mode)" : ""}
2305
+ logger.info(`Starting SPFN Server on http://${serverHost}:${serverPort}${watchMode2 ? " (watch mode)" : ""}
2280
2306
  `);
2281
2307
  let serverProcess2 = null;
2282
2308
  let watcherProcess2 = null;
@@ -2392,7 +2418,7 @@ catch (error)
2392
2418
  };
2393
2419
  const startNext = () => {
2394
2420
  const nextCmd = pm === "npm" ? "npm" : pm;
2395
- const nextArgs = pm === "npm" ? ["run", "spfn:next"] : ["run", "spfn:next"];
2421
+ const nextArgs = ["run", "spfn:next", "--", "--port", String(nextPort)];
2396
2422
  nextProcess = execa6(nextCmd, nextArgs, {
2397
2423
  cwd,
2398
2424
  stdio: "inherit",
@@ -2498,6 +2524,19 @@ import { execa as execa7 } from "execa";
2498
2524
  import ora5 from "ora";
2499
2525
  import chalk7 from "chalk";
2500
2526
  import { build } from "tsup";
2527
+ function renderProdServerEntry() {
2528
+ return `// Load environment variables FIRST (before any imports that depend on them)
2529
+ // Use centralized environment loader for standard dotenv priority
2530
+ await import('@spfn/core/config');
2531
+
2532
+ // Now import server (logger singleton will be created with correct NODE_ENV)
2533
+ const { startServer } = await import('@spfn/core/server');
2534
+
2535
+ // No address here on purpose: SPFN_PORT / SPFN_HOST and spfn.config.js decide,
2536
+ // and startServer reads both.
2537
+ await startServer({ debug: false });
2538
+ `;
2539
+ }
2501
2540
  async function buildProject(options) {
2502
2541
  if (!process.env.NODE_ENV) {
2503
2542
  process.env.NODE_ENV = "production";
@@ -2571,6 +2610,12 @@ async function buildProject(options) {
2571
2610
  entry: ["src/server/**/*.ts"],
2572
2611
  format: ["esm"],
2573
2612
  outDir: ".spfn/server",
2613
+ // Pin the extension. Left to tsup it follows the app's
2614
+ // package.json — `.mjs` normally, `.js` under
2615
+ // `"type": "module"` — and @spfn/core looks the compiled
2616
+ // server.config up by name, so an app that declared that field
2617
+ // shipped a production server running on defaults.
2618
+ outExtension: () => ({ js: ".mjs" }),
2574
2619
  clean: true,
2575
2620
  splitting: false,
2576
2621
  tsconfig: "src/server/tsconfig.json",
@@ -2590,29 +2635,7 @@ async function buildProject(options) {
2590
2635
  }
2591
2636
  });
2592
2637
  const prodServerPath = join16(cwd, ".spfn", "prod-server.mjs");
2593
- const prodServerContent = `// Load environment variables FIRST (before any imports that depend on them)
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
- `;
2638
+ const prodServerContent = renderProdServerEntry();
2616
2639
  writeFileSync11(prodServerPath, prodServerContent);
2617
2640
  spinner.succeed(`SPFN server build completed \u2192 .spfn/server`);
2618
2641
  const routesDir = join16(cwd, ".spfn", "server", "routes");
@@ -2676,7 +2699,8 @@ import { existsSync as existsSync16, readFileSync as readFileSync8 } from "fs";
2676
2699
  import { join as join17 } from "path";
2677
2700
  import { execa as execa8 } from "execa";
2678
2701
  import chalk8 from "chalk";
2679
- 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>", "Server port", "8790").option("-h, --host <host>", "Server host", "0.0.0.0").option("--allow-pending-migrations", "Start even when migrations are pending (they are listed as a warning)").action(async (options) => {
2702
+ import { loadAppConfig as loadAppConfig2, resolvePorts as resolvePorts2 } from "@spfn/core/app-config";
2703
+ 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
2704
  if (!process.env.NODE_ENV) {
2681
2705
  process.env.NODE_ENV = "production";
2682
2706
  }
@@ -2705,8 +2729,13 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2705
2729
  logger.error('.spfn/prod-server.mjs not found. Please run "spfn build" first.');
2706
2730
  process.exit(1);
2707
2731
  }
2708
- process.env.SPFN_PORT = options.port;
2709
- process.env.SPFN_HOST = options.host;
2732
+ if (options.port) {
2733
+ process.env.SPFN_PORT = options.port;
2734
+ }
2735
+ if (options.host) {
2736
+ process.env.SPFN_HOST = options.host;
2737
+ }
2738
+ const nextPort = resolvePorts2(await loadAppConfig2(cwd)).next;
2710
2739
  if (!options.nextOnly) {
2711
2740
  const { checkPendingMigrationsBeforeStart: checkPendingMigrationsBeforeStart2 } = await Promise.resolve().then(() => (init_migration_status(), migration_status_exports));
2712
2741
  const migrationCheck = await checkPendingMigrationsBeforeStart2(
@@ -2722,8 +2751,7 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2722
2751
  }
2723
2752
  }
2724
2753
  if (options.serverOnly || !hasNext) {
2725
- logger.info(`Starting SPFN Server (production) on http://${options.host}:${options.port}
2726
- `);
2754
+ logger.info("Starting SPFN Server (production)\n");
2727
2755
  try {
2728
2756
  await execa8("node", [serverEntry], {
2729
2757
  stdio: "inherit",
@@ -2737,9 +2765,10 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2737
2765
  return;
2738
2766
  }
2739
2767
  if (options.nextOnly) {
2740
- logger.info("Starting Next.js (production) on http://0.0.0.0:3790\n");
2768
+ logger.info(`Starting Next.js (production) on http://0.0.0.0:${nextPort}
2769
+ `);
2741
2770
  try {
2742
- await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p", "3790"], {
2771
+ await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p", String(nextPort)], {
2743
2772
  stdio: "inherit",
2744
2773
  cwd
2745
2774
  });
@@ -2749,12 +2778,11 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2749
2778
  }
2750
2779
  return;
2751
2780
  }
2752
- const nextCmd = "next start -H 0.0.0.0 -p 3790";
2781
+ const nextCmd = `next start -H 0.0.0.0 -p ${nextPort}`;
2753
2782
  const serverCmd = `node "${serverEntry}"`;
2754
2783
  console.log(chalk8.blue.bold("\n\u{1F680} Starting SPFN production server...\n"));
2755
- logger.info("Next.js: http://0.0.0.0:3790");
2756
- logger.info(`SPFN API: http://${options.host}:${options.port}
2757
- `);
2784
+ logger.info(`Next.js: http://0.0.0.0:${nextPort}`);
2785
+ logger.info("SPFN API: announced by the server below\n");
2758
2786
  try {
2759
2787
  await execa8(
2760
2788
  pm === "npm" ? "npx" : pm,
@@ -5380,7 +5408,39 @@ async function fetchOpsManifest(appUrl, token) {
5380
5408
  if (manifest?.manifestVersion !== 1 || !Array.isArray(manifest.commands)) {
5381
5409
  throw new Error("The manifest answer has an unknown shape.");
5382
5410
  }
5383
- return manifest;
5411
+ return { manifestVersion: 1, commands: usableCommands(manifest.commands) };
5412
+ }
5413
+ var OPS_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
5414
+ var OPS_PATH_PREFIX = "/_ops/";
5415
+ function unusableBecause(command) {
5416
+ if (typeof command?.name !== "string" || command.name.length === 0) {
5417
+ return "it has no name";
5418
+ }
5419
+ if (typeof command.method !== "string" || !OPS_METHODS.has(command.method)) {
5420
+ return `its method is ${JSON.stringify(command.method)}`;
5421
+ }
5422
+ if (typeof command.path !== "string" || !command.path.startsWith(OPS_PATH_PREFIX)) {
5423
+ return `its path ${JSON.stringify(command.path)} is outside ${OPS_PATH_PREFIX}`;
5424
+ }
5425
+ if (command.path.split("/").includes("..")) {
5426
+ return `its path ${JSON.stringify(command.path)} climbs out of the ops namespace`;
5427
+ }
5428
+ return null;
5429
+ }
5430
+ function usableCommands(commands) {
5431
+ const usable = [];
5432
+ for (const command of commands) {
5433
+ const reason = unusableBecause(command);
5434
+ if (reason !== null) {
5435
+ console.error(`\u26A0\uFE0F Ignoring an ops command the manifest announced: ${reason}.`);
5436
+ continue;
5437
+ }
5438
+ usable.push({ ...command, input: isPlainObject(command.input) ? command.input : {} });
5439
+ }
5440
+ return usable;
5441
+ }
5442
+ function isPlainObject(value) {
5443
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5384
5444
  }
5385
5445
  function buildCommandPath(command, params, query) {
5386
5446
  const path5 = command.path.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => {
@@ -5407,6 +5467,10 @@ var SECTIONS = [
5407
5467
  function isSchema(value) {
5408
5468
  return value !== null && typeof value === "object" && !Array.isArray(value);
5409
5469
  }
5470
+ var MAX_SCHEMA_DEPTH = 12;
5471
+ function plain(value) {
5472
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
5473
+ }
5410
5474
  function typeName(schema) {
5411
5475
  if (Array.isArray(schema.enum)) {
5412
5476
  return schema.enum.every((v) => typeof v === "string") ? "string" : "value";
@@ -5453,7 +5517,7 @@ function constraintNotes(schema) {
5453
5517
  }
5454
5518
  return notes;
5455
5519
  }
5456
- function collectFields(schema, prefix = "") {
5520
+ function collectFields(schema, prefix = "", depth = 0) {
5457
5521
  const properties = isSchema(schema.properties) ? schema.properties : {};
5458
5522
  const required = Array.isArray(schema.required) ? schema.required : [];
5459
5523
  const rows = [];
@@ -5461,17 +5525,17 @@ function collectFields(schema, prefix = "") {
5461
5525
  if (!isSchema(raw)) {
5462
5526
  continue;
5463
5527
  }
5464
- const path5 = prefix ? `${prefix}.${name}` : name;
5465
- const nested = isSchema(raw.properties) ? collectFields(raw, path5) : [];
5528
+ const path5 = prefix ? `${prefix}.${plain(name)}` : plain(name);
5529
+ const nested = isSchema(raw.properties) && depth < MAX_SCHEMA_DEPTH ? collectFields(raw, path5, depth + 1) : [];
5466
5530
  if (nested.length > 0) {
5467
5531
  rows.push(...nested);
5468
5532
  continue;
5469
5533
  }
5470
5534
  rows.push({
5471
5535
  name: path5,
5472
- type: typeName(raw),
5536
+ type: plain(typeName(raw)),
5473
5537
  requirement: required.includes(name) ? "required" : "optional",
5474
- notes: constraintNotes(raw).join(", ")
5538
+ notes: plain(constraintNotes(raw).join(", "))
5475
5539
  });
5476
5540
  }
5477
5541
  return rows;
@@ -5490,7 +5554,7 @@ function renderSection(label, flag, rows) {
5490
5554
  return lines;
5491
5555
  }
5492
5556
  function renderCommandUsage(command) {
5493
- const lines = [`${command.name} ${command.method} ${command.path}`, ""];
5557
+ const lines = [`${plain(command.name)} ${command.method} ${plain(command.path)}`, ""];
5494
5558
  let described = false;
5495
5559
  for (const section of SECTIONS) {
5496
5560
  const schema = command.input[section.key];
@@ -5507,7 +5571,7 @@ function renderCommandUsage(command) {
5507
5571
  if (!described) {
5508
5572
  lines.push(" Takes no input.", "");
5509
5573
  }
5510
- lines.push(` Invoke: spfn ops call ${command.name}${exampleFlags(command)}`);
5574
+ lines.push(` Invoke: spfn ops call ${plain(command.name)}${exampleFlags(command)}`);
5511
5575
  return lines.join("\n");
5512
5576
  }
5513
5577
  function exampleFlags(command) {
@@ -5598,18 +5662,37 @@ function deleteOpsToken(account) {
5598
5662
  }
5599
5663
 
5600
5664
  // src/commands/ops/resolve.ts
5665
+ function isLoopback(hostname) {
5666
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
5667
+ }
5668
+ function assertTransportSafe(parsed, appUrl) {
5669
+ if (parsed.protocol === "https:" || parsed.protocol === "http:" && isLoopback(parsed.hostname)) {
5670
+ return;
5671
+ }
5672
+ if (parsed.protocol === "http:") {
5673
+ console.error(chalk33.red(`\u274C Refusing to talk to ${appUrl} over http.`));
5674
+ console.error(chalk33.gray(" An administrator password and an ops token cross this connection, and http sends both in the clear."));
5675
+ console.error(chalk33.gray(" Use https. http is allowed only against localhost."));
5676
+ process.exit(1);
5677
+ }
5678
+ console.error(chalk33.red(`\u274C App URL must be https, got "${parsed.protocol}//".`));
5679
+ console.error(chalk33.gray(" The ops surface is reached over HTTP(S) \u2014 http is allowed only against localhost."));
5680
+ process.exit(1);
5681
+ }
5601
5682
  function resolveAppUrl(options) {
5602
5683
  const appUrl = options.app ?? process.env.SPFN_OPS_APP;
5603
5684
  if (!appUrl) {
5604
5685
  console.error(chalk33.red("\u274C No app URL. Pass --app <url> or set SPFN_OPS_APP."));
5605
5686
  process.exit(1);
5606
5687
  }
5688
+ let parsed;
5607
5689
  try {
5608
- new URL(appUrl);
5690
+ parsed = new URL(appUrl);
5609
5691
  } catch {
5610
5692
  console.error(chalk33.red(`\u274C Invalid app URL: ${appUrl}`));
5611
5693
  process.exit(1);
5612
5694
  }
5695
+ assertTransportSafe(parsed, appUrl);
5613
5696
  return appUrl;
5614
5697
  }
5615
5698
  function appAccount(appUrl) {
@@ -5623,7 +5706,13 @@ async function resolveToken(options, appUrl) {
5623
5706
  return process.env.SPFN_OPS_TOKEN;
5624
5707
  }
5625
5708
  if (keychainSupported()) {
5626
- const stored = await loadOpsToken(appAccount(appUrl));
5709
+ const stored = await loadOpsToken(appAccount(appUrl)).catch((err) => {
5710
+ console.error(chalk33.yellow(
5711
+ `\u26A0\uFE0F Could not read the keychain (${err instanceof Error ? err.message : String(err)}).`
5712
+ ));
5713
+ console.error(chalk33.gray(" Unlock it, or pass --token / set SPFN_OPS_TOKEN for this run."));
5714
+ return null;
5715
+ });
5627
5716
  if (stored) {
5628
5717
  return stored;
5629
5718
  }
@@ -5654,16 +5743,26 @@ import prompts9 from "prompts";
5654
5743
  import chalk34 from "chalk";
5655
5744
 
5656
5745
  // src/utils/ops/auth-crypto.ts
5746
+ import { createRequire } from "module";
5747
+ import { join as join25 } from "path";
5748
+ import { pathToFileURL as pathToFileURL3 } from "url";
5657
5749
  var CRYPTO_ENTRY_SINCE = "0.3.0-beta.2";
5658
5750
  var CRYPTO_ENTRY = "@spfn/auth/crypto";
5751
+ async function importFromApp(specifier) {
5752
+ const requireFromApp = createRequire(join25(process.cwd(), "noop.js"));
5753
+ return await import(pathToFileURL3(requireFromApp.resolve(specifier)).href);
5754
+ }
5659
5755
  async function loadAuthCrypto() {
5660
5756
  try {
5661
- return await import(CRYPTO_ENTRY);
5757
+ return await importFromApp(CRYPTO_ENTRY);
5662
5758
  } catch (err) {
5663
5759
  const code = err.code;
5664
5760
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
5665
5761
  throw new Error(
5666
- "This project does not have @spfn/auth installed, and ops tokens live in its schema \u2014 so this app has none to issue, list or revoke.\n An app that uses the ops surface installs @spfn/auth for opsTokenAuth; add it there.\n Invoking commands with a token you already hold (spfn ops list / call) does not need it."
5762
+ `No @spfn/auth is installed in ${process.cwd()}, and ops tokens live in its schema \u2014 so this app has none to issue, list or revoke.
5763
+ An app that uses the ops surface installs @spfn/auth for opsTokenAuth; add it there.
5764
+ Run this from the app directory \u2014 the package is resolved from where the command runs.
5765
+ Invoking commands with a token you already hold (spfn ops list / call) does not need it.`
5667
5766
  );
5668
5767
  }
5669
5768
  if (code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
@@ -5677,8 +5776,18 @@ async function loadAuthCrypto() {
5677
5776
  }
5678
5777
 
5679
5778
  // src/utils/ops/admin-session.ts
5779
+ function parseJsonAnswer(text, status, statusText) {
5780
+ if (!text) {
5781
+ return {};
5782
+ }
5783
+ try {
5784
+ return JSON.parse(text);
5785
+ } catch {
5786
+ throw new Error(`The app answered ${status} ${statusText} with something that is not JSON.`);
5787
+ }
5788
+ }
5680
5789
  async function postJson(appUrl, path5, body, authorization) {
5681
- const response = await fetch(new URL(path5, appUrl), {
5790
+ const response = await fetch(joinUrl(appUrl, path5), {
5682
5791
  method: "POST",
5683
5792
  headers: {
5684
5793
  "Content-Type": "application/json",
@@ -5687,7 +5796,7 @@ async function postJson(appUrl, path5, body, authorization) {
5687
5796
  body: JSON.stringify(body)
5688
5797
  });
5689
5798
  const text = await response.text();
5690
- const parsed = text ? JSON.parse(text) : {};
5799
+ const parsed = parseJsonAnswer(text, response.status, response.statusText);
5691
5800
  if (!response.ok) {
5692
5801
  throw new Error(parsed.message ?? parsed.error ?? `${response.status} ${response.statusText}`);
5693
5802
  }
@@ -5752,7 +5861,7 @@ async function withAdminSession(appUrl, run2) {
5752
5861
  return result;
5753
5862
  }
5754
5863
  async function adminRequest(appUrl, method, path5, session, body) {
5755
- const response = await fetch(new URL(path5, appUrl), {
5864
+ const response = await fetch(joinUrl(appUrl, path5), {
5756
5865
  method,
5757
5866
  headers: {
5758
5867
  Authorization: session.authorization,
@@ -5761,7 +5870,7 @@ async function adminRequest(appUrl, method, path5, session, body) {
5761
5870
  ...body === void 0 ? {} : { body: JSON.stringify(body) }
5762
5871
  });
5763
5872
  const text = await response.text();
5764
- const parsed = text ? JSON.parse(text) : {};
5873
+ const parsed = parseJsonAnswer(text, response.status, response.statusText);
5765
5874
  if (!response.ok) {
5766
5875
  throw new Error(parsed.message ?? parsed.error ?? `${response.status} ${response.statusText}`);
5767
5876
  }
@@ -5920,7 +6029,7 @@ async function listCommands(options) {
5920
6029
  console.log(chalk36.bold(`Ops commands at ${appUrl}:
5921
6030
  `));
5922
6031
  for (const command of manifest.commands) {
5923
- console.log(` ${chalk36.cyan(command.name)} ${chalk36.gray(`${command.method} ${command.path}`)} input: ${inputSummary(command)}`);
6032
+ console.log(` ${chalk36.cyan(plain(command.name))} ${chalk36.gray(`${command.method} ${plain(command.path)}`)} input: ${inputSummary(command)}`);
5924
6033
  }
5925
6034
  console.log(chalk36.gray(`
5926
6035
  \u{1F4A1} Invoke: spfn ops call <name> [--param k=v] [--query k=v] [--data '{"..."}']`));
@@ -5932,8 +6041,8 @@ async function callCommand(name, options) {
5932
6041
  const manifest = await fetchOpsManifest(appUrl, token);
5933
6042
  const command = manifest.commands.find((c) => c.name === name);
5934
6043
  if (!command) {
5935
- console.error(chalk36.red(`\u274C Unknown ops command "${name}".`));
5936
- console.error(chalk36.gray(` Known: ${manifest.commands.map((c) => c.name).join(", ") || "(none)"}`));
6044
+ console.error(chalk36.red(`\u274C Unknown ops command "${plain(name)}".`));
6045
+ console.error(chalk36.gray(` Known: ${manifest.commands.map((c) => plain(c.name)).join(", ") || "(none)"}`));
5937
6046
  process.exit(1);
5938
6047
  }
5939
6048
  if (options.describe) {
@@ -5943,7 +6052,7 @@ async function callCommand(name, options) {
5943
6052
  let body;
5944
6053
  if (options.data !== void 0) {
5945
6054
  if (command.method === "GET") {
5946
- console.error(chalk36.red(`\u274C "${name}" is a ${command.method} command and takes no request body.`));
6055
+ console.error(chalk36.red(`\u274C "${plain(name)}" is a ${command.method} command and takes no request body.`));
5947
6056
  console.error(chalk36.gray(" Pass its input with --query k=v (or --param k=v for path segments)."));
5948
6057
  process.exit(1);
5949
6058
  }
@@ -5964,7 +6073,7 @@ async function callCommand(name, options) {
5964
6073
  console.log(rendered);
5965
6074
  return;
5966
6075
  }
5967
- console.error(chalk36.red(`\u274C ${command.method} ${command.path} answered ${response.status}`));
6076
+ console.error(chalk36.red(`\u274C ${command.method} ${plain(command.path)} answered ${response.status}`));
5968
6077
  console.error(rendered);
5969
6078
  process.exit(1);
5970
6079
  }
@@ -5994,7 +6103,7 @@ function resolveEnv(env9) {
5994
6103
 
5995
6104
  // src/commands/secret/store-value.ts
5996
6105
  init_logger();
5997
- import { join as join26 } from "path";
6106
+ import { join as join27 } from "path";
5998
6107
  init_env_file();
5999
6108
 
6000
6109
  // src/utils/sops.ts
@@ -6043,19 +6152,19 @@ async function sopsUpdateKeys(absFile) {
6043
6152
 
6044
6153
  // src/utils/secret-config.ts
6045
6154
  import { existsSync as existsSync28, readdirSync as readdirSync2 } from "fs";
6046
- import { join as join25 } from "path";
6155
+ import { join as join26 } from "path";
6047
6156
  var SECRETS_DIR = "secrets";
6048
6157
  function getSopsFile(cwd, env9) {
6049
6158
  const relFile = `${SECRETS_DIR}/${env9}.enc.json`;
6050
- return { absFile: join25(cwd, relFile), relFile };
6159
+ return { absFile: join26(cwd, relFile), relFile };
6051
6160
  }
6052
6161
  function findUp(cwd, filename, maxDepth = 6) {
6053
6162
  let dir = cwd;
6054
6163
  for (let depth = 0; depth < maxDepth; depth++) {
6055
- if (existsSync28(join25(dir, filename))) {
6164
+ if (existsSync28(join26(dir, filename))) {
6056
6165
  return dir;
6057
6166
  }
6058
- const parent = join25(dir, "..");
6167
+ const parent = join26(dir, "..");
6059
6168
  if (parent === dir) {
6060
6169
  break;
6061
6170
  }
@@ -6065,17 +6174,17 @@ function findUp(cwd, filename, maxDepth = 6) {
6065
6174
  }
6066
6175
  function findSopsConfig(cwd) {
6067
6176
  const dir = findUp(cwd, ".sops.yaml");
6068
- return dir ? join25(dir, ".sops.yaml") : null;
6177
+ return dir ? join26(dir, ".sops.yaml") : null;
6069
6178
  }
6070
6179
  function hasSopsConfig(cwd) {
6071
6180
  return findSopsConfig(cwd) !== null;
6072
6181
  }
6073
6182
  function listSopsFiles(cwd) {
6074
- const dir = join25(cwd, SECRETS_DIR);
6183
+ const dir = join26(cwd, SECRETS_DIR);
6075
6184
  if (!existsSync28(dir)) {
6076
6185
  return [];
6077
6186
  }
6078
- return readdirSync2(dir).filter((name) => name.endsWith(".enc.json")).map((name) => join25(dir, name));
6187
+ return readdirSync2(dir).filter((name) => name.endsWith(".enc.json")).map((name) => join26(dir, name));
6079
6188
  }
6080
6189
 
6081
6190
  // src/commands/secret/store-value.ts
@@ -6104,7 +6213,7 @@ async function storeSecret(cwd, env9, key, value) {
6104
6213
  );
6105
6214
  }
6106
6215
  await store.set(keychainName(key), value);
6107
- const serverEnvPath = join26(cwd, ".env.server");
6216
+ const serverEnvPath = join27(cwd, ".env.server");
6108
6217
  const result = upsertEnvVar(serverEnvPath, key, keychainRef(key));
6109
6218
  restrictEnvFilePerms(serverEnvPath);
6110
6219
  ensureGitignored(cwd, [{ pattern: ".env.server", comment: "spfn server env (secrets)" }]);
@@ -6344,10 +6453,10 @@ init_logger();
6344
6453
  import { execa as execa12 } from "execa";
6345
6454
  import { existsSync as existsSync29, mkdirSync as mkdirSync6 } from "fs";
6346
6455
  import { homedir } from "os";
6347
- import { dirname as dirname5, join as join27 } from "path";
6456
+ import { dirname as dirname5, join as join28 } from "path";
6348
6457
  import chalk41 from "chalk";
6349
6458
  function ageKeyFile() {
6350
- return process.env.SOPS_AGE_KEY_FILE ?? join27(homedir(), ".config", "sops", "age", "keys.txt");
6459
+ return process.env.SOPS_AGE_KEY_FILE ?? join28(homedir(), ".config", "sops", "age", "keys.txt");
6351
6460
  }
6352
6461
  async function ensureAgeInstalled() {
6353
6462
  try {
@@ -6395,7 +6504,7 @@ function printPublicKeys(keys) {
6395
6504
  // src/commands/secret/recipients.ts
6396
6505
  init_logger();
6397
6506
  import { existsSync as existsSync30, readFileSync as readFileSync12, writeFileSync as writeFileSync19 } from "fs";
6398
- import { join as join28 } from "path";
6507
+ import { join as join29 } from "path";
6399
6508
  import { parse as parse3, stringify } from "yaml";
6400
6509
  import chalk42 from "chalk";
6401
6510
  var SOPS_CONFIG = ".sops.yaml";
@@ -6403,7 +6512,7 @@ var DEFAULT_PATH_REGEX = "secrets/.*\\.enc\\.json$";
6403
6512
  var AGE_RECIPIENT = /^age1[0-9a-z]+$/;
6404
6513
  async function secretRecipients(action, key, _options) {
6405
6514
  const cwd = process.cwd();
6406
- const configPath = join28(cwd, SOPS_CONFIG);
6515
+ const configPath = join29(cwd, SOPS_CONFIG);
6407
6516
  if (action === "list") {
6408
6517
  listRecipients(configPath);
6409
6518
  return;
@@ -6488,7 +6597,7 @@ async function reencrypt(cwd) {
6488
6597
 
6489
6598
  // src/commands/secret/check.ts
6490
6599
  init_logger();
6491
- import { join as join29 } from "path";
6600
+ import { join as join30 } from "path";
6492
6601
  import chalk43 from "chalk";
6493
6602
  init_env_file();
6494
6603
  var COMMITTED_FILES = [".env", ".env.example"];
@@ -6507,14 +6616,14 @@ async function secretCheck(options) {
6507
6616
  const issues = [];
6508
6617
  const warnings = [];
6509
6618
  for (const file of COMMITTED_FILES) {
6510
- const parsed = parseEnvFile(join29(cwd, file));
6619
+ const parsed = parseEnvFile(join30(cwd, file));
6511
6620
  for (const [key, value] of Object.entries(parsed)) {
6512
6621
  if (secretKeys.has(key) && value.length > 0 && !PLACEHOLDER.test(value)) {
6513
6622
  issues.push(`${chalk43.cyan(key)} has a real value in committed ${chalk43.yellow(file)} \u2014 move it to the keychain/SOPS.`);
6514
6623
  }
6515
6624
  }
6516
6625
  }
6517
- const serverEnv = parseEnvFile(join29(cwd, ".env.server"));
6626
+ const serverEnv = parseEnvFile(join30(cwd, ".env.server"));
6518
6627
  for (const key of secretKeys) {
6519
6628
  const value = serverEnv[key];
6520
6629
  if (value && !value.startsWith(KEYCHAIN_REF_PREFIX)) {
@@ -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"]
@@ -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
 
@@ -22,13 +22,20 @@ services:
22
22
  context: .
23
23
  dockerfile: Dockerfile
24
24
  ports:
25
- - "3790:3790" # Next.js
26
- - "8790:8790" # SPFN API
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
@@ -8,7 +8,6 @@ import { authRouter, authenticate } from '@spfn/auth/server';
8
8
  import { defineRouter } from '@spfn/core/route';
9
9
  import { mcpRouter } from './mcp';
10
10
  import { getRoot } from './routes/root';
11
- import { getHealth } from './routes/health';
12
11
  import {
13
12
  listExamples,
14
13
  getExample,
@@ -19,7 +18,6 @@ import {
19
18
 
20
19
  export const appRouter = defineRouter({
21
20
  getRoot,
22
- getHealth,
23
21
  listExamples,
24
22
  getExample,
25
23
  createExample,
@@ -7,7 +7,7 @@ export const getRoot = route.get('/')
7
7
  version: '1.0.0',
8
8
  status: 'running',
9
9
  endpoints: {
10
- health: '/health',
10
+ health: '/_core/health',
11
11
  examples: '/examples',
12
12
  auth: '/_auth',
13
13
  mcp: '/mcp',
@@ -7,8 +7,6 @@ import { defineServerConfig } from '@spfn/core/server';
7
7
  import { appRouter } from '@/server/router';
8
8
 
9
9
  export default defineServerConfig()
10
- .port(8790)
11
- .host('0.0.0.0')
12
10
  .routes(appRouter)
13
11
  .lifecycle(createAuthLifecycle())
14
12
  .build();
@@ -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,
@@ -14,7 +14,7 @@ export const getRoot = route.get('/')
14
14
  version: '1.0.0',
15
15
  status: 'running',
16
16
  endpoints: {
17
- health: '/health',
17
+ health: '/_core/health',
18
18
  examples: '/examples',
19
19
  },
20
20
  message: 'Welcome to SPFN! Visit /examples for usage examples.',
@@ -5,7 +5,5 @@ import { defineServerConfig } from '@spfn/core/server';
5
5
  import { appRouter } from '@/server/router';
6
6
 
7
7
  export default defineServerConfig()
8
- .port(8790)
9
- .host('0.0.0.0')
10
8
  .routes(appRouter)
11
9
  .build();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.3",
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.1 <0.4.0",
60
+ "@spfn/core": ">=0.3.0-beta.3 <0.4.0",
61
61
  "typescript": "^5.3.0"
62
62
  },
63
63
  "peerDependenciesMeta": {
@@ -1,9 +0,0 @@
1
- import { route } from '@spfn/core/route';
2
-
3
- export const getHealth = route.get('/health')
4
- .skip(['auth'])
5
- .handler(async () => ({
6
- status: 'ok',
7
- timestamp: Date.now(),
8
- uptime: process.uptime(),
9
- }));
@@ -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
- });