import { IHttpServer, RouteHandler, Middleware, HttpResponseObserver, Plugin, PluginContext } from '@objectstack/core'; export * 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 { EnableLike } from '@objectstack/spec/data'; 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). */ 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[]; 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; 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); 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; 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; 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[]; /** * Fold the `'*'` wildcard super-user grant into every per-object entry of a * `/me/permissions` `objects` map, mutating it in place. * * The endpoint merges each resolved permission set's explicit `objects` entries * most-permissively per key, but treats `'*'` and named objects as independent * keys — so a wildcard "Modify/View All Data" grant is never propagated into a * per-object entry another set explicitly denied. That makes the client's * per-object FLS STRICTER than the server's actual enforcement * (`PermissionEvaluator.checkObjectPermission`, which returns allow as soon as * ANY set grants — including via the `'*'` modifyAll/viewAll super-user bypass, * with no deny-wins). The mismatch surfaces for a platform admin * (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds * `organization_admin` (which denies writes on identity tables): the client * would see `sys_user.allowEdit:false` and disable a form the server accepts * (verified: `PATCH /data/sys_user {name}` → 200). ADR-0124 D1 makes the * server the authoritative gate, and D4 makes this direction explicit: what * the client is told must be derived from the server's actual effective * enforcement, never from an independent reading of the declarations. * * The super-user grant covers private/managed objects on the server, so folding * it here is exactly as broad as real enforcement — never broader. */ declare function foldWildcardSuperUser(objects: Record): void; /** Minimal schema shape the managed-write clamp needs. */ interface ManagedSchemaLike { managedBy?: string; userActions?: { create?: boolean | { enabled?: boolean; }; edit?: boolean | { enabled?: boolean; }; delete?: boolean | { enabled?: boolean; }; } | null; } /** * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side * enforcement layer that permission sets don't model: the engine write guards. * They fail-closed reject USER-CONTEXT insert/update/delete on every managed * object whose resolved affordances forbid the verb — `better-auth` * (ADR-0092 D2) and `engine-owned`/`append-only` (ADR-0103) — except where the * object opted the write affordance in via `userActions.{create,edit,delete}` * (e.g. sys_user opens `edit` for its profile fields). * * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` * for a platform admin on tables the guard actually blocks (sys_member, * sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the * false-negative the fold fixes. The real effective answer for a user-context * caller is `permission-set grant ∩ guard policy`, and the guard policy for a * guarded object is exactly its resolved CRUD affordance. `config`/`platform`/`system-data` * objects are NOT clamped — no guard covers them, so their permission-set result * stands (an admin CAN write them via the data API, and the hint must not * under-report that). */ declare function clampManagedObjectWrites(objects: Record, schemaOf: (objectName: string) => ManagedSchemaLike | undefined): void; /** The API-exposure-relevant slice of a registered object schema. */ interface ApiExposureSchemaLike { name?: string; enable?: EnableLike | null; } /** * [#3391] Seed false-initialized per-object entries for a MODIFY-ALL super-user, * for every registered object whose `apiMethods` whitelist tightens exposure. * * A super-user's grant is usually the `'*'` wildcard, not explicit per-object * entries — so restricting objects never appear in the merged `objects` map and * would miss their `apiOperations` annotation. Seeding a `{allow*: false}` entry * lets {@link foldWildcardSuperUser} pull it true (super-user reads/writes * everything) and lets {@link annotateEffectiveApiOperations} attach the effective * set. Runs BEFORE fold. * * Guarded to `modifyAllRecords` super-users ONLY: for a viewAll-only caller, * materializing a `false` entry would flip the client's `check('edit')` from * "undefined → default-allow" to "explicit false → deny" — a scope-exceeding * behavior change. A modify-all caller is folded to `true` anyway, so seeding is * harmless there. Unrestricted objects are skipped (they carry no annotation). */ declare function seedSuperUserRestrictedObjects(objects: Record, allSchemas: readonly ApiExposureSchemaLike[]): void; /** * [#3391] Annotate each per-object `/me/permissions` entry with the SERVER's * effective API operation set (`apiOperations`), mutating the map in place. * * This is the single "effective" channel the frontend consumes — it renders the * operations the server hands down here, never the raw `apiMethods` whitelist. * Only objects whose whitelist actually tightens exposure are annotated (a * `deny-all` object gets an empty array; an unrestricted object gets nothing, so * the client keeps its default-allow behavior). Runs AFTER fold + clamp so the * annotation sits alongside the final CRUD affordances. */ declare function annotateEffectiveApiOperations(objects: Record, schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined): void; /** * 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 ApiExposureSchemaLike, type CurrentUserEndpointsContext, DEFAULT_CORS_ALLOW_HEADERS, DEFAULT_CORS_EXPOSE_HEADERS, DEFAULT_CURRENT_USER_PREFIX, type DebugTimingMode, type HonoCorsOptions, HonoHttpServer, type HonoPluginOptions, HonoServerPlugin, type KernelResolverLike, type ManagedSchemaLike, type RegisterCurrentUserEndpointsOptions, type ResolveSignedInUserLocaleInput, type StaticMount, annotateEffectiveApiOperations, buildTimingDetail, clampManagedObjectWrites, compileRoutePattern, createOriginMatcher, currentUserRoutePaths, debugTimingMode, foldWildcardSuperUser, hasWildcardPattern, isDebugTimingRequested, isLocalhostOrigin, makeExecutionContextResolver, matchOriginPattern, matchesRoutePattern, normalizeOriginPatterns, registerCurrentUserEndpoints, resolveSignedInUserLocale, seedSuperUserRestrictedObjects };