11import { OpenAPIHono } from '@hono/zod-openapi' ;
2+ import type { Context } from 'hono' ;
23import { cors } from 'hono/cors' ;
34import { HTTPException } from 'hono/http-exception' ;
45import { streamSSE } from 'hono/streaming' ;
56import 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
816export type { WebConfig } from './web' ;
917
1018import { getStatusForError , TreqError , ValidationError } from './errors' ;
1119import { createEventManager , type EventEnvelope } from './events' ;
1220import {
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' ;
2943import type { ErrorResponse } from './schemas' ;
3044import { createService , resolveWorkspaceRoot } from './service' ;
3145import { createWebRoutes , isApiPath , type WebConfig } from './web' ;
3246
3347const 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
3599export 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