import { IHttpServer, RouteHandler, Middleware, HttpResponseObserver, Plugin, PluginContext } from '@objectstack/core'; export * from '@objectstack/core'; export { ApiExposureSchemaLike, ManagedSchemaLike, annotateEffectiveApiOperations, clampManagedObjectWrites, foldWildcardSuperUser, seedSuperUserRestrictedObjects } from '@objectstack/core'; import { RestServerConfig } from '@objectstack/spec/api'; import * as hono_types from 'hono/types'; import { Logger } from '@objectstack/spec/contracts'; import { MetricsRegistry, PerfTiming } from '@objectstack/observability'; import { Hono } from 'hono'; import { ExecutionContext } from '@objectstack/spec/kernel'; /** * Request headers allowed on preflight, by default. * * **The** default — three Hono-based CORS sites apply it (this package's * `hono-plugin.ts` and the `@objectstack/hono` adapter, which depends on this * package), and they used to each carry their own copy under "keep in sync" * comments. The copies happened to agree; the TSDoc on {@link * HonoCorsOptions.allowHeaders} did not — it had been three headers behind for * long enough to predate multi-tenant routing, so the one description a caller * actually reads was the one that drifted (#3786). * * `X-Tenant-ID` / `X-Environment-Id` route a request to its environment. * `If-Match` carries the OCC token on record PATCHes (objectui's inline edit, * REST `update` with `ifMatch`) — without it in the preflight allow-list every * cross-origin save fails in the browser with "Failed to fetch" (objectui#2572). * `X-Share-Password` carries a share-link password to the public * `/share-links/:token/resolve` and `/messages` routes — the preferred form, * since a header stays out of URLs (#21839); without it here a cross-origin * client could only use the query-parameter form. */ declare const DEFAULT_CORS_ALLOW_HEADERS: readonly string[]; /** * Response headers exposed to cross-origin JS, by default. Same three sites, * same reason as {@link DEFAULT_CORS_ALLOW_HEADERS}. * * `set-auth-token` lets better-auth's `bearer()` plugin hand rotated session * tokens to cross-origin clients (see plugin-auth). `x-objectstack-dropped-fields` * (#3455) exposes the single-write drop warning (#3431); the body `droppedFields` * channel remains the primary, cross-origin-safe surface. */ declare const DEFAULT_CORS_EXPOSE_HEADERS: readonly string[]; /** * PEM bytes for a TLS listener — the certificate and its private key, already * read (#16804). * * ⭐ BYTES, ⛔ not paths, and that division is the contract. Whoever hands this * adapter TLS material is the layer that knows WHY it has it — in the shipped * case, `os dev --cert … --key …`, which owns the flag names and can therefore * refuse an unreadable file by naming the flag the operator typed. A transport * adapter that took paths would have to invent that refusal from a filename, * and would own a second reader of the same file. * * ⛔ Nothing in this package generates a certificate, and nothing in it says * anything about installing one into a trust store. Absent this option the * listener is plain http, exactly as it always was. */ interface HttpsListenerMaterial { /** The certificate chain, PEM. */ cert: string | Buffer; /** The certificate's private key, PEM. */ key: string | Buffer; } interface HonoCorsOptions { enabled?: boolean; origins?: string | string[]; methods?: string[]; /** * Request headers allowed on preflight (`Access-Control-Allow-Headers`). * * Defaults to {@link DEFAULT_CORS_ALLOW_HEADERS} — deliberately a link and * not a restatement. Supplying this REPLACES the default rather than * extending it, so spread the constant if you only mean to add: * `allowHeaders: [...DEFAULT_CORS_ALLOW_HEADERS, 'X-My-Header']`. */ allowHeaders?: string[]; /** * Response headers exposed to JS (`Access-Control-Expose-Headers`). * * Defaults to {@link DEFAULT_CORS_EXPOSE_HEADERS}. Unlike `allowHeaders`, * user-supplied values are MERGED with the default — those are always * exposed unless CORS is disabled entirely. */ exposeHeaders?: string[]; credentials?: boolean; maxAge?: number; } /** * Hono Implementation of IHttpServer */ declare class HonoHttpServer implements IHttpServer { private port; private staticRoot?; /** * Max time (ms) to let in-flight requests drain on `close()` before * force-closing the remainder. Kept well under the kernel's 60s * `shutdownTimeout` so a slow request can't hang the whole shutdown. */ private drainTimeoutMs; /** * When present, {@link tryListen} binds a TLS listener instead of a * plain one (#16804). Fixed at construction because it decides what * the socket IS, not how a request is handled: every origin the boot * advertises is derived from the same answer one layer up, so a * mid-flight change would leave the process speaking one protocol and * advertising another. */ private tls?; private app; private server; private listeningPort; /** * Every `(method, pattern)` pair registered through this server, kept so * the `notFound` handler can answer "the path exists but the method is * wrong" with a `405` + `Allow` instead of an opaque `404`. Populated by * the verb methods below; static/SPA catch-alls registered straight on the * raw Hono app are intentionally NOT tracked, so they never produce a 405. */ private registeredRoutes; /** Registered {@link Middleware}s, in registration order. See `use()`. */ private middlewares; /** Whether the Hono middleware that runs {@link middlewares} is mounted. */ private middlewareSeamInstalled; /** * Local idempotence for {@link installHttpMetricsSeam}. The cross-caller * "at most one counter per server" latch lives in * `armHttpRequestCounter` (per-server, whoever arms); this flag only * short-circuits repeat calls on this adapter instance. */ private httpMetricsSeamInstalled; /** * Registered {@link HttpResponseObserver}s, in registration order — read * per request by the observation seam, so a consumer may register at ANY * moment after boot (same design as {@link middlewares}). See * {@link afterResponse}. */ private responseObservers; /** Whether the Hono middleware that delivers to {@link responseObservers} is mounted. */ private responseObservationSeamInstalled; /** * Requests that reached the `notFound` sink — i.e. matched NO registered * route (Hono routes method mismatches there too). Marked in * {@link installNotFoundSeam}'s hook, read by the observation seam so * `routePattern` can carry the contract's reserved unmatched label: * `routePath(c)` cannot answer this itself — after `next()` it reports * the deepest EXECUTED handler, which for an unrouted request is one of * this adapter's own `use('*')` seams (measured: `/*`), a spelling * indistinguishable from a real static catch-all route. Keyed on the * fetch `Request` object (per request, GC-safe). */ private unmatchedMarks; /** * The LAST-RESORT handler installed by {@link setFallbackHandler}, or * `undefined` when no consumer installed one. Exactly one — installing * again REPLACES, per the contract. */ private fallbackHandler; /** Whether the Hono `notFound` hook that runs {@link unmatchedResponse} is mounted. */ private notFoundSeamInstalled; /** * Where {@link reportHandlerFailure} writes. See {@link setLogger} for why * the default is a REAL logger and not a no-op. */ private logger; constructor(port?: number, staticRoot?: string | undefined, /** * Max time (ms) to let in-flight requests drain on `close()` before * force-closing the remainder. Kept well under the kernel's 60s * `shutdownTimeout` so a slow request can't hang the whole shutdown. */ drainTimeoutMs?: number, /** * When present, {@link tryListen} binds a TLS listener instead of a * plain one (#16804). Fixed at construction because it decides what * the socket IS, not how a request is handled: every origin the boot * advertises is derived from the same answer one layer up, so a * mid-flight change would leave the process speaking one protocol and * advertising another. */ tls?: HttpsListenerMaterial | undefined); /** The scheme this server binds — derived from {@link tls}, settable nowhere else. */ getProtocol(): 'http' | 'https'; private wrap; /** * Run ONE {@link RouteHandler} against a Hono context and report what it * produced: a `Response` when it answered (buffered or streamed), `null` * when it wrote nothing, plus whether it threw. * * ## Why this is its own method (#5090) * * Two call sites now drive a `RouteHandler`: {@link wrap} (a registered * route) and the `notFound` seam ({@link installNotFoundSeam}, for the * handler installed by {@link setFallbackHandler}). The contract on * `IHttpServer.setFallbackHandler` promises the fallback a FULLY POPULATED * `IHttpRequest` — `req.body` included, parsed by content-type exactly as a * route handler gets it — and the only way to promise that credibly is for * both paths to build the request with the same code. A second, parallel * request-builder for the fallback is how the two would drift into * disagreeing about, say, `application/octet-stream`. * * The two callers differ only in what they make of "wrote nothing": * a route handler that answers nothing is a bug (500), while a FALLBACK * that answers nothing is the documented way to say "not mine" and leaves * the adapter's standard unmatched answer in place. */ private runHandler; /** * Point this adapter's diagnostics at the host's logger. Called by * `HonoServerPlugin.init()` with `ctx.logger`; a host that embeds * `HonoHttpServer` directly (cloud's serverless entrypoints, tests) may * call it itself, at any time. * * ## Why the default is a real logger, not a no-op (#5848) * * The failure this reports is one nobody can see any other way: a throw * that escapes a route handler produces a 500 carrying no cause, so a * silent default reproduces exactly the bug — bare 5xx, zero log — for * every host that forgets to wire this. That is not hypothetical: the * production report behind #5848 came from a control plane built on the * BARE adapter, i.e. the path that never sees `ctx.logger`, and its only * remedy was to re-wrap every route in its own try/catch (cloud#1144) — * paying off this seam's debt one route at a time, which is the tax * #4264 already described and did not remove. * * So the default is `createLogger()`: level `info` (an `error` always * passes), secrets redacted by field name, and `message`/`stack` lifted * out of the `Error` slot by name. Wiring a host logger REPLACES it; * silencing is a deliberate act (pass a `NoopLogger`), never the default. */ setLogger(logger: Logger): void; /** * Report a throw that escaped a {@link RouteHandler} — the diagnostic exit * that did not exist before #5848. * * Deliberately at `error`, not `warn`: per AGENTS.md "Degradation log * levels", the third legal answer — "the failure was handed to the CALLER" * — does NOT apply here. What the caller gets is a bare 500 whose body * names no cause, no code and no message; they were told that something * broke, not what, and nothing downstream can reconstruct it. Nor is this * a validation path that could fire once per malformed keystroke: an * unhandled throw out of a handler is a server-side defect, and one * `error` per occurrence is the correct volume. * * Method and path only. The request body is NOT logged — it is the most * likely place for credentials and PII to sit, and `message` + `stack` * already locate the failure in the code. */ private reportHandlerFailure; /** * Report a throw that escaped to the TRANSPORT seam — the diagnostic exit * for the population {@link reportHandlerFailure} cannot see. * * Two reporters, DISJOINT populations: `wrap()` catches everything a * {@link RouteHandler} throws and reports it there, so nothing reaching * this method has been reported already and nothing it reports will be * reported again. Hono's default handler wrote these to `console.error`; * routing them through the host logger is what puts a raw-mount failure in * the same stream as every other one. * * `error`, not `warn`, for the reason {@link reportHandlerFailure} states: * an unhandled throw out of a handler is a server-side defect, and the * AGENTS.md "handed to the CALLER" exemption does not apply to a throw * nobody caught. Method and path only — never the body. */ private reportTransportEscape; /** * Answer a throw that escaped to the TRANSPORT with the declared ADR-0112 * envelope — the seam a route mounted through {@link getRawApp} funnels * through (#17411). * * ## The door this closes * * {@link wrap} catches everything a {@link RouteHandler} throws, so every * route registered through {@link get} / {@link post} / … already answers * the declared envelope (#16545). A route mounted on the framework handle * passes through NEITHER `wrap()` nor any registrar wrapper, so its * escaped throw reached Hono's own default handler — measured on * `7d350a46`, one `HonoHttpServer`, the raw pair mounted the way * `marketplace-install-local-plugin.ts` mounts: * * ``` * /raw/envelope -> 500 text/plain; charset=UTF-8 Internal Server Error * /raw/plain -> 500 text/plain; charset=UTF-8 Internal Server Error * ``` * * Byte-identical: a throw that DECLARED `503` / `SERVICE_UNAVAILABLE` and * a bare driver error answered the same thing, so the transport discarded * the producer's own declaration — the half of the defect that is * invisible from the producer's side, which is where anyone would look. * * ## Why the transport, and why this does not close the escape hatch * * {@link getRawApp}'s exemption is scoped to framework-native MOUNTING and * to route introspection, never to the wire shape of a refusal. The * contract says raw-handle mounts are "outside this table by construction * … this answers 'what routes did I register', not 'what paths might * respond'" ({@link IHttpServer.getMountedRoutes}), and the same contract * requires the unmatched answer to carry "the shared not-found error body * (the `errors.zod` envelope), never an adapter-native error page". An * error seam on the handle leaves the hatch fully intact: consumers still * mount natively, still stay outside `getMountedRoutes()`, still need no * adapter verb. It is the reasoning {@link installHttpMetricsSeam}'s * seam already rests on (#9650) — the transport is the one layer every * inbound request converges on, whatever registered the handler. * * ## ONE rule, not a second one * * The render is {@link declaredEnvelopeForThrow}, the same gate `wrap()` * opted into, so `/raw/*` and a direct-mount route answer the same shape * for the same throw — the `ValidationError`-shape-as-declaration limb * included. The fallback arm is the ADR-0112 `INTERNAL_ERROR` body * carrying {@link INTERNAL_ERROR_MESSAGE}: a non-envelope throw still * answers 500 with NO cause in the body, #16545's pinned invariant. * * ⛔ It deliberately does NOT copy `wrap()`'s literal `"No response from * handler"`. That sentence describes a handler that wrote nothing — a * state this seam never observes, because a Hono handler that returns * nothing is Hono's own error, not ours. Copying it would put a false * diagnosis on the wire; the `code` and the `status`, which are what a * client branches on, agree with `wrap()` exactly. * * ## Hono's own declared-`Response` limb is preserved * * Hono's default handler honours a thrown value carrying its own * `Response` (`HTTPException`) before falling back to * `text('Internal Server Error', 500)`, and that limb is kept verbatim: an * `HTTPException` is a framework-native refusal the producer DECLARED, and * overriding it would be this card's own defect with the roles reversed. * Measured at `7d350a46`: zero `HTTPException` producers anywhere in * `packages/`, so this preserves behaviour rather than adding any. * * ## Installed from the constructor, and overridable on purpose * * Once, unconditionally, so a bare `HonoHttpServer` (cloud's serverless * entrypoints, tests) gets it without wiring — the same reason * {@link setLogger}'s default is a real logger. A consumer that calls * `getRawApp().onError(...)` itself replaces it, which is the escape hatch * working as designed. */ private installErrorEnvelopeSeam; get(path: string, handler: RouteHandler): void; post(path: string, handler: RouteHandler): void; put(path: string, handler: RouteHandler): void; delete(path: string, handler: RouteHandler): void; patch(path: string, handler: RouteHandler): void; /** * The LIVE mount table — every `(method, pattern)` this server registered, * in registration order. See `IHttpServer.getMountedRoutes` for the * contract; the ordering guarantee is load-bearing and honoured here * because {@link registeredRoutes} is appended to inside the verb methods, * on the same call that reaches `this.app`. * * A COPY, not the live array: a consumer of an OBSERVATION must not be able * to edit the thing observed. */ getMountedRoutes(): ReadonlyArray<{ method: string; pattern: string; }>; /** * Ask the LIVE Hono router which registered route actually answers a * concrete request — see `IHttpServer.resolveMountedRoute` for why * "is it in the table" is not the same question. * * Implemented against `app.router.match()`, i.e. the very router object * that serves production traffic, so the answer is Hono's own and cannot * drift from it. `match()` returns the matched handlers in the order Hono * would run them — middleware included — so this walks that list and takes * the first entry whose `RouterRoute` corresponds to a route THIS adapter * registered. Middleware and the raw-app catch-alls (static / SPA) are * absent from {@link registeredRoutes} by construction, so they are skipped * rather than mistaken for the answer. * * `undefined` means the router matched no registered route at all — the * request would reach the `notFound` sink (404, or 405 via * {@link allowedMethodsForPath}). */ resolveMountedRoute(method: string, path: string): { method: string; pattern: string; } | undefined; /** * The HTTP methods registered for a concrete request `path`, ignoring the * request's own method. Empty when no registered route matches the path at * all (a genuine 404). Used by the `notFound` handler to build a `405` * response with an accurate `Allow` header. `HEAD` is implied by `GET` * (Hono answers HEAD from GET routes automatically). */ allowedMethodsForPath(path: string): string[]; /** * Install the LAST-RESORT handler — see the CONTRACT on * `IHttpServer.setFallbackHandler` in `@objectstack/spec/contracts` * (#5040 §1-C). This implementation honours it as follows: * * 1. **Only after every registered route has missed.** It is mounted on * Hono's `app.notFound` hook — NEVER as a `/*` route. Hono runs * `notFound` only when its router matched no handler, so a fallback is * structurally incapable of shadowing a registered route and carries * ZERO registration-order dependency (ADR-0076 D11: one route, one * owner, by construction rather than by convention). Empirically * confirmed against this Hono version in `fallback-seam.test.ts`, * including the case the design flagged as a risk (#5040 §7-1): a * METHOD mismatch on an existing path routes to the same `notFound` * sink, so the fallback sees those too — and declining to answer leaves * the 405 below intact. * 2. **`req.body` IS readable.** Nothing consumed the request stream — * no route handler ran — so {@link runHandler} parses it by * content-type exactly as it does for a route. That is the whole * reason this seam exists and `use()` middleware cannot serve: the * middleware contract explicitly does NOT populate `body`. * 3. **Replacement, not a chain.** One handler; calling again replaces it. * The Hono hook is mounted once (idempotent) and reads the field per * request, so replacing never re-mounts and can never stack. * 4. **A handler that writes nothing leaves the standard answer.** See * {@link unmatchedResponse} — the 404/405 semantics this interface * documents are produced there, after the fallback declines. */ setFallbackHandler(handler: RouteHandler): void; /** * Mount the single Hono `notFound` hook that produces this adapter's * unmatched-request answer, running {@link fallbackHandler} first when one * is installed. Idempotent. * * WHY THE ADAPTER OWNS THIS (#5090). The 405/404 answer used to be written * by `HonoServerPlugin.start()` calling `getRawApp().notFound(...)` itself. * `app.notFound` is LAST-CALL-WINS (verified, not assumed), so with the * fallback seam added there would have been two writers of one hook and the * survivor would depend on plugin start order — the loser silently losing * either the fallback or the 405. One hook, one owner: the plugin now calls * this method instead, the two behaviours COMPOSE inside it, and * `setFallbackHandler` may be called at any moment, before or after. * * A bare `HonoHttpServer` (no plugin, e.g. cloud's serverless entrypoints) * still gets Hono's own 404 unless it calls this — or installs a fallback, * which mounts the seam for it. */ installNotFoundSeam(): void; /** * This adapter's standard answer for a request that matched no route — * the `IHttpServer` unmatched-request CONTRACT (#3607 / ADR-0076 OQ#10), * validated across adapters by `@objectstack/http-conformance`. * * Hono routes a method mismatch to the SAME `notFound` sink as a genuinely * missing path, so a `POST` to a `PUT`-only route (e.g. the metadata save * endpoint, see #2684) would otherwise return an opaque 404 with no hint * that the path exists under another verb. We re-match the request path * against the registered route patterns: if it lines up with routes under * other methods, answer `405 Method Not Allowed` with an accurate `Allow` * header so callers can self-correct. A path that matches nothing stays a * 404. This is framework-wide — every registered endpoint benefits, not * just metadata. * * ## Envelope * * Both answers are the declared `BaseResponseSchema` refusal envelope — * `{ success: false, error: { code, message } }` — with the ADR-0112 * semantic code in `error.code` and the HTTP status carried only by the * response line. Until this was converted the two bodies spoke the * pre-#3675 dialect (`error` a bare STRING, so `body.error.message` read * `undefined`) and the 405 additionally put `code`/`method`/`path`/ * `allowed` BESIDE `error` (#7035's twin, so `body.error.code` read * `undefined` too). The 405's three context keys moved into * `error.details`, which `ApiErrorSchema` declares for exactly this. * * The wire `code` VALUES are unchanged — `METHOD_NOT_ALLOWED` was already * the spelling this route shipped, and it is a `StandardErrorCode` member * — so a caller that already branched on `body.code` reads the same string * one level in. `ENDPOINT_NOT_FOUND` is the standard catalog's 404 member * for "API endpoint not found"; the 404 previously carried no code at all. * * ⚠️ The literals here are deliberately INLINE rather than hoisted into a * shared constant: `scripts/check-route-envelope.mjs` judges the object * LITERAL passed to `c.json(...)`, and an identifier reads to it as a * relayed body it must not police. Hoisting would zero this file's * counters by hiding the bodies from the scanner rather than by * conforming them — and would leave every later edit to them unaudited. * `packages/qa/http-conformance`'s `NodeHttpServer` mirrors these bodies * byte-for-byte and is locked to them cross-adapter by * `fallback-seam.conformance.test.ts`; change one and you must change both. */ private unmatchedResponse; /** * Register middleware — see the CONTRACT on `IHttpServer.use` in * `@objectstack/spec/contracts`. * * ## What this used to be, and why it matters (#4910) * * Until #4910 both branches here handed the middleware `{} as any` for BOTH * `req` and `res`, and then ran `if (!nextCalled) await next()` — so a * middleware could not read the request, could not write a response, and * could not decline to continue. Every registered middleware was, in * practice, an `await`ed no-op with a `next()` bolted on. `IHttpServer.use` * was a declared seam with no execution behind it: exactly the * declared-≠-enforced shape Prime Directive #10 names, one layer below the * spec keys #4686 opened on. Nothing production caught it because nothing * production called it — the inbound rate limiter is the first consumer, and * building it is what surfaced this. * * ## Semantics now * * Middlewares run in registration order, before any route handler, and each * one either: * * - calls `next()` — the chain continues; or * - writes a response (`res.status(...).json(...)` / `.send(...)`) without * calling `next()` — the chain SHORT-CIRCUITS and that response is * returned. This is the branch that makes a 429 (or a 401, or a * maintenance 503) possible at all. * * A middleware that does neither is treated as pass-through, so an * early-return on some condition cannot silently black-hole a request. * * ## Two deliberate limits, stated so they are not discovered * * - **`req.body` is not populated.** Reading the body here would consume * the request stream before the route handler that owns it, so a * middleware sees headers/method/path/query only. Body-dependent policy * belongs in a route handler or a dispatcher gate stage. * - **The seam must be mounted before routes, but `use()` need not be * called before them.** Hono composes the handlers that matched, in * registration order, so a middleware Hono learns about after a route * runs after that route's handler — useless for short-circuiting. This * class therefore mounts ONE Hono middleware (the chain runner) and lets * `use()` append to the chain it reads per request. {@link * installMiddlewareSeam} places that runner; `HonoServerPlugin` calls it * at the end of `init()`, after the transport's own built-ins and before * any route exists, so every later `use()` — from any plugin, in either * boot phase — gates everything. A standalone `HonoHttpServer` that never * calls it gets the runner mounted on its first `use()` instead, and only * that path carries the register-before-routes requirement. */ use(pathOrHandler: string | Middleware, handler?: Middleware): void; /** * Mount the single Hono middleware that runs the registered * {@link Middleware} chain. Idempotent. * * WHERE this is called decides what the seam can gate, and the two callers * are deliberate: * * - **`HonoServerPlugin.init()`, at the end** — after the transport's own * built-ins (Server-Timing, CORS) so a 429 short-circuit still carries * CORS headers (otherwise a browser reports an opaque network error * instead of the status), and before any route exists, since every route * in the platform is mounted in some plugin's `start()`. From there a * `use()` at ANY later moment gates the whole server, which is what lets * the dispatcher install the rate limiter in `start()` — where "no * http.server" is a settled fact rather than a mid-Phase-1 guess that a * later plugin could contradict. * - **the first `use()`** — for a bare `HonoHttpServer` composed without * the plugin, so the seam is never silently absent. */ installMiddlewareSeam(): void; /** * Arm `http_requests_total{method,route,status}` for EVERY inbound * request on this transport, by registering the counter-emitting observer * on the {@link afterResponse} seam. Idempotent. * * ## Why the counter lives here and not one layer up (#9650) * * The counter used to be emitted by a wrapper the runtime dispatcher built * over its own `IHttpServer` handle, so it saw only the routes the * dispatcher itself registered. Everything else on the same server was * invisible to it — measured, at least 14 inbound surfaces in two classes: * * - plugins that mount through {@link getRawApp} (auth, metadata HMR, * cloud-connection, marketplace, runtime-config, trigger-api, webhooks, * approvals, the console SPA). These bypass `IHttpServer` entirely, so * NO wrapper at that level can ever reach them. * - plugins that resolve `http.server` themselves and mount through the * verb methods (the REST data API via `RouteManager`, storage, i18n, * settings, datasource admin). * * An operator following the documented guidance ("alert on the 5xx rate * from `http_requests_total`") was therefore watching a counter that could * stay flat while `/api/v1/*` melted down. The transport is the one layer * every inbound request already converges on, whatever registered the * handler, which is why the counter is emitted from here. * * ## Route label is the PATTERN, never the concrete path * * `/api/v1/auth/*`, not `/api/v1/auth/sign-in/email`; `/api/v1/:object/:id`, * not one series per record id. Cardinality has to stay bounded or the * counter is unusable for exactly the alerting it exists for — and the * label is unfixable in place once dashboards are wired against the first * shipped one. * * ## Two consequences, stated so they are not discovered * * - **A transport that does not install this seam reports no HTTP * metrics.** The seam is Hono's; another `IHttpServer` implementation * emits nothing until it grows its own. Zero is "not instrumented", * never "no traffic". * - **WHERE it is mounted decides what is counted.** `HonoServerPlugin` * installs it immediately BEFORE {@link installMiddlewareSeam}, so a * request a `use()` middleware short-circuits (the inbound rate * limiter's 429) is still counted — a refused request is exactly the * one an operator is alerting on. It sits AFTER the transport's own * CORS built-in, so a preflight `OPTIONS` that CORS answers itself * never reaches a route and is not counted. * * @param metrics the host's registry. Resolve it with the canonical chain * (explicit option → `observability:metrics` service → none); pass * nothing at all rather than a no-op, so an unconfigured deployment pays * no per-request cost. */ installHttpMetricsSeam(metrics: MetricsRegistry): void; /** * Register a RESPONSE OBSERVER — the `IHttpServer.afterResponse` CONTRACT * (#9835); see `@objectstack/spec/contracts` for the full text. Honoured * here as follows: * * - **Delivery** rides ONE Hono middleware ({@link * installResponseObservationSeam}) that reads {@link responseObservers} * per request, so registration works at any moment after boot — same * design as {@link use}. `HonoServerPlugin` mounts the seam at the end * of `init()`; a bare `HonoHttpServer` gets it mounted on first * registration, and only that path carries the register-before-routes * requirement. * - **`routePattern` is the matched PATTERN** — `routePath(c)` after * `next()`, i.e. the route that actually answered; the reserved * `UNMATCHED_ROUTE_PATTERN` when nothing matched. * - **Reach**: raw-app mounts included (the seam is a raw-app * middleware), `use()` short-circuits included (mounted before the * middleware seam). The documented boundary: a CORS preflight the * transport's own built-in answers is outside the seam. * - **Isolation**: each observer runs in its own try/catch — a throwing * observer affects neither the response nor sibling observers. */ afterResponse(observer: HttpResponseObserver): void; /** * Mount the single Hono middleware that delivers * {@link HttpResponseObservation}s to every registered observer. * Idempotent. * * WHERE this is called decides what can be observed, exactly like * {@link installMiddlewareSeam} (the two callers are the same pair): * * - **`HonoServerPlugin.init()`, immediately BEFORE * `installMiddlewareSeam()`** — after the transport's own built-ins * (Server-Timing, CORS), so a preflight CORS answers itself is not * observed; before the middleware seam, so a request a `use()` * middleware refuses (the inbound rate limiter's 429) IS observed — a * refused request is exactly the one an operator alerts on; and before * any route exists (every route mounts in some plugin's `start()`), so * the observation wraps every handler however late the observer * registers. * - **the first {@link afterResponse}** — for a bare `HonoHttpServer` * composed without the plugin, so the seam is never silently absent. * * A request pays one array-length check when no observers are registered * — the disarmed cost, same shape as the middleware seam's empty-chain * fast path. */ installResponseObservationSeam(): void; /** * Mount a sub-application or router */ mount(path: string, subApp: Hono): void; listen(port: number): Promise; private tryListen; getPort(): number; getRawApp(): Hono; close(): Promise; } interface StaticMount { root: string; path?: string; rewrite?: boolean; spa?: boolean; } interface HonoPluginOptions { port?: number; /** * Terminate TLS on this server's own socket, using the caller's PEM bytes * (#16804). * * Absent — the overwhelmingly common case, and the only one before this * option existed — the listener is plain http and nothing about the boot * changes. ⛔ This package neither generates a certificate nor says anything * about trusting one; see {@link HttpsListenerMaterial}. */ tls?: HttpsListenerMaterial; staticRoot?: string; /** * Multiple static resource mounts */ staticMounts?: StaticMount[]; /** * REST server configuration * Controls automatic endpoint generation and API behavior */ restConfig?: RestServerConfig; /** * Whether to enable SPA fallback * If true, returns index.html for non-API 404s * @default false */ spaFallback?: boolean; /** * CORS configuration. Set to `false` to disable entirely. * Enabled by default with origin '*'. * Can also be controlled via environment variables: * OS_CORS_ENABLED, OS_CORS_ORIGIN, OS_CORS_CREDENTIALS, OS_CORS_MAX_AGE * (legacy CORS_* names still honoured with a deprecation warning). */ cors?: HonoCorsOptions | false; /** * Per-request performance timing via the `Server-Timing` response header * ("perf-tuning mode"). The header discloses internal phase durations * (total / auth / db / hooks / serialize), which is handy for profiling but * is also a mild backend-fingerprinting surface, so disclosure is gated: * * - **GLOBAL** — `serverTiming: true`, or `OS_SERVER_TIMING=true` / * `OS_PERF_TIMING=1`: every response carries the header (an environment * under active investigation). * - **PER-REQUEST** — always available unless hard-disabled: a caller sends * `X-OS-Debug-Timing: 1` and the header is returned ONLY after the request * resolves an admin/service identity (the dispatcher opens the disclosure * gate). Ordinary users can never pull timings just by sending the header. * `X-OS-Debug-Timing: json` additionally returns an admin-only * `X-OS-Debug-Timing-Detail` header — compact JSON listing the slowest * per-query SQL *shapes* (parametrized, no bindings) — never disclosed to * a non-admin, even under global mode. * - `serverTiming: false` hard-disables BOTH paths (no middleware). * * `undefined` (the default) leaves global mode off but keeps the * admin-gated per-request path available. * @default undefined */ serverTiming?: boolean; /** * Observability backends for the transport's own signals. * * `metrics` receives `http_requests_total{method,route,status}` for every * inbound request on this server — see * {@link HonoHttpServer.installHttpMetricsSeam} for why the counter is * emitted at the transport rather than one layer up (#9650). * * Resolution chain, the canonical one (`ObservabilityServicePlugin`): * * 1. this option — explicit wiring, and the escape hatch tests use; * 2. the `observability:metrics` service, when the host registered one * BEFORE this plugin; * 3. neither — then **no counter-emitting observer is registered**, so * an unconfigured deployment pays no per-request metrics cost (the * `afterResponse` delivery seam is still mounted, disarmed at one * array-length check per request — #9835). Not a disabled counter; * no counter. */ observability?: { metrics?: MetricsRegistry; }; } /** * How much per-request timing the caller opted into via `X-OS-Debug-Timing`: * - `off` — no header sent (or an unrecognized value). * - `basic` — `1` / `true` / `yes` / `on`: the `Server-Timing` header only. * - `json` — `json` / `detail` / `verbose`: also the admin-only richer detail * payload (per-query SQL shapes, slowest query). */ type DebugTimingMode = 'off' | 'basic' | 'json'; /** Parse the `X-OS-Debug-Timing` request-header value into a {@link DebugTimingMode}. */ declare function debugTimingMode(value: string | undefined | null): DebugTimingMode; /** * Whether a request opted into per-request perf timing via `X-OS-Debug-Timing` * (any recognized mode — basic or json). */ declare function isDebugTimingRequested(value: string | undefined | null): boolean; /** * Build the admin-only `X-OS-Debug-Timing-Detail` payload (compact JSON) from a * collector's captured `db` detail: the slowest queries by shape, the single * slowest, and the captured count + total. Returns `''` when nothing was * captured. SQL is parametrized (no bindings) and sanitized to printable ASCII. */ declare function buildTimingDetail(timing: PerfTiming): string; /** * Hono Server Plugin * * Provides HTTP server capabilities using Hono framework. * Registers the IHttpServer service so other plugins can register routes. * * Route registration is handled by plugins: * - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch * - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc. * * The current-user endpoints (`/auth/me/permissions`, `/auth/me/localization`, * `/me/apps`) are this plugin's own, and are the platform's only supply — see * `./current-user-endpoints`, which exports the registrar so a host serving a * bare {@link HonoHttpServer} instead of this plugin can supply them too. */ declare class HonoServerPlugin implements Plugin { name: string; /** * Services init() registers on every path (ADR-0116, #4131) — lets the * kernel name this plugin when a consumer requires one before it inits. */ providesServices: string[]; type: "server"; version: string; private options; private server; constructor(options?: HonoPluginOptions); /** * Init phase - Setup HTTP server and register as service */ init: (ctx: PluginContext) => Promise; /** * The canonical metrics-resolution chain, as documented on * `ObservabilityServicePlugin`: explicit option, then the * `observability:metrics` service, then nothing. * * Returns `undefined` rather than a no-op registry so the caller can skip * installing the middleware entirely when no backend is configured. */ private resolveMetrics; /** * Start phase - Configure static files and start listening */ start: (ctx: PluginContext) => Promise; /** * Destroy phase - Stop server */ destroy(): Promise; } /** API prefix these endpoints mount under unless the host overrides it. */ declare const DEFAULT_CURRENT_USER_PREFIX = "/api/v1"; /** * The service locator + logger the current-user endpoints resolve their answer * from. A `PluginContext` satisfies this structurally, so the plugin passes its * own context unchanged; a host outside the plugin lifecycle (cloud's * serverless entrypoints) passes a thin adapter over its kernel. * * `getService` may return `undefined` for an absent service — every service * these endpoints read is optional, and each read has a defined degraded answer * (see the handlers). A locator that THROWS instead is equally accepted: the * reads are wrapped, exactly as they were when `PluginContext.getService` (which * throws) was the only caller. */ interface CurrentUserEndpointsContext { getService(name: string): T | undefined; logger?: Partial>; /** * The kernel this locator reads from. Optional, and used for ONE thing: it * is the `defaultKernel` argument the host's `kernel-resolver` seam takes * (see {@link resolveRequestContext}). `PluginContext.getKernel` satisfies * it; a host adapter should supply it too. Without it a multi-tenant host * cannot be asked which kernel owns the request, and these endpoints answer * from `getService` — which on such a host is the wrong kernel (#927 is the * cloud-side record of that failure), so omitting it is a downgrade, not a * neutral choice. */ getKernel?(): unknown; } /** * Minimal shape of the host's ADR-0006 kernel-resolution seam, as registered * under the `kernel-resolver` service name. Structurally the framework's * `KernelResolver` (`@objectstack/runtime`), declared here rather than imported * so this package keeps no runtime dependency on the dispatcher. */ interface KernelResolverLike { resolveKernel(context: { request: { headers: unknown; }; routePath?: string; environmentId?: string; }, defaultKernel: unknown): Promise | unknown | undefined; } /** Options for {@link registerCurrentUserEndpoints}. */ interface RegisterCurrentUserEndpointsOptions { /** * The raw Hono app to register on. Routes go on the raw app (not through * `IHttpServer`'s verb methods) because that is where the `/api/v1/auth/*` * wildcards they must outrank are mounted. */ rawApp: any; /** Service locator + logger — see {@link CurrentUserEndpointsContext}. */ ctx: CurrentUserEndpointsContext; /** API prefix. @default '/api/v1' */ prefix?: string; } /** The three route paths this module owns, under `prefix`. */ declare function currentUserRoutePaths(prefix?: string): string[]; /** * Build the session → `ExecutionContext` resolver the current-user endpoints * need. * * Extracted from `registerDiscoveryAndCrudEndpoints` when these endpoints * stopped being gated on `registerStandardEndpoints` (#4073), so the two groups * would agree on who the caller is. That surface has since been deleted and * these endpoints are the only remaining caller — kept as a named export * because the serverless host path (cloud#924) composes it directly. * * ## It resolves the SESSION only; every grant comes from the ONE resolver (#6334) * * This used to re-read the grant tables itself — `sys_member` + * `sys_user_permission_set`, and nothing else. It never read `sys_user_position` * / `sys_position_permission_set`, so a permission set bound to a POSITION (the * ADR-0090 D3 distribution mechanism — showcase grants every persona that way) * was invisible here: `/auth/me/permissions` answered `positions: []` and * withheld that set's `systemPermissions`, while the data plane — which runs on * SecurityPlugin's middleware over the canonical chain — granted the same * action. objectui's four `useCapabilityGate` surfaces (ADR-0066 D4) read * exactly this endpoint, so a user who genuinely HELD the capability had the * button hidden: the failure direction the fail-open design names as the worse * one. Same shape as the REST copy that once silently dropped `sys_user_role`, * which is the drift `resolveAuthzContext` was extracted to end. * * A second, quieter half of the same divergence: the hand-rolled envelope * published membership roles under `roles`, while `ExecutionContext` — and every * reader in this file — calls that field `positions` (ADR-0090 D3, "formerly * `roles`"). So the endpoint's `positions` was ALWAYS `[]` and the resolved role * names never reached `resolvePermissionSets` either, independently of the * position tables. * * So the session lookup — the genuinely transport-specific part — stays here and * ALL grant aggregation delegates to `resolveUserAuthzGrants`, the canonical * resolver's userId-driven core, exported for exactly this caller shape: a * surface that already knows WHO the principal is and needs the SAME envelope * with no HTTP request to resolve it from. `sys_user_position` (null org = * global, active-org match, ADR-0091 validity windows), the implicit `everyone` * position (ADR-0090 D5), `sys_position_permission_set`, `mapMembershipRole` * normalization, the platform-admin derivation and the `ai_seat` synthesis * arrive with it instead of being re-implemented — one copy fewer to drift. */ declare function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext): (c: any) => Promise; /** Input of {@link resolveCurrentUserLocalization} and {@link resolveSignedInUserLocale}. */ interface ResolveSignedInUserLocaleInput { /** The locator of the kernel that OWNS the request (see `withRequestContext`). */ ctx: CurrentUserEndpointsContext; /** The signed-in user — this surface never resolves a language for an anonymous caller. */ userId: string; /** Active org/tenant, for the deployment-default cascade. */ tenantId?: string; /** The request's raw `Accept-Language` header value, if any. */ acceptLanguage?: string | null; } /** * The signed-in user's language — the ONE read face (#14788), the `locale` * rung of {@link resolveCurrentUserLocalization} on its own. * * Always answers a string for an authenticated caller: rung 3 has a floor. * Exported for the serverless host path that composes the resolver directly * (cloud#924) and for the pin that asserts the precedence. Its ANSWER is * unchanged by #15387; what changed underneath is that the deployment cascade * is now read even when rung 1 or 2 wins (see above). */ declare function resolveSignedInUserLocale(input: ResolveSignedInUserLocaleInput): Promise; /** * Register the current-user endpoints — `/auth/me/permissions`, * `/auth/me/localization` and `/me/apps` — on `rawApp`. * * When {@link HonoServerPlugin} drives this, it is UNCONDITIONAL — and these are * now the only routes it mounts beyond the socket itself. * * They used to ride on the `registerStandardEndpoints` flag alongside a raw * `/data` CRUD + discovery surface, one flag over two opposite things (#4073). * That surface was DUPLICATE supply and has been deleted; these three are the * opposite and could not be. Nothing else in the platform mounts them: * `packages/rest` and `packages/runtime` register no `/me/*` route at all, the * console reads `/auth/me/permissions` for its whole permission layer and * `/auth/me/localization` for regional defaults, and * `core/security/auth-gate.ts` allow-lists `/me/apps` + `/me/localization` as * endpoints a gated user MUST still reach to bootstrap the remediation UI. The * split (#4144) had to land before the deletion for exactly that reason. * * IDEMPOTENT: returns `false` and registers nothing when all three paths are * already served. That is what lets a host call this eagerly on its own raw app * AND mount the plugin — the plugin's `kernel:ready` registration then finds them * present and skips, instead of shadowing the host's routes with dead * duplicates (cloud#924). */ declare function registerCurrentUserEndpoints(options: RegisterCurrentUserEndpointsOptions): boolean; /** * CORS origin pattern matching utilities. * * Supports the same wildcard syntax as better-auth's `trustedOrigins`: * - `*` → matches any origin * - `https://*.example.com` → matches any subdomain * - `http://localhost:*` → matches any port * - Comma-separated list of the above * * These helpers are shared between the Hono plugin's CORS middleware and * consumers that need to apply CORS headers outside the Hono request * pipeline (e.g., the Vercel serverless entrypoint's preflight * short-circuit in `apps/objectos` — that app lives in the separate * `objectstack-ai/cloud` repo, not in this one). Keeping a single * implementation ensures both paths stay consistent — divergence caused * bug where wildcard `CORS_ORIGIN` values worked locally but produced * browser CORS errors on Vercel. */ /** * Returns true when the origin points to localhost (any port, http or https). * * Matches: * - `http://localhost` * - `http://localhost:3000` * - `https://localhost:8443` * - `http://127.0.0.1:5173` * - `http://[::1]:3000` */ declare function isLocalhostOrigin(origin: string): boolean; /** * Check if an origin matches a pattern with wildcards. * * Localhost origins (`http(s)://localhost:`, `127.0.0.1`, `[::1]`) * are **always allowed** regardless of the pattern — this removes the need to * enumerate every development port in `CORS_ORIGIN`. * * @param origin The origin to check (e.g., `https://app.example.com`) * @param pattern The pattern to match against (supports `*` wildcard) * @returns true if origin matches the pattern */ declare function matchOriginPattern(origin: string, pattern: string): boolean; /** * Normalize a single string / comma-separated string / array into a * trimmed array of non-empty patterns. */ declare function normalizeOriginPatterns(patterns: string | string[]): string[]; /** * Create a CORS origin matcher function that supports wildcard patterns. * * The returned function follows Hono's `cors({ origin })` contract: * given the request's `Origin` header, it returns the origin to echo * back in `Access-Control-Allow-Origin`, or `null` if the origin is not * allowed. * * @param patterns Single pattern, array of patterns, or comma-separated patterns */ declare function createOriginMatcher(patterns: string | string[]): (origin: string) => string | null; /** * True if any pattern in the given list contains a `*` wildcard. */ declare function hasWildcardPattern(patterns: string | string[]): boolean; /** * Compile a Hono-style route pattern into an anchored `RegExp` that matches a * concrete request path. Named params match one segment; `*` matches the rest. */ declare function compileRoutePattern(pattern: string): RegExp; /** True when `path` is matched by the compiled route `pattern`. */ declare function matchesRoutePattern(pattern: string, path: string): boolean; export { type CurrentUserEndpointsContext, DEFAULT_CORS_ALLOW_HEADERS, DEFAULT_CORS_EXPOSE_HEADERS, DEFAULT_CURRENT_USER_PREFIX, type DebugTimingMode, type HonoCorsOptions, HonoHttpServer, type HonoPluginOptions, HonoServerPlugin, type HttpsListenerMaterial, type KernelResolverLike, type RegisterCurrentUserEndpointsOptions, type ResolveSignedInUserLocaleInput, type StaticMount, buildTimingDetail, compileRoutePattern, createOriginMatcher, currentUserRoutePaths, debugTimingMode, hasWildcardPattern, isDebugTimingRequested, isLocalhostOrigin, makeExecutionContextResolver, matchOriginPattern, matchesRoutePattern, normalizeOriginPatterns, registerCurrentUserEndpoints, resolveSignedInUserLocale };