spfn 0.3.0-beta.3 → 0.3.0-beta.5
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 +72 -16
- package/dist/index.js +1271 -27
- 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 +2 -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/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.5",
|
|
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": {
|
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
"@types/pg": "^8.10.9",
|
|
93
93
|
"@types/prompts": "^2.4.9",
|
|
94
94
|
"concurrently": "^9.2.1",
|
|
95
|
+
"hono": "^4.12.34",
|
|
95
96
|
"tsx": "^4.20.6",
|
|
96
97
|
"typescript": "^5.3.3",
|
|
97
98
|
"vitest": "^4.0.6"
|
|
@@ -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
|
-
}
|