Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337import { createHash, randomBytes, randomUUID } from 'node:crypto';import { readFile, stat } from 'node:fs/promises';import { join, normalize, relative } from 'node:path';import { Broker, type ProxyConnection } from './proxy.js';import { decodeWsFrame, assertTextFrame } from '@/protocol/framing/ws.js';import { hashCredential, hashOpaque, verifyCredential, constantTimeEqual } from '@/security/crypto.js';import { EnrollmentManager, SetupTokenManager } from '@/security/enrollment.js';import { issueSessionCookie, parseSessionCookie, hasSecureSessionCookie } from '@/security/cookie.js';import { validateHost, validateOrigin, corsHeaders, type CorsHeaders } from '@/security/policy.js';import { resolveEffectiveTransport, isLoopbackBind, type TrustedProxyConfig, type EffectiveTransport, type ForwardedHeaders, type ResolveEffectiveTransportArgs, type ResolveResult,} from '@/security/proxy-resolver.js';// Re-export so callers can import the type from the daemon layer (saga vr7kgx.9.1 plumbing).export type { TrustedProxyConfig } from '@/security/proxy-resolver.js';import { WsTicketManager } from '@/security/ws-ticket.js';import { SecurityStore, type BrowserPrincipal } from '@/security/store.js';import { PROTOCOL_VERSION } from '@/protocol/lifecycle.js';import { loadThemeFiles } from './theme-loader.js';import type { Envelope } from '@/protocol/envelope.js';import type { AuthContext } from './auth.js';export type { AuthContext } from './auth.js';
export type WebBroker = Pick<Broker, 'routeInbound' | 'onConnectionClosed'>;
/** * The locked loopback port `shard web` / `shard daemon` bind to when no `--web-port` is * given, so the browser URL is predictable across restarts instead of drifting to a fresh * random port each launch. When this port is unavailable the server falls back to an * ephemeral port (see {@link DaemonWebServer.start}); an explicitly requested `--web-port` * is always honored exactly and a bind failure surfaces loudly. */export const DEFAULT_WEB_PORT = 8787;
export interface DaemonWebServerLike { readonly port: number | undefined; start(): Promise<{ port: number; url: string }>; stop(): Promise<void>; broadcast(message: unknown): Promise<void>; administer?(command: WebAdminCommand): Promise<WebAdminResult>; /** * True when no non-revoked browser principal is enrolled yet (first run). The CLI uses * this to decide whether the printed URL should default to the approval-code flow — only * the first approval run needs it; an already-approved operator gets the plain URL. */ readonly needsEnrollment?: boolean;}export interface WebServerOptions { host?: string; port?: number; /** * The locked port bound when `port` is omitted (defaults to {@link DEFAULT_WEB_PORT}). * Injectable so tests can exercise the fallback path without claiming the real default * port in a parallel test run. */ defaultPort?: number; expectedHost?: string; allowedOrigins: readonly string[]; /** Pinned public `host:port` required in any remote mode (saga vr7kgx.9). Never derived from a client header. */ publicHost?: string; /** Opt-in trusted-proxy config; consumed by the effective-transport resolver (saga vr7kgx.9.2). */ trustedProxy?: TrustedProxyConfig; /** Explicit opt-in for non-`https` remote operation (plaintext cookie exposure). */ allowInsecureRemote?: boolean; staticDir?: string; broker: WebBroker; store?: SecurityStore; enrollment?: EnrollmentManager; setupTokens?: SetupTokenManager; tickets?: WsTicketManager; serverVersion?: string; heartbeatIntervalMs?: number; heartbeatTimeoutMs?: number; /** XDG theme directory; defaults to `<config>/shard/themes`. Injectable for tests. */ themeDir?: string; /** * Fired when a browser initiates an approval-code enrollment request * (`POST /enroll/request`). The CLI uses this to prompt for inline approval in the * same terminal that started the daemon — no separate `shard web approve` shell needed * (saga vr7kgx.19). Receives the 6-digit code + expiry so the operator can verify and * approve. The `claimNonce` is deliberately withheld — it is the browser's collection * secret and the operator never needs it. */ onEnrollmentRequest?: (enrollment: { code: string; expiresAt: number }) => void;}type Headers = Record<string, string | undefined> | CorsHeaders | undefined;interface SocketData { auth: AuthContext; origin?: string; connection: ProxyConnection & { socket: ServerWebSocket<SocketData> | undefined }; lastPong: number; pingTimer?: ReturnType<typeof setInterval> | undefined; }export interface WebAdminResult { setupToken?: string; token?: string; clients?: Array<{ id: string; name?: string; createdAt: number; lastSeenAt?: number; revokedAt?: number }>; }export type WebAdminCommand = { type: 'approve'; code: string } | { type: 'token.create'; name: string } | { type: 'clients' } | { type: 'revoke'; id?: string; all?: boolean } | { type: 'token.rotate' };
export class DaemonWebServer { readonly enrollment: EnrollmentManager; readonly setupTokens: SetupTokenManager; readonly tickets: WsTicketManager; readonly store: SecurityStore; private server?: ReturnType<typeof Bun.serve<SocketData>>; private readonly cspCache = new Map<string, { mtimeMs: number; size: number; hashes: string[] }>(); private lastSeenPersistAt = 0; private lastSeenTimer: ReturnType<typeof setTimeout> | undefined; private readonly sockets = new Set<ServerWebSocket<SocketData>>(); private state: Awaited<ReturnType<SecurityStore['load']>> = { version: 1, principals: [], allowedOrigins: [] }; private loaded = false; constructor(private readonly options: WebServerOptions) { this.enrollment = options.enrollment ?? new EnrollmentManager(); this.setupTokens = options.setupTokens ?? new SetupTokenManager(); this.tickets = options.tickets ?? new WsTicketManager(); this.store = options.store ?? new SecurityStore(); } get port(): number | undefined { return this.server?.port; } get url(): URL | undefined { return this.port === undefined ? undefined : new URL(`http://127.0.0.1:${this.port}`); } get needsEnrollment(): boolean { return !this.state.principals.some((p) => p.revokedAt === undefined); } async broadcast(message: unknown): Promise<void> { await Promise.all([...this.sockets].map(async (socket) => { if (socket.readyState !== 1) return; try { socket.send(JSON.stringify(message)); } catch { // A dead browser socket must not prevent delivery to other recipients. } })); } async start(): Promise<{ port: number; url: string }> { if (this.server) throw new Error('web server already started'); this.state = await this.store.load(); this.loaded = true; const host = this.options.host ?? '127.0.0.1'; // No explicit port → bind the locked default so the URL is stable across restarts. If // it is unavailable, fall back to an ephemeral port (0) so the daemon still boots. An // explicit --web-port pins the port; a bind failure there surfaces loudly rather than // silently moving the listener (a reverse proxy targeting a pinned port must not be // quietly rerouted to a random one). const requestedPort = this.options.port ?? this.options.defaultPort ?? DEFAULT_WEB_PORT; const serve = (port: number) => Bun.serve<SocketData>({ hostname: host, port, fetch: (r, s) => this.fetch(r, s), websocket: { open: (ws) => this.open(ws), message: (ws, d) => this.message(ws, d), close: (ws) => this.close(ws), pong: (ws) => { ws.data.lastPong = Date.now(); } } }); try { this.server = serve(requestedPort); } catch (error) { if (this.options.port !== undefined) { this.loaded = false; throw error; } this.server = serve(0); } const port = this.server.port; if (!port) { await this.stop(); throw new Error('web server did not report a bound port'); } return { port, url: `http://127.0.0.1:${port}` }; } async stop(): Promise<void> { const lastSeenPending = this.lastSeenTimer !== undefined; if (this.lastSeenTimer) clearTimeout(this.lastSeenTimer); this.lastSeenTimer = undefined; if (lastSeenPending && this.loaded) await this.store.save(this.state); this.tickets.revokeAll(); for (const socket of this.sockets) { this.clearHeartbeat(socket); socket.terminate(); } this.sockets.clear(); const server = this.server; delete this.server; this.loaded = false; if (server) await Promise.race([server.stop(true), new Promise<void>((resolve) => setTimeout(resolve, 100))]); } async administer(command: WebAdminCommand): Promise<WebAdminResult> { if (!this.loaded) this.state = await this.store.load(); if (command.type === 'approve') { if (!/^[0-9]{6}$/.test(command.code) || !this.enrollment.approveCode(command.code)) throw new Error('invalid or expired approval code'); return {}; } if (command.type === 'token.create') { if (!/^[^\s]{1,64}$/.test(command.name)) throw new Error('token name must be 1-64 non-space characters'); return { setupToken: this.setupTokens.issue(command.name) }; } if (command.type === 'token.rotate') { const token = randomBytes(32).toString('base64url'); this.state.controlTokenHash = hashOpaque(token); for (const socket of this.sockets) if (socket.data.auth.kind === 'bearer') socket.terminate(); await this.store.save(this.state); return { token }; } if (command.type === 'clients') return { clients: this.state.principals.map(({ id, name, createdAt, lastSeenAt, revokedAt }) => ({ id, ...(name === undefined ? {} : { name }), createdAt, ...(lastSeenAt === undefined ? {} : { lastSeenAt }), ...(revokedAt === undefined ? {} : { revokedAt }) })) }; const now = Date.now(); const targets = command.all ? this.state.principals : this.state.principals.filter((p) => p.id === command.id); if (!command.all && targets.length === 0) throw new Error('browser client not found'); for (const p of targets) { p.revokedAt = now; this.tickets.revokePrincipal(p.id); for (const socket of this.sockets) if (socket.data.auth.kind === 'browser' && socket.data.auth.principal.id === p.id) socket.terminate(); } if (command.all) { this.tickets.revokeAll(); for (const socket of this.sockets) if (socket.data.auth.kind === 'browser') socket.terminate(); } await this.store.save(this.state); return {}; } private async fetch(request: Request, server: Bun.Server<SocketData>): Promise<Response> { const transport = this.resolveTransport(request, server); if ('rejected' in transport) return this.json({ error: 'forwarded host mismatch' }, 400); const host = request.headers.get('host') ?? ''; if (!validateHost(host, transport.host)) return this.json({ error: 'bad host' }, 400); const origin = request.headers.get('origin') ?? undefined; const allowed = this.allowedOrigins(); if (origin && !validateOrigin(origin, allowed)) return this.json({ error: 'bad origin' }, 400); const path = new URL(request.url).pathname; const changing = !['GET', 'HEAD', 'OPTIONS'].includes(request.method); if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') return this.upgrade(request, server, origin, host, allowed); if (changing && !validateOrigin(origin, allowed)) return this.json({ error: 'origin required' }, 403); const cors = corsHeaders(origin, allowed); if (request.method === 'OPTIONS') return this.response('', 204, cors); if (path === '/enroll/request' && request.method === 'POST') { let enrollment; try { enrollment = this.enrollment.create(); } catch { return this.json({ error: 'enrollment busy' }, 429, cors); } this.options.onEnrollmentRequest?.({ code: enrollment.code, expiresAt: enrollment.expiresAt }); return this.json({ request_id: enrollment.id, code: enrollment.code, claim_nonce: enrollment.claimNonce, expires_at: enrollment.expiresAt }, 200, cors); } if (path === '/enroll/collect' && request.method === 'POST') return this.collect(request, cors, transport); if (path === '/enroll/setup' && request.method === 'POST') return this.setup(request, cors, transport); if (path === '/bootstrap' && request.method === 'POST') return this.bootstrap(request, cors, transport); if (path === '/api/themes' && request.method === 'GET') return this.themes(request, cors); // The SPA is the enrollment surface, so it must be reachable before a browser // has a session. Keep protocol/state routes behind authentication instead of // accidentally serving the SPA as their fallback. if (request.method === 'GET' || request.method === 'HEAD') { if (!this.isStatefulPath(path)) return this.static(request, path, cors, transport); return this.json({ error: 'not found' }, 404, cors); } const auth = await this.authenticate(request); if (!auth) return this.json({ error: 'unauthorized' }, 401, cors); return this.json({ error: 'not found' }, 404, cors); } /** * Compute the effective transport for a request via the pure trusted-proxy resolver * (saga vr7kgx.9.2, D2). The peer address is the trust anchor; forwarded headers are * honored only from the configured upstream. `expectedHost` is the resolved `host`. */ private resolveTransport(request: Request, server: Bun.Server<SocketData>): ResolveResult { const loopbackPort = this.port ?? 0; const peerIp = server.requestIP(request)?.address ?? ''; const tp = this.options.trustedProxy; let headers: ForwardedHeaders = {}; if (tp) { const scheme = request.headers.get(tp.headerScheme ?? 'x-forwarded-proto') ?? undefined; const host = request.headers.get(tp.headerHost ?? 'x-forwarded-host') ?? undefined; if (scheme !== undefined) headers = { ...headers, scheme }; if (host !== undefined) headers = { ...headers, host }; } const args: ResolveEffectiveTransportArgs = { peer: { ip: peerIp }, headers, loopbackPort, directRemote: !isLoopbackBind(this.options.host), }; if (tp) args.trustedProxy = tp; if (this.options.publicHost !== undefined) args.pinnedPublicHost = this.options.publicHost; return resolveEffectiveTransport(args); } /** Pure-loopback mode: all remote flags unset + loopback bind (D3). Only then is the * implicit `http://127.0.0.1:<port>` origin auto-allowed. Byte-identical to today. */ private get pureLoopback(): boolean { return ( this.options.trustedProxy === undefined && this.options.publicHost === undefined && this.options.allowInsecureRemote !== true && isLoopbackBind(this.options.host) ); } private allowedOrigins(): readonly string[] { const origins = new Set<string>([...this.options.allowedOrigins, ...this.state.allowedOrigins]); if (this.pureLoopback && this.port !== undefined) origins.add(`http://127.0.0.1:${this.port}`); return [...origins]; } /** * D4: a `Secure` cookie may be issued only over an `https` request. Over plaintext the * cookie is non-`Secure` by design — but only under the explicit `--allow-insecure-remote` * opt-in for remote http; pure loopback always issues non-`Secure`. A remote `http` * request without that opt-in (e.g. a misconfigured proxy forwarding plaintext when https * was intended) is refused rather than silently minting an interceptable cookie. */ private canIssueCookie(transport: EffectiveTransport): boolean { if (transport.scheme === 'https') return true; if (!transport.remote) return true; // pure loopback / direct loopback peer return this.options.allowInsecureRemote === true; } private isStatefulPath(path: string): boolean { return path === '/bootstrap' || path === '/ws' || path.startsWith('/enroll/'); } private async upgrade(request: Request, server: Bun.Server<SocketData>, origin: string | undefined, host: string, allowed: readonly string[]): Promise<Response> { const bearerRaw = request.headers.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]; const auth = await this.authenticate(request); if (!auth) return this.json({ error: 'unauthorized' }, 401); if (!bearerRaw && (!origin || !validateOrigin(origin, allowed))) return this.json({ error: 'origin required' }, 403); const protocols = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map((x) => x.trim()).filter(Boolean); if (!protocols.includes('shard.v1')) return this.json({ error: 'unsupported websocket protocol' }, 400); if (auth.kind === 'browser') { const ticket = protocols.find((x) => x.startsWith('shard.ticket.'))?.slice(13); if (!origin || !ticket || !this.tickets.consume(ticket, auth.principal.id, auth.sessionId, origin)) return this.json({ error: 'invalid websocket ticket' }, 401); } const connection = this.connection(auth); if (!server.upgrade(request, { headers: { 'Sec-WebSocket-Protocol': 'shard.v1' }, data: { auth, ...(origin ? { origin } : {}), connection, lastPong: Date.now() } })) return this.json({ error: 'upgrade failed' }, 400); return undefined as never; } private connection(auth: AuthContext): ProxyConnection & { socket: ServerWebSocket<SocketData> | undefined } { let socket: ServerWebSocket<SocketData> | undefined; return { id: randomUUID(), auth, get closed() { return socket?.readyState !== 1; }, send: async (message) => { socket?.send(JSON.stringify(message)); }, set socket(value: ServerWebSocket<SocketData> | undefined) { socket = value; } }; } private open(ws: ServerWebSocket<SocketData>): void { this.sockets.add(ws); ws.data.connection.socket = ws; const interval = this.options.heartbeatIntervalMs ?? 30_000; const timeout = this.options.heartbeatTimeoutMs ?? 60_000; ws.data.pingTimer = setInterval(() => { if (Date.now() - ws.data.lastPong >= timeout) { ws.close(1008, 'pong timeout'); return; } ws.ping(); }, interval); ws.send(JSON.stringify({ type: 'hello', protocol_version: PROTOCOL_VERSION, server_version: this.options.serverVersion ?? '0.1.0', capabilities: ['websocket'] })); } private async message(ws: ServerWebSocket<SocketData>, data: string | Buffer): Promise<void> { try { assertTextFrame(typeof data !== 'string'); await this.options.broker.routeInbound(decodeWsFrame(String(data)) as Envelope, ws.data.connection); } catch { ws.close(1003, 'protocol error'); } } private clearHeartbeat(ws: ServerWebSocket<SocketData>): void { if (ws.data.pingTimer) { clearInterval(ws.data.pingTimer); ws.data.pingTimer = undefined; } } private close(ws: ServerWebSocket<SocketData>): void { this.sockets.delete(ws); this.clearHeartbeat(ws); this.options.broker.onConnectionClosed(ws.data.connection); } private async authenticate(request: Request): Promise<AuthContext | undefined> { const rawBearer = request.headers.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]; if (rawBearer) { if (!this.state.controlTokenHash || !constantTimeEqual(hashOpaque(rawBearer), this.state.controlTokenHash)) return undefined; const principal: BrowserPrincipal = { id: 'control', name: 'control', credentialHash: '', createdAt: 0 }; return { kind: 'bearer', principal, credential: rawBearer, sessionId: hashOpaque(rawBearer) }; } const token = parseSessionCookie(request.headers.get('cookie') ?? undefined); if (!token) return undefined; const sessionId = hashOpaque(token); let principal = this.state.principals.find((item) => !item.revokedAt && item.credentialLookupHash === sessionId); if (principal !== undefined) { if (!verifyCredential(token, principal.credentialHash)) return undefined; } else { // Legacy records predate the cheap lookup digest. Search only that shrinking subset, // then migrate the matching record in memory; noteLastSeen persists it shortly. principal = this.state.principals.find((item) => !item.revokedAt && item.credentialLookupHash === undefined && verifyCredential(token, item.credentialHash)); if (principal === undefined) return undefined; principal.credentialLookupHash = sessionId; } this.noteLastSeen(principal); return { kind: 'browser', principal, credential: token, sessionId }; } private noteLastSeen(principal: BrowserPrincipal): void { const now = Date.now(); principal.lastSeenAt = now; if (now - this.lastSeenPersistAt < 5_000 || this.lastSeenTimer) return; this.lastSeenPersistAt = now; this.lastSeenTimer = setTimeout(() => { this.lastSeenTimer = undefined; void this.store.save(this.state); }, 5_000); } private async collect(request: Request, cors: Headers, transport: EffectiveTransport): Promise<Response> { if (!this.canIssueCookie(transport)) return this.json({ error: 'secure cookie requires https' }, 403, cors); const body = await this.body(request); if (typeof body?.request_id !== 'string' || typeof body.claim_nonce !== 'string') return this.json({ error: 'invalid body' }, 400, cors); const item = this.enrollment.collect(body.request_id, '', body.claim_nonce); if (!item) return this.json({ error: 'pending or invalid enrollment' }, 409, cors); const credential = randomBytes(32).toString('base64url'); const principal: BrowserPrincipal = { id: randomUUID(), credentialHash: hashCredential(credential), credentialLookupHash: hashOpaque(credential), createdAt: Date.now() }; this.state.principals.push(principal); await this.store.save(this.state); return this.json({ principal: this.publicPrincipal(principal) }, 200, { ...cors, 'Set-Cookie': issueSessionCookie(credential, { secure: transport.scheme === 'https' }) }); } private async setup(request: Request, cors: Headers, transport: EffectiveTransport): Promise<Response> { if (!this.canIssueCookie(transport)) return this.json({ error: 'secure cookie requires https' }, 403, cors); const body = await this.body(request); if (typeof body?.setup_token !== 'string') return this.json({ error: 'invalid body' }, 400, cors); const token = this.setupTokens.consumeWithMetadata(body.setup_token); if (!token) return this.json({ error: 'invalid setup token' }, 401, cors); const credential = randomBytes(32).toString('base64url'); const principal: BrowserPrincipal = { id: randomUUID(), credentialHash: hashCredential(credential), credentialLookupHash: hashOpaque(credential), createdAt: Date.now(), ...(token.name === undefined ? {} : { name: token.name }) }; this.state.principals.push(principal); await this.store.save(this.state); return this.json({ principal: this.publicPrincipal(principal) }, 200, { ...cors, 'Set-Cookie': issueSessionCookie(credential, { secure: transport.scheme === 'https' }) }); } private async bootstrap(request: Request, cors: Headers, transport: EffectiveTransport): Promise<Response> { const auth = await this.authenticate(request); if (!auth || auth.kind !== 'browser') return this.json({ error: 'unauthorized' }, 401, cors); if (transport.scheme !== 'https' && hasSecureSessionCookie(request.headers.get('cookie') ?? undefined)) return this.json({ error: 'secure cookie requires https' }, 403, cors); const ticket = this.tickets.issue(auth.principal.id, auth.sessionId, request.headers.get('origin') ?? ''); return this.json({ ticket: ticket.value, expires_at: ticket.expiresAt }, 200, cors); } private async themes(_request: Request, cors: Headers): Promise<Response> { try { return this.json({ themes: await loadThemeFiles(this.options.themeDir) }, 200, cors); } catch { return this.json({ error: 'unable to load themes' }, 500, cors); } } private publicPrincipal(p: BrowserPrincipal): object { return { id: p.id, ...(p.name === undefined ? {} : { name: p.name }), createdAt: p.createdAt }; } private async static(request: Request, path: string, cors: Headers, transport: EffectiveTransport): Promise<Response> { const root = this.options.staticDir; const requested = path === '/' ? 'index.html' : path.slice(1); const basename = requested.slice(requested.lastIndexOf('/') + 1); const extension = basename.slice(basename.lastIndexOf('.')).toLowerCase(); const assetExtensions = new Set(['.html', '.js', '.css', '.json', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2', '.map']); const hasExtension = basename.includes('.') && !basename.startsWith('.'); if (!root || basename === 'security-v1.json' || basename === 'daemon.json' || (extension === '.html' && requested !== 'index.html') || (hasExtension && !assetExtensions.has(extension))) return this.json({ error: 'not found' }, 404, cors); const candidate = normalize(join(root, requested)); if (relative(root, candidate).startsWith('..')) return this.json({ error: 'not found' }, 404, cors); let file = candidate; try { await readFile(file); } catch { if (!path.includes('.')) file = join(root, 'index.html'); else return this.json({ error: 'not found' }, 404, cors); } try { const content = await readFile(file); const headers = { ...cors, 'Content-Type': this.mime(file), 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': await this.contentSecurityPolicy(file, content, transport) }; return this.response(content, 200, headers); } catch { return this.json({ error: 'not found' }, 404, cors); } } private async contentSecurityPolicy(file: string, content: Uint8Array, transport: EffectiveTransport): Promise<string> { // D5: derive the WS endpoint from the request's effective transport (resolved once // in `fetch` via the trusted-proxy resolver). https -> wss://<host>; http -> ws://<host>. // Pure loopback: transport.host is `127.0.0.1:<port>` so this is byte-identical to the // previous hard-coded `ws://127.0.0.1:<port>`. const wsScheme = transport.scheme === 'https' ? 'wss' : 'ws'; const connect = `${wsScheme}://${transport.host}`; const base = `default-src 'self'; connect-src 'self' ${connect}; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'`; // SvelteKit may emit style attributes/blocks; retain the existing narrow style exception // until those generated styles can be converted to hashes without changing app output. if (this.mime(file) !== 'text/html; charset=utf-8') return `${base}; script-src 'self'; style-src 'self'`; const metadata = await stat(file); let entry = this.cspCache.get(file); if (!entry || entry.mtimeMs !== metadata.mtimeMs || entry.size !== metadata.size) { const html = Buffer.from(content).toString('utf8'); const hashes = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)] .filter((match) => !/\bsrc\s*=/.test(match[1] ?? '')) .map((match) => `'sha256-${createHash('sha256').update(match[2] ?? '', 'utf8').digest('base64')}'`); entry = { mtimeMs: metadata.mtimeMs, size: metadata.size, hashes }; this.cspCache.set(file, entry); } return `${base}; script-src 'self'${entry.hashes.length ? ` ${entry.hashes.join(' ')}` : ''}; style-src 'self' 'unsafe-inline'`; } private mime(file: string): string { return ({ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' } as Record<string, string>)[file.slice(file.lastIndexOf('.')).toLowerCase()] ?? 'application/octet-stream'; } private async body(request: Request): Promise<Record<string, unknown> | undefined> { const type = request.headers.get('content-type') ?? ''; if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(type)) return undefined; const length = Number(request.headers.get('content-length') ?? 0); if (Number.isFinite(length) && length > 16_384) return undefined; const reader = request.body?.getReader(); if (!reader) return undefined; const chunks: Uint8Array[] = []; let total = 0; try { while (true) { const part = await reader.read(); if (part.done) break; total += part.value.byteLength; if (total > 16_384) { await reader.cancel('body too large'); return undefined; } chunks.push(part.value); } const value: unknown = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')); return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined; } catch { await reader.cancel().catch(() => undefined); return undefined; } } private json(value: unknown, status: number, headers?: Headers): Response { return this.response(JSON.stringify(value), status, { ...headers, 'Content-Type': 'application/json' }, true); } private response(body: string | Uint8Array | null, status: number, headers?: Headers, noStore = false): Response { return new Response(body === null || typeof body === 'string' ? body : Buffer.from(body), { status, headers: { ...(headers ?? {}), ...(noStore ? { 'Cache-Control': 'no-store' } : {}) } }); }}export type ServerWebSocket<T> = Bun.ServerWebSocket<T>;