Skip to content

Commit 1608bdf

Browse files
Feat/scoped script tokens (#11)
* add scoped tokens for server-spawned scripts Scripts now authenticate via HMAC-signed tokens scoped to their flow/session. Tokens auto-revoke on exit and have limited permissions. * add web scope * fix tests * add tests
1 parent 638ceed commit 1608bdf

31 files changed

+3843
-161
lines changed

packages/app/src/server/app.ts

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
import { OpenAPIHono } from '@hono/zod-openapi';
2+
import type { Context } from 'hono';
23
import { cors } from 'hono/cors';
34
import { HTTPException } from 'hono/http-exception';
45
import { streamSSE } from 'hono/streaming';
56
import packageJson from '../../package.json';
6-
import { createAuthMiddleware, startSessionCleanup, stopSessionCleanup } from './auth';
7+
import {
8+
createAuthMiddleware,
9+
type ScriptTokenPayload,
10+
startScriptTokenCleanup,
11+
startSessionCleanup,
12+
stopScriptTokenCleanup,
13+
stopSessionCleanup
14+
} from './auth';
715

816
export type { WebConfig } from './web';
917

1018
import { getStatusForError, TreqError, ValidationError } from './errors';
1119
import { createEventManager, type EventEnvelope } from './events';
1220
import {
21+
cancelScriptRoute,
22+
cancelTestRoute,
1323
capabilitiesRoute,
1424
configRoute,
1525
createFlowRoute,
@@ -19,18 +29,72 @@ import {
1929
executeRoute,
2030
finishFlowRoute,
2131
getExecutionRoute,
32+
getRunnersRoute,
2233
getSessionRoute,
34+
getTestFrameworksRoute,
2335
healthRoute,
2436
listWorkspaceFilesRoute,
2537
listWorkspaceRequestsRoute,
2638
parseRoute,
39+
runScriptRoute,
40+
runTestRoute,
2741
updateSessionVariablesRoute
2842
} from './openapi';
2943
import type { ErrorResponse } from './schemas';
3044
import { createService, resolveWorkspaceRoot } from './service';
3145
import { createWebRoutes, isApiPath, type WebConfig } from './web';
3246

3347
const SERVER_VERSION = packageJson.version;
48+
const SSE_HEARTBEAT_INTERVAL_MS = 5000;
49+
50+
// ============================================================================
51+
// Script Token Authorization Helper
52+
// ============================================================================
53+
54+
/**
55+
* Options for enforceScriptScope.
56+
*/
57+
interface EnforceScriptScopeOptions {
58+
/** Whether this endpoint is allowed for script tokens at all */
59+
allowedEndpoint: boolean;
60+
/** If specified, the script token's flowId must match this value */
61+
requiredFlowId?: string;
62+
/** If specified, the script token's sessionId must match this value */
63+
requiredSessionId?: string;
64+
}
65+
66+
/**
67+
* Enforce script token scope restrictions.
68+
*
69+
* When a request is authenticated via script token, this function enforces
70+
* that the token's scope (flowId, sessionId) matches the requested resource.
71+
*
72+
* For non-script auth methods (bearer, cookie, none), this is a no-op.
73+
*
74+
* @throws HTTPException 403 if endpoint not allowed for scripts
75+
* @throws HTTPException 403 if flowId/sessionId mismatch
76+
*/
77+
function enforceScriptScope(c: Context, opts: EnforceScriptScopeOptions): void {
78+
const authMethod = c.get('authMethod');
79+
if (authMethod !== 'script') return; // Not a script token, no restrictions
80+
81+
const payload = c.get('scriptTokenPayload') as ScriptTokenPayload | undefined;
82+
if (!payload) {
83+
throw new HTTPException(401, { message: 'Missing script token payload' });
84+
}
85+
86+
if (!opts.allowedEndpoint) {
87+
throw new HTTPException(403, { message: 'Endpoint not allowed for script tokens' });
88+
}
89+
90+
if (opts.requiredFlowId && opts.requiredFlowId !== payload.flowId) {
91+
throw new HTTPException(403, { message: 'Flow ID mismatch' });
92+
}
93+
94+
if (opts.requiredSessionId && opts.requiredSessionId !== payload.sessionId) {
95+
throw new HTTPException(403, { message: 'Session ID mismatch' });
96+
}
97+
}
3498

3599
export type ServerConfig = {
36100
workspace?: string;
@@ -92,6 +156,11 @@ export function createApp(config: ServerConfig) {
92156
startSessionCleanup();
93157
}
94158

159+
// Start script token cleanup (always needed when token auth is enabled)
160+
if (config.token) {
161+
startScriptTokenCleanup();
162+
}
163+
95164
// Apply auth to all API paths (non-web routes)
96165
app.use('*', async (c, next) => {
97166
const pathname = new URL(c.req.url).pathname;
@@ -152,6 +221,9 @@ export function createApp(config: ServerConfig) {
152221
// ============================================================================
153222

154223
app.openapi(configRoute, async (c) => {
224+
// Script tokens cannot access config (may leak sensitive structure)
225+
enforceScriptScope(c, { allowedEndpoint: false });
226+
155227
const { profile, path } = c.req.valid('query');
156228
const result = await service.getConfig({ profile, path });
157229
return c.json(result, 200);
@@ -162,6 +234,9 @@ export function createApp(config: ServerConfig) {
162234
// ============================================================================
163235

164236
app.openapi(parseRoute, async (c) => {
237+
// Script tokens cannot use parse endpoint (unnecessary for execution)
238+
enforceScriptScope(c, { allowedEndpoint: false });
239+
165240
const request = c.req.valid('json');
166241
const result = await service.parse(request);
167242
return c.json(result, 200);
@@ -173,6 +248,17 @@ export function createApp(config: ServerConfig) {
173248

174249
app.openapi(executeRoute, async (c) => {
175250
const request = c.req.valid('json');
251+
252+
// Script tokens can execute, but must use their assigned flow/session
253+
const payload = c.get('scriptTokenPayload') as ScriptTokenPayload | undefined;
254+
if (payload) {
255+
enforceScriptScope(c, {
256+
allowedEndpoint: true,
257+
requiredFlowId: request.flowId,
258+
requiredSessionId: request.sessionId
259+
});
260+
}
261+
176262
const result = await service.execute(request);
177263
return c.json(result, 200);
178264
});
@@ -182,25 +268,38 @@ export function createApp(config: ServerConfig) {
182268
// ============================================================================
183269

184270
app.openapi(createSessionRoute, (c) => {
271+
// Script tokens cannot create sessions (use pre-created session)
272+
enforceScriptScope(c, { allowedEndpoint: false });
273+
185274
const request = c.req.valid('json');
186275
const result = service.createSession(request);
187276
return c.json(result, 201);
188277
});
189278

190279
app.openapi(getSessionRoute, (c) => {
280+
// Script tokens cannot read session data (unnecessary surface area)
281+
enforceScriptScope(c, { allowedEndpoint: false });
282+
191283
const { id } = c.req.valid('param');
192284
const result = service.getSession(id);
193285
return c.json(result, 200);
194286
});
195287

196288
app.openapi(updateSessionVariablesRoute, async (c) => {
197289
const { id } = c.req.valid('param');
290+
291+
// Script tokens can update variables, but only for their own session
292+
enforceScriptScope(c, { allowedEndpoint: true, requiredSessionId: id });
293+
198294
const request = c.req.valid('json');
199295
const result = await service.updateSessionVariables(id, request);
200296
return c.json(result, 200);
201297
});
202298

203299
app.openapi(deleteSessionRoute, (c) => {
300+
// Script tokens cannot delete sessions
301+
enforceScriptScope(c, { allowedEndpoint: false });
302+
204303
const { id } = c.req.valid('param');
205304
service.deleteSession(id);
206305
return c.body(null, 204);
@@ -211,19 +310,29 @@ export function createApp(config: ServerConfig) {
211310
// ============================================================================
212311

213312
app.openapi(createFlowRoute, (c) => {
313+
// Script tokens cannot create flows (use pre-created flow)
314+
enforceScriptScope(c, { allowedEndpoint: false });
315+
214316
const request = c.req.valid('json');
215317
const result = service.createFlow(request);
216318
return c.json(result, 201);
217319
});
218320

219321
app.openapi(finishFlowRoute, (c) => {
322+
// Script tokens cannot finish flows
323+
enforceScriptScope(c, { allowedEndpoint: false });
324+
220325
const { flowId } = c.req.valid('param');
221326
const result = service.finishFlow(flowId);
222327
return c.json(result, 200);
223328
});
224329

225330
app.openapi(getExecutionRoute, (c) => {
226331
const { flowId, reqExecId } = c.req.valid('param');
332+
333+
// Script tokens can read executions, but only from their own flow
334+
enforceScriptScope(c, { allowedEndpoint: true, requiredFlowId: flowId });
335+
227336
const result = service.getExecution(flowId, reqExecId);
228337
return c.json(result, 200);
229338
});
@@ -233,18 +342,95 @@ export function createApp(config: ServerConfig) {
233342
// ============================================================================
234343

235344
app.openapi(listWorkspaceFilesRoute, async (c) => {
345+
// Script tokens cannot list workspace files (prevents file enumeration)
346+
enforceScriptScope(c, { allowedEndpoint: false });
347+
236348
const { ignore } = c.req.valid('query');
237349
const additionalIgnore = ignore ? ignore.split(',').map((p) => p.trim()) : undefined;
238350
const result = await service.listWorkspaceFiles(additionalIgnore);
239351
return c.json(result, 200);
240352
});
241353

242354
app.openapi(listWorkspaceRequestsRoute, async (c) => {
355+
// Script tokens cannot list workspace requests (prevents file enumeration)
356+
enforceScriptScope(c, { allowedEndpoint: false });
357+
243358
const { path } = c.req.valid('query');
244359
const result = await service.listWorkspaceRequests(path);
245360
return c.json(result, 200);
246361
});
247362

363+
// ============================================================================
364+
// Script Endpoints
365+
// ============================================================================
366+
367+
// Build server URL from config
368+
const serverUrl = `http://${config.host}:${config.port}`;
369+
370+
app.openapi(runScriptRoute, async (c) => {
371+
// Script tokens cannot spawn nested scripts
372+
enforceScriptScope(c, { allowedEndpoint: false });
373+
374+
const request = c.req.valid('json');
375+
const result = await service.executeScript(request, serverUrl, config.token);
376+
return c.json(result, 200);
377+
});
378+
379+
app.openapi(cancelScriptRoute, (c) => {
380+
// Script tokens cannot cancel scripts
381+
enforceScriptScope(c, { allowedEndpoint: false });
382+
383+
const { runId } = c.req.valid('param');
384+
const found = service.stopScript(runId);
385+
if (!found) {
386+
return c.json({ error: { code: 'NOT_FOUND', message: 'Script not found' } }, 404);
387+
}
388+
return c.body(null, 204);
389+
});
390+
391+
app.openapi(getRunnersRoute, async (c) => {
392+
// Script tokens cannot list runners
393+
enforceScriptScope(c, { allowedEndpoint: false });
394+
395+
const { filePath } = c.req.valid('query');
396+
const result = await service.getRunners(filePath);
397+
return c.json(result, 200);
398+
});
399+
400+
// ============================================================================
401+
// Test Endpoints
402+
// ============================================================================
403+
404+
app.openapi(runTestRoute, async (c) => {
405+
// Script tokens cannot spawn nested tests
406+
enforceScriptScope(c, { allowedEndpoint: false });
407+
408+
const request = c.req.valid('json');
409+
const result = await service.executeTest(request, serverUrl, config.token);
410+
return c.json(result, 200);
411+
});
412+
413+
app.openapi(cancelTestRoute, (c) => {
414+
// Script tokens cannot cancel tests
415+
enforceScriptScope(c, { allowedEndpoint: false });
416+
417+
const { runId } = c.req.valid('param');
418+
const found = service.stopTest(runId);
419+
if (!found) {
420+
return c.json({ error: { code: 'NOT_FOUND', message: 'Test not found' } }, 404);
421+
}
422+
return c.body(null, 204);
423+
});
424+
425+
app.openapi(getTestFrameworksRoute, async (c) => {
426+
// Script tokens cannot list test frameworks
427+
enforceScriptScope(c, { allowedEndpoint: false });
428+
429+
const { filePath } = c.req.valid('query');
430+
const result = await service.getTestFrameworks(filePath);
431+
return c.json(result, 200);
432+
});
433+
248434
// ============================================================================
249435
// Event Streaming (SSE)
250436
// ============================================================================
@@ -259,6 +445,14 @@ export function createApp(config: ServerConfig) {
259445
);
260446
}
261447

448+
// Script tokens can subscribe to events, but only for their own flow
449+
if (flowId) {
450+
enforceScriptScope(c, { allowedEndpoint: true, requiredFlowId: flowId });
451+
} else {
452+
// If no flowId but script token, deny (scripts must use flowId)
453+
enforceScriptScope(c, { allowedEndpoint: !!flowId, requiredFlowId: flowId });
454+
}
455+
262456
return streamSSE(c, async (stream) => {
263457
let subscriberId: string | undefined;
264458

@@ -292,7 +486,7 @@ export function createApp(config: ServerConfig) {
292486
} catch {
293487
clearInterval(heartbeatInterval);
294488
}
295-
}, 30000);
489+
}, SSE_HEARTBEAT_INTERVAL_MS);
296490

297491
// Handle abort signal for cleanup
298492
const abortHandler = () => {
@@ -346,6 +540,11 @@ export function createApp(config: ServerConfig) {
346540
{ name: 'Sessions', description: 'Manage stateful sessions with variables and cookies' },
347541
{ name: 'Flows', description: 'Observer Mode - track and correlate request executions' },
348542
{ name: 'Workspace', description: 'Workspace discovery - list .http files and requests' },
543+
{ name: 'Scripts', description: 'Run JavaScript, TypeScript, and Python scripts' },
544+
{
545+
name: 'Tests',
546+
description: 'Run tests with detected frameworks (bun, vitest, jest, pytest)'
547+
},
349548
{ name: 'Events', description: 'Real-time event streaming via Server-Sent Events' }
350549
],
351550
externalDocs: {
@@ -378,6 +577,7 @@ export function createApp(config: ServerConfig) {
378577
// Cleanup function for graceful shutdown
379578
const dispose = () => {
380579
stopSessionCleanup();
580+
stopScriptTokenCleanup();
381581
};
382582

383583
return { app, service, eventManager, workspaceRoot, dispose };

0 commit comments

Comments
 (0)