/** * The HTTP adapter (SPEC.md §40). * * Fastify lives here and nowhere else. A handler never sees it: what reaches a * handler is validated input and the application context, and what leaves is the * response the route declared (SPEC.md §41, §125.1). */ import { type Actor, type CommandBus, type ErrorTrackingPort, type LocaleSettings, type Logger, type QueryBus, type SchemaRegistry } from '@assemora/core'; import { type AssetsOptions } from './assets.js'; import { type CommandRouteOptions } from './commands.js'; import { type RequestLogOptions } from './request-log.js'; import { type Route } from './route.js'; import { type ApiVersion, type VersionDeclaration } from './version.js'; export type ActorResolver = (headers: Readonly>) => Promise; export type HttpServerOptions = { readonly registry: SchemaRegistry; readonly commands: CommandBus; readonly queries: QueryBus; readonly logger: Logger; /** * The one structured line every request writes: method, route, status, duration * (SPEC.md §88). * * On by default, because §88 lists request timing among the minimum an application * ships with, and a line somebody has to switch on is not there on the night it is * wanted. `requestLog: false` is the deliberate way to have none. * * ```ts * createHttpServer({ registry, commands, queries, logger, requestLog: { slowMs: 250 } }) * ``` */ readonly requestLog?: RequestLogOptions | false; /** * Where a failure this layer could not attribute to the caller is reported * (SPEC.md §88). * * Defaults to writing the incident to `logger`, exactly as the buses do. A * composition root that registers a real reporter should hand the same instance to * `createApplication` and to this, so an incident reaches the same place wherever it * was thrown. * * ```ts * const errors = sentry() * * createApplication({ modules, errors }) * createHttpServer({ registry, commands, queries, logger, errors }) * ``` * * A failure thrown inside a command and answered over HTTP then passes two layers * that both report it, and this layer reports what it saw: the route and the status. * Whether the tracker is told twice is the port's to decide, because the port is the * only thing that sees both — `assemora()` wires one that drops the repeat. */ readonly errors?: ErrorTrackingPort; /** Everything is mounted below this. `/api` by default (SPEC.md §43). */ readonly prefix?: string; /** * Allowed origins. Listed explicitly and never `*`: CORS is configured, not * waved through (SPEC.md §85). */ readonly cors?: { readonly origins: readonly string[]; readonly credentials?: boolean; }; readonly rateLimit?: { readonly max: number; readonly windowMs: number; }; /** * The largest request body this server accepts, in bytes. 1 MiB by default * (SPEC.md §85). * * A route may raise or lower its own, and one that takes an upload has to: a file * reaches `media.upload` as base64, which is four bytes on the wire for every three * stored, so a 1 MiB ceiling refuses a 786 KB photograph — and a phone takes photographs * several times that. Widening the server instead would hand the same ceiling to every * address that only ever receives a form. */ readonly bodyLimit?: number; /** * The languages this deployment serves, and which one a request is in by default * (SPEC.md §131). * * A language is a path segment in front of the prefix — `/api/ru/articles` is * `/api/articles` read in Russian — and it is *stripped before routing*, so every * route is declared once, at one address, and the Schema Registry, OpenAPI and the * SDK describe one path rather than one per language. Three languages would otherwise * treble every endpoint in a document whose whole purpose is to be read. * * A segment that is not a configured language is not a language: `/api/v1/articles` * keeps meaning what it meant, because `v1` is not in the list. */ readonly locales?: LocaleSettings; /** Turns credentials into an actor. Registered by `@assemora/auth` in phase 6. */ readonly resolveActor?: ActorResolver; /** * Double-submit CSRF protection for cookie-authenticated mutations (SPEC.md §85). * * A request that mutates and arrives with cookies but no `Authorization` header is * a browser acting on an ambient credential — the one case another site can * provoke. It must then repeat the CSRF cookie in a header, which a cross-site * caller cannot read and therefore cannot repeat. */ readonly csrf?: { readonly cookie?: string; readonly header?: string; }; /** * Headers every response carries (SPEC.md §85). * * `frameAncestors` is the one an application has to think about: the builder canvas * is an iframe, so the page it renders must let Studio's origin frame it — and * nothing else (SPEC.md §59). Everything else defaults to the strict answer. */ readonly security?: { readonly frameAncestors?: readonly string[]; /** * Origins a stored file may be rendered from, beside this one (SPEC.md §63, §85). * * S3-compatible storage is mandatory in v1 and its URLs are a bucket or a CDN, so * `img-src 'self'` alone is a policy that blocks every image the media library * hands out. These entries widen `img-src` and `media-src`, and nothing else: an * origin trusted to hold an upload has not thereby been trusted to run a script * here. */ readonly mediaSources?: readonly string[]; /** * Origins a script may be loaded from, beside this one (SPEC.md §85). * * `script-src 'self'` is the right default, and the reason it withholds * `'unsafe-inline'` is that it is what stops a stray `