sparda-mcp 0.5.4 → 0.6.0

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
@@ -157,7 +157,10 @@ protocol. The live, per-project tool list always comes from `sparda_get_context`
157
157
  runtime, so the guidance never goes stale.
158
158
 
159
159
  ## Supported frameworks
160
- Express 4/5 (JS/TS, ESM/CJS) and FastAPI today. We are actively expanding SPARDA internally to support more Node.js environments (including NestJS, Fastify, and Next.js API routes) in the near future.
160
+
161
+ - **Next.js App Router (13/14/15)** — file-based injection. Since Next.js uses file-system routing, SPARDA simply creates a catch-all route handler under `app/mcp/[...sparda]/route.js`. **Nothing in your existing codebase's code is touched**; running `remove` simply deletes the generated file.
162
+ - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection.
163
+ - **FastAPI** (Python >= 3.9) — AST-based router injection.
161
164
 
162
165
  ## Security posture (honest)
163
166
  - 4 runtime dependencies, exact-pinned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
6
6
  "type": "module",
@@ -90,7 +90,7 @@ export async function runDoctor(opts) {
90
90
  }
91
91
  }
92
92
  } catch {
93
- const startCmd = m.framework === 'express' ? 'npm run dev' : 'fastapi dev';
93
+ const startCmd = m.framework === 'fastapi' ? 'fastapi dev' : 'npm run dev';
94
94
  fail(
95
95
  ` ✗ Host app on :${m.port} — NOT reachable\n → start it with: ${startCmd}`,
96
96
  );
@@ -5,9 +5,11 @@ import * as p from '@clack/prompts';
5
5
  import { detectStack } from '../detect.js';
6
6
  import { parseExpressProject } from '../parser/express.js';
7
7
  import { parseFastAPIProject } from '../parser/fastapi.js';
8
+ import { parseNextProject } from '../parser/nextjs.js';
8
9
  import { sanitizeDescription } from '../security/sanitize.js';
9
10
  import { generateExpress } from '../generator/express.js';
10
11
  import { generateFastAPI } from '../generator/fastapi.js';
12
+ import { generateNext } from '../generator/nextjs.js';
11
13
  import { c, gradient, colorizeJson } from '../ui/style.js';
12
14
 
13
15
  const VERSION = JSON.parse(
@@ -39,6 +41,13 @@ export async function runInit(opts) {
39
41
  s.stop(
40
42
  `Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`,
41
43
  );
44
+ } else if (stack.framework === 'nextjs') {
45
+ const res = parseNextProject(opts.cwd, stack.entryFile);
46
+ routes = res.routes;
47
+ skipped = res.skipped;
48
+ s.stop(
49
+ `Stack detected: ${c.cyan('Next.js (App Router)')} — app dir: ${stack.entryFile}/, port: ${stack.port}`,
50
+ );
42
51
  }
43
52
 
44
53
  // Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
@@ -46,7 +55,11 @@ export async function runInit(opts) {
46
55
  // behavior byte-identical. Executing the host app has side-effects, so it is
47
56
  // gated behind --probe, warned on stderr, and degrades to static-only on any
48
57
  // failure — a probe error must never break init (R1).
49
- if (opts.probe) {
58
+ if (opts.probe && stack.framework === 'nextjs') {
59
+ p.log.warn(
60
+ '--probe is not supported on Next.js (file-based routing needs no runtime probe).',
61
+ );
62
+ } else if (opts.probe) {
50
63
  p.log.warn(
51
64
  "--probe runs your app to observe routes the static scan missed. Use only on code you trust (it triggers your app's import side-effects).",
52
65
  );
@@ -77,7 +90,10 @@ export async function runInit(opts) {
77
90
  if (routes.length === 0) {
78
91
  throw Object.assign(new Error('0 routes extracted.'), {
79
92
  code: 'USER',
80
- hint: `SPARDA found ${stack.framework === 'express' ? 'Express' : 'FastAPI'} but no literal-path routes. Dynamic routing is not supported in v0.`,
93
+ hint:
94
+ stack.framework === 'nextjs'
95
+ ? `SPARDA found Next.js but no route.js handlers under ${stack.entryFile}/. Page components are not API routes — SPARDA exposes route handlers only.`
96
+ : `SPARDA found ${stack.framework === 'express' ? 'Express' : 'FastAPI'} but no literal-path routes. Dynamic routing is not supported in v0.`,
81
97
  });
82
98
  }
83
99
 
@@ -118,7 +134,9 @@ export async function runInit(opts) {
118
134
  );
119
135
 
120
136
  let inject = true;
121
- if (!opts.yes) {
137
+ // Next.js is file-based: SPARDA adds one route-handler file and touches
138
+ // nothing else — there is no injection decision to make.
139
+ if (!opts.yes && stack.framework !== 'nextjs') {
122
140
  const answer = await p.select({
123
141
  message: `Inject the MCP router into ${stack.entryFile}?`,
124
142
  options: [
@@ -145,14 +163,21 @@ export async function runInit(opts) {
145
163
  port: stack.port,
146
164
  routes,
147
165
  })
148
- : generateFastAPI({
149
- cwd: opts.cwd,
150
- entryFile: stack.entryFile,
151
- port: stack.port,
152
- routes,
153
- entryAppVars,
154
- pythonCmd: stack.pythonCmd,
155
- });
166
+ : stack.framework === 'nextjs'
167
+ ? generateNext({
168
+ cwd: opts.cwd,
169
+ appDir: stack.entryFile,
170
+ port: stack.port,
171
+ routes,
172
+ })
173
+ : generateFastAPI({
174
+ cwd: opts.cwd,
175
+ entryFile: stack.entryFile,
176
+ port: stack.port,
177
+ routes,
178
+ entryAppVars,
179
+ pythonCmd: stack.pythonCmd,
180
+ });
156
181
 
157
182
  fs.mkdirSync(path.join(opts.cwd, '.sparda'), { recursive: true });
158
183
  const scanReport = { routes, skipped };
@@ -163,7 +188,11 @@ export async function runInit(opts) {
163
188
  );
164
189
 
165
190
  p.log.success(`Generated ${result.routerFile}`);
166
- if (inject && result.injection.injected)
191
+ if (result.injection.fileBased)
192
+ p.log.success(
193
+ 'File-based injection: your code was not modified (remove deletes the file)',
194
+ );
195
+ else if (inject && result.injection.injected)
167
196
  p.log.success(`Injected into ${stack.entryFile} (backup: .sparda/backup/)`);
168
197
  else if (result.injection.manual) {
169
198
  p.note(
@@ -187,7 +216,7 @@ export async function runInit(opts) {
187
216
  p.outro(`${c.green(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)}
188
217
 
189
218
  Next steps:
190
- 1. Start your app: ${c.cyan(stack.framework === 'express' ? 'npm run dev' : 'fastapi dev')}
219
+ 1. Start your app: ${c.cyan(stack.framework === 'fastapi' ? 'fastapi dev' : 'npm run dev')}
191
220
  2. Start the MCP bridge: ${c.cyan('npx sparda-mcp dev')}
192
221
  3. Add to Claude Desktop config (claude_desktop_config.json):
193
222
 
@@ -61,6 +61,23 @@ export async function runRemove(opts) {
61
61
  console.log(`✓ Deleted ${f}`);
62
62
  }
63
63
  }
64
+ // nextjs: the generated route lives in its own directory chain
65
+ // (app/mcp/[...sparda]/) — prune the now-empty dirs up to the app root so
66
+ // the tree is byte-identical, not just the git diff.
67
+ if (manifest.framework === 'nextjs') {
68
+ const stopAt = path.resolve(opts.cwd, manifest.entryFile ?? '.');
69
+ for (const f of manifest.generatedFiles ?? []) {
70
+ let dir = path.dirname(path.resolve(opts.cwd, f));
71
+ while (dir.startsWith(stopAt) && dir !== stopAt) {
72
+ try {
73
+ fs.rmdirSync(dir); // throws if not empty — that is the stop condition
74
+ } catch {
75
+ break;
76
+ }
77
+ dir = path.dirname(dir);
78
+ }
79
+ }
80
+ }
64
81
  if (revertGitignore(opts.cwd, manifest.gitignore))
65
82
  console.log('✓ Reverted .gitignore edit');
66
83
  if (removeHook(opts.cwd)) console.log('✓ Uninstalled post-commit sentinel hook');
@@ -4,9 +4,11 @@ import path from 'node:path';
4
4
  import { detectStack } from '../detect.js';
5
5
  import { parseExpressProject } from '../parser/express.js';
6
6
  import { parseFastAPIProject } from '../parser/fastapi.js';
7
+ import { parseNextProject } from '../parser/nextjs.js';
7
8
  import { sanitizeDescription } from '../security/sanitize.js';
8
9
  import { generateExpress } from '../generator/express.js';
9
10
  import { generateFastAPI } from '../generator/fastapi.js';
11
+ import { generateNext } from '../generator/nextjs.js';
10
12
 
11
13
  export async function runSync(opts) {
12
14
  const log = (m) => {
@@ -25,6 +27,8 @@ export async function runSync(opts) {
25
27
  let routes, entryAppVars;
26
28
  if (stack.framework === 'express') {
27
29
  ({ routes } = parseExpressProject(opts.cwd, stack.entryFile));
30
+ } else if (stack.framework === 'nextjs') {
31
+ ({ routes } = parseNextProject(opts.cwd, stack.entryFile));
28
32
  } else {
29
33
  ({ routes, entryAppVars } = parseFastAPIProject(
30
34
  opts.cwd,
@@ -52,22 +56,31 @@ export async function runSync(opts) {
52
56
  }
53
57
 
54
58
  // regenerate; carry-over keeps enabled overrides, semantic cache and localKey
55
- stack.framework === 'express'
56
- ? generateExpress({
57
- cwd: opts.cwd,
58
- entryFile: stack.entryFile,
59
- moduleType: stack.moduleType,
60
- port: manifest.port ?? stack.port,
61
- routes,
62
- })
63
- : generateFastAPI({
64
- cwd: opts.cwd,
65
- entryFile: stack.entryFile,
66
- port: manifest.port ?? stack.port,
67
- routes,
68
- entryAppVars,
69
- pythonCmd: stack.pythonCmd,
70
- });
59
+ if (stack.framework === 'express') {
60
+ generateExpress({
61
+ cwd: opts.cwd,
62
+ entryFile: stack.entryFile,
63
+ moduleType: stack.moduleType,
64
+ port: manifest.port ?? stack.port,
65
+ routes,
66
+ });
67
+ } else if (stack.framework === 'nextjs') {
68
+ generateNext({
69
+ cwd: opts.cwd,
70
+ appDir: stack.entryFile,
71
+ port: manifest.port ?? stack.port,
72
+ routes,
73
+ });
74
+ } else {
75
+ generateFastAPI({
76
+ cwd: opts.cwd,
77
+ entryFile: stack.entryFile,
78
+ port: manifest.port ?? stack.port,
79
+ routes,
80
+ entryAppVars,
81
+ pythonCmd: stack.pythonCmd,
82
+ });
83
+ }
71
84
 
72
85
  for (const a of added) log(`[sparda] + ${a}`);
73
86
  for (const r of removed) log(`[sparda] - ${r}`);
package/src/detect.js CHANGED
@@ -10,6 +10,25 @@ export function detectStack(cwd) {
10
10
  if (fs.existsSync(pkgPath)) {
11
11
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
12
12
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
13
+ // next before express: a project with the next dep IS a Next app (express is
14
+ // occasionally present as a utility dep and would misroute detection)
15
+ if (deps.next) {
16
+ const appDir = ['app', 'src/app'].find((d) => {
17
+ const abs = path.join(cwd, d);
18
+ return fs.existsSync(abs) && fs.statSync(abs).isDirectory();
19
+ });
20
+ if (!appDir)
21
+ throw err(
22
+ 'Next.js detected but no App Router directory (app/ or src/app/).',
23
+ 'SPARDA supports the App Router only (route.js handlers) — the Pages Router is not supported.',
24
+ );
25
+ return {
26
+ framework: 'nextjs',
27
+ entryFile: appDir,
28
+ port: detectNextPort(cwd, pkg),
29
+ nextVersion: deps.next,
30
+ };
31
+ }
13
32
  if (deps.express) {
14
33
  const entryFile = findExpressEntry(cwd, pkg);
15
34
  const moduleType = detectModuleType(cwd, pkg, entryFile);
@@ -22,7 +41,7 @@ export function detectStack(cwd) {
22
41
  expressVersion: deps.express,
23
42
  };
24
43
  }
25
- const known = ['@nestjs/core', 'next', 'fastify', 'koa'].find((d) => deps[d]);
44
+ const known = ['@nestjs/core', 'fastify', 'koa'].find((d) => deps[d]);
26
45
  if (known)
27
46
  throw err(
28
47
  `${known} detected — not supported yet. Express & FastAPI only in v0.`,
@@ -47,6 +66,27 @@ export function detectStack(cwd) {
47
66
  );
48
67
  }
49
68
 
69
+ // `next dev -p 3001` / `--port 3001` in scripts, then PORT= in .env, then 3000
70
+ function detectNextPort(cwd, pkg) {
71
+ for (const script of [pkg.scripts?.dev, pkg.scripts?.start]) {
72
+ if (!script) continue;
73
+ const m = script.match(/(?:-p|--port)[\s=]+(\d{2,5})/);
74
+ if (m) return Number(m[1]);
75
+ }
76
+ const envPath = path.join(cwd, '.env');
77
+ if (fs.existsSync(envPath)) {
78
+ const line = fs
79
+ .readFileSync(envPath, 'utf8')
80
+ .split(/\r?\n/)
81
+ .find((l) => l.startsWith('PORT='));
82
+ if (line) {
83
+ const v = Number(line.split('=')[1].trim());
84
+ if (v) return v;
85
+ }
86
+ }
87
+ return 3000;
88
+ }
89
+
50
90
  function detectPython() {
51
91
  const cmds = ['python3', 'python', 'py'];
52
92
  for (const cmd of cmds) {
@@ -283,7 +283,8 @@ export function removeInjection(cwd, manifest) {
283
283
 
284
284
  // Returns what was done ('created' | 'appended' | null) so the manifest can
285
285
  // record it and `remove` can revert the exact edit (byte-for-byte promise).
286
- function ensureGitignore(cwd) {
286
+ // Exported: the Next.js generator shares the exact same contract.
287
+ export function ensureGitignore(cwd) {
287
288
  const gi = path.join(cwd, '.gitignore');
288
289
  const line = '.sparda/';
289
290
  if (fs.existsSync(gi)) {
@@ -0,0 +1,113 @@
1
+ // generator/nextjs.js — file-based injection for the App Router.
2
+ // Next.js has no `app = express()` line to inject after: the filesystem is the
3
+ // router, so SPARDA's injection IS a generated catch-all route handler at
4
+ // <appDir>/mcp/[...sparda]/route.js. Nothing in the user's code is touched —
5
+ // `remove` deletes the file and the diff is clean by construction.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { toolNameFor } from '../parser/express.js';
11
+ import { ensureGitignore } from './express.js';
12
+ import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
13
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ export function generateNext({ cwd, appDir, port, routes }) {
18
+ const taken = new Set([
19
+ 'sparda_info',
20
+ 'sparda_list_disabled_tools',
21
+ 'sparda_get_context',
22
+ ]);
23
+ const tools = {};
24
+ for (const r of routes) {
25
+ const name = toolNameFor(r, taken);
26
+ tools[name] = {
27
+ method: r.method.toUpperCase(),
28
+ path: r.path,
29
+ enabled: !r.mutating, // write-safety: mutating tools off by default
30
+ pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
31
+ description: r.description,
32
+ params: r.params,
33
+ confidence: r.confidence,
34
+ };
35
+ }
36
+ const prev = carryOverManifest(cwd, tools);
37
+
38
+ // --- sparding safety memory & fingerprints (same contract as Express/FastAPI)
39
+ const sparding = defaultSpardingMemory(prev);
40
+ const oldFingerprints = sparding.toolFingerprints ?? {};
41
+ const newFingerprints = {};
42
+ for (const [name, t] of Object.entries(tools)) {
43
+ const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
44
+ const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
45
+ newFingerprints[name] = fp;
46
+ const oldFp = oldFingerprints[name];
47
+ if (oldFp && oldFp !== fp) {
48
+ sparding.events.push({
49
+ ts: new Date().toISOString(),
50
+ tool: name,
51
+ decision: 'audit',
52
+ risk: 'medium',
53
+ reasons: [
54
+ `route structure modified (fingerprint changed from ${oldFp} to ${fp})`,
55
+ ],
56
+ });
57
+ if (sparding.events.length > 100) sparding.events.shift();
58
+ }
59
+ }
60
+ sparding.toolFingerprints = newFingerprints;
61
+
62
+ // stable across re-runs so a running bridge/host pair never desyncs
63
+ const localKey = prev?.localKey ?? crypto.randomUUID();
64
+
65
+ // .js on purpose: Next enables allowJs in the tsconfig it manages, so the
66
+ // generated handler compiles untouched inside TS projects too.
67
+ const routerRel = `${appDir}/mcp/[...sparda]/route.js`;
68
+ const routerAbs = path.join(cwd, ...routerRel.split('/'));
69
+ fs.mkdirSync(path.dirname(routerAbs), { recursive: true });
70
+
71
+ let tpl = fs.readFileSync(
72
+ path.join(__dirname, '..', '..', 'templates', 'nextjs-router.txt'),
73
+ 'utf8',
74
+ );
75
+ tpl = tpl
76
+ .replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
77
+ .replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
78
+ .replace('__LOCAL_KEY__', localKey)
79
+ .replace('__PORT__', String(port));
80
+ atomicWrite(routerAbs, tpl);
81
+
82
+ const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
83
+
84
+ const manifest = {
85
+ version: 1,
86
+ framework: 'nextjs',
87
+ entryFile: appDir, // the scanned App Router root, not a code entry point
88
+ port,
89
+ localKey,
90
+ generatedFiles: [routerRel],
91
+ injectedFiles: [], // file-based injection: no user file is ever modified
92
+ createdAt: new Date().toISOString(),
93
+ tools: Object.fromEntries(
94
+ Object.entries(tools).map(([k, v]) => [
95
+ k,
96
+ { method: v.method, path: v.path, enabled: v.enabled },
97
+ ]),
98
+ ),
99
+ ...(gitignore ? { gitignore } : {}),
100
+ ...(prev?.semantic ? { semantic: prev.semantic } : {}),
101
+ ...(prev?.immune ? { immune: prev.immune } : {}),
102
+ ...(prev?.labs ? { labs: prev.labs } : {}),
103
+ sparding,
104
+ };
105
+ atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
106
+
107
+ return {
108
+ tools,
109
+ manifest,
110
+ routerFile: routerRel,
111
+ injection: { injected: false, manual: null, fileBased: true },
112
+ };
113
+ }
@@ -0,0 +1,248 @@
1
+ // parser/nextjs.js — App Router route-handler extraction.
2
+ // The filesystem IS the router: a route.{js,ts} file's directory chain is the
3
+ // URL. We walk app/ (groups stripped, dynamic segments mapped to :params) and
4
+ // AST-parse each route file for its exported HTTP-verb handlers. Catch-all,
5
+ // optional catch-all, parallel slots and interception segments are skipped
6
+ // with a reason (scan-report), never guessed.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parse } from '@babel/parser';
10
+
11
+ const VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
12
+ const ROUTE_FILES = new Set([
13
+ 'route.js',
14
+ 'route.ts',
15
+ 'route.mjs',
16
+ 'route.jsx',
17
+ 'route.tsx',
18
+ ]);
19
+ const EXCLUDE = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.sparda']);
20
+
21
+ export function parseNextProject(cwd, appDir) {
22
+ const routes = [];
23
+ const skipped = [];
24
+ const seen = new Set(); // `${METHOD} ${path}` — group collapse can collide: (a)/x + (b)/x → /x
25
+
26
+ walk(path.resolve(cwd, appDir), []);
27
+ return { routes, skipped };
28
+
29
+ function walk(dir, segments) {
30
+ let items;
31
+ try {
32
+ items = fs.readdirSync(dir, { withFileTypes: true });
33
+ } catch {
34
+ return;
35
+ }
36
+ for (const item of items) {
37
+ const abs = path.join(dir, item.name);
38
+ if (item.isDirectory()) {
39
+ const name = item.name;
40
+ if (EXCLUDE.has(name)) continue;
41
+ if (name.startsWith('_')) continue; // private folder — excluded from routing by Next itself
42
+ if (name.startsWith('@')) {
43
+ skipped.push({
44
+ reason: `parallel route slot ${name} — not a URL segment, skipped`,
45
+ file: rel(abs),
46
+ });
47
+ continue;
48
+ }
49
+ if (/^\(\.{1,3}\)/.test(name)) {
50
+ skipped.push({
51
+ reason: `intercepting route ${name} — UI-layer routing, skipped`,
52
+ file: rel(abs),
53
+ });
54
+ continue;
55
+ }
56
+ if (/^\[\[\.\.\..+\]\]$/.test(name) || /^\[\.\.\..+\]$/.test(name)) {
57
+ skipped.push({
58
+ reason: `catch-all segment ${name} — variable-arity paths not supported in v1`,
59
+ file: rel(abs),
60
+ });
61
+ continue;
62
+ }
63
+ if (/^\(.+\)$/.test(name)) {
64
+ walk(abs, segments); // route group — organizational only, stripped from the URL
65
+ continue;
66
+ }
67
+ const m = name.match(/^\[(.+)\]$/);
68
+ walk(abs, [...segments, m ? `:${m[1]}` : name]);
69
+ } else if (item.isFile() && ROUTE_FILES.has(item.name)) {
70
+ const urlPath = '/' + segments.join('/');
71
+ if (urlPath === '/mcp' || urlPath.startsWith('/mcp/')) {
72
+ skipped.push({
73
+ reason: `self-referential path ${urlPath} blocked`,
74
+ file: rel(abs),
75
+ });
76
+ continue;
77
+ }
78
+ parseRouteFile(abs, urlPath || '/');
79
+ }
80
+ }
81
+ }
82
+
83
+ function parseRouteFile(absFile, urlPath) {
84
+ let src;
85
+ try {
86
+ src = fs.readFileSync(absFile, 'utf8');
87
+ } catch {
88
+ return;
89
+ }
90
+ let ast;
91
+ try {
92
+ ast = parse(src, {
93
+ sourceType: 'unambiguous',
94
+ plugins: ['typescript', 'jsx', ['decorators', { decoratorsBeforeExport: true }]],
95
+ attachComment: true,
96
+ });
97
+ } catch (e) {
98
+ skipped.push({
99
+ reason: `parse error in ${rel(absFile)}: ${e.message.slice(0, 80)}`,
100
+ file: rel(absFile),
101
+ });
102
+ return;
103
+ }
104
+
105
+ for (const node of ast.program.body) {
106
+ if (node.type !== 'ExportNamedDeclaration') continue;
107
+ const found = []; // [{ verb, fnNode|null }]
108
+
109
+ const decl = node.declaration;
110
+ if (decl?.type === 'FunctionDeclaration' && VERBS.has(decl.id?.name)) {
111
+ found.push({ verb: decl.id.name, fnNode: decl });
112
+ } else if (decl?.type === 'VariableDeclaration') {
113
+ for (const d of decl.declarations) {
114
+ if (d.id?.type !== 'Identifier' || !VERBS.has(d.id.name)) continue;
115
+ const isFn =
116
+ d.init?.type === 'ArrowFunctionExpression' ||
117
+ d.init?.type === 'FunctionExpression';
118
+ found.push({ verb: d.id.name, fnNode: isFn ? d.init : null });
119
+ }
120
+ } else if (!decl && node.specifiers?.length) {
121
+ // export { GET } [from './impl'] — verb confirmed, body out of reach
122
+ for (const spec of node.specifiers) {
123
+ const name = spec.exported?.name ?? spec.exported?.value;
124
+ if (VERBS.has(name)) found.push({ verb: name, fnNode: null });
125
+ }
126
+ }
127
+
128
+ for (const { verb, fnNode } of found) {
129
+ const method = verb.toLowerCase();
130
+ const key = `${verb} ${urlPath}`;
131
+ if (seen.has(key)) {
132
+ skipped.push({
133
+ reason: `route group collision: ${key} already extracted from another group`,
134
+ file: rel(absFile),
135
+ line: node.loc?.start.line,
136
+ });
137
+ continue;
138
+ }
139
+ seen.add(key);
140
+
141
+ const description = (node.leadingComments ?? [])
142
+ .map((c) =>
143
+ c.value
144
+ .replace(/^\*+/gm, '')
145
+ .replace(/^\s*\*\s?/gm, '')
146
+ .trim(),
147
+ )
148
+ .filter(Boolean)
149
+ .join(' ')
150
+ .slice(0, 400);
151
+
152
+ const params = [...urlPath.matchAll(/:(\w+)/g)].map((mm) => ({
153
+ name: mm[1],
154
+ in: 'path',
155
+ type: 'string',
156
+ required: true,
157
+ description: 'path parameter',
158
+ }));
159
+ const taken = new Set(params.map((x) => x.name));
160
+ for (const q of queryParamsOf(fnNode)) {
161
+ if (taken.has(q)) continue;
162
+ taken.add(q);
163
+ params.push({
164
+ name: q,
165
+ in: 'query',
166
+ type: 'string',
167
+ required: false,
168
+ description: 'query parameter',
169
+ });
170
+ }
171
+
172
+ const mutating = method !== 'get';
173
+ let confidence = fnNode ? 'high' : 'low'; // re-exported handler body is unreadable here
174
+ if (mutating) {
175
+ params.push({
176
+ name: 'body',
177
+ in: 'body',
178
+ type: 'object',
179
+ required: false,
180
+ description: 'JSON body — schema not statically detected',
181
+ });
182
+ confidence = 'low';
183
+ }
184
+
185
+ routes.push({
186
+ method,
187
+ path: urlPath,
188
+ handlerName: verb,
189
+ sourceFile: rel(absFile),
190
+ sourceLine: node.loc?.start.line ?? 0,
191
+ params,
192
+ description,
193
+ mutating,
194
+ confidence,
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ function rel(abs) {
201
+ return path.relative(cwd, abs).split(path.sep).join('/');
202
+ }
203
+ }
204
+
205
+ // Query params only surface in the handler body — the canonical App Router
206
+ // shapes are `searchParams.get('x')` / `.getAll('x')`, whether searchParams
207
+ // came from `new URL(request.url)` or `request.nextUrl`. Plain recursive AST
208
+ // walk (no scope needed), bounded to 15 like the Express scanner.
209
+ function queryParamsOf(fnNode) {
210
+ const found = [];
211
+ if (!fnNode) return found;
212
+ visit(fnNode.body);
213
+ return [...new Set(found)];
214
+
215
+ function visit(node) {
216
+ if (!node || typeof node !== 'object' || found.length >= 15) return;
217
+ if (Array.isArray(node)) {
218
+ for (const n of node) visit(n);
219
+ return;
220
+ }
221
+ if (
222
+ node.type === 'CallExpression' &&
223
+ node.callee?.type === 'MemberExpression' &&
224
+ !node.callee.computed &&
225
+ (node.callee.property?.name === 'get' || node.callee.property?.name === 'getAll') &&
226
+ node.arguments[0]?.type === 'StringLiteral'
227
+ ) {
228
+ const obj = node.callee.object;
229
+ const isSearchParams =
230
+ (obj.type === 'Identifier' && obj.name === 'searchParams') ||
231
+ (obj.type === 'MemberExpression' &&
232
+ !obj.computed &&
233
+ obj.property?.name === 'searchParams');
234
+ if (isSearchParams) found.push(node.arguments[0].value);
235
+ }
236
+ for (const k of Object.keys(node)) {
237
+ if (
238
+ k === 'loc' ||
239
+ k === 'range' ||
240
+ k === 'leadingComments' ||
241
+ k === 'trailingComments'
242
+ )
243
+ continue;
244
+ const v = node[k];
245
+ if (v && typeof v === 'object') visit(v);
246
+ }
247
+ }
248
+ }
@@ -1059,7 +1059,7 @@ async function waitForHost(port, key, framework, ctx) {
1059
1059
  }
1060
1060
  }
1061
1061
  if (attempt === 0) {
1062
- const startCmd = framework === 'express' ? 'npm run dev' : 'fastapi dev';
1062
+ const startCmd = framework === 'fastapi' ? 'fastapi dev' : 'npm run dev';
1063
1063
  console.error(
1064
1064
  `[sparda] Waiting for host app on :${port} ... (start it with ${startCmd} — Ctrl+C to abort)`,
1065
1065
  );
@@ -0,0 +1,383 @@
1
+ // ============================================================
2
+ // SPARDA ROUTER (Next.js App Router) — Auto-generated. DO NOT EDIT.
3
+ // Regenerate: npx sparda-mcp init • Remove: npx sparda-mcp remove
4
+ // File-based injection: this catch-all handler IS the /mcp router.
5
+ // Web-standard Request/Response only — zero imports, zero dependencies.
6
+ // ============================================================
7
+
8
+ const SPARDA_TOOLS = __TOOLS_JSON__;
9
+ const SPARDA_POLICIES = __SPARDING_POLICIES__;
10
+
11
+ const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
12
+ const SPARDA_PORT = __PORT__;
13
+
14
+ // never statically optimized, always on the Node.js runtime (process, timers)
15
+ export const dynamic = 'force-dynamic';
16
+ export const runtime = 'nodejs';
17
+
18
+ // Module-scope state survives requests on a long-lived server (next start /
19
+ // next dev). On serverless each instance keeps its own gauges — RAM-only by
20
+ // design, the durable memory lives in sparda.json via the bridge.
21
+ const SPARDA_STATS = {};
22
+ const SPARDA_EVENTS = [];
23
+ let SPARDA_SEQ = 0;
24
+ // immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
25
+ const SPARDA_QUARANTINE = {};
26
+ const SPARDA_QUARANTINE_MS = Number(process.env.SPARDA_QUARANTINE_MS ?? 60000);
27
+ // recycling gauge: calls answered from SPARDA's own knowledge vs calls that paid the host route.
28
+ const SPARDA_RECYCLE = { servedByCircle: 0, paidFull: 0 };
29
+ function spardaRecycleRate() {
30
+ const total = SPARDA_RECYCLE.servedByCircle + SPARDA_RECYCLE.paidFull;
31
+ return total ? Math.round((SPARDA_RECYCLE.servedByCircle * 100) / total) : 0;
32
+ }
33
+ // thermodynamic route classification — observation only, never a guess.
34
+ const SPARDA_PURITY = {};
35
+ function spardaHash(s) {
36
+ let h = 0x811c9dc5; // FNV-1a, capped: a fingerprint, not a checksum of megabytes
37
+ for (let i = 0; i < s.length && i < 65536; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
38
+ return h;
39
+ }
40
+ function spardaObservePurity(tool, argsig, body) {
41
+ const pu = SPARDA_PURITY[tool] ?? (SPARDA_PURITY[tool] = { sigs: {}, repeats: 0, mismatches: 0 });
42
+ const h = spardaHash(body);
43
+ const known = pu.sigs[argsig];
44
+ if (known === undefined) {
45
+ if (Object.keys(pu.sigs).length >= 20) return; // bounded: enough sigs to judge
46
+ pu.sigs[argsig] = h;
47
+ } else if (known === h) pu.repeats += 1;
48
+ else { pu.mismatches += 1; pu.sigs[argsig] = h; } // the latest real answer is the truth
49
+ }
50
+ function spardaPuritySnapshot() {
51
+ const out = {};
52
+ for (const [name, spec] of Object.entries(SPARDA_TOOLS)) {
53
+ if (spec.method !== 'GET') { out[name] = { class: 'erasing', repeats: 0, mismatches: 0 }; continue; }
54
+ const pu = SPARDA_PURITY[name];
55
+ out[name] = {
56
+ class: !pu ? 'unknown' : pu.mismatches > 0 ? 'volatile' : pu.repeats >= 3 ? 'pure' : 'unknown',
57
+ repeats: pu ? pu.repeats : 0,
58
+ mismatches: pu ? pu.mismatches : 0,
59
+ };
60
+ }
61
+ return out;
62
+ }
63
+ // ── horizontal scale: Quorum Sensing × G-Map CRDT (same contract as Express) ──
64
+ const SPARDA_PEERS = (process.env.SPARDA_PEERS ?? '').split(',').map((s) => s.trim().replace(/\/$/, '')).filter((s) => /^https?:\/\//.test(s));
65
+ const SPARDA_GOSSIP_MS = Math.max(50, Number(process.env.SPARDA_GOSSIP_MS) || 30000);
66
+ const SPARDA_GOSSIP_TIMEOUT_MS = Math.max(250, Number(process.env.SPARDA_GOSSIP_TIMEOUT_MS) || 2000);
67
+ function spardaGossipSnapshot() {
68
+ const out = {};
69
+ for (const [name, pu] of Object.entries(SPARDA_PURITY)) out[name] = { repeats: pu.repeats, mismatches: pu.mismatches };
70
+ return out;
71
+ }
72
+ function spardaMergeGossip(remote) {
73
+ if (!remote || typeof remote !== 'object' || Array.isArray(remote)) return 0;
74
+ let merged = 0;
75
+ for (const [name, rv] of Object.entries(remote)) {
76
+ if (!SPARDA_TOOLS[name] || !rv || typeof rv !== 'object') continue;
77
+ const r = Number(rv.repeats), m = Number(rv.mismatches);
78
+ if (!Number.isFinite(r) || !Number.isFinite(m) || r < 0 || m < 0) continue;
79
+ const pu = SPARDA_PURITY[name] ?? (SPARDA_PURITY[name] = { sigs: {}, repeats: 0, mismatches: 0 });
80
+ if (r > pu.repeats) pu.repeats = r;
81
+ if (m > pu.mismatches) pu.mismatches = m;
82
+ merged += 1;
83
+ }
84
+ return merged;
85
+ }
86
+ function spardaGossipTick() {
87
+ if (SPARDA_PEERS.length === 0) return;
88
+ const body = JSON.stringify(spardaGossipSnapshot());
89
+ for (const peer of SPARDA_PEERS) {
90
+ fetch(`${peer}/mcp/gossip`, {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json', 'x-sparda-key': SPARDA_LOCAL_KEY },
93
+ body,
94
+ signal: AbortSignal.timeout(SPARDA_GOSSIP_TIMEOUT_MS),
95
+ }).catch(() => {});
96
+ }
97
+ }
98
+ function spardaEvent(source, tool, status, message) {
99
+ SPARDA_EVENTS.push({ seq: ++SPARDA_SEQ, ts: new Date().toISOString(), source, tool, status, message: String(message ?? '').slice(0, 500) });
100
+ if (SPARDA_EVENTS.length > 100) SPARDA_EVENTS.shift();
101
+ }
102
+ // hot-reload guard: dev re-evaluates this module; process listeners and timers
103
+ // must be registered exactly once per process.
104
+ if (!globalThis.__SPARDA_WIRED__) {
105
+ globalThis.__SPARDA_WIRED__ = true;
106
+ process.on('uncaughtExceptionMonitor', (err) => spardaEvent('process', null, null, err && err.message ? err.message : err));
107
+ if (SPARDA_PEERS.length > 0) {
108
+ spardaEvent('gossip', null, null, `horizontal scale: gossiping to ${SPARDA_PEERS.length} peer(s) every ${SPARDA_GOSSIP_MS}ms`);
109
+ setInterval(spardaGossipTick, SPARDA_GOSSIP_MS).unref();
110
+ }
111
+ }
112
+
113
+ // two-phase commit: a write/delete flagged require_human is NOT executed on /invoke.
114
+ const SPARDA_PENDING = {};
115
+ const SPARDA_CONFIRM_TTL_MS = Number(process.env.SPARDA_CONFIRM_TTL_MS ?? 120000);
116
+ function spardaNonce() {
117
+ return 'cfm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
118
+ }
119
+ function spardaSweepPending() {
120
+ const now = Date.now();
121
+ for (const k of Object.keys(SPARDA_PENDING)) if (now > SPARDA_PENDING[k].expiresAt) delete SPARDA_PENDING[k];
122
+ }
123
+
124
+ function spardaProof(tool, spec, args) {
125
+ const checks = {
126
+ knownTool: spec !== undefined,
127
+ enabled: spec ? spec.enabled : false,
128
+ loopSafe: spec ? !spec.path.startsWith('/mcp') : false,
129
+ methodClass: spec ? (spec.method === 'GET' ? 'read' : 'write') : 'read',
130
+ pathParamsPresent: true,
131
+ hasBodyForWrite: true,
132
+ reversibleHint: false,
133
+ quarantineSafe: true,
134
+ };
135
+ const reasons = [];
136
+ if (!checks.knownTool) {
137
+ reasons.push('unknown tool');
138
+ return { version: 'sparding-proof/v0.1', risk: 'blocked', decision: 'block', reasons, checks };
139
+ }
140
+ if (!checks.enabled) reasons.push('tool disabled (write-safety)');
141
+ if (!checks.loopSafe) reasons.push('self-referential tool blocked (loop protection)');
142
+ const quarantined = SPARDA_QUARANTINE[tool];
143
+ if (quarantined) {
144
+ if (Date.now() < quarantined.until) {
145
+ checks.quarantineSafe = false;
146
+ reasons.push(`tool quarantined (immune system): ${quarantined.reason}`);
147
+ } else {
148
+ delete SPARDA_QUARANTINE[tool];
149
+ if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
150
+ }
151
+ }
152
+ for (const name of spec.pathParams ?? []) {
153
+ if (args[name] === undefined) {
154
+ checks.pathParamsPresent = false;
155
+ reasons.push(`missing path param: ${name}`);
156
+ }
157
+ }
158
+ if (checks.methodClass === 'write' && args.body === undefined) checks.hasBodyForWrite = false;
159
+ if (spec.method === 'GET') checks.reversibleHint = true;
160
+ else checks.reversibleHint = Object.values(SPARDA_TOOLS).some((t) => t.method === 'GET' && t.path === spec.path);
161
+
162
+ let risk = 'low';
163
+ let decision = 'allow';
164
+ if (!checks.enabled || !checks.loopSafe || !checks.quarantineSafe || !checks.pathParamsPresent) {
165
+ return { version: 'sparding-proof/v0.1', risk: 'blocked', decision: 'block', reasons, checks };
166
+ }
167
+ const policies = SPARDA_POLICIES ?? {};
168
+ if (checks.methodClass === 'read') {
169
+ const readPolicy = policies.reads ?? 'allow';
170
+ if (readPolicy === 'block') { decision = 'block'; risk = 'blocked'; reasons.push('read policy blocks execution'); }
171
+ else if (readPolicy === 'require_human') { decision = 'require_human'; risk = 'medium'; reasons.push('read policy requires human confirmation'); }
172
+ } else {
173
+ const isDelete = spec.method === 'DELETE';
174
+ const deletePolicy = policies.deletes ?? 'block';
175
+ const writePolicy = policies.writes ?? 'require_human';
176
+ if (isDelete && deletePolicy === 'block') { decision = 'block'; risk = 'blocked'; reasons.push('delete policy blocks execution'); }
177
+ else if (isDelete && deletePolicy === 'require_human') { decision = 'require_human'; risk = 'high'; reasons.push('delete operation requires human confirmation'); }
178
+ else if (isDelete && deletePolicy === 'allow') { decision = 'allow'; risk = 'low'; }
179
+ else if (writePolicy === 'block') { decision = 'block'; risk = 'blocked'; reasons.push('write policy blocks execution'); }
180
+ else if (writePolicy === 'require_human') { decision = 'require_human'; risk = 'medium'; reasons.push('write operation requires human confirmation'); }
181
+ else { decision = 'allow'; risk = 'low'; }
182
+ }
183
+ return { version: 'sparding-proof/v0.1', risk, decision, reasons, checks };
184
+ }
185
+
186
+ function spardaRecord(tool, status, ms) {
187
+ const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
188
+ if (st.calls >= 5 && ms > Math.max((st.totalMs / st.calls) * 10, 200)) {
189
+ spardaEvent('immune', tool, status, `latency anomaly: ${ms}ms vs ~${Math.round(st.totalMs / st.calls)}ms baseline`);
190
+ }
191
+ st.calls += 1;
192
+ st.totalMs += ms;
193
+ if (status >= 500) st.errors += 1;
194
+ else if (status >= 400) st.clientErrors += 1;
195
+ st.lastStatus = status;
196
+ st.lastTs = new Date().toISOString();
197
+ if (status >= 500) st.consecutive5xx += 1;
198
+ else if (status < 400) st.consecutive5xx = 0;
199
+ if (st.consecutive5xx >= 3 && !SPARDA_QUARANTINE[tool]) {
200
+ SPARDA_QUARANTINE[tool] = { since: new Date().toISOString(), until: Date.now() + SPARDA_QUARANTINE_MS, reason: `${st.consecutive5xx} consecutive 5xx` };
201
+ spardaEvent('immune', tool, 503, `quarantined after ${st.consecutive5xx} consecutive 5xx (cooldown ${SPARDA_QUARANTINE_MS}ms)`);
202
+ }
203
+ }
204
+
205
+ const spardaJson = (status, obj) =>
206
+ new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json' } });
207
+
208
+ // SPARDA owns its endpoints' body parsing: 64KB cap and a JSON 400 on malformed
209
+ // bodies — never a framework HTML error page.
210
+ async function spardaReadJson(request) {
211
+ let text;
212
+ try { text = await request.text(); } catch { return { error: spardaJson(400, { error: 'unreadable body' }) }; }
213
+ if (text.length > 65536) return { error: spardaJson(413, { error: 'payload too large (64KB max)' }) };
214
+ if (!text) return { data: {} };
215
+ try { return { data: JSON.parse(text) }; } catch {
216
+ spardaEvent('router', null, 400, 'invalid JSON body');
217
+ return { error: spardaJson(400, { error: 'invalid JSON body' }) };
218
+ }
219
+ }
220
+
221
+ // the actual host-route call, shared by the allow-path and the confirm-path.
222
+ // The "host" is this same Next server: SPARDA proxies to 127.0.0.1 like on
223
+ // Express, so handlers run with their full middleware/context untouched.
224
+ async function spardaExecute(tool, spec, args, proof, t0) {
225
+ try {
226
+ let url = spec.path.replace(/:(\w+)/g, (_, name) => encodeURIComponent(String(args[name])));
227
+ const query = [];
228
+ for (const [k, v] of Object.entries(args)) {
229
+ if (k === 'body' || (spec.pathParams ?? []).includes(k) || v === undefined) continue;
230
+ query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
231
+ }
232
+ if (query.length) url += `?${query.join('&')}`;
233
+ const init = { method: spec.method, headers: {} };
234
+ if (spec.method !== 'GET' && args.body !== undefined) {
235
+ init.headers['content-type'] = 'application/json';
236
+ init.body = JSON.stringify(args.body);
237
+ }
238
+ SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
239
+ const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
240
+ const text = await upstream.text();
241
+ let data; try { data = JSON.parse(text); } catch { data = text; }
242
+ spardaRecord(tool, upstream.status, Date.now() - t0);
243
+ if (spec.method === 'GET' && upstream.status === 200) {
244
+ spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e) => e[1] !== undefined).sort()), text);
245
+ }
246
+ if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
247
+ return spardaJson(200, { upstreamStatus: upstream.status, data, spardingProof: proof });
248
+ } catch (err) {
249
+ spardaRecord(tool, err.status ?? 502, Date.now() - t0);
250
+ spardaEvent('invoke', tool, err.status ?? 502, err.message);
251
+ return spardaJson(err.status ?? 502, { error: err.message, spardingProof: proof });
252
+ }
253
+ }
254
+
255
+ async function spardaInvoke(request) {
256
+ const t0 = Date.now();
257
+ const read = await spardaReadJson(request);
258
+ if (read.error) return read.error;
259
+ const reqBody = read.data ?? {};
260
+ const tool = reqBody.tool;
261
+ if (reqBody.args !== undefined && (reqBody.args === null || typeof reqBody.args !== 'object' || Array.isArray(reqBody.args))) {
262
+ return spardaJson(400, { error: 'args must be a JSON object', got: reqBody.args === null ? 'null' : Array.isArray(reqBody.args) ? 'array' : typeof reqBody.args });
263
+ }
264
+ const args = reqBody.args ?? {};
265
+ const spec = SPARDA_TOOLS[tool];
266
+ const proof = spardaProof(tool, spec, args);
267
+
268
+ if (proof.decision === 'block') {
269
+ if (!proof.checks.knownTool) return spardaJson(404, { error: `unknown tool: ${tool}`, spardingProof: proof });
270
+ if (!proof.checks.enabled)
271
+ return spardaJson(403, { error: `tool disabled (write-safety): ${tool}`, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init', spardingProof: proof });
272
+ if (!proof.checks.loopSafe)
273
+ return spardaJson(400, { error: 'self-referential tool blocked (loop protection)', spardingProof: proof });
274
+ if (!proof.checks.quarantineSafe) {
275
+ const quarantined = SPARDA_QUARANTINE[tool];
276
+ SPARDA_RECYCLE.servedByCircle += 1; // the doomed host call was never paid
277
+ return spardaJson(503, {
278
+ error: `tool quarantined (immune system): ${tool}`,
279
+ reason: quarantined ? quarantined.reason : '',
280
+ retryInMs: quarantined ? Math.max(0, quarantined.until - Date.now()) : 0,
281
+ spardingProof: proof,
282
+ });
283
+ }
284
+ const status = proof.reasons.some((r) => r.includes('policy blocks execution')) ? 403 : 400;
285
+ return spardaJson(status, { error: proof.reasons[0] || 'blocked', spardingProof: proof });
286
+ }
287
+
288
+ if (proof.decision === 'require_human') {
289
+ spardaSweepPending();
290
+ const confirm = spardaNonce();
291
+ SPARDA_PENDING[confirm] = { tool, args, expiresAt: Date.now() + SPARDA_CONFIRM_TTL_MS, createdAt: new Date().toISOString() };
292
+ let siblingName = null;
293
+ for (const [n, t] of Object.entries(SPARDA_TOOLS)) { if (t.method === 'GET' && t.path === spec.path) { siblingName = n; break; } }
294
+ spardaEvent('confirm', tool, 202, `gated, awaiting confirmation (risk: ${proof.risk})`);
295
+ return spardaJson(202, {
296
+ status: 'awaiting_confirmation',
297
+ confirm,
298
+ expiresInMs: SPARDA_CONFIRM_TTL_MS,
299
+ preview: {
300
+ tool,
301
+ method: spec.method,
302
+ path: spec.path,
303
+ willSendBody: spec.method !== 'GET' && args.body !== undefined,
304
+ reversibleHint: proof.checks.reversibleHint,
305
+ inspectFirst: siblingName ? { tool: siblingName, why: 'GET on the same path — read current state before committing this write' } : null,
306
+ },
307
+ instruction: `NOT EXECUTED. ${spec.method} ${spec.path} is gated by policy (${proof.reasons[0] || 'require_human'}); the host route was not touched.${siblingName ? ` First call "${siblingName}" to inspect current state, then` : ' To'} confirm via POST /invoke/confirm { "confirm": "${confirm}" }. Single-use, expires in ${Math.round(SPARDA_CONFIRM_TTL_MS / 1000)}s.`,
308
+ spardingProof: proof,
309
+ });
310
+ }
311
+
312
+ return spardaExecute(tool, spec, args, proof, t0);
313
+ }
314
+
315
+ async function spardaConfirm(request) {
316
+ const t0 = Date.now();
317
+ const read = await spardaReadJson(request);
318
+ if (read.error) return read.error;
319
+ const confirm = (read.data ?? {}).confirm;
320
+ if (typeof confirm !== 'string' || !confirm) {
321
+ return spardaJson(400, { error: 'missing confirm token (expected { confirm: "..." })' });
322
+ }
323
+ spardaSweepPending();
324
+ const pending = SPARDA_PENDING[confirm];
325
+ if (pending) delete SPARDA_PENDING[confirm]; // consume first — a token can never replay
326
+ if (!pending) return spardaJson(409, { error: 'unknown or expired confirmation token', hint: 're-issue the write via /invoke to mint a fresh token' });
327
+ if (Date.now() > pending.expiresAt) return spardaJson(409, { error: 'confirmation token expired', hint: 're-issue the write via /invoke' });
328
+ const spec = SPARDA_TOOLS[pending.tool];
329
+ const proof = spardaProof(pending.tool, spec, pending.args); // re-judge at commit time
330
+ if (proof.decision === 'block') {
331
+ return spardaJson(403, { error: 'confirmation no longer valid', reason: proof.reasons[0] || 'blocked', spardingProof: proof });
332
+ }
333
+ spardaEvent('confirm', pending.tool, 200, 'confirmed -> executing');
334
+ return spardaExecute(pending.tool, spec, pending.args, proof, t0);
335
+ }
336
+
337
+ async function spardaHandle(request, ctx) {
338
+ try {
339
+ if (request.headers.get('x-sparda-key') !== SPARDA_LOCAL_KEY) {
340
+ return spardaJson(401, { error: 'unauthorized' });
341
+ }
342
+ const params = ctx?.params ? await ctx.params : {};
343
+ const seg = Array.isArray(params.sparda) ? params.sparda : [];
344
+ const sub = '/' + seg.join('/');
345
+ const method = request.method;
346
+
347
+ if (sub === '/tools' && method === 'GET') return spardaJson(200, SPARDA_TOOLS);
348
+ if (sub === '/stats' && method === 'GET')
349
+ return spardaJson(200, {
350
+ uptimeSec: Math.round(process.uptime()),
351
+ stats: SPARDA_STATS,
352
+ quarantine: SPARDA_QUARANTINE,
353
+ recycle: { servedByCircle: SPARDA_RECYCLE.servedByCircle, paidFull: SPARDA_RECYCLE.paidFull, ratePct: spardaRecycleRate() },
354
+ purity: spardaPuritySnapshot(),
355
+ });
356
+ if (sub === '/events' && method === 'GET') {
357
+ const since = Number(new URL(request.url).searchParams.get('since') ?? 0) || 0;
358
+ return spardaJson(200, { seq: SPARDA_SEQ, events: SPARDA_EVENTS.filter((e) => e.seq > since) });
359
+ }
360
+ if (sub === '/gossip' && method === 'POST') {
361
+ const read = await spardaReadJson(request);
362
+ if (read.error) return read.error;
363
+ spardaMergeGossip(read.data);
364
+ return new Response(null, { status: 204 });
365
+ }
366
+ if (sub === '/invoke' && method === 'POST') return spardaInvoke(request);
367
+ if (sub === '/invoke/confirm' && method === 'POST') return spardaConfirm(request);
368
+ if (sub === '/invoke' || sub === '/invoke/confirm')
369
+ return spardaJson(405, { error: 'method not allowed', allow: 'POST' });
370
+ return spardaJson(404, { error: 'not found', path: sub });
371
+ } catch (err) {
372
+ // error envelope: the stack stays server-side, correlated to /events by errorId
373
+ const errorId = 'err_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
374
+ spardaEvent('router', null, (err && err.status) || 500, `[${errorId}] ${err && err.message ? err.message : err}`);
375
+ return spardaJson((err && err.status) || 500, { error: 'internal error', errorId });
376
+ }
377
+ }
378
+
379
+ export async function GET(request, ctx) { return spardaHandle(request, ctx); }
380
+ export async function POST(request, ctx) { return spardaHandle(request, ctx); }
381
+ export async function PUT(request, ctx) { return spardaHandle(request, ctx); }
382
+ export async function PATCH(request, ctx) { return spardaHandle(request, ctx); }
383
+ export async function DELETE(request, ctx) { return spardaHandle(request, ctx); }