diff --git a/apps/api/src/app/create-api-app.ts b/apps/api/src/app/create-api-app.ts new file mode 100644 index 000000000..42e92a12d --- /dev/null +++ b/apps/api/src/app/create-api-app.ts @@ -0,0 +1,29 @@ +import { Hono } from 'hono'; + +import type { Env } from '../env'; +import { registerErrorHandling, registerNotFound } from './errors'; +import { registerMcpCorsPolicy, registerMcpEndpoint } from './mcp'; +import { registerGlobalMiddleware } from './middleware'; +import { registerPagesProxy } from './pages-proxy'; +import { registerPublicRoutes } from './public-routes'; +import { registerApiRoutes } from './register-routes'; +import type { ApiApp } from './types'; +import { registerWellKnownRoutes } from './well-known'; +import { registerWorkspaceProxy } from './workspace-proxy'; + +export function createApiApp(): ApiApp { + const app = new Hono<{ Bindings: Env }>(); + + registerErrorHandling(app); + registerPagesProxy(app); + registerWorkspaceProxy(app); + registerMcpCorsPolicy(app); + registerGlobalMiddleware(app); + registerPublicRoutes(app); + registerWellKnownRoutes(app); + registerApiRoutes(app); + registerMcpEndpoint(app); + registerNotFound(app); + + return app; +} diff --git a/apps/api/src/app/errors.ts b/apps/api/src/app/errors.ts new file mode 100644 index 000000000..8b5de8b23 --- /dev/null +++ b/apps/api/src/app/errors.ts @@ -0,0 +1,45 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status'; + +import { log, serializeError } from '../lib/logger'; +import { AppError } from '../middleware/error'; +import { GcpApiError, sanitizeGcpError } from '../services/gcp-errors'; +import type { ApiApp } from './types'; + +export function registerErrorHandling(app: ApiApp): void { + // Global error handler — catches errors from all routes including subrouters. + // Must use app.onError() instead of middleware try/catch because Hono's + // app.route() subrouter errors don't propagate to parent middleware. + app.onError((err, c) => { + log.error('request_error', serializeError(err)); + + if (err instanceof AppError) { + return c.json(err.toJSON(), err.statusCode as ContentfulStatusCode); + } + + // Defense-in-depth: sanitize GcpApiError if it escapes route-level catch blocks. + if (err instanceof GcpApiError) { + const safe = sanitizeGcpError(err, 'global-handler'); + return c.json({ error: 'GCP_UPSTREAM_ERROR', message: safe }, 502); + } + + return c.json( + { + error: 'INTERNAL_ERROR', + message: 'Internal server error', + }, + 500, + ); + }); +} + +export function registerNotFound(app: ApiApp): void { + app.notFound((c) => { + return c.json( + { + error: 'NOT_FOUND', + message: 'Endpoint not found', + }, + 404, + ); + }); +} diff --git a/apps/api/src/app/mcp.ts b/apps/api/src/app/mcp.ts new file mode 100644 index 000000000..6038974cc --- /dev/null +++ b/apps/api/src/app/mcp.ts @@ -0,0 +1,32 @@ +import type { MiddlewareHandler } from 'hono'; +import { cors } from 'hono/cors'; + +import { mcpRoutes } from '../routes/mcp'; +import type { ApiApp } from './types'; + +export function registerMcpCorsPolicy(app: ApiApp): void { + // MCP uses bearer token auth, not browser cookies. Keep its CORS behavior + // separate from credentialed browser routes. Register before global CORS so + // preflight requests are not swallowed by the credentialed app-wide policy, + // while the actual route can still be mounted after global middleware. + const mcpCors = cors({ + origin: '*', + credentials: false, + allowHeaders: ['Content-Type', 'Authorization'], + allowMethods: ['GET', 'POST', 'OPTIONS'], + }); + + const removeCredentialedCors: MiddlewareHandler = async (c, next) => { + await next(); + c.res.headers.delete('Access-Control-Allow-Credentials'); + }; + + app.use('/mcp', mcpCors); + app.use('/mcp/*', mcpCors); + app.use('/mcp', removeCredentialedCors); + app.use('/mcp/*', removeCredentialedCors); +} + +export function registerMcpEndpoint(app: ApiApp): void { + app.route('/mcp', mcpRoutes); +} diff --git a/apps/api/src/app/middleware.ts b/apps/api/src/app/middleware.ts new file mode 100644 index 000000000..3054c45ef --- /dev/null +++ b/apps/api/src/app/middleware.ts @@ -0,0 +1,57 @@ +import { cors } from 'hono/cors'; + +import { log } from '../lib/logger'; +import { analyticsMiddleware } from '../middleware/analytics'; +import type { ApiApp } from './types'; + +export function registerGlobalMiddleware(app: ApiApp): void { + registerStructuredRequestLogging(app); + + // Analytics Engine — writes one data point per request (non-blocking, fire-and-forget). + app.use('*', analyticsMiddleware()); + + app.use('*', cors({ + origin: (origin, c) => { + if (!origin) return null; + const baseDomain = c.env?.BASE_DOMAIN || ''; + // Allow localhost only in development (BASE_DOMAIN contains 'localhost' or is empty). + const isDevEnvironment = !baseDomain || baseDomain.includes('localhost'); + try { + const url = new URL(origin); + if (isDevEnvironment && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) return origin; + } catch { + return null; + } + // Allow subdomains of the configured BASE_DOMAIN (e.g., app.example.com, api.example.com). + if (baseDomain) { + try { + const url = new URL(origin); + if (url.hostname === baseDomain || url.hostname.endsWith(`.${baseDomain}`)) return origin; + } catch { + return null; + } + } + return null; + }, + credentials: true, + allowHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'anthropic-version', 'anthropic-beta'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + })); +} + +function registerStructuredRequestLogging(app: ApiApp): void { + app.use('*', async (c, next) => { + const start = Date.now(); + await next(); + const durationMs = Date.now() - start; + const path = new URL(c.req.url).pathname; + // Skip noisy health checks from structured logs. + if (path === '/health') return; + log.info('http.request', { + method: c.req.method, + path, + status: c.res.status, + durationMs, + }); + }); +} diff --git a/apps/api/src/app/pages-proxy.ts b/apps/api/src/app/pages-proxy.ts new file mode 100644 index 000000000..46f2ac200 --- /dev/null +++ b/apps/api/src/app/pages-proxy.ts @@ -0,0 +1,33 @@ +import type { ApiApp } from './types'; + +export function registerPagesProxy(app: ApiApp): void { + // Proxy non-API subdomains to their respective Cloudflare Pages deployments. + // The Worker wildcard route *.{domain}/* intercepts ALL subdomains, so we must + // proxy app.* and www.* requests to Pages before any other middleware runs. + // The apex domain is redirected to www.* for the marketing site. + app.use('*', async (c, next) => { + const hostname = new URL(c.req.url).hostname; + const baseDomain = c.env?.BASE_DOMAIN || ''; + if (!baseDomain) { await next(); return; } + + if (hostname === `app.${baseDomain}`) { + const pagesUrl = new URL(c.req.url); + pagesUrl.hostname = `${c.env.PAGES_PROJECT_NAME || 'sam-web-prod'}.pages.dev`; + return fetch(new Request(pagesUrl.toString(), c.req.raw)); + } + + if (hostname === `www.${baseDomain}`) { + const pagesUrl = new URL(c.req.url); + pagesUrl.hostname = `${c.env.WWW_PAGES_PROJECT_NAME || 'sam-www'}.pages.dev`; + return fetch(new Request(pagesUrl.toString(), c.req.raw)); + } + + if (hostname === baseDomain) { + const wwwUrl = new URL(c.req.url); + wwwUrl.hostname = `www.${baseDomain}`; + return c.redirect(wwwUrl.toString(), 301); + } + + await next(); + }); +} diff --git a/apps/api/src/app/public-routes.ts b/apps/api/src/app/public-routes.ts new file mode 100644 index 000000000..96ed1fb56 --- /dev/null +++ b/apps/api/src/app/public-routes.ts @@ -0,0 +1,24 @@ +import type { ApiApp } from './types'; + +export function registerPublicRoutes(app: ApiApp): void { + // Health check — public endpoint returns minimal info only. + app.get('/health', (c) => { + const hasCriticalBindings = !!( + c.env.DATABASE && + c.env.KV && + c.env.PROJECT_DATA && + c.env.NODE_LIFECYCLE && + c.env.TASK_RUNNER + ); + + return c.json({ + status: hasCriticalBindings ? 'healthy' : 'degraded', + timestamp: new Date().toISOString(), + }, hasCriticalBindings ? 200 : 503); + }); + + // Public config — exposes feature flags the UI needs before auth. + app.get('/api/config/artifacts-enabled', (c) => { + return c.json({ enabled: c.env.ARTIFACTS_ENABLED === 'true' && !!c.env.ARTIFACTS }); + }); +} diff --git a/apps/api/src/app/register-routes.ts b/apps/api/src/app/register-routes.ts new file mode 100644 index 000000000..f5ef465cf --- /dev/null +++ b/apps/api/src/app/register-routes.ts @@ -0,0 +1,199 @@ +import { accountMapRoutes } from '../routes/account-map'; +import { activityRoutes } from '../routes/activity'; +import { adminRoutes } from '../routes/admin'; +import { adminAiAllowanceRoutes } from '../routes/admin-ai-allowance'; +import { adminAIProxyRoutes } from '../routes/admin-ai-proxy'; +import { adminAiUsageRoutes } from '../routes/admin-ai-usage'; +import { adminAnalyticsRoutes } from '../routes/admin-analytics'; +import { adminCcBackfillRoutes } from '../routes/admin-cc-backfill'; +import { adminCostRoutes } from '../routes/admin-costs'; +import { adminGithubInstallationLeakSweepRoutes } from '../routes/admin-github-installation-leak-sweep'; +import { adminGithubRepoIdBackfillRoutes } from '../routes/admin-github-repo-id-backfill'; +import { adminPlatformCredentialRoutes } from '../routes/admin-platform-credentials'; +import { adminQuotaRoutes } from '../routes/admin-quotas'; +import { adminSandboxRoutes } from '../routes/admin-sandbox'; +import { adminUsageRoutes } from '../routes/admin-usage'; +import { agentRoutes } from '../routes/agent'; +import { agentProfileRoutes } from '../routes/agent-profiles'; +import { agentSettingsRoutes } from '../routes/agent-settings'; +import { agentsCatalogRoutes } from '../routes/agents-catalog'; +import { aiProxyRoutes } from '../routes/ai-proxy'; +import { aiProxyAnthropicRoutes } from '../routes/ai-proxy-anthropic'; +import { aiProxyPassthroughRoutes } from '../routes/ai-proxy-passthrough'; +import { analyticsIngestRoutes } from '../routes/analytics-ingest'; +import { apiTokenRoutes } from '../routes/api-tokens'; +import { authRoutes } from '../routes/auth'; +import { bootstrapRoutes } from '../routes/bootstrap'; +import { cachedCommandRoutes } from '../routes/cached-commands'; +import { chatRoutes } from '../routes/chat'; +import { chatsRoutes } from '../routes/chats'; +import { cliRoutes } from '../routes/cli'; +import { clientErrorsRoutes } from '../routes/client-errors'; +import { codexRefreshRoutes } from '../routes/codex-refresh'; +import { ccRoutes } from '../routes/composable-credentials'; +import { credentialsRoutes } from '../routes/credentials'; +import { dashboardRoutes } from '../routes/dashboard'; +import { deployReleaseCallbackRoute } from '../routes/deploy-release-callback'; +import { deploymentEnvironmentRoutes } from '../routes/deployment-environments'; +import { deploymentReleaseRoutes } from '../routes/deployment-releases'; +import { deploymentSecretRoutes } from '../routes/deployment-secrets'; +import { deploymentVolumeRoutes } from '../routes/deployment-volumes'; +import { deviceFlowRoutes } from '../routes/device-flow'; +import { gcpRoutes } from '../routes/gcp'; +import { githubRoutes } from '../routes/github'; +import { googleAuthRoutes } from '../routes/google-auth'; +import { knowledgeRoutes } from '../routes/knowledge'; +import { libraryRoutes } from '../routes/library'; +import { mailboxRoutes } from '../routes/mailbox'; +import { missionRoutes } from '../routes/missions'; +import { nodeLifecycleRoutes } from '../routes/node-lifecycle'; +import { nodesRoutes } from '../routes/nodes'; +import { notificationRoutes } from '../routes/notifications'; +import { observabilityIngestRoutes } from '../routes/observability-ingest'; +import { orchestratorRoutes } from '../routes/orchestrator'; +import { policyRoutes } from '../routes/policies'; +import { profileRuntimeRoutes } from '../routes/profile-runtime'; +import { projectAgentRoutes } from '../routes/project-agent'; +import { deploymentIdentityTokenRoute, gcpDeployCallbackRoute, projectDeploymentRoutes } from '../routes/project-deployment'; +import { projectsRoutes } from '../routes/projects'; +import { agentActivityCallbackRoute } from '../routes/projects/agent-activity-callback'; +import { nodeAcpHeartbeatRoute } from '../routes/projects/node-acp-heartbeat'; +import { providersRoutes } from '../routes/providers'; +import { resolutionStatusRoute } from '../routes/resolution-status'; +import { samRoutes } from '../routes/sam'; +import { skillRuntimeRoutes } from '../routes/skill-runtime'; +import { skillRoutes } from '../routes/skills'; +import { taskCallbackRoute, tasksRoutes } from '../routes/tasks'; +import { terminalRoutes } from '../routes/terminal'; +import { transcribeRoutes } from '../routes/transcribe'; +import { trialRoutes } from '../routes/trial'; +import { trialOnboardingRoutes } from '../routes/trial/index'; +import { triggersRoutes } from '../routes/triggers'; +import { ttsRoutes } from '../routes/tts'; +import { uiGovernanceRoutes } from '../routes/ui-governance'; +import { usageRoutes } from '../routes/usage'; +import { workspacesRoutes } from '../routes/workspaces'; +import type { ApiApp } from './types'; + +export function registerApiRoutes(app: ApiApp): void { + registerAuthPrecedenceRoutes(app); + registerCredentialProviderAndNodeRoutes(app); + registerWorkspaceAndAgentRoutes(app); + registerProjectCallbackRoutesBeforeSessionAuthRoutes(app); + registerProjectSessionAndChildRoutes(app); + registerDeploymentRoutes(app); + registerAdminRoutes(app); + registerProductRoutes(app); + registerAiAndExternalAuthRoutes(app); +} + +export function registerAuthPrecedenceRoutes(app: ApiApp): void { + // These `/api/auth` routes use callback/API-token/device-flow auth and must run + // before BetterAuth's wildcard catch-all mounted by `authRoutes`. + app.route('/api/auth', codexRefreshRoutes); + app.route('/api/auth', apiTokenRoutes); + app.route('/api/auth', deviceFlowRoutes); + app.route('/api/auth', authRoutes); +} + +function registerCredentialProviderAndNodeRoutes(app: ApiApp): void { + app.route('/api/credentials', resolutionStatusRoute); + app.route('/api/credentials', credentialsRoutes); + app.route('/api/cc', ccRoutes); + app.route('/api/providers', providersRoutes); + app.route('/api/github', githubRoutes); + + // Deploy release callback uses callback JWT auth and must be before session-auth node routes. + app.route('/api/nodes', deployReleaseCallbackRoute); + app.route('/api/nodes', nodesRoutes); + app.route('/api/nodes', nodeLifecycleRoutes); +} + +function registerWorkspaceAndAgentRoutes(app: ApiApp): void { + app.route('/api/workspaces', workspacesRoutes); + app.route('/api/terminal', terminalRoutes); + app.route('/api/agent', agentRoutes); + app.route('/api/agents', agentsCatalogRoutes); + app.route('/api/bootstrap', bootstrapRoutes); + app.route('/api/ui-governance', uiGovernanceRoutes); + app.route('/api/transcribe', transcribeRoutes); + app.route('/api/tts', ttsRoutes); + app.route('/api/agent-settings', agentSettingsRoutes); + app.route('/api/client-errors', clientErrorsRoutes); + app.route('/api/cli', cliRoutes); + app.route('/api/chats', chatsRoutes); + app.route('/api/t', analyticsIngestRoutes); +} + +export function registerProjectCallbackRoutesBeforeSessionAuthRoutes(app: ApiApp): void { + // Callback JWT routes must be registered before `projectsRoutes`, whose + // wildcard BetterAuth session middleware otherwise catches same-base siblings. + app.route('/api/projects', deploymentIdentityTokenRoute); + app.route('/api/projects', nodeAcpHeartbeatRoute); + app.route('/api/projects', agentActivityCallbackRoute); + app.route('/api/projects', taskCallbackRoute); +} + +function registerProjectSessionAndChildRoutes(app: ApiApp): void { + app.route('/api/projects', projectsRoutes); + app.route('/api/projects/:projectId/tasks', tasksRoutes); + app.route('/api/projects/:projectId/sessions', chatRoutes); + app.route('/api/projects/:projectId/cached-commands', cachedCommandRoutes); + app.route('/api/projects/:projectId/activity', activityRoutes); + app.route('/api/projects/:projectId/library', libraryRoutes); + app.route('/api/projects/:projectId/agent-profiles/:profileId/runtime', profileRuntimeRoutes); + app.route('/api/projects/:projectId/agent-profiles', agentProfileRoutes); + app.route('/api/projects/:projectId/skills/:skillId/runtime', skillRuntimeRoutes); + app.route('/api/projects/:projectId/skills', skillRoutes); + app.route('/api/projects/:projectId/triggers', triggersRoutes); + app.route('/api/projects/:projectId/knowledge', knowledgeRoutes); + app.route('/api/projects/:projectId/mailbox', mailboxRoutes); + app.route('/api/projects/:projectId/missions', missionRoutes); + app.route('/api/projects/:projectId/orchestrator', orchestratorRoutes); + app.route('/api/projects/:projectId/policies', policyRoutes); + app.route('/api/projects/:projectId/agent', projectAgentRoutes); +} + +function registerDeploymentRoutes(app: ApiApp): void { + app.route('/api/projects', projectDeploymentRoutes); + app.route('/api/projects', deploymentEnvironmentRoutes); + app.route('/api/projects', deploymentReleaseRoutes); + app.route('/api/projects', deploymentSecretRoutes); + app.route('/api/projects', deploymentVolumeRoutes); + app.route('/api/deployment', gcpDeployCallbackRoute); +} + +function registerAdminRoutes(app: ApiApp): void { + app.route('/api/admin/observability/logs/ingest', observabilityIngestRoutes); + app.route('/api/admin', adminRoutes); + app.route('/api/admin/ai-proxy', adminAIProxyRoutes); + app.route('/api/admin/analytics', adminAnalyticsRoutes); + app.route('/api/admin/analytics/ai-usage', adminAiUsageRoutes); + app.route('/api/admin/platform-credentials', adminPlatformCredentialRoutes); + app.route('/api/admin/quotas', adminQuotaRoutes); + app.route('/api/admin/usage', adminUsageRoutes); + app.route('/api/admin/costs', adminCostRoutes); + app.route('/api/admin/cc-backfill', adminCcBackfillRoutes); + app.route('/api/admin/github-repo-id-backfill', adminGithubRepoIdBackfillRoutes); + app.route('/api/admin/github-installation-leak-sweep', adminGithubInstallationLeakSweepRoutes); + app.route('/api/admin/sandbox', adminSandboxRoutes); + app.route('/api/admin/ai-allowance', adminAiAllowanceRoutes); +} + +function registerProductRoutes(app: ApiApp): void { + app.route('/api/usage', usageRoutes); + app.route('/api/account-map', accountMapRoutes); + app.route('/api/dashboard', dashboardRoutes); + app.route('/api/sam', samRoutes); + app.route('/api/notifications', notificationRoutes); + app.route('/api', trialRoutes); + app.route('/api/trial', trialOnboardingRoutes); + app.route('/api/gcp', gcpRoutes); +} + +function registerAiAndExternalAuthRoutes(app: ApiApp): void { + app.route('/ai/v1', aiProxyRoutes); + app.route('/ai/anthropic/v1', aiProxyAnthropicRoutes); + app.route('/ai/proxy', aiProxyPassthroughRoutes); + app.route('/auth/google', googleAuthRoutes); +} diff --git a/apps/api/src/app/scheduled.ts b/apps/api/src/app/scheduled.ts new file mode 100644 index 000000000..f9fc90d2c --- /dev/null +++ b/apps/api/src/app/scheduled.ts @@ -0,0 +1,145 @@ +import { drizzle } from 'drizzle-orm/d1'; + +import * as schema from '../db/schema'; +import type { Env } from '../env'; +import { log } from '../lib/logger'; +import { runAnalyticsForwardJob } from '../scheduled/analytics-forward'; +import { runComputeUsageCleanup } from '../scheduled/compute-usage-cleanup'; +import { runCronTriggerSweep } from '../scheduled/cron-triggers'; +import { runNodeCleanupSweep } from '../scheduled/node-cleanup'; +import { runObservabilityPurge } from '../scheduled/observability-purge'; +import { recoverStuckTasks } from '../scheduled/stuck-tasks'; +import { runTrialExpireSweep } from '../scheduled/trial-expire'; +import { runTrialRolloverAudit } from '../scheduled/trial-rollover'; +import { runTrialWaitlistCleanup } from '../scheduled/trial-waitlist-cleanup'; +import { runTriggerExecutionCleanup } from '../scheduled/trigger-execution-cleanup'; +import { runMonthlyCostAggregation } from '../services/ai-monthly-cost-cron'; +import { checkProvisioningTimeouts } from '../services/timeout'; +import { migrateOrphanedWorkspaces } from '../services/workspace-migration'; + +export async function handleScheduled( + controller: ScheduledController, + env: Env, + ctx: ExecutionContext, +): Promise { + const rolloverCron = env.TRIAL_CRON_ROLLOVER_CRON ?? '0 5 1 * *'; + const waitlistCleanupCron = env.TRIAL_CRON_WAITLIST_CLEANUP ?? '0 4 * * *'; + + const isDailyForward = controller.cron === '0 3 * * *'; + const isMonthlyCostAggregation = controller.cron === '30 * * * *'; + const isTrialRollover = controller.cron === rolloverCron; + const isTrialWaitlistCleanup = controller.cron === waitlistCleanupCron; + + const cronType = isDailyForward + ? 'daily-forward' + : isMonthlyCostAggregation + ? 'monthly-cost-aggregation' + : isTrialRollover + ? 'trial-rollover' + : isTrialWaitlistCleanup + ? 'trial-waitlist-cleanup' + : 'sweep'; + + log.info('cron.started', { + cron: controller.cron, + type: cronType, + }); + + if (isMonthlyCostAggregation) { + ctx.waitUntil((async () => { + const result = await runMonthlyCostAggregation(env); + log.info('cron.completed', { + cron: controller.cron, + type: 'monthly-cost-aggregation', + monthlyCostEnabled: result.enabled, + monthlyCostUsersUpdated: result.usersUpdated, + monthlyCostTotalEntries: result.totalEntries, + monthlyCostErrors: result.errors, + }); + })()); + return; + } + + if (isDailyForward) { + ctx.waitUntil((async () => { + const forward = await runAnalyticsForwardJob(env); + log.info('cron.completed', { + cron: controller.cron, + type: 'daily-forward', + forwardEnabled: forward.enabled, + forwardEventsQueried: forward.eventsQueried, + forwardSegmentSent: forward.segment.sent, + forwardGA4Sent: forward.ga4.sent, + forwardCursorUpdated: forward.cursorUpdated, + }); + })()); + return; + } + + if (isTrialRollover) { + ctx.waitUntil((async () => { + const rollover = await runTrialRolloverAudit(env); + log.info('cron.completed', { + cron: controller.cron, + type: 'trial-rollover', + trialRolloverMonthKey: rollover.monthKey, + trialRolloverPruned: rollover.pruned, + }); + })()); + return; + } + + if (isTrialWaitlistCleanup) { + ctx.waitUntil((async () => { + const waitlist = await runTrialWaitlistCleanup(env); + log.info('cron.completed', { + cron: controller.cron, + type: 'trial-waitlist-cleanup', + trialWaitlistPurged: waitlist.purged, + }); + })()); + return; + } + + const timedOut = await checkProvisioningTimeouts(env.DATABASE, env, env.OBSERVABILITY_DATABASE); + const db = drizzle(env.DATABASE, { schema }); + const migrated = await migrateOrphanedWorkspaces(db); + const nodeCleanup = await runNodeCleanupSweep(env); + const stuckTasks = await recoverStuckTasks(env); + const observabilityPurge = await runObservabilityPurge(env); + const cronTriggers = await runCronTriggerSweep(env); + const triggerCleanup = await runTriggerExecutionCleanup(env); + const computeUsageClosed = await runComputeUsageCleanup(env); + const trialExpire = await runTrialExpireSweep(env); + + log.info('cron.completed', { + cron: controller.cron, + type: 'sweep', + provisioningTimedOut: timedOut, + workspacesMigrated: migrated, + staleNodesDestroyed: nodeCleanup.staleDestroyed, + lifetimeNodesDestroyed: nodeCleanup.lifetimeDestroyed, + lifetimeNodesSkipped: nodeCleanup.lifetimeSkipped, + nodeCleanupErrors: nodeCleanup.errors, + orphanedWorkspacesFlagged: nodeCleanup.orphanedWorkspacesFlagged, + orphanedNodesFlagged: nodeCleanup.orphanedNodesFlagged, + stuckTasksFailedQueued: stuckTasks.failedQueued, + stuckTasksFailedDelegated: stuckTasks.failedDelegated, + stuckTasksFailedInProgress: stuckTasks.failedInProgress, + stuckTasksHeartbeatSkipped: stuckTasks.heartbeatSkipped, + stuckTaskErrors: stuckTasks.errors, + stuckTaskDoHealthChecked: stuckTasks.doHealthChecked, + observabilityPurgedByAge: observabilityPurge.deletedByAge, + observabilityPurgedByCount: observabilityPurge.deletedByCount, + cronTriggersChecked: cronTriggers.checked, + cronTriggersFired: cronTriggers.fired, + cronTriggersSkipped: cronTriggers.skipped, + cronTriggersFailed: cronTriggers.failed, + triggerExecStaleRecovered: triggerCleanup.staleRecovered, + triggerExecStaleQueuedRecovered: triggerCleanup.staleQueuedRecovered, + triggerExecRetentionPurged: triggerCleanup.retentionPurged, + triggerExecCleanupErrors: triggerCleanup.errors, + computeUsageOrphansClosed: computeUsageClosed, + trialExpired: trialExpire.expired, + }); +} diff --git a/apps/api/src/app/types.ts b/apps/api/src/app/types.ts new file mode 100644 index 000000000..d9b305347 --- /dev/null +++ b/apps/api/src/app/types.ts @@ -0,0 +1,5 @@ +import type { Hono } from 'hono'; + +import type { Env } from '../env'; + +export type ApiApp = Hono<{ Bindings: Env }>; diff --git a/apps/api/src/app/well-known.ts b/apps/api/src/app/well-known.ts new file mode 100644 index 000000000..33305a4cc --- /dev/null +++ b/apps/api/src/app/well-known.ts @@ -0,0 +1,21 @@ +import type { ApiApp } from './types'; + +export function registerWellKnownRoutes(app: ApiApp): void { + // JWKS endpoint (must be at root level). + app.get('/.well-known/jwks.json', async (c) => { + const { getJWKS } = await import('../services/jwt'); + const jwks = await getJWKS(c.env); + c.header('Cache-Control', 'public, max-age=3600'); + c.header('X-Content-Type-Options', 'nosniff'); + return c.json(jwks); + }); + + // OIDC Discovery endpoint — used by GCP Workload Identity Federation to verify SAM as an IdP. + app.get('/.well-known/openid-configuration', async (c) => { + const { getOidcDiscovery } = await import('../services/jwt'); + const discovery = getOidcDiscovery(c.env); + c.header('Cache-Control', 'public, max-age=3600'); + c.header('X-Content-Type-Options', 'nosniff'); + return c.json(discovery); + }); +} diff --git a/apps/api/src/app/workspace-proxy.ts b/apps/api/src/app/workspace-proxy.ts new file mode 100644 index 000000000..de1dbbe68 --- /dev/null +++ b/apps/api/src/app/workspace-proxy.ts @@ -0,0 +1,234 @@ +import { and, eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; + +import { createAuth } from '../auth'; +import * as schema from '../db/schema'; +import { log, serializeError } from '../lib/logger'; +import { parseWorkspaceSubdomain } from '../lib/workspace-subdomain'; +import { signTerminalToken, verifyPortAccessToken, verifyTerminalToken } from '../services/jwt'; +import { recordNodeRoutingMetric } from '../services/telemetry'; +import type { ApiApp } from './types'; + +export function registerWorkspaceProxy(app: ApiApp): void { + // Proxy requests for workspace subdomains (ws-{id}.*) to the VM agent. + // The wildcard DNS *.{domain} routes through this Worker, so we must proxy + // workspace requests to the actual VM running the agent on the configured port. + // vm-{id} DNS records are orange-clouded; CF edge terminates TLS and re-encrypts + // to the VM agent's Origin CA cert. This handles both HTTP and WebSocket requests. + app.use('*', async (c, next) => { + const url = new URL(c.req.url); + const hostname = url.hostname; + const baseDomain = c.env?.BASE_DOMAIN || ''; + + const parsed = parseWorkspaceSubdomain(hostname, baseDomain); + if (!parsed) { + await next(); + return; + } + if ('error' in parsed) { + log.info('ws_proxy_invalid_subdomain', { hostname, reason: parsed.error }); + return c.json({ error: 'INVALID_WORKSPACE', message: 'Invalid workspace subdomain' }, 400); + } + const { workspaceId, targetPort } = parsed; + + let userId: string | null = null; + let portAccessRedirect: Response | null = null; + let publicPortAccess = false; + + if (targetPort !== null) { + const cookieHeader = c.req.raw.headers.get('cookie') || ''; + const cookieMatch = cookieHeader.match(/(?:^|;\s*)sam_port_access=([^\s;]+)/); + if (cookieMatch?.[1]) { + try { + const payload = await verifyPortAccessToken(cookieMatch[1], c.env); + if (payload.workspace === workspaceId && payload.port === targetPort) { + userId = payload.subject; + } + } catch { + // Cookie expired or invalid — fall through to token check. + } + } + + if (!userId) { + const portToken = url.searchParams.get('port_token'); + if (portToken) { + try { + const payload = await verifyPortAccessToken(portToken, c.env); + if (payload.workspace === workspaceId && payload.port === targetPort) { + const cookieMaxAge = c.env.PORT_ACCESS_COOKIE_MAX_AGE_SECONDS + ? parseInt(c.env.PORT_ACCESS_COOKIE_MAX_AGE_SECONDS, 10) : 14400; + const redirectUrl = new URL(url.toString()); + redirectUrl.searchParams.delete('port_token'); + portAccessRedirect = new Response(null, { + status: 302, + headers: { + Location: redirectUrl.toString(), + 'Set-Cookie': `sam_port_access=${portToken}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${cookieMaxAge}`, + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }, + }); + userId = payload.subject; + } + } catch (err) { + log.warn('ws_proxy_port_token_rejected', { workspaceId, targetPort, ...serializeError(err) }); + } + } + } + + if (!userId) { + const db = drizzle(c.env.DATABASE, { schema }); + const publicWorkspace = await db + .select({ + userId: schema.workspaces.userId, + portsPublicEnabled: schema.workspaces.portsPublicEnabled, + }) + .from(schema.workspaces) + .where(eq(schema.workspaces.id, workspaceId)) + .get(); + + if (publicWorkspace?.portsPublicEnabled) { + userId = publicWorkspace.userId; + publicPortAccess = true; + } + } + + if (!userId) { + return new Response( + ` +Session expired + + +

Session expired

+

Your access to this port has expired or is invalid.

+

Ask the agent to run expose_port again for a fresh link.

+`, + { status: 401, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } }, + ); + } + } + + if (!userId) { + const auth = createAuth(c.env); + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + userId = session?.user.id ?? null; + } + + if (!userId) { + const token = url.searchParams.get('token'); + if (!token) { + return c.json({ error: 'UNAUTHORIZED', message: 'Authentication required' }, 401); + } + + try { + const payload = await verifyTerminalToken(token, c.env); + if (payload.workspace !== workspaceId || payload.subject === 'port-proxy') { + return c.json({ error: 'UNAUTHORIZED', message: 'Invalid workspace token' }, 401); + } + userId = payload.subject; + } catch (err) { + log.warn('ws_proxy_terminal_token_rejected', { + workspaceId, + ...serializeError(err), + }); + return c.json({ error: 'UNAUTHORIZED', message: 'Invalid workspace token' }, 401); + } + } + + const db = drizzle(c.env.DATABASE, { schema }); + const workspace = await db + .select({ + nodeId: schema.workspaces.nodeId, + status: schema.workspaces.status, + }) + .from(schema.workspaces) + .where(and(eq(schema.workspaces.id, workspaceId), eq(schema.workspaces.userId, userId))) + .get(); + + if (!workspace) { + return c.json({ error: 'NOT_FOUND', message: 'Workspace not found' }, 404); + } + + if (workspace.status !== 'running' && workspace.status !== 'recovery') { + if (workspace.status === 'creating' && url.pathname === '/boot-log/ws') { + // Allow boot-log WebSocket during creation for real-time streaming. + } else { + return c.json({ error: 'NOT_READY', message: `Workspace is ${workspace.status}` }, 503); + } + } + + if (portAccessRedirect) { + return portAccessRedirect; + } + + const routedNodeId = (workspace.nodeId || workspaceId).toLowerCase(); + const backendHostname = `${routedNodeId}.vm.${baseDomain}`; + log.info('ws_proxy_route', { + workspaceId, + nodeId: workspace.nodeId || workspaceId, + backendHostname, + targetPort, + publicPortAccess, + method: c.req.raw.method, + path: url.pathname, + }); + recordNodeRoutingMetric({ + metric: 'ws_proxy_route', + nodeId: workspace.nodeId || workspaceId, + workspaceId, + }, c.env); + const vmAgentProtocol = c.env.VM_AGENT_PROTOCOL || 'https'; + const vmAgentPort = c.env.VM_AGENT_PORT || '8443'; + const vmUrl = new URL(c.req.url); + vmUrl.protocol = `${vmAgentProtocol}:`; + vmUrl.hostname = backendHostname; + vmUrl.port = vmAgentPort; + + if (targetPort !== null) { + const subPath = url.pathname === '/' ? '' : url.pathname; + vmUrl.pathname = `/workspaces/${workspaceId}/ports/${targetPort}${subPath}`; + vmUrl.searchParams.delete('port_token'); + + try { + const { token } = await signTerminalToken('port-proxy', workspaceId, c.env); + vmUrl.searchParams.set('token', token); + } catch (err) { + log.error('port_proxy_token_error', { + workspaceId, + ...serializeError(err), + }); + return c.json({ error: 'TOKEN_ERROR', message: 'Failed to generate port proxy token' }, 500); + } + } + + const headers = new Headers(c.req.raw.headers); + headers.delete('x-sam-node-id'); + headers.delete('x-sam-workspace-id'); + headers.delete('x-forwarded-host'); + headers.set('X-SAM-Node-Id', (workspace.nodeId || workspaceId)); + headers.set('X-SAM-Workspace-Id', workspaceId); + headers.set('X-Forwarded-Host', hostname); + headers.set('X-Forwarded-Proto', 'https'); + + const response = await fetch(vmUrl.toString(), { + method: c.req.raw.method, + headers, + body: c.req.raw.body, + // @ts-expect-error — Cloudflare Workers support duplex for streaming request bodies. + duplex: c.req.raw.body ? 'half' : undefined, + }); + + if (targetPort !== null) { + const headers = new Headers(response.headers); + headers.delete('set-cookie'); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + + return response; + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d7fe65a18..e2d018eb5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,6 @@ -// Re-export Durable Object classes for Cloudflare Workers runtime +// Re-export Durable Object classes for Cloudflare Workers runtime. export { AdminLogs } from './durable-objects/admin-logs'; export { AiTokenBudgetCounter } from './durable-objects/ai-token-budget-counter'; -// Sandbox SDK DO class — re-exported from @cloudflare/sandbox (experimental prototype) export { CodexRefreshLock } from './durable-objects/codex-refresh-lock'; export { NodeLifecycle } from './durable-objects/node-lifecycle'; export { NotificationService } from './durable-objects/notification'; @@ -16,800 +15,12 @@ export { TrialOrchestrator } from './durable-objects/trial-orchestrator'; export type { Env } from './env'; export { Sandbox as SandboxDO } from '@cloudflare/sandbox'; -import { and, eq } from 'drizzle-orm'; -import { drizzle } from 'drizzle-orm/d1'; -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import { createApiApp } from './app/create-api-app'; +import { handleScheduled } from './app/scheduled'; -import { createAuth } from './auth'; -import * as schema from './db/schema'; -import type { Env } from './env'; -import { log, serializeError } from './lib/logger'; -import { parseWorkspaceSubdomain } from './lib/workspace-subdomain'; -import { analyticsMiddleware } from './middleware/analytics'; -import { AppError } from './middleware/error'; -import { accountMapRoutes } from './routes/account-map'; -import { activityRoutes } from './routes/activity'; -import { adminRoutes } from './routes/admin'; -import { adminAiAllowanceRoutes } from './routes/admin-ai-allowance'; -import { adminAIProxyRoutes } from './routes/admin-ai-proxy'; -import { adminAiUsageRoutes } from './routes/admin-ai-usage'; -import { adminAnalyticsRoutes } from './routes/admin-analytics'; -import { adminCcBackfillRoutes } from './routes/admin-cc-backfill'; -import { adminCostRoutes } from './routes/admin-costs'; -import { adminGithubInstallationLeakSweepRoutes } from './routes/admin-github-installation-leak-sweep'; -import { adminGithubRepoIdBackfillRoutes } from './routes/admin-github-repo-id-backfill'; -import { adminPlatformCredentialRoutes } from './routes/admin-platform-credentials'; -import { adminQuotaRoutes } from './routes/admin-quotas'; -import { adminSandboxRoutes } from './routes/admin-sandbox'; -import { adminUsageRoutes } from './routes/admin-usage'; -import { agentRoutes } from './routes/agent'; -import { agentProfileRoutes } from './routes/agent-profiles'; -import { agentSettingsRoutes } from './routes/agent-settings'; -import { agentsCatalogRoutes } from './routes/agents-catalog'; -import { aiProxyRoutes } from './routes/ai-proxy'; -import { aiProxyAnthropicRoutes } from './routes/ai-proxy-anthropic'; -import { aiProxyPassthroughRoutes } from './routes/ai-proxy-passthrough'; -import { analyticsIngestRoutes } from './routes/analytics-ingest'; -import { apiTokenRoutes } from './routes/api-tokens'; -import { authRoutes } from './routes/auth'; -import { bootstrapRoutes } from './routes/bootstrap'; -import { cachedCommandRoutes } from './routes/cached-commands'; -import { chatRoutes } from './routes/chat'; -import { chatsRoutes } from './routes/chats'; -import { cliRoutes } from './routes/cli'; -import { clientErrorsRoutes } from './routes/client-errors'; -import { codexRefreshRoutes } from './routes/codex-refresh'; -import { ccRoutes } from './routes/composable-credentials'; -import { credentialsRoutes } from './routes/credentials'; -import { dashboardRoutes } from './routes/dashboard'; -import { deployReleaseCallbackRoute } from './routes/deploy-release-callback'; -import { deploymentEnvironmentRoutes } from './routes/deployment-environments'; -import { deploymentReleaseRoutes } from './routes/deployment-releases'; -import { deploymentSecretRoutes } from './routes/deployment-secrets'; -import { deploymentVolumeRoutes } from './routes/deployment-volumes'; -import { deviceFlowRoutes } from './routes/device-flow'; -import { gcpRoutes } from './routes/gcp'; -import { githubRoutes } from './routes/github'; -import { googleAuthRoutes } from './routes/google-auth'; -import { knowledgeRoutes } from './routes/knowledge'; -import { libraryRoutes } from './routes/library'; -import { mailboxRoutes } from './routes/mailbox'; -import { mcpRoutes } from './routes/mcp'; -import { missionRoutes } from './routes/missions'; -import { nodeLifecycleRoutes } from './routes/node-lifecycle'; -import { nodesRoutes } from './routes/nodes'; -import { notificationRoutes } from './routes/notifications'; -import { observabilityIngestRoutes } from './routes/observability-ingest'; -import { orchestratorRoutes } from './routes/orchestrator'; -import { policyRoutes } from './routes/policies'; -import { profileRuntimeRoutes } from './routes/profile-runtime'; -import { projectAgentRoutes } from './routes/project-agent'; -import { deploymentIdentityTokenRoute,gcpDeployCallbackRoute, projectDeploymentRoutes } from './routes/project-deployment'; -import { projectsRoutes } from './routes/projects'; -import { agentActivityCallbackRoute } from './routes/projects/agent-activity-callback'; -import { nodeAcpHeartbeatRoute } from './routes/projects/node-acp-heartbeat'; -import { providersRoutes } from './routes/providers'; -import { resolutionStatusRoute } from './routes/resolution-status'; -import { samRoutes } from './routes/sam'; -import { skillRuntimeRoutes } from './routes/skill-runtime'; -import { skillRoutes } from './routes/skills'; -import { taskCallbackRoute, tasksRoutes } from './routes/tasks'; -import { terminalRoutes } from './routes/terminal'; -import { transcribeRoutes } from './routes/transcribe'; -import { trialRoutes } from './routes/trial'; -import { trialOnboardingRoutes } from './routes/trial/index'; -import { triggersRoutes } from './routes/triggers'; -import { ttsRoutes } from './routes/tts'; -import { uiGovernanceRoutes } from './routes/ui-governance'; -import { usageRoutes } from './routes/usage'; -import { workspacesRoutes } from './routes/workspaces'; -import { runAnalyticsForwardJob } from './scheduled/analytics-forward'; -import { runComputeUsageCleanup } from './scheduled/compute-usage-cleanup'; -import { runCronTriggerSweep } from './scheduled/cron-triggers'; -import { runNodeCleanupSweep } from './scheduled/node-cleanup'; -import { runObservabilityPurge } from './scheduled/observability-purge'; -import { recoverStuckTasks } from './scheduled/stuck-tasks'; -import { runTrialExpireSweep } from './scheduled/trial-expire'; -import { runTrialRolloverAudit } from './scheduled/trial-rollover'; -import { runTrialWaitlistCleanup } from './scheduled/trial-waitlist-cleanup'; -import { runTriggerExecutionCleanup } from './scheduled/trigger-execution-cleanup'; -import { runMonthlyCostAggregation } from './services/ai-monthly-cost-cron'; -import { GcpApiError, sanitizeGcpError } from './services/gcp-errors'; -import { signTerminalToken, verifyPortAccessToken, verifyTerminalToken } from './services/jwt'; -import { recordNodeRoutingMetric } from './services/telemetry'; -import { checkProvisioningTimeouts } from './services/timeout'; -import { migrateOrphanedWorkspaces } from './services/workspace-migration'; +const app = createApiApp(); -const app = new Hono<{ Bindings: Env }>(); - -// Global error handler — catches errors from all routes including subrouters. -// Must use app.onError() instead of middleware try/catch because Hono's -// app.route() subrouter errors don't propagate to parent middleware. -app.onError((err, c) => { - log.error('request_error', serializeError(err)); - - if (err instanceof AppError) { - return c.json(err.toJSON(), err.statusCode as ContentfulStatusCode); - } - - // Defense-in-depth: sanitize GcpApiError if it escapes route-level catch blocks - if (err instanceof GcpApiError) { - const safe = sanitizeGcpError(err, 'global-handler'); - return c.json({ error: 'GCP_UPSTREAM_ERROR', message: safe }, 502); - } - - return c.json( - { - error: 'INTERNAL_ERROR', - message: 'Internal server error', - }, - 500 - ); -}); - -// Proxy non-API subdomains to their respective Cloudflare Pages deployments. -// The Worker wildcard route *.{domain}/* intercepts ALL subdomains, so we must -// proxy app.* and www.* requests to Pages before any other middleware runs. -// The apex domain is redirected to www.* for the marketing site. -app.use('*', async (c, next) => { - const hostname = new URL(c.req.url).hostname; - const baseDomain = c.env?.BASE_DOMAIN || ''; - if (!baseDomain) { await next(); return; } - - // Proxy app.* to web UI Pages project - if (hostname === `app.${baseDomain}`) { - const pagesUrl = new URL(c.req.url); - pagesUrl.hostname = `${c.env.PAGES_PROJECT_NAME || 'sam-web-prod'}.pages.dev`; - return fetch(new Request(pagesUrl.toString(), c.req.raw)); - } - - // Proxy www.* to marketing site Pages project - if (hostname === `www.${baseDomain}`) { - const pagesUrl = new URL(c.req.url); - pagesUrl.hostname = `${c.env.WWW_PAGES_PROJECT_NAME || 'sam-www'}.pages.dev`; - return fetch(new Request(pagesUrl.toString(), c.req.raw)); - } - - // Redirect apex domain to www - if (hostname === baseDomain) { - const wwwUrl = new URL(c.req.url); - wwwUrl.hostname = `www.${baseDomain}`; - return c.redirect(wwwUrl.toString(), 301); - } - - await next(); -}); - -// Proxy requests for workspace subdomains (ws-{id}.*) to the VM agent. -// The wildcard DNS *.{domain} routes through this Worker, so we must proxy -// workspace requests to the actual VM running the agent on the configured port. -// vm-{id} DNS records are orange-clouded; CF edge terminates TLS and re-encrypts -// to the VM agent's Origin CA cert. This handles both HTTP and WebSocket requests. -app.use('*', async (c, next) => { - const url = new URL(c.req.url); - const hostname = url.hostname; - const baseDomain = c.env?.BASE_DOMAIN || ''; - - // Parse workspace ID and optional port from subdomain. - const parsed = parseWorkspaceSubdomain(hostname, baseDomain); - if (!parsed) { - await next(); - return; - } - if ('error' in parsed) { - log.info('ws_proxy_invalid_subdomain', { hostname, reason: parsed.error }); - return c.json({ error: 'INVALID_WORKSPACE', message: 'Invalid workspace subdomain' }, 400); - } - const { workspaceId, targetPort } = parsed; - - // --- Port-access authentication (cookie + token handshake) --- - // For port-specific subdomains (ws-{id}--{port}), check the port-access cookie - // and ?port_token= query param BEFORE the normal session/terminal-token paths. - // This is necessary because BetterAuth cookies are scoped to api.{BASE_DOMAIN} - // and are NOT sent to ws-{id}--{port}.{BASE_DOMAIN} subdomains. - let userId: string | null = null; - let portAccessRedirect: Response | null = null; - let publicPortAccess = false; - - if (targetPort !== null) { - // 5a: Check sam_port_access cookie (subsequent requests) - const cookieHeader = c.req.raw.headers.get('cookie') || ''; - const cookieMatch = cookieHeader.match(/(?:^|;\s*)sam_port_access=([^\s;]+)/); - if (cookieMatch?.[1]) { - try { - const payload = await verifyPortAccessToken(cookieMatch[1], c.env); - if (payload.workspace === workspaceId && payload.port === targetPort) { - userId = payload.subject; - } - } catch { - // Cookie expired or invalid — fall through to token check - } - } - - // 5b: Check ?port_token= query param (initial request from expose_port URL) - if (!userId) { - const portToken = url.searchParams.get('port_token'); - if (portToken) { - try { - const payload = await verifyPortAccessToken(portToken, c.env); - if (payload.workspace === workspaceId && payload.port === targetPort) { - // Set cookie and 302 redirect to strip token from URL - const cookieMaxAge = c.env.PORT_ACCESS_COOKIE_MAX_AGE_SECONDS - ? parseInt(c.env.PORT_ACCESS_COOKIE_MAX_AGE_SECONDS, 10) : 14400; - const redirectUrl = new URL(url.toString()); - redirectUrl.searchParams.delete('port_token'); - portAccessRedirect = new Response(null, { - status: 302, - headers: { - Location: redirectUrl.toString(), - 'Set-Cookie': `sam_port_access=${portToken}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${cookieMaxAge}`, - 'Cache-Control': 'no-store', - 'Referrer-Policy': 'no-referrer', - }, - }); - userId = payload.subject; - } - } catch (err) { - log.warn('ws_proxy_port_token_rejected', { workspaceId, targetPort, ...serializeError(err) }); - } - } - } - - if (!userId) { - const db = drizzle(c.env.DATABASE, { schema }); - const publicWorkspace = await db - .select({ - userId: schema.workspaces.userId, - portsPublicEnabled: schema.workspaces.portsPublicEnabled, - }) - .from(schema.workspaces) - .where(eq(schema.workspaces.id, workspaceId)) - .get(); - - if (publicWorkspace?.portsPublicEnabled) { - userId = publicWorkspace.userId; - publicPortAccess = true; - } - } - - // 5f: HTML error page for expired/invalid port access - if (!userId) { - return new Response( - ` -Session expired - - -

Session expired

-

Your access to this port has expired or is invalid.

-

Ask the agent to run expose_port again for a fresh link.

-`, - { status: 401, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } }, - ); - } - - // Return the 302 redirect after we confirm the DB lookup passes below - // (moved after DB check to ensure ownership is validated first) - } - - // --- Standard session/terminal-token authentication (non-port or fallback) --- - if (!userId) { - const auth = createAuth(c.env); - const session = await auth.api.getSession({ headers: c.req.raw.headers }); - userId = session?.user.id ?? null; - } - - if (!userId) { - const token = url.searchParams.get('token'); - if (!token) { - return c.json({ error: 'UNAUTHORIZED', message: 'Authentication required' }, 401); - } - - try { - const payload = await verifyTerminalToken(token, c.env); - if (payload.workspace !== workspaceId || payload.subject === 'port-proxy') { - return c.json({ error: 'UNAUTHORIZED', message: 'Invalid workspace token' }, 401); - } - userId = payload.subject; - } catch (err) { - log.warn('ws_proxy_terminal_token_rejected', { - workspaceId, - ...serializeError(err), - }); - return c.json({ error: 'UNAUTHORIZED', message: 'Invalid workspace token' }, 401); - } - } - - // Look up workspace routing metadata from D1. - const db = drizzle(c.env.DATABASE, { schema }); - const workspace = await db - .select({ - nodeId: schema.workspaces.nodeId, - status: schema.workspaces.status, - }) - .from(schema.workspaces) - .where(and(eq(schema.workspaces.id, workspaceId), eq(schema.workspaces.userId, userId))) - .get(); - - if (!workspace) { - return c.json({ error: 'NOT_FOUND', message: 'Workspace not found' }, 404); - } - - if (workspace.status !== 'running' && workspace.status !== 'recovery') { - // Allow boot-log WebSocket during creation for real-time streaming - if (workspace.status === 'creating' && url.pathname === '/boot-log/ws') { - // Fall through to proxy - } else { - return c.json({ error: 'NOT_READY', message: `Workspace is ${workspace.status}` }, 503); - } - } - - // 5b continued: Return the 302 redirect now that ownership is verified. - // This ensures the cookie is only set after the D1 ownership check passes. - if (portAccessRedirect) { - return portAccessRedirect; - } - - // Proxy to the VM agent via its proxied (orange-clouded) backend hostname. - // Cloudflare Workers cannot fetch IP addresses directly (Error 1003), - // so we use the {id}.vm.{domain} hostname. The two-level subdomain bypasses - // the wildcard Worker route *.{domain}/* (which only matches one level). - const routedNodeId = (workspace.nodeId || workspaceId).toLowerCase(); - const backendHostname = `${routedNodeId}.vm.${baseDomain}`; - log.info('ws_proxy_route', { - workspaceId, - nodeId: workspace.nodeId || workspaceId, - backendHostname, - targetPort, - publicPortAccess, - method: c.req.raw.method, - path: url.pathname, - }); - recordNodeRoutingMetric({ - metric: 'ws_proxy_route', - nodeId: workspace.nodeId || workspaceId, - workspaceId, - }, c.env); - const vmAgentProtocol = c.env.VM_AGENT_PROTOCOL || 'https'; - const vmAgentPort = c.env.VM_AGENT_PORT || '8443'; - const vmUrl = new URL(c.req.url); - vmUrl.protocol = `${vmAgentProtocol}:`; - vmUrl.hostname = backendHostname; - vmUrl.port = vmAgentPort; - - // Route port-specific requests to the VM agent's port proxy endpoint. - // ws-{id}--3000.example.com/foo → {backend}/workspaces/{id}/ports/3000/foo - if (targetPort !== null) { - const subPath = url.pathname === '/' ? '' : url.pathname; - vmUrl.pathname = `/workspaces/${workspaceId}/ports/${targetPort}${subPath}`; - - // Strip port_token from the proxied URL (it was already validated above). - vmUrl.searchParams.delete('port_token'); - - // Inject a workspace-scoped JWT so the VM agent can authenticate this request. - // Port-forwarded URLs are accessed directly by browsers which have no pre-existing - // workspace session cookie or token. The Worker is a trusted intermediary that has - // already validated the workspace exists and is running. - try { - const { token } = await signTerminalToken('port-proxy', workspaceId, c.env); - vmUrl.searchParams.set('token', token); - } catch (err) { - log.error('port_proxy_token_error', { - workspaceId, - ...serializeError(err), - }); - return c.json({ error: 'TOKEN_ERROR', message: 'Failed to generate port proxy token' }, 500); - } - } - - // Strip client-supplied routing headers and inject trusted routing context. - const headers = new Headers(c.req.raw.headers); - headers.delete('x-sam-node-id'); - headers.delete('x-sam-workspace-id'); - headers.delete('x-forwarded-host'); - headers.set('X-SAM-Node-Id', (workspace.nodeId || workspaceId)); - headers.set('X-SAM-Workspace-Id', workspaceId); - - // Preserve the original client-facing hostname (e.g., ws-abc123--3000.example.com) - // so the VM agent can forward it to container services. The fetch() to the VM agent - // rewrites the Host header to the VM hostname for Cloudflare edge routing, losing - // the original. X-Forwarded-Proto is always https since clients connect via CF edge. - headers.set('X-Forwarded-Host', hostname); - headers.set('X-Forwarded-Proto', 'https'); - - const response = await fetch(vmUrl.toString(), { - method: c.req.raw.method, - headers, - body: c.req.raw.body, - // @ts-expect-error — Cloudflare Workers support duplex for streaming request bodies - duplex: c.req.raw.body ? 'half' : undefined, - }); - - // 5e: Strip Set-Cookie headers from container responses on port-proxy path. - // Prevents a malicious container app from overwriting the sam_port_access cookie. - if (targetPort !== null) { - const headers = new Headers(response.headers); - headers.delete('set-cookie'); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - } - - return response; -}); - -// Structured request/response logging middleware. -// Emits one JSON log per request with method, path, status, and duration. -app.use('*', async (c, next) => { - const start = Date.now(); - await next(); - const durationMs = Date.now() - start; - const path = new URL(c.req.url).pathname; - // Skip noisy health checks from structured logs - if (path === '/health') return; - log.info('http.request', { - method: c.req.method, - path, - status: c.res.status, - durationMs, - }); -}); - -// Analytics Engine — writes one data point per request (non-blocking, fire-and-forget) -app.use('*', analyticsMiddleware()); - -app.use('*', cors({ - origin: (origin, c) => { - if (!origin) return null; - const baseDomain = c.env?.BASE_DOMAIN || ''; - // Allow localhost only in development (BASE_DOMAIN contains 'localhost' or is empty) - const isDevEnvironment = !baseDomain || baseDomain.includes('localhost'); - try { - const url = new URL(origin); - if (isDevEnvironment && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) return origin; - } catch { - // Malformed origin — reject - return null; - } - // Allow subdomains of the configured BASE_DOMAIN (e.g., app.example.com, api.example.com) - if (baseDomain) { - try { - const url = new URL(origin); - if (url.hostname === baseDomain || url.hostname.endsWith(`.${baseDomain}`)) return origin; - } catch { - return null; - } - } - // Reject all other origins — returning null prevents Access-Control-Allow-Origin - // from being set, which blocks credentialed cross-origin requests from unknown sites. - return null; - }, - credentials: true, - allowHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'anthropic-version', 'anthropic-beta'], - allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], -})); - -// Health check — public endpoint returns minimal info only -app.get('/health', (c) => { - // Check critical bindings to determine status, but don't expose details - const hasCriticalBindings = !!( - c.env.DATABASE && - c.env.KV && - c.env.PROJECT_DATA && - c.env.NODE_LIFECYCLE && - c.env.TASK_RUNNER - ); - - return c.json({ - status: hasCriticalBindings ? 'healthy' : 'degraded', - timestamp: new Date().toISOString(), - }, hasCriticalBindings ? 200 : 503); -}); - -// Public config — exposes feature flags the UI needs before auth -app.get('/api/config/artifacts-enabled', (c) => { - return c.json({ enabled: c.env.ARTIFACTS_ENABLED === 'true' && !!c.env.ARTIFACTS }); -}); - -// JWKS endpoint (must be at root level) -// Add cache headers per constitution principle XI -app.get('/.well-known/jwks.json', async (c) => { - const { getJWKS } = await import('./services/jwt'); - const jwks = await getJWKS(c.env); - c.header('Cache-Control', 'public, max-age=3600'); - c.header('X-Content-Type-Options', 'nosniff'); - return c.json(jwks); -}); - -// OIDC Discovery endpoint — used by GCP Workload Identity Federation to verify SAM as an IdP -app.get('/.well-known/openid-configuration', async (c) => { - const { getOidcDiscovery } = await import('./services/jwt'); - const discovery = getOidcDiscovery(c.env); - c.header('Cache-Control', 'public, max-age=3600'); - c.header('X-Content-Type-Options', 'nosniff'); - return c.json(discovery); -}); - -// API routes — codex refresh and smoke test routes registered before BetterAuth catch-all. -// codexRefreshRoutes uses workspace callback token auth (query param), not session auth. -// apiTokenRoutes uses dedicated API token auth, not session auth. -// Both must be mounted before authRoutes to avoid BetterAuth's wildcard catch-all. -app.route('/api/auth', codexRefreshRoutes); -app.route('/api/auth', apiTokenRoutes); -app.route('/api/auth', deviceFlowRoutes); -app.route('/api/auth', authRoutes); -app.route('/api/credentials', resolutionStatusRoute); -app.route('/api/credentials', credentialsRoutes); -app.route('/api/cc', ccRoutes); -app.route('/api/providers', providersRoutes); -app.route('/api/github', githubRoutes); -app.route('/api/nodes', deployReleaseCallbackRoute); // Callback JWT auth — deploy node fetches signed release payload -app.route('/api/nodes', nodesRoutes); -app.route('/api/nodes', nodeLifecycleRoutes); -app.route('/api/workspaces', workspacesRoutes); -app.route('/api/terminal', terminalRoutes); -app.route('/api/agent', agentRoutes); -app.route('/api/agents', agentsCatalogRoutes); -app.route('/api/bootstrap', bootstrapRoutes); -app.route('/api/ui-governance', uiGovernanceRoutes); -app.route('/api/transcribe', transcribeRoutes); -app.route('/api/tts', ttsRoutes); -app.route('/api/agent-settings', agentSettingsRoutes); -app.route('/api/client-errors', clientErrorsRoutes); -app.route('/api/cli', cliRoutes); -app.route('/api/chats', chatsRoutes); -app.route('/api/t', analyticsIngestRoutes); -// ORDERING IS CRITICAL: Routes using callback JWT auth MUST be mounted before -// projectsRoutes. projectsRoutes has use('/*', requireAuth()) which leaks to -// all siblings at the same base path — mounting these routes first causes them -// to match and return before the session auth middleware runs. -// See docs/notes/2026-03-25-deployment-identity-token-middleware-leak-postmortem.md -// See .claude/rules/06-api-patterns.md (Hono middleware scoping) -app.route('/api/projects', deploymentIdentityTokenRoute); -app.route('/api/projects', nodeAcpHeartbeatRoute); -app.route('/api/projects', agentActivityCallbackRoute); // Must be before projectsRoutes — uses callback JWT, not session auth -app.route('/api/projects', taskCallbackRoute); // Must be before projectsRoutes — uses callback JWT, not session auth -app.route('/api/projects', projectsRoutes); -app.route('/api/projects/:projectId/tasks', tasksRoutes); -app.route('/api/projects/:projectId/sessions', chatRoutes); -app.route('/api/projects/:projectId/cached-commands', cachedCommandRoutes); -app.route('/api/projects/:projectId/activity', activityRoutes); -app.route('/api/projects/:projectId/library', libraryRoutes); -app.route('/api/projects/:projectId/agent-profiles/:profileId/runtime', profileRuntimeRoutes); -app.route('/api/projects/:projectId/agent-profiles', agentProfileRoutes); -app.route('/api/projects/:projectId/skills/:skillId/runtime', skillRuntimeRoutes); -app.route('/api/projects/:projectId/skills', skillRoutes); -app.route('/api/projects/:projectId/triggers', triggersRoutes); -app.route('/api/projects/:projectId/knowledge', knowledgeRoutes); -app.route('/api/projects/:projectId/mailbox', mailboxRoutes); -app.route('/api/projects/:projectId/missions', missionRoutes); -app.route('/api/projects/:projectId/orchestrator', orchestratorRoutes); -app.route('/api/projects/:projectId/policies', policyRoutes); -app.route('/api/projects/:projectId/agent', projectAgentRoutes); -app.route('/api/projects', projectDeploymentRoutes); -app.route('/api/projects', deploymentEnvironmentRoutes); -app.route('/api/projects', deploymentReleaseRoutes); -app.route('/api/projects', deploymentSecretRoutes); -app.route('/api/projects', deploymentVolumeRoutes); -app.route('/api/deployment', gcpDeployCallbackRoute); -app.route('/api/admin/observability/logs/ingest', observabilityIngestRoutes); -app.route('/api/admin', adminRoutes); -app.route('/api/admin/ai-proxy', adminAIProxyRoutes); -app.route('/api/admin/analytics', adminAnalyticsRoutes); -app.route('/api/admin/analytics/ai-usage', adminAiUsageRoutes); -app.route('/api/admin/platform-credentials', adminPlatformCredentialRoutes); -app.route('/api/admin/quotas', adminQuotaRoutes); -app.route('/api/admin/usage', adminUsageRoutes); -app.route('/api/admin/costs', adminCostRoutes); -app.route('/api/admin/cc-backfill', adminCcBackfillRoutes); -app.route('/api/admin/github-repo-id-backfill', adminGithubRepoIdBackfillRoutes); -app.route('/api/admin/github-installation-leak-sweep', adminGithubInstallationLeakSweepRoutes); -app.route('/api/admin/sandbox', adminSandboxRoutes); -app.route('/api/admin/ai-allowance', adminAiAllowanceRoutes); -app.route('/api/usage', usageRoutes); -app.route('/api/account-map', accountMapRoutes); -app.route('/api/dashboard', dashboardRoutes); -app.route('/api/sam', samRoutes); -app.route('/api/notifications', notificationRoutes); -app.route('/api', trialRoutes); -app.route('/api/trial', trialOnboardingRoutes); -app.route('/api/gcp', gcpRoutes); -app.route('/ai/v1', aiProxyRoutes); -app.route('/ai/anthropic/v1', aiProxyAnthropicRoutes); -app.route('/ai/proxy', aiProxyPassthroughRoutes); -app.route('/auth/google', googleAuthRoutes); -// MCP endpoint CORS override — MCP uses Bearer token auth (not cookies/sessions), -// so it needs credentials: false + origin: '*' to allow VM agent requests from any origin. -// This must run after the global CORS middleware to overwrite its headers. -app.use('/mcp/*', cors({ - origin: '*', - credentials: false, - allowHeaders: ['Content-Type', 'Authorization'], - allowMethods: ['GET', 'POST', 'OPTIONS'], -})); -// Explicitly remove Access-Control-Allow-Credentials set by the global CORS middleware. -// origin: '*' + credentials: true is invalid in the CORS spec and browsers reject it. -app.use('/mcp/*', async (c, next) => { - await next(); - c.res.headers.delete('Access-Control-Allow-Credentials'); -}); -// MCP server endpoint — at /mcp (not /api/mcp) because VM agents use this URL -// and it uses its own task-scoped Bearer token auth, not session auth. -app.route('/mcp', mcpRoutes); - -// 404 handler -app.notFound((c) => { - return c.json({ - error: 'NOT_FOUND', - message: 'Endpoint not found', - }, 404); -}); - -// Export handler with scheduled (cron) support export default { fetch: app.fetch, - - /** - * Scheduled (cron) handler for background tasks. - * Cron schedules: - * - Every 5 minutes: operational cleanup (provisioning, nodes, tasks, observability, trial expiry) - * - Hourly at :30: monthly AI cost aggregation per user (Gateway logs → KV cache) - * - Daily at 03:00 UTC: analytics event forwarding to external platforms - * - Daily at 04:00 UTC (configurable via TRIAL_CRON_WAITLIST_CLEANUP): trial waitlist purge - * - Monthly at 03:00 UTC on the 1st (configurable via TRIAL_CRON_ROLLOVER_CRON): trial counter rollover audit - */ - async scheduled( - controller: ScheduledController, - env: Env, - ctx: ExecutionContext - ): Promise { - const rolloverCron = env.TRIAL_CRON_ROLLOVER_CRON ?? '0 5 1 * *'; - const waitlistCleanupCron = env.TRIAL_CRON_WAITLIST_CLEANUP ?? '0 4 * * *'; - - const isDailyForward = controller.cron === '0 3 * * *'; - const isMonthlyCostAggregation = controller.cron === '30 * * * *'; - const isTrialRollover = controller.cron === rolloverCron; - const isTrialWaitlistCleanup = controller.cron === waitlistCleanupCron; - - const cronType = isDailyForward - ? 'daily-forward' - : isMonthlyCostAggregation - ? 'monthly-cost-aggregation' - : isTrialRollover - ? 'trial-rollover' - : isTrialWaitlistCleanup - ? 'trial-waitlist-cleanup' - : 'sweep'; - - log.info('cron.started', { - cron: controller.cron, - type: cronType, - }); - - // Hourly: aggregate per-user monthly AI cost from Gateway logs → KV cache. - if (isMonthlyCostAggregation) { - ctx.waitUntil((async () => { - const result = await runMonthlyCostAggregation(env); - log.info('cron.completed', { - cron: controller.cron, - type: 'monthly-cost-aggregation', - monthlyCostEnabled: result.enabled, - monthlyCostUsersUpdated: result.usersUpdated, - monthlyCostTotalEntries: result.totalEntries, - monthlyCostErrors: result.errors, - }); - })()); - return; - } - - // Daily analytics forwarding (Phase 4) — use ctx.waitUntil to keep the - // isolate alive for the full duration of multi-step external API calls. - if (isDailyForward) { - ctx.waitUntil((async () => { - const forward = await runAnalyticsForwardJob(env); - log.info('cron.completed', { - cron: controller.cron, - type: 'daily-forward', - forwardEnabled: forward.enabled, - forwardEventsQueried: forward.eventsQueried, - forwardSegmentSent: forward.segment.sent, - forwardGA4Sent: forward.ga4.sent, - forwardCursorUpdated: forward.cursorUpdated, - }); - })()); - return; - } - - // Monthly trial counter rollover audit (prune old DO counter rows, verify month-key drift). - if (isTrialRollover) { - ctx.waitUntil((async () => { - const rollover = await runTrialRolloverAudit(env); - log.info('cron.completed', { - cron: controller.cron, - type: 'trial-rollover', - trialRolloverMonthKey: rollover.monthKey, - trialRolloverPruned: rollover.pruned, - }); - })()); - return; - } - - // Daily trial waitlist cleanup (purge notified-and-aged rows). - if (isTrialWaitlistCleanup) { - ctx.waitUntil((async () => { - const waitlist = await runTrialWaitlistCleanup(env); - log.info('cron.completed', { - cron: controller.cron, - type: 'trial-waitlist-cleanup', - trialWaitlistPurged: waitlist.purged, - }); - })()); - return; - } - - // 5-minute operational sweep - // Check for stuck provisioning workspaces - const timedOut = await checkProvisioningTimeouts(env.DATABASE, env, env.OBSERVABILITY_DATABASE); - - // Migrate orphaned workspaces (those with NULL projectId) to projects - const db = drizzle(env.DATABASE, { schema }); - const migrated = await migrateOrphanedWorkspaces(db); - - // Clean up stale warm nodes and expired auto-provisioned nodes - const nodeCleanup = await runNodeCleanupSweep(env); - - // Recover stuck tasks (queued/delegated/in_progress past timeout) - const stuckTasks = await recoverStuckTasks(env); - - // Purge expired observability errors (retention + row count limits) - const observabilityPurge = await runObservabilityPurge(env); - - // Fire due cron triggers - const cronTriggers = await runCronTriggerSweep(env); - - // Recover stale trigger executions and purge old logs - const triggerCleanup = await runTriggerExecutionCleanup(env); - - // Close orphaned compute_usage records - const computeUsageClosed = await runComputeUsageCleanup(env); - - // Expire stale pending/ready trial rows (cap slot is NOT refunded — it was - // consumed for the month). - const trialExpire = await runTrialExpireSweep(env); - - log.info('cron.completed', { - cron: controller.cron, - type: 'sweep', - provisioningTimedOut: timedOut, - workspacesMigrated: migrated, - staleNodesDestroyed: nodeCleanup.staleDestroyed, - lifetimeNodesDestroyed: nodeCleanup.lifetimeDestroyed, - lifetimeNodesSkipped: nodeCleanup.lifetimeSkipped, - nodeCleanupErrors: nodeCleanup.errors, - orphanedWorkspacesFlagged: nodeCleanup.orphanedWorkspacesFlagged, - orphanedNodesFlagged: nodeCleanup.orphanedNodesFlagged, - stuckTasksFailedQueued: stuckTasks.failedQueued, - stuckTasksFailedDelegated: stuckTasks.failedDelegated, - stuckTasksFailedInProgress: stuckTasks.failedInProgress, - stuckTasksHeartbeatSkipped: stuckTasks.heartbeatSkipped, - stuckTaskErrors: stuckTasks.errors, - stuckTaskDoHealthChecked: stuckTasks.doHealthChecked, - observabilityPurgedByAge: observabilityPurge.deletedByAge, - observabilityPurgedByCount: observabilityPurge.deletedByCount, - cronTriggersChecked: cronTriggers.checked, - cronTriggersFired: cronTriggers.fired, - cronTriggersSkipped: cronTriggers.skipped, - cronTriggersFailed: cronTriggers.failed, - triggerExecStaleRecovered: triggerCleanup.staleRecovered, - triggerExecStaleQueuedRecovered: triggerCleanup.staleQueuedRecovered, - triggerExecRetentionPurged: triggerCleanup.retentionPurged, - triggerExecCleanupErrors: triggerCleanup.errors, - computeUsageOrphansClosed: computeUsageClosed, - trialExpired: trialExpire.expired, - }); - }, + scheduled: handleScheduled, }; diff --git a/apps/api/tests/integration/agent-profiles.test.ts b/apps/api/tests/integration/agent-profiles.test.ts index f90e80319..5c9697a17 100644 --- a/apps/api/tests/integration/agent-profiles.test.ts +++ b/apps/api/tests/integration/agent-profiles.test.ts @@ -10,20 +10,20 @@ import { resolve } from 'node:path'; import { describe, expect,it } from 'vitest'; -const indexFile = readFileSync(resolve(process.cwd(), 'src/index.ts'), 'utf8'); +const routeRegistryFile = readFileSync(resolve(process.cwd(), 'src/app/register-routes.ts'), 'utf8'); const routeFile = readFileSync(resolve(process.cwd(), 'src/routes/agent-profiles.ts'), 'utf8'); const serviceFile = readFileSync(resolve(process.cwd(), 'src/services/agent-profiles.ts'), 'utf8'); const schemaFile = readFileSync(resolve(process.cwd(), 'src/db/schema.ts'), 'utf8'); describe('agent profiles integration wiring', () => { it('registers agent-profiles route under /api/projects/:projectId/', () => { - expect(indexFile).toContain( + expect(routeRegistryFile).toContain( "app.route('/api/projects/:projectId/agent-profiles', agentProfileRoutes)" ); }); - it('imports agent profile routes in index', () => { - expect(indexFile).toContain("import { agentProfileRoutes }"); + it('imports agent profile routes in the API route registry', () => { + expect(routeRegistryFile).toContain("import { agentProfileRoutes }"); }); it('route handler delegates all operations to the service layer', () => { diff --git a/apps/api/tests/integration/composable-credentials-routes.test.ts b/apps/api/tests/integration/composable-credentials-routes.test.ts index 2b91a7282..8fd4e0955 100644 --- a/apps/api/tests/integration/composable-credentials-routes.test.ts +++ b/apps/api/tests/integration/composable-credentials-routes.test.ts @@ -2,7 +2,7 @@ * Integration tests for composable-credentials CRUD routes and resolver wiring. * * Validates: - * - Route registration in index.ts + * - Route registration in the API route registry * - Route handler delegates to correct schema tables * - Auth middleware is applied (requireAuth + requireApproved) * - IDOR protection (owner_id / user_id scoping) @@ -14,7 +14,7 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -const indexFile = readFileSync(resolve(process.cwd(), 'src/index.ts'), 'utf8'); +const routeRegistryFile = readFileSync(resolve(process.cwd(), 'src/app/register-routes.ts'), 'utf8'); const routeFile = readFileSync(resolve(process.cwd(), 'src/routes/composable-credentials.ts'), 'utf8'); const schemaFile = readFileSync(resolve(process.cwd(), 'src/db/schema.ts'), 'utf8'); const resolveFile = readFileSync(resolve(process.cwd(), 'src/services/composable-credentials/resolve.ts'), 'utf8'); @@ -23,11 +23,11 @@ const backfillFile = readFileSync(resolve(process.cwd(), 'src/services/composabl describe('composable-credentials route registration', () => { it('mounts ccRoutes under /api/cc', () => { - expect(indexFile).toContain("app.route('/api/cc', ccRoutes)"); + expect(routeRegistryFile).toContain("app.route('/api/cc', ccRoutes)"); }); it('imports ccRoutes from composable-credentials module', () => { - expect(indexFile).toContain("import { ccRoutes }"); + expect(routeRegistryFile).toContain("import { ccRoutes }"); }); }); diff --git a/apps/api/tests/integration/compute-quotas.test.ts b/apps/api/tests/integration/compute-quotas.test.ts index b448b1f2b..43105b9fe 100644 --- a/apps/api/tests/integration/compute-quotas.test.ts +++ b/apps/api/tests/integration/compute-quotas.test.ts @@ -20,7 +20,7 @@ describe('compute quota pipeline', () => { const schemaFile = readFileSync(resolve(process.cwd(), 'src/db/schema.ts'), 'utf8'); const serviceFile = readFileSync(resolve(process.cwd(), 'src/services/compute-quotas.ts'), 'utf8'); const providerCredsFile = readFileSync(resolve(process.cwd(), 'src/services/provider-credentials.ts'), 'utf8'); - const indexFile = readFileSync(resolve(process.cwd(), 'src/index.ts'), 'utf8'); + const routeRegistryFile = readFileSync(resolve(process.cwd(), 'src/app/register-routes.ts'), 'utf8'); const envFile = readFileSync(resolve(process.cwd(), 'src/env.ts'), 'utf8'); const adminQuotaRoute = readFileSync(resolve(process.cwd(), 'src/routes/admin-quotas.ts'), 'utf8'); const usageRoute = readFileSync(resolve(process.cwd(), 'src/routes/usage.ts'), 'utf8'); @@ -168,8 +168,8 @@ describe('compute quota pipeline', () => { // =========================================================================== describe('admin quota routes', () => { it('routes are mounted at /api/admin/quotas', () => { - expect(indexFile).toContain("adminQuotaRoutes"); - expect(indexFile).toContain("'/api/admin/quotas'"); + expect(routeRegistryFile).toContain("adminQuotaRoutes"); + expect(routeRegistryFile).toContain("'/api/admin/quotas'"); }); it('requires superadmin for all routes', () => { diff --git a/apps/api/tests/integration/compute-usage.test.ts b/apps/api/tests/integration/compute-usage.test.ts index 8c6601dea..c40719bd0 100644 --- a/apps/api/tests/integration/compute-usage.test.ts +++ b/apps/api/tests/integration/compute-usage.test.ts @@ -23,7 +23,8 @@ describe('compute usage metering pipeline', () => { const stateMachineFile = readFileSync(resolve(process.cwd(), 'src/durable-objects/task-runner/state-machine.ts'), 'utf8'); const workspaceStepsFile = readFileSync(resolve(process.cwd(), 'src/durable-objects/task-runner/workspace-steps.ts'), 'utf8'); const cleanupFile = readFileSync(resolve(process.cwd(), 'src/scheduled/compute-usage-cleanup.ts'), 'utf8'); - const indexFile = readFileSync(resolve(process.cwd(), 'src/index.ts'), 'utf8'); + const routeRegistryFile = readFileSync(resolve(process.cwd(), 'src/app/register-routes.ts'), 'utf8'); + const scheduledFile = readFileSync(resolve(process.cwd(), 'src/app/scheduled.ts'), 'utf8'); const adminUsageRoute = readFileSync(resolve(process.cwd(), 'src/routes/admin-usage.ts'), 'utf8'); const usageRoute = readFileSync(resolve(process.cwd(), 'src/routes/usage.ts'), 'utf8'); @@ -179,7 +180,7 @@ describe('compute usage metering pipeline', () => { }); it('cron handler invokes compute usage cleanup', () => { - expect(indexFile).toContain('runComputeUsageCleanup'); + expect(scheduledFile).toContain('runComputeUsageCleanup'); }); }); @@ -187,14 +188,14 @@ describe('compute usage metering pipeline', () => { // API Routes // =========================================================================== describe('API route wiring', () => { - it('admin usage route is mounted in index', () => { - expect(indexFile).toContain('adminUsageRoutes'); - expect(indexFile).toContain('/api/admin/usage'); + it('admin usage route is mounted in the API route registry', () => { + expect(routeRegistryFile).toContain('adminUsageRoutes'); + expect(routeRegistryFile).toContain('/api/admin/usage'); }); - it('user usage route is mounted in index', () => { - expect(indexFile).toContain('usageRoutes'); - expect(indexFile).toContain('/api/usage'); + it('user usage route is mounted in the API route registry', () => { + expect(routeRegistryFile).toContain('usageRoutes'); + expect(routeRegistryFile).toContain('/api/usage'); }); it('admin usage route requires superadmin', () => { diff --git a/apps/api/tests/integration/multi-workspace-nodes.test.ts b/apps/api/tests/integration/multi-workspace-nodes.test.ts index fc3022983..311f164fb 100644 --- a/apps/api/tests/integration/multi-workspace-nodes.test.ts +++ b/apps/api/tests/integration/multi-workspace-nodes.test.ts @@ -11,15 +11,16 @@ describe('multi-workspace nodes integration wiring', () => { readFileSync(resolve(process.cwd(), 'src/routes/workspaces/lifecycle.ts'), 'utf8'), readFileSync(resolve(process.cwd(), 'src/routes/workspaces/agent-sessions.ts'), 'utf8'), ].join('\n'); - const indexFile = readFileSync(resolve(process.cwd(), 'src/index.ts'), 'utf8'); + const routeRegistryFile = readFileSync(resolve(process.cwd(), 'src/app/register-routes.ts'), 'utf8'); + const workspaceProxyFile = readFileSync(resolve(process.cwd(), 'src/app/workspace-proxy.ts'), 'utf8'); - it('wires node and workspace routes into API index', () => { - expect(indexFile).toContain("app.route('/api/nodes', nodesRoutes)"); - expect(indexFile).toContain("app.route('/api/workspaces', workspacesRoutes)"); + it('wires node and workspace routes into the API route registry', () => { + expect(routeRegistryFile).toContain("app.route('/api/nodes', nodesRoutes)"); + expect(routeRegistryFile).toContain("app.route('/api/workspaces', workspacesRoutes)"); }); it('includes cross-component proxy + node-agent call paths', () => { - expect(indexFile).toContain('ws_proxy_route'); + expect(workspaceProxyFile).toContain('ws_proxy_route'); expect(workspacesRoute).toContain('createWorkspaceOnNode'); expect(workspacesRoute).toContain('stopWorkspaceOnNode'); expect(nodesRoute).toContain('stopWorkspaceOnNode'); diff --git a/apps/api/tests/integration/observability-ingestion.test.ts b/apps/api/tests/integration/observability-ingestion.test.ts index fd33cb2f8..d6ee107aa 100644 --- a/apps/api/tests/integration/observability-ingestion.test.ts +++ b/apps/api/tests/integration/observability-ingestion.test.ts @@ -24,7 +24,7 @@ describe('observability error ingestion pipeline', () => { const observabilityService = readFileSync(resolve(process.cwd(), 'src/services/observability.ts'), 'utf8'); const observabilitySchema = readFileSync(resolve(process.cwd(), 'src/db/observability-schema.ts'), 'utf8'); const scheduledPurge = readFileSync(resolve(process.cwd(), 'src/scheduled/observability-purge.ts'), 'utf8'); - const indexFile = readFileSync(resolve(process.cwd(), 'src/index.ts'), 'utf8'); + const scheduledFile = readFileSync(resolve(process.cwd(), 'src/app/scheduled.ts'), 'utf8'); // =========================================================================== // Schema & Service Layer @@ -213,17 +213,17 @@ describe('observability error ingestion pipeline', () => { expect(scheduledPurge).toContain('deletedByAge: 0, deletedByCount: 0'); }); - it('index.ts imports runObservabilityPurge', () => { - expect(indexFile).toContain("import { runObservabilityPurge } from './scheduled/observability-purge'"); + it('scheduled app module imports runObservabilityPurge', () => { + expect(scheduledFile).toContain("import { runObservabilityPurge } from '../scheduled/observability-purge'"); }); - it('index.ts calls runObservabilityPurge in the scheduled handler', () => { - expect(indexFile).toContain('runObservabilityPurge(env)'); + it('scheduled app module calls runObservabilityPurge in the scheduled handler', () => { + expect(scheduledFile).toContain('runObservabilityPurge(env)'); }); - it('index.ts logs observability purge results in cron.completed', () => { - expect(indexFile).toContain('observabilityPurgedByAge'); - expect(indexFile).toContain('observabilityPurgedByCount'); + it('scheduled app module logs observability purge results in cron.completed', () => { + expect(scheduledFile).toContain('observabilityPurgedByAge'); + expect(scheduledFile).toContain('observabilityPurgedByCount'); }); }); diff --git a/apps/api/tests/unit/app/create-api-app.test.ts b/apps/api/tests/unit/app/create-api-app.test.ts new file mode 100644 index 000000000..3973d7238 --- /dev/null +++ b/apps/api/tests/unit/app/create-api-app.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { getJWKS, getOidcDiscovery } = vi.hoisted(() => ({ + getJWKS: vi.fn(), + getOidcDiscovery: vi.fn(), +})); + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class {}, +}), { virtual: true }); + +vi.mock('@cloudflare/sandbox', () => ({ + Sandbox: class {}, +})); + +vi.mock('../../../src/services/jwt', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getJWKS, + getOidcDiscovery, + }; +}); + +const { createApiApp } = await import('../../../src/app/create-api-app'); + +function makeEnv(overrides: Record = {}) { + return { + BASE_DOMAIN: 'example.com', + DATABASE: {}, + KV: {}, + PROJECT_DATA: {}, + NODE_LIFECYCLE: {}, + TASK_RUNNER: {}, + ...overrides, + }; +} + +describe('createApiApp composition', () => { + beforeEach(() => { + vi.clearAllMocks(); + getJWKS.mockResolvedValue({ keys: [{ kid: 'test-key' }] }); + getOidcDiscovery.mockReturnValue({ issuer: 'https://api.example.com' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response('pages'))); + }); + + it('registers well-known endpoints with cache and nosniff headers', async () => { + const app = createApiApp(); + + const jwks = await app.request('/.well-known/jwks.json', {}, makeEnv()); + expect(jwks.status).toBe(200); + expect(jwks.headers.get('cache-control')).toBe('public, max-age=3600'); + expect(jwks.headers.get('x-content-type-options')).toBe('nosniff'); + expect(await jwks.json()).toEqual({ keys: [{ kid: 'test-key' }] }); + + const discovery = await app.request('/.well-known/openid-configuration', {}, makeEnv()); + expect(discovery.status).toBe(200); + expect(await discovery.json()).toEqual({ issuer: 'https://api.example.com' }); + }); + + it('preserves the API 404 response shape', async () => { + const app = createApiApp(); + + const res = await app.request('/api/does-not-exist', {}, makeEnv()); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: 'NOT_FOUND', + message: 'Endpoint not found', + }); + }); + + it('keeps browser API CORS credentialed and domain-scoped', async () => { + const app = createApiApp(); + + const res = await app.request('/api/does-not-exist', { + headers: { Origin: 'https://app.example.com' }, + }, makeEnv()); + + expect(res.headers.get('access-control-allow-origin')).toBe('https://app.example.com'); + expect(res.headers.get('access-control-allow-credentials')).toBe('true'); + }); + + it('keeps MCP CORS open for bearer-token clients without credentials', async () => { + const app = createApiApp(); + + const res = await app.request('/mcp', { + method: 'OPTIONS', + headers: { + Origin: 'https://random-client.example', + 'Access-Control-Request-Method': 'POST', + }, + }, makeEnv()); + + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect(res.headers.get('access-control-allow-credentials')).toBeNull(); + }); + + it('passes normal API hosts through proxy middleware and intercepts Pages hosts', async () => { + const app = createApiApp(); + + const apiRes = await app.request('https://api.example.com/health', {}, makeEnv()); + expect(apiRes.status).toBe(200); + expect(fetch).not.toHaveBeenCalled(); + + const pagesRes = await app.request('https://app.example.com/', {}, makeEnv()); + expect(await pagesRes.text()).toBe('pages'); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/tests/unit/app/scheduled.test.ts b/apps/api/tests/unit/app/scheduled.test.ts new file mode 100644 index 000000000..173e06851 --- /dev/null +++ b/apps/api/tests/unit/app/scheduled.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + runMonthlyCostAggregation, + runAnalyticsForwardJob, + runTrialRolloverAudit, + runTrialWaitlistCleanup, + checkProvisioningTimeouts, + migrateOrphanedWorkspaces, + runNodeCleanupSweep, + recoverStuckTasks, + runObservabilityPurge, + runCronTriggerSweep, + runTriggerExecutionCleanup, + runComputeUsageCleanup, + runTrialExpireSweep, +} = vi.hoisted(() => ({ + runMonthlyCostAggregation: vi.fn(), + runAnalyticsForwardJob: vi.fn(), + runTrialRolloverAudit: vi.fn(), + runTrialWaitlistCleanup: vi.fn(), + checkProvisioningTimeouts: vi.fn(), + migrateOrphanedWorkspaces: vi.fn(), + runNodeCleanupSweep: vi.fn(), + recoverStuckTasks: vi.fn(), + runObservabilityPurge: vi.fn(), + runCronTriggerSweep: vi.fn(), + runTriggerExecutionCleanup: vi.fn(), + runComputeUsageCleanup: vi.fn(), + runTrialExpireSweep: vi.fn(), +})); + +vi.mock('../../../src/lib/logger', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('drizzle-orm/d1', () => ({ + drizzle: vi.fn(() => ({ db: true })), +})); + +vi.mock('../../../src/db/schema', () => ({})); +vi.mock('../../../src/services/ai-monthly-cost-cron', () => ({ runMonthlyCostAggregation })); +vi.mock('../../../src/scheduled/analytics-forward', () => ({ runAnalyticsForwardJob })); +vi.mock('../../../src/scheduled/trial-rollover', () => ({ runTrialRolloverAudit })); +vi.mock('../../../src/scheduled/trial-waitlist-cleanup', () => ({ runTrialWaitlistCleanup })); +vi.mock('../../../src/services/timeout', () => ({ checkProvisioningTimeouts })); +vi.mock('../../../src/services/workspace-migration', () => ({ migrateOrphanedWorkspaces })); +vi.mock('../../../src/scheduled/node-cleanup', () => ({ runNodeCleanupSweep })); +vi.mock('../../../src/scheduled/stuck-tasks', () => ({ recoverStuckTasks })); +vi.mock('../../../src/scheduled/observability-purge', () => ({ runObservabilityPurge })); +vi.mock('../../../src/scheduled/cron-triggers', () => ({ runCronTriggerSweep })); +vi.mock('../../../src/scheduled/trigger-execution-cleanup', () => ({ runTriggerExecutionCleanup })); +vi.mock('../../../src/scheduled/compute-usage-cleanup', () => ({ runComputeUsageCleanup })); +vi.mock('../../../src/scheduled/trial-expire', () => ({ runTrialExpireSweep })); + +const { handleScheduled } = await import('../../../src/app/scheduled'); + +function makeCtx() { + const promises: Promise[] = []; + return { + ctx: { + waitUntil: vi.fn((promise: Promise) => { + promises.push(promise); + }), + } as unknown as ExecutionContext, + promises, + }; +} + +describe('handleScheduled', () => { + beforeEach(() => { + vi.clearAllMocks(); + runMonthlyCostAggregation.mockResolvedValue({ enabled: true, usersUpdated: 1, totalEntries: 2, errors: 0 }); + runAnalyticsForwardJob.mockResolvedValue({ + enabled: true, + eventsQueried: 3, + segment: { sent: 2 }, + ga4: { sent: 1 }, + cursorUpdated: true, + }); + runTrialRolloverAudit.mockResolvedValue({ monthKey: '2026-06', pruned: 4 }); + runTrialWaitlistCleanup.mockResolvedValue({ purged: 5 }); + checkProvisioningTimeouts.mockResolvedValue(6); + migrateOrphanedWorkspaces.mockResolvedValue(7); + runNodeCleanupSweep.mockResolvedValue({ + staleDestroyed: 1, + lifetimeDestroyed: 2, + lifetimeSkipped: 3, + errors: 0, + orphanedWorkspacesFlagged: 4, + orphanedNodesFlagged: 5, + }); + recoverStuckTasks.mockResolvedValue({ + failedQueued: 1, + failedDelegated: 2, + failedInProgress: 3, + heartbeatSkipped: 4, + errors: 0, + doHealthChecked: 5, + }); + runObservabilityPurge.mockResolvedValue({ deletedByAge: 1, deletedByCount: 2 }); + runCronTriggerSweep.mockResolvedValue({ checked: 1, fired: 2, skipped: 3, failed: 0 }); + runTriggerExecutionCleanup.mockResolvedValue({ + staleRecovered: 1, + staleQueuedRecovered: 2, + retentionPurged: 3, + errors: 0, + }); + runComputeUsageCleanup.mockResolvedValue(8); + runTrialExpireSweep.mockResolvedValue({ expired: 9 }); + }); + + it('delegates hourly cost aggregation through waitUntil', async () => { + const { ctx, promises } = makeCtx(); + + await handleScheduled({ cron: '30 * * * *' } as ScheduledController, {} as never, ctx); + await Promise.all(promises); + + expect(ctx.waitUntil).toHaveBeenCalledTimes(1); + expect(runMonthlyCostAggregation).toHaveBeenCalledTimes(1); + expect(checkProvisioningTimeouts).not.toHaveBeenCalled(); + }); + + it('delegates the operational sweep jobs for the default cron', async () => { + const { ctx } = makeCtx(); + const env = { DATABASE: {}, OBSERVABILITY_DATABASE: {} } as never; + + await handleScheduled({ cron: '*/5 * * * *' } as ScheduledController, env, ctx); + + expect(checkProvisioningTimeouts).toHaveBeenCalledWith({}, env, {}); + expect(migrateOrphanedWorkspaces).toHaveBeenCalledWith({ db: true }); + expect(runNodeCleanupSweep).toHaveBeenCalledWith(env); + expect(recoverStuckTasks).toHaveBeenCalledWith(env); + expect(runObservabilityPurge).toHaveBeenCalledWith(env); + expect(runCronTriggerSweep).toHaveBeenCalledWith(env); + expect(runTriggerExecutionCleanup).toHaveBeenCalledWith(env); + expect(runComputeUsageCleanup).toHaveBeenCalledWith(env); + expect(runTrialExpireSweep).toHaveBeenCalledWith(env); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/tests/unit/recovery-resilience.test.ts b/apps/api/tests/unit/recovery-resilience.test.ts index 45acb523a..c2f762776 100644 --- a/apps/api/tests/unit/recovery-resilience.test.ts +++ b/apps/api/tests/unit/recovery-resilience.test.ts @@ -26,8 +26,8 @@ const taskRunnerSource = readFileSync( resolve(process.cwd(), 'src/services/task-runner.ts'), 'utf8' ); -const indexSource = readFileSync( - resolve(process.cwd(), 'src/index.ts'), +const scheduledAppSource = readFileSync( + resolve(process.cwd(), 'src/app/scheduled.ts'), 'utf8' ); @@ -189,7 +189,7 @@ describe('stuck-tasks DO health checks (TDF-7)', () => { }); it('cron handler logs doHealthChecked count', () => { - expect(indexSource).toContain('stuckTaskDoHealthChecked: stuckTasks.doHealthChecked'); + expect(scheduledAppSource).toContain('stuckTaskDoHealthChecked: stuckTasks.doHealthChecked'); }); }); @@ -285,8 +285,8 @@ describe('node-cleanup orphan detection (TDF-7)', () => { }); it('cron handler logs orphan counts', () => { - expect(indexSource).toContain('orphanedWorkspacesFlagged'); - expect(indexSource).toContain('orphanedNodesFlagged'); + expect(scheduledAppSource).toContain('orphanedWorkspacesFlagged'); + expect(scheduledAppSource).toContain('orphanedNodesFlagged'); }); it('uses grace period to avoid flagging recently created resources', () => { @@ -330,7 +330,7 @@ describe('timeout service OBSERVABILITY_DATABASE recording (TDF-7)', () => { }); it('cron handler passes OBSERVABILITY_DATABASE to checkProvisioningTimeouts', () => { - expect(indexSource).toContain('checkProvisioningTimeouts(env.DATABASE, env, env.OBSERVABILITY_DATABASE)'); + expect(scheduledAppSource).toContain('checkProvisioningTimeouts(env.DATABASE, env, env.OBSERVABILITY_DATABASE)'); }); }); diff --git a/apps/api/tests/unit/routes/deploy-release-route-order.test.ts b/apps/api/tests/unit/routes/deploy-release-route-order.test.ts index 6988b3c94..55590510d 100644 --- a/apps/api/tests/unit/routes/deploy-release-route-order.test.ts +++ b/apps/api/tests/unit/routes/deploy-release-route-order.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; describe('deployment release callback route ordering', () => { it('mounts callback-auth deploy release route before session-auth node routes', () => { - const source = readFileSync(join(process.cwd(), 'src/index.ts'), 'utf8'); + const source = readFileSync(join(process.cwd(), 'src/app/register-routes.ts'), 'utf8'); const deployReleaseIndex = source.indexOf("app.route('/api/nodes', deployReleaseCallbackRoute)"); const nodesIndex = source.indexOf("app.route('/api/nodes', nodesRoutes)"); diff --git a/apps/api/tests/unit/ws-proxy.test.ts b/apps/api/tests/unit/ws-proxy.test.ts index 01c000b10..c0be71ebc 100644 --- a/apps/api/tests/unit/ws-proxy.test.ts +++ b/apps/api/tests/unit/ws-proxy.test.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; describe('ws proxy source contract', () => { - const file = readFileSync(resolve(process.cwd(), 'src/index.ts'), 'utf8'); + const file = readFileSync(resolve(process.cwd(), 'src/app/workspace-proxy.ts'), 'utf8'); it('routes workspace traffic via node backend hostname', () => { expect(file).toContain('${routedNodeId}.vm.${baseDomain}'); diff --git a/tasks/backlog/2026-06-18-api-composition-root-extraction.md b/tasks/active/2026-06-18-api-composition-root-extraction.md similarity index 72% rename from tasks/backlog/2026-06-18-api-composition-root-extraction.md rename to tasks/active/2026-06-18-api-composition-root-extraction.md index 22f4a4768..da5857a18 100644 --- a/tasks/backlog/2026-06-18-api-composition-root-extraction.md +++ b/tasks/active/2026-06-18-api-composition-root-extraction.md @@ -21,14 +21,14 @@ ## Implementation Checklist -- [ ] Inventory the current `index.ts` responsibilities and route groups. -- [ ] Extract Hono app creation into `src/app/create-api-app.ts`. -- [ ] Extract global error handling, middleware, well-known routes, MCP CORS/routes, 404, workspace proxy, Pages proxy, and scheduled handling into explicit modules. -- [ ] Move route registration into named registration groups that encode order-sensitive behavior. -- [ ] Keep Durable Object runtime exports in `index.ts`. -- [ ] Preserve route paths, auth behavior, CORS behavior, response shapes, workspace proxy behavior, and scheduled behavior. -- [ ] Add or strengthen behavior tests for auth route precedence, project callback precedence, MCP CORS, workspace proxy pass-through/intercept behavior, well-known endpoints, 404, and scheduled delegation. -- [ ] Run API test, Worker test if relevant, typecheck, and lint. +- [x] Inventory the current `index.ts` responsibilities and route groups. +- [x] Extract Hono app creation into `src/app/create-api-app.ts`. +- [x] Extract global error handling, middleware, well-known routes, MCP CORS/routes, 404, workspace proxy, Pages proxy, and scheduled handling into explicit modules. +- [x] Move route registration into named registration groups that encode order-sensitive behavior. +- [x] Keep Durable Object runtime exports in `index.ts`. +- [x] Preserve route paths, auth behavior, CORS behavior, response shapes, workspace proxy behavior, and scheduled behavior. +- [x] Add or strengthen behavior tests for auth route precedence, project callback precedence, MCP CORS, workspace proxy pass-through/intercept behavior, well-known endpoints, 404, and scheduled delegation. +- [x] Run API test, Worker test if relevant, typecheck, and lint. - [ ] Open a draft/open PR marked DO NOT MERGE / DO NOT DEPLOY TO STAGING. ## Acceptance Criteria @@ -40,3 +40,11 @@ - Tests cover fragile route-order/auth/CORS/proxy/scheduled behavior. - No staging deployment is performed. - PR is clearly marked do-not-merge and do-not-deploy. + +## Verification Notes + +- `pnpm --filter @simple-agent-manager/api test` passed: 330 files, 5470 tests. +- `pnpm --filter @simple-agent-manager/api typecheck` passed. +- `pnpm --filter @simple-agent-manager/api lint` passed with existing warnings. +- `pnpm --filter @simple-agent-manager/api test:workers` was attempted twice. Both runs failed before executing tests because `workerd` crashed with signal 11 and Vitest reported Cloudflare pool errors. +- No staging deployment was performed.