spfn 0.3.0-beta.3 → 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 +22 -15
- package/dist/index.js +14 -9
- package/dist/templates/README.md +21 -6
- package/dist/templates/modes/full/src/server/router.ts +3 -3
- package/dist/templates/modes/full/src/server/routes/ops.ts +52 -0
- package/dist/templates/modes/full/src/server/routes/root.ts +1 -1
- package/package.json +1 -1
- 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/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
|
|
@@ -498,24 +498,30 @@ src/
|
|
|
498
498
|
app/auth/callback/page.tsx # OAuth session handoff
|
|
499
499
|
i18n/catalogs.ts # application-owned en/ko starter messages
|
|
500
500
|
i18n/server.ts # configured server-side i18n registry
|
|
501
|
-
server/
|
|
502
|
-
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
|
|
503
503
|
server/server.config.ts # createAuthLifecycle + i18n startup
|
|
504
504
|
next.config.ts # /_auth/* callback rewrite
|
|
505
505
|
.env.local # generated auth session secret (gitignored)
|
|
506
|
-
.env.server # auth keyring
|
|
506
|
+
.env.server # auth keyring (gitignored)
|
|
507
507
|
```
|
|
508
508
|
|
|
509
509
|
The full RPC proxy imports the auth interceptor and merges `authRouteMap`. Internal auth
|
|
510
|
-
|
|
510
|
+
keys are generated with cryptographic randomness in ignored local env files;
|
|
511
511
|
`.env.example` contains placeholders only. Add only the provider keys you use, then run
|
|
512
|
-
`pnpm spfn db migrate`.
|
|
513
|
-
|
|
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`.
|
|
514
520
|
|
|
515
521
|
`init` also patches `package.json` (scripts: `spfn:dev`, `spfn:server`, `spfn:next`,
|
|
516
522
|
`spfn:build`, `spfn:start`, `codegen`; deps: `@spfn/core`, `spfn`, `drizzle-orm`,
|
|
517
523
|
`@sinclair/typebox`, `concurrently`, etc.; full also adds `@spfn/auth`, `@spfn/i18n`,
|
|
518
|
-
|
|
524
|
+
auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
|
|
519
525
|
existing range still permits older Node versions), excludes `src/server` from the root
|
|
520
526
|
`tsconfig.json` (Vercel compat), and adds `.spfn/`, `.env.local`, `.env.server` to
|
|
521
527
|
`.gitignore`.
|
|
@@ -621,8 +627,8 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
|
|
|
621
627
|
something with an AI coding agent and now want a real backend under it, `init` is the one.
|
|
622
628
|
|
|
623
629
|
**`bare` or `full`?**
|
|
624
|
-
`full` is the recommended baseline: core, auth, i18n and
|
|
625
|
-
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
|
|
626
632
|
else decided. Automation should always pass `--mode` explicitly, because a `--yes` run
|
|
627
633
|
without one still produces `bare` for backward compatibility.
|
|
628
634
|
|
|
@@ -637,7 +643,7 @@ pointing at your own PostgreSQL works too. PostgreSQL itself is not optional.
|
|
|
637
643
|
|
|
638
644
|
**Which Node version do I need?**
|
|
639
645
|
20 or later, in both modes. `@spfn/core` runs on `@hono/node-server` 2, which declares
|
|
640
|
-
that floor, and full mode's
|
|
646
|
+
that floor, and full mode's `@spfn/auth` needs the same.
|
|
641
647
|
|
|
642
648
|
**When do I have to run codegen by hand?**
|
|
643
649
|
Whenever routes change outside `spfn dev`, which runs a codegen watcher for you. A stale or
|
|
@@ -685,5 +691,6 @@ committed.
|
|
|
685
691
|
|
|
686
692
|
- [`@spfn/core`](../core/README.md) — server, route DSL, codegen, db, client runtime.
|
|
687
693
|
- [`@spfn/auth`](../auth/README.md) — what `--mode full` wires in for accounts and roles.
|
|
688
|
-
- [`@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`.
|
|
689
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
|
{
|
|
@@ -944,7 +944,7 @@ var init_deployment_config = __esm({
|
|
|
944
944
|
|
|
945
945
|
// src/utils/version.ts
|
|
946
946
|
function getCliVersion() {
|
|
947
|
-
return "0.3.0-beta.
|
|
947
|
+
return "0.3.0-beta.4";
|
|
948
948
|
}
|
|
949
949
|
function getTagFromVersion(version) {
|
|
950
950
|
const match = version.match(/-([a-z]+)\./i);
|
|
@@ -988,7 +988,6 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
|
|
|
988
988
|
if (mode === "full") {
|
|
989
989
|
packageJson.dependencies["@spfn/auth"] = spfnTag;
|
|
990
990
|
packageJson.dependencies["@spfn/i18n"] = spfnTag;
|
|
991
|
-
packageJson.dependencies["@spfn/mcp"] = spfnTag;
|
|
992
991
|
packageJson.dependencies["@spfn/notification"] = spfnTag;
|
|
993
992
|
}
|
|
994
993
|
packageJson.devDependencies["@types/node"] = "^20.11.0";
|
|
@@ -1187,9 +1186,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:${randomSecret("base64")}
|
|
|
1187
1186
|
# SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
|
|
1188
1187
|
# SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
|
|
1189
1188
|
|
|
1190
|
-
# Full scaffold:
|
|
1191
|
-
|
|
1192
|
-
|
|
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"}]
|
|
1193
1195
|
`;
|
|
1194
1196
|
}
|
|
1195
1197
|
function randomSecret(encoding) {
|
|
@@ -1419,9 +1421,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:replace-with-a-base64-encoded-32-byte-key
|
|
|
1419
1421
|
# SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
|
|
1420
1422
|
# SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
|
|
1421
1423
|
|
|
1422
|
-
# Full scaffold:
|
|
1423
|
-
|
|
1424
|
-
|
|
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"}]
|
|
1425
1430
|
`;
|
|
1426
1431
|
}
|
|
1427
1432
|
});
|
package/dist/templates/README.md
CHANGED
|
@@ -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
|
|
@@ -2,12 +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';
|
|
10
|
+
import { opsRouter } from './routes/ops';
|
|
11
11
|
import {
|
|
12
12
|
listExamples,
|
|
13
13
|
getExample,
|
|
@@ -24,7 +24,7 @@ export const appRouter = defineRouter({
|
|
|
24
24
|
updateExample,
|
|
25
25
|
deleteExample,
|
|
26
26
|
})
|
|
27
|
-
.packages([authRouter,
|
|
27
|
+
.packages([authRouter, opsRouter])
|
|
28
28
|
.use([authenticate]);
|
|
29
29
|
|
|
30
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 });
|
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": {
|
|
@@ -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
|
-
}
|