Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/api/src/app/create-api-app.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions apps/api/src/app/errors.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
}
32 changes: 32 additions & 0 deletions apps/api/src/app/mcp.ts
Original file line number Diff line number Diff line change
@@ -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);
}
57 changes: 57 additions & 0 deletions apps/api/src/app/middleware.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
}
33 changes: 33 additions & 0 deletions apps/api/src/app/pages-proxy.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
24 changes: 24 additions & 0 deletions apps/api/src/app/public-routes.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
}
Loading
Loading