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.
@@ -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
- ## Agent operations with MCP
74
+ ## Operations from the terminal
75
75
 
76
- The SPFN API serves MCP at `http://localhost:8790/mcp`. Connect with the Bearer
77
- token stored as `SPFN_MCP_API_KEY` in `.env.server`, then replace the starter
78
- `app_status` tool in `src/server/mcp.ts` with operations from your domain layer.
79
- Before third-party access, replace the generated operator-key validator with your
80
- OAuth access-token validator and scope each tool to the resolved operator.
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 MCP router owns its Bearer-token boundary.
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, mcpRouter])
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 });
@@ -10,6 +10,6 @@ export const getRoot = route.get('/')
10
10
  health: '/_core/health',
11
11
  examples: '/examples',
12
12
  auth: '/_auth',
13
- mcp: '/mcp',
13
+ ops: '/_ops',
14
14
  },
15
15
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.3.0-beta.3",
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
- }