Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 202 additions & 2 deletions packages/app/src/server/app.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { OpenAPIHono } from '@hono/zod-openapi';
import type { Context } from 'hono';
import { cors } from 'hono/cors';
import { HTTPException } from 'hono/http-exception';
import { streamSSE } from 'hono/streaming';
import packageJson from '../../package.json';
import { createAuthMiddleware, startSessionCleanup, stopSessionCleanup } from './auth';
import {
createAuthMiddleware,
type ScriptTokenPayload,
startScriptTokenCleanup,
startSessionCleanup,
stopScriptTokenCleanup,
stopSessionCleanup
} from './auth';

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

import { getStatusForError, TreqError, ValidationError } from './errors';
import { createEventManager, type EventEnvelope } from './events';
import {
cancelScriptRoute,
cancelTestRoute,
capabilitiesRoute,
configRoute,
createFlowRoute,
Expand All @@ -19,18 +29,72 @@ import {
executeRoute,
finishFlowRoute,
getExecutionRoute,
getRunnersRoute,
getSessionRoute,
getTestFrameworksRoute,
healthRoute,
listWorkspaceFilesRoute,
listWorkspaceRequestsRoute,
parseRoute,
runScriptRoute,
runTestRoute,
updateSessionVariablesRoute
} from './openapi';
import type { ErrorResponse } from './schemas';
import { createService, resolveWorkspaceRoot } from './service';
import { createWebRoutes, isApiPath, type WebConfig } from './web';

const SERVER_VERSION = packageJson.version;
const SSE_HEARTBEAT_INTERVAL_MS = 5000;

// ============================================================================
// Script Token Authorization Helper
// ============================================================================

/**
* Options for enforceScriptScope.
*/
interface EnforceScriptScopeOptions {
/** Whether this endpoint is allowed for script tokens at all */
allowedEndpoint: boolean;
/** If specified, the script token's flowId must match this value */
requiredFlowId?: string;
/** If specified, the script token's sessionId must match this value */
requiredSessionId?: string;
}

/**
* Enforce script token scope restrictions.
*
* When a request is authenticated via script token, this function enforces
* that the token's scope (flowId, sessionId) matches the requested resource.
*
* For non-script auth methods (bearer, cookie, none), this is a no-op.
*
* @throws HTTPException 403 if endpoint not allowed for scripts
* @throws HTTPException 403 if flowId/sessionId mismatch
*/
function enforceScriptScope(c: Context, opts: EnforceScriptScopeOptions): void {
const authMethod = c.get('authMethod');
if (authMethod !== 'script') return; // Not a script token, no restrictions

const payload = c.get('scriptTokenPayload') as ScriptTokenPayload | undefined;
if (!payload) {
throw new HTTPException(401, { message: 'Missing script token payload' });
}

if (!opts.allowedEndpoint) {
throw new HTTPException(403, { message: 'Endpoint not allowed for script tokens' });
}

if (opts.requiredFlowId && opts.requiredFlowId !== payload.flowId) {
throw new HTTPException(403, { message: 'Flow ID mismatch' });
}

if (opts.requiredSessionId && opts.requiredSessionId !== payload.sessionId) {
throw new HTTPException(403, { message: 'Session ID mismatch' });
}
}

export type ServerConfig = {
workspace?: string;
Expand Down Expand Up @@ -92,6 +156,11 @@ export function createApp(config: ServerConfig) {
startSessionCleanup();
}

// Start script token cleanup (always needed when token auth is enabled)
if (config.token) {
startScriptTokenCleanup();
}

// Apply auth to all API paths (non-web routes)
app.use('*', async (c, next) => {
const pathname = new URL(c.req.url).pathname;
Expand Down Expand Up @@ -152,6 +221,9 @@ export function createApp(config: ServerConfig) {
// ============================================================================

app.openapi(configRoute, async (c) => {
// Script tokens cannot access config (may leak sensitive structure)
enforceScriptScope(c, { allowedEndpoint: false });

const { profile, path } = c.req.valid('query');
const result = await service.getConfig({ profile, path });
return c.json(result, 200);
Expand All @@ -162,6 +234,9 @@ export function createApp(config: ServerConfig) {
// ============================================================================

app.openapi(parseRoute, async (c) => {
// Script tokens cannot use parse endpoint (unnecessary for execution)
enforceScriptScope(c, { allowedEndpoint: false });

const request = c.req.valid('json');
const result = await service.parse(request);
return c.json(result, 200);
Expand All @@ -173,6 +248,17 @@ export function createApp(config: ServerConfig) {

app.openapi(executeRoute, async (c) => {
const request = c.req.valid('json');

// Script tokens can execute, but must use their assigned flow/session
const payload = c.get('scriptTokenPayload') as ScriptTokenPayload | undefined;
if (payload) {
enforceScriptScope(c, {
allowedEndpoint: true,
requiredFlowId: request.flowId,
requiredSessionId: request.sessionId
});
}

const result = await service.execute(request);
return c.json(result, 200);
});
Expand All @@ -182,25 +268,38 @@ export function createApp(config: ServerConfig) {
// ============================================================================

app.openapi(createSessionRoute, (c) => {
// Script tokens cannot create sessions (use pre-created session)
enforceScriptScope(c, { allowedEndpoint: false });

const request = c.req.valid('json');
const result = service.createSession(request);
return c.json(result, 201);
});

app.openapi(getSessionRoute, (c) => {
// Script tokens cannot read session data (unnecessary surface area)
enforceScriptScope(c, { allowedEndpoint: false });

const { id } = c.req.valid('param');
const result = service.getSession(id);
return c.json(result, 200);
});

app.openapi(updateSessionVariablesRoute, async (c) => {
const { id } = c.req.valid('param');

// Script tokens can update variables, but only for their own session
enforceScriptScope(c, { allowedEndpoint: true, requiredSessionId: id });

const request = c.req.valid('json');
const result = await service.updateSessionVariables(id, request);
return c.json(result, 200);
});

app.openapi(deleteSessionRoute, (c) => {
// Script tokens cannot delete sessions
enforceScriptScope(c, { allowedEndpoint: false });

const { id } = c.req.valid('param');
service.deleteSession(id);
return c.body(null, 204);
Expand All @@ -211,19 +310,29 @@ export function createApp(config: ServerConfig) {
// ============================================================================

app.openapi(createFlowRoute, (c) => {
// Script tokens cannot create flows (use pre-created flow)
enforceScriptScope(c, { allowedEndpoint: false });

const request = c.req.valid('json');
const result = service.createFlow(request);
return c.json(result, 201);
});

app.openapi(finishFlowRoute, (c) => {
// Script tokens cannot finish flows
enforceScriptScope(c, { allowedEndpoint: false });

const { flowId } = c.req.valid('param');
const result = service.finishFlow(flowId);
return c.json(result, 200);
});

app.openapi(getExecutionRoute, (c) => {
const { flowId, reqExecId } = c.req.valid('param');

// Script tokens can read executions, but only from their own flow
enforceScriptScope(c, { allowedEndpoint: true, requiredFlowId: flowId });

const result = service.getExecution(flowId, reqExecId);
return c.json(result, 200);
});
Expand All @@ -233,18 +342,95 @@ export function createApp(config: ServerConfig) {
// ============================================================================

app.openapi(listWorkspaceFilesRoute, async (c) => {
// Script tokens cannot list workspace files (prevents file enumeration)
enforceScriptScope(c, { allowedEndpoint: false });

const { ignore } = c.req.valid('query');
const additionalIgnore = ignore ? ignore.split(',').map((p) => p.trim()) : undefined;
const result = await service.listWorkspaceFiles(additionalIgnore);
return c.json(result, 200);
});

app.openapi(listWorkspaceRequestsRoute, async (c) => {
// Script tokens cannot list workspace requests (prevents file enumeration)
enforceScriptScope(c, { allowedEndpoint: false });

const { path } = c.req.valid('query');
const result = await service.listWorkspaceRequests(path);
return c.json(result, 200);
});

// ============================================================================
// Script Endpoints
// ============================================================================

// Build server URL from config
const serverUrl = `http://${config.host}:${config.port}`;

app.openapi(runScriptRoute, async (c) => {
// Script tokens cannot spawn nested scripts
enforceScriptScope(c, { allowedEndpoint: false });

const request = c.req.valid('json');
const result = await service.executeScript(request, serverUrl, config.token);
return c.json(result, 200);
});

app.openapi(cancelScriptRoute, (c) => {
// Script tokens cannot cancel scripts
enforceScriptScope(c, { allowedEndpoint: false });

const { runId } = c.req.valid('param');
const found = service.stopScript(runId);
if (!found) {
return c.json({ error: { code: 'NOT_FOUND', message: 'Script not found' } }, 404);
}
return c.body(null, 204);
});

app.openapi(getRunnersRoute, async (c) => {
// Script tokens cannot list runners
enforceScriptScope(c, { allowedEndpoint: false });

const { filePath } = c.req.valid('query');
const result = await service.getRunners(filePath);
return c.json(result, 200);
});

// ============================================================================
// Test Endpoints
// ============================================================================

app.openapi(runTestRoute, async (c) => {
// Script tokens cannot spawn nested tests
enforceScriptScope(c, { allowedEndpoint: false });

const request = c.req.valid('json');
const result = await service.executeTest(request, serverUrl, config.token);
return c.json(result, 200);
});

app.openapi(cancelTestRoute, (c) => {
// Script tokens cannot cancel tests
enforceScriptScope(c, { allowedEndpoint: false });

const { runId } = c.req.valid('param');
const found = service.stopTest(runId);
if (!found) {
return c.json({ error: { code: 'NOT_FOUND', message: 'Test not found' } }, 404);
}
return c.body(null, 204);
});

app.openapi(getTestFrameworksRoute, async (c) => {
// Script tokens cannot list test frameworks
enforceScriptScope(c, { allowedEndpoint: false });

const { filePath } = c.req.valid('query');
const result = await service.getTestFrameworks(filePath);
return c.json(result, 200);
});

// ============================================================================
// Event Streaming (SSE)
// ============================================================================
Expand All @@ -259,6 +445,14 @@ export function createApp(config: ServerConfig) {
);
}

// Script tokens can subscribe to events, but only for their own flow
if (flowId) {
enforceScriptScope(c, { allowedEndpoint: true, requiredFlowId: flowId });
} else {
// If no flowId but script token, deny (scripts must use flowId)
enforceScriptScope(c, { allowedEndpoint: !!flowId, requiredFlowId: flowId });
}

return streamSSE(c, async (stream) => {
let subscriberId: string | undefined;

Expand Down Expand Up @@ -292,7 +486,7 @@ export function createApp(config: ServerConfig) {
} catch {
clearInterval(heartbeatInterval);
}
}, 30000);
}, SSE_HEARTBEAT_INTERVAL_MS);

// Handle abort signal for cleanup
const abortHandler = () => {
Expand Down Expand Up @@ -346,6 +540,11 @@ export function createApp(config: ServerConfig) {
{ name: 'Sessions', description: 'Manage stateful sessions with variables and cookies' },
{ name: 'Flows', description: 'Observer Mode - track and correlate request executions' },
{ name: 'Workspace', description: 'Workspace discovery - list .http files and requests' },
{ name: 'Scripts', description: 'Run JavaScript, TypeScript, and Python scripts' },
{
name: 'Tests',
description: 'Run tests with detected frameworks (bun, vitest, jest, pytest)'
},
{ name: 'Events', description: 'Real-time event streaming via Server-Sent Events' }
],
externalDocs: {
Expand Down Expand Up @@ -378,6 +577,7 @@ export function createApp(config: ServerConfig) {
// Cleanup function for graceful shutdown
const dispose = () => {
stopSessionCleanup();
stopScriptTokenCleanup();
};

return { app, service, eventManager, workspaceRoot, dispose };
Expand Down
Loading