import { DataStore } from '@voltro/database'; import { Schema } from 'effect'; declare interface ActionProcedureDescriptor { readonly kind: 'action'; readonly name: Name; readonly input: Input; readonly output: Output; readonly error: Error; /** * Store table(s) this action READS / WRITES. * * An action is non-transactional external I/O, and it very often touches a * table on the way — a cache it fills, a job row it stamps. Until these * existed there was NO WAY to declare it, which made every such table * invisible to static analysis: `voltro check` reported one that five action * paths read and wrote as an orphan, and advised removing it. * * A primitive that cannot declare what it touches turns every analysis over * it into a guess. These are the slots that let the answer be checked instead. * Same shape as a query's `source` and a mutation's `target`. */ readonly source: string | ReadonlyArray | undefined; readonly target: TargetSpec | ReadonlyArray | undefined; /** Declarative authorization guard(s) — enforced before the executor runs, * failing with a typed `ScopeError`. Absent → no framework-level authz. */ readonly guards: DeclaredAccess | undefined; /** The declared reason this procedure needs NO authorization check — * `openAccess: ''`. Mutually exclusive with `guards`; together they are * the only two shapes `security.defaultDeny` accepts. */ readonly openAccess: string | undefined; /** Opt this action into a public REST endpoint (innovation/11). */ readonly publicApi: PublicApiSpec | undefined; /** Opt this action into the auto-synthesized agent toolset (innovation/07). */ readonly exposeAsTool: ExposeAsTool | undefined; /** Require a SECOND human to approve before this action takes effect. The gate * runs in the dispatch spine after `guards:` and BEFORE the executor's * external I/O — the only point at which nothing has happened yet. */ readonly requiresApproval: AnyApprovalPolicy | undefined; /** True when the procedure is kept OFF the wire — no client-group entry and no * route in dev or serve. See `internal` on the definer's options. */ /** * Replace a PLUGIN route that answers to this same tag. * * Without it, a user route and a plugin route sharing a tag is a hard error, * and correctly so — two handlers behind one name is not a thing a caller can * reason about. But refusing is the wrong answer when the app deliberately * wants its own version: the two escapes available otherwise are to rename * your procedure (so the split runs along "who built it" rather than along a * domain boundary) or to `alias` the whole plugin away (same, one level up). * For a frontend developer that is the worst possible partition. * * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose * surface is richer than theirs, add `archive`/`unarchive` beside it — which * already composes, since the collision check compares FULL tags and not * prefixes — and replace `markRead`, because theirs maintains archive state. * * Explicit, never inferred. Silently letting the app win would mean a plugin * upgrade that adds a route could shadow an app procedure with no diff to * read; declaring it makes the intent reviewable and puts the override in the * file that performs it. */ readonly overridesPlugin: boolean | undefined; readonly internal: boolean | undefined; } /** The erased policy a descriptor carries (input generic dropped). */ declare interface AnyApprovalPolicy { readonly approvers: Guards; readonly expiresIn?: string; readonly reason?: string; } /** A guard is either a scope check or a relationship check. */ declare type AnyGuardSpec = GuardSpec | PolicyGuardSpec; export declare const bytes: (stream: ReadableStream | (() => ReadableStream), options?: { readonly contentType?: string; readonly contentLength?: number; readonly contentDisposition?: string; }) => RestByteResponse; /** * Project every descriptor carrying a `publicApi` annotation into a REST * route, in stable (path) order. The boot layer feeds these straight into * `restRoutesToHttpRoutes` alongside any hand-authored `restRoutes`. */ export declare const collectPublicApiRoutes: (entries: ReadonlyArray) => ReadonlyArray, unknown>>; /** * What a descriptor CARRIES: the author's guards, or the erased form of their * `openAccess:` decision — never both. * * The option a user writes is still `Guards` (an `OpenAccessSpec` is not * something to hand-write into `guards:`; there is one spelling for the * decision and it is the `openAccess:` field). The DESCRIPTOR type is wider * because that is where the normalised decision lands, and because every * enforcement path reads the descriptor's array and nothing else. */ declare type DeclaredAccess = ReadonlyArray | OpenAccessSpec>; /** * Identity helper that fixes the descriptor's types (like `defineAction` / * `definePlugin`). Returns its argument unchanged at runtime; exists for * the type-level enforcement at the declaration site. */ export declare const defineRestRoute: (descriptor: RestRouteDescriptor) => RestRouteDescriptor; declare interface DeleteTarget extends NestedTargetFields { readonly table: string; readonly op: 'delete'; /** Identify the row(s) to remove. Default: `input.id`. Return an ARRAY to * remove MANY rows/items in one mutation. */ readonly identify?: ((input: Input) => string | ReadonlyArray) | undefined; } /** query→GET, mutation/action→POST, unless the spec overrides. */ export declare const derivePublicMethod: (kind: PublicApiDescriptor["kind"], spec: PublicApiSpec) => RestMethod; /** `//` unless the spec gives an explicit * path. `orders.create` @ v1 → `/v1/orders/create`. */ export declare const derivePublicPath: (tag: string, spec: PublicApiSpec) => string; /** * Dispatch a request across REST routes that SHARE a path. Every REST route * mounts as `*` and gates its own method, returning a precise 405 BEFORE any * side effect when the method isn't its own — so probing the group in order and * taking the FIRST non-405 result yields the route that owns the request's * method. This is what lets the standard GET + POST on one resource path * coexist: the runtime's rpc server (`rpcServer.ts`, started by both boot * paths) mounts ONE dispatcher per path (the underlying * router rejects two mounts on the same `(method, path)`). A single-route group * returns that route's result directly (its own 405 included); when no route in * the group owns the method, the last 405 stands. * * `group` must be non-empty (the serve pipeline only builds a dispatcher for a * path that has at least one route). */ export declare const dispatchSharedPath: (group: ReadonlyArray, req: PluginHttpRouteRequest) => Promise; /** Normalize the `exposeAsTool` shorthand. `true` is only valid when the * descriptor carries a top-level `description`; callers pass that in. */ declare type ExposeAsTool = boolean | ExposeAsToolSpec; declare interface ExposeAsToolSpec { /** Shown to the model — REQUIRED to expose (a tool with no description is * unusable). What the tool does + when to call it. */ readonly description: string; /** Require human confirmation of the concrete call before it executes. * Default posture: writes (mutation/action) confirm, reads don't. */ readonly confirm?: boolean; /** Cap how many times the agent may call this tool per run. */ readonly maxPerRun?: number; } /** One or more declarative guards on a descriptor. All must pass (AND across * entries; `mode` controls AND/OR WITHIN one entry's scope array). */ declare type Guards = ReadonlyArray>; declare interface GuardSpec { /** Required permission scope(s). A single string, or an array combined by * `mode`. Scope strings are the same values `hasScope` / rbac roles use. */ readonly scope: string | ReadonlyArray; /** How an array of scopes combines. `'all'` (default) = AND (hold every * scope); `'any'` = OR (hold at least one). Ignored for a single scope. */ readonly mode?: 'all' | 'any'; /** * PURE `input → resource id` extractor. Browser-safe (no DB, no server * import) — exactly like `target.identify`. Omit for a plain subject-scope * guard. * * **On a `GuardSpec` this id is ADVISORY.** A scope guard answers "what may * this subject do at all", against the subject's global scope set; the id is * carried for logging and for a future subject-scope resolver that narrows by * resource. It does not, on its own, make the check per-resource. * * **If your authority is per-resource, you want {@link PolicyGuardSpec}, not * this field** — `guards: [{ action, resourceType, resource }]`, backed by * `defineResourcePolicy` + a tuple source you register. That is built, wired * on both boot paths, fail-closed without a resolver, and documented under * *Authentication → Authorization*. An app whose relationships already live * in its own tables (a `teamMembers` row, say) registers its own tuple source * rather than copying data across; see `policyGuardResolver.ts`. * * That paragraph is here because its absence cost a deployment their access * gate. This comment used to describe the resolver as "a future ReBAC / * `accessPolicy()` resolver" — written before the ReBAC path shipped and * never updated. They read the type, quoted the sentence, concluded there was * "nothing in between" declaring an untruth and turning the gate off, and set * `security: { defaultDeny: false }` on an app with 565 undecided procedures. * The capability they needed was two fields away. A doc comment that says * "future" about something shipped is not a small inaccuracy: it is the only * thing a careful reader has, and it argued them out of a feature. */ readonly resource?: (input: Input) => string | undefined; } declare interface IdempotencyRecord { readonly scope: string; readonly key: string; readonly status: 'in_flight' | 'completed'; readonly response: IdempotencyResponse | null; /** epoch ms */ readonly createdAt: number; } declare interface IdempotencyResponse { readonly status: number; readonly body: unknown; } declare interface IdempotencyStore { /** Current record for (scope, key), or null. Read-side (inspect/dashboard). */ readonly get: (scope: string, key: string) => Promise; /** * Atomically claim (scope, key) IF absent OR stale (older than `ttlMs`): * write a fresh `in_flight` row and return `'claimed'`. Otherwise return the * EXISTING (still-fresh) record. The atomicity here is the whole game — two * concurrent same-key requests both hit this, exactly one gets `'claimed'`, * the loser reads the winner's record. In SQL this is one * `INSERT … ON CONFLICT DO UPDATE … WHERE existing.createdAt < now - ttl`. */ readonly claim: (scope: string, key: string, now: number, ttlMs: number) => Promise<'claimed' | IdempotencyRecord>; /** Flip the claim to `completed` and store the response. */ readonly complete: (scope: string, key: string, response: IdempotencyResponse, now: number) => Promise; /** Drop the claim (handler errored → a retry may re-process). */ readonly release: (scope: string, key: string) => Promise; } declare interface InsertTarget> extends NestedTargetFields { readonly table: string; readonly op: 'insert'; readonly order?: 'prepend' | 'append' | undefined; /** * Build the optimistic row from the mutation input. The framework * injects `id` (from `optimisticId`) and the `optimistic: true` flag * around the return — your `shape` returns ONLY the user-controllable * row body. Default when omitted: `{ ...input, id, optimistic: true }`. * * Return type intentionally excludes `id`: it's server-generated; * the optimistic id placeholder is the framework's responsibility. * Excluding `optimistic` (also framework-injected) follows the same * principle — `Omit` lets you describe * EVERYTHING ELSE without re-stating the bookkeeping fields. * * The `optimisticId` parameter is exposed for rare cases where the * shape function needs to reference it (e.g., setting a foreign key * on a child row spread in the same optimistic insert). For a NESTED * (`path`) insert, use `shapeItem` instead — it is typed to the item. */ readonly shape?: ((input: Input, optimisticId: string) => Omit) | undefined; /** NESTED (`path`) insert: build the ITEM to insert, typed to the item (not * the output). 2nd arg is the optimistic id. */ readonly shapeItem?: ((input: Input, optimisticId: string) => Item) | undefined; } export declare const isRestByteResponse: (v: unknown) => v is RestByteResponse; export declare const isRestStreamResponse: (v: unknown) => v is RestStreamResponse; /** Extract `:name` path params by aligning the route PATTERN with the request * path segment-by-segment (`/projects/:project/deploy` + `/projects/acme/deploy` * → `{ project: 'acme' }`). `PluginHttpRouteRequest` carries no matched params, * so the desugar re-derives them here — without this, `input.params` is always * empty and every `:param` route 400s on decode. */ export declare const matchPathParams: (pattern: string, path: string) => Record; declare interface MutationProcedureDescriptor { readonly kind: 'mutation'; readonly name: Name; readonly input: Input; readonly output: Output; readonly error: Error; /** Declarative target(s) — drives auto-optimistic on the client AND * surfaces which tables this mutation touches (debug, future * query-invalidation analytics). Mutations without a target run * normally but skip auto-optimistic. */ readonly target: Target | undefined; /** Declarative authorization guard(s) — enforced before the transaction * opens, failing with a typed `ScopeError`. Absent → no framework-level * authz (author gates in-handler, or the mutation is unguarded). */ readonly guards: DeclaredAccess | undefined; /** The declared reason this procedure needs NO authorization check — * `openAccess: ''`. Mutually exclusive with `guards`; together they are * the only two shapes `security.defaultDeny` accepts. */ readonly openAccess: string | undefined; /** Opt this mutation into a public REST endpoint (innovation/11). */ readonly publicApi: PublicApiSpec | undefined; /** Opt this mutation into the auto-synthesized agent toolset (innovation/07). */ readonly exposeAsTool: ExposeAsTool | undefined; /** Require a SECOND human to approve before this mutation takes effect. The * gate runs in the dispatch spine after `guards:` and before the transaction * opens; the pending intent is a durable `_voltro_approvals` row. */ readonly requiresApproval: AnyApprovalPolicy | undefined; /** True when the procedure is kept OFF the wire — no client-group entry and no * route in dev or serve. See `internal` on the definer's options. */ /** * Replace a PLUGIN route that answers to this same tag. * * Without it, a user route and a plugin route sharing a tag is a hard error, * and correctly so — two handlers behind one name is not a thing a caller can * reason about. But refusing is the wrong answer when the app deliberately * wants its own version: the two escapes available otherwise are to rename * your procedure (so the split runs along "who built it" rather than along a * domain boundary) or to `alias` the whole plugin away (same, one level up). * For a frontend developer that is the worst possible partition. * * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose * surface is richer than theirs, add `archive`/`unarchive` beside it — which * already composes, since the collision check compares FULL tags and not * prefixes — and replace `markRead`, because theirs maintains archive state. * * Explicit, never inferred. Silently letting the app win would mean a plugin * upgrade that adds a route could shadow an app procedure with no diff to * read; declaring it makes the intent reviewable and puts the override in the * file that performs it. */ readonly overridesPlugin: boolean | undefined; readonly internal: boolean | undefined; } declare interface NestedTargetFields { /** Dot-path to the nested array within the query VALUE to patch (e.g. * `'snapshot.projects'`). Absent → patch the top-level row array (default). */ readonly path?: string | undefined; /** Item key within the nested array (default `'id'`). Only meaningful with * `path`. */ readonly by?: string | undefined; /** Guard WHICH cached query entries this target patches: only entries whose * CURRENT value satisfies the predicate. Pure + browser-safe. Prevents a * patch from bleeding across sibling subscriptions that share a source table * (the guard AWB hand-writes as `roadmap.id === input.roadmapId`). Absent → * every entry matching the target `table` is patched. */ readonly match?: ((value: unknown, input: Input) => boolean) | undefined; } /** * The runtime-erased form of a procedure's `openAccess:` — a DECLARED decision * that this procedure needs no authorization check, and the reason. * * It is a guard entry rather than a bare descriptor field on purpose. Every * enforcement path in the framework — `servePipeline`'s `enforceGuards`, * `bindStream`, `bindEvent`, `@voltro/testing`'s `invoke` — is handed the * `guards` ARRAY and nothing else. A decision that does not live in that array * is invisible to all of them, so "guarded" and "deliberately open" would be * distinguishable in the source and identical at the point that enforces. * * It always passes. The value is the WHY, and the why is the point: it is what * a reviewer reads, what `voltro doctor` prints, and what makes an open * procedure a decision somebody made rather than a field somebody forgot. */ declare interface OpenAccessSpec { /** Why this procedure is callable without an authorization check. Non-empty * by construction — `defineQuery` & co. refuse an empty reason. */ readonly open: string; } /** A public raw-HTTP route a plugin serves on the framework listener. */ declare interface PluginHttpRoute { /** HTTP method, or `'*'` for any (the handler decides). PATCH/HEAD/OPTIONS * are first-class — the REST desugar used to mount `'*'` partly BECAUSE * this union lacked PATCH; that reason is gone (the `'*'` mount remains * for its other job: one dispatcher per shared path + a precise 405). */ readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; /** Absolute path prefix, e.g. `/_voltro/storage`. Matches the path AND * any sub-path (`/_voltro/storage/abc123`). */ readonly path: string; readonly handle: (req: PluginHttpRouteRequest) => Promise; /** Per-route body cap override (bytes) — wins over the listener's shared * `maxBodyBytes`. NOTE: routes SHARING a path share one body read, so the * widest override on the path's group applies to the whole group. */ readonly maxBodyBytes?: number; /** * Opt this route's path OUT of the listener's cross-site origin check. * * Every state-changing request (anything but GET/HEAD/OPTIONS) is * origin-checked by default, because the default assumption has to be that a * route can be reached with the browser's ambient session cookie — and a * route that can is CSRF-reachable. Declaring `'exempt'` is a claim that this * route CANNOT be: its caller must present something a browser will not * attach cross-site. * * The test to apply, and it is the only one: * * > If an attacker's page makes a browser send this request with the * > victim's cookies attached, does anything happen? * * If the answer is "no, the request still needs a signature / a bearer token * / a signed ticket the attacker does not have", the route is exempt. * Otherwise it is not, and no amount of "but it is behind the dashboard" * makes it so. * * The first-party exemptions and why each qualifies: * - `@voltro/plugin-sso-saml` `/saml` — the IdP delivers the assertion as a * genuine cross-site browser form POST; authority is the signed * SAMLResponse, not the cookie. * - `@voltro/plugin-storage` `/…/upload` + `/…/upload/resumable` — a signed * upload ticket in the query string, and the route ships its own CORS * allowlist because a cross-origin upload is the point. * - `@voltro/plugin-billing` `/billing/webhook` — an HMAC-verified provider * callback. * - `@voltro/plugin-scim` `/scim/v2` — bearer-only, refuses to mount without * a token. * * Granularity is the PATH PREFIX the route mounts, not the sub-path its * handler branches on: exempting `/saml` exempts `POST /saml/anything`. */ readonly originGuard?: 'exempt'; } /** * A binary streaming body — the download/export shape. The serve layer pipes * the Web ReadableStream to the socket without buffering, so a response * larger than the heap is fine; the LAZY thunk form defers opening the * source (a provider connection, a file handle) until the response actually * streams. */ declare interface PluginHttpRouteByteStream { readonly stream: ReadableStream | (() => ReadableStream); /** Declared up front when known — lets the client render progress. */ readonly contentLength?: number; /** e.g. `attachment; filename="export.zip"`. */ readonly contentDisposition?: string; } declare interface PluginHttpRouteRequest { readonly method: string; /** Path WITHOUT query string. */ readonly path: string; /** Query string WITHOUT the leading `?` (empty when absent). Parse with * `new URLSearchParams(req.query)`. */ readonly query: string; readonly headers: Record; readonly rawBody: Uint8Array; /** * The app's DataStore, for a route that must read or write to do its job. * * The same seam as `AuthStrategyInput.store`, one layer over — and the report * that produced it is the same file. An adopter's `auth/db.ts` has five * consumers: two are strategies and collapsed onto `input.store`; three are * plugin HTTP routes and could not, so the second `ManagedRuntime` + * `MysqlClient` stayed for them. * * Login is the sharpest case and it is not exotic: it MUST write (the session * row), it cannot be an rpc mutation because it is what mints the cookie, and * it is a documented first-class pattern — `@voltro/plugin-auth` ships * `handleSignIn` / `handleSignUp` and the reference consumer mounts them here. * Every app that does so needed a store this contract did not give it. * * Unlike a handler's `ctx.store`, this is the BOOT store: routes are mounted * before any request exists, and it arrives through the same lazy getter the * auth chain uses. `undefined` only while the store is still being built and * on an app with no store — answer the request rather than throwing. * * The line is **everything that does not need a Subject** — not "less than * `ctx.store`". `AuthStrategyInput.store` draws it the same way. Spelled out, * because naming only ONE of the absences invites the reader to assume the * rest are present, and a team porting raw SQL onto this seam did exactly * that: * * | Behaviour | here | * |--------------------------------------------|------| * | `.encrypted()` columns decrypt / encrypt | YES | * | Array columns round-trip on non-native dialects | YES | * | Tenant scope | no | * | **Soft-delete filter (`deletedAt IS NULL`)** | **no** | * | Audit-column stamping | no | * | Row-level security | no | * * The four `no`s need a resolved Subject and a route serves raw HTTP without * one, so a route reading tenant-owned rows must derive and apply that scope * itself. That is the price of the surface being raw, and it is why an rpc * procedure remains the better place for anything that CAN be one. * * The soft-delete row is the one worth reading twice if you are porting: a * read here behaves like your raw SQL did and returns tombstones. Nothing * silently starts hiding rows, so a lookup that must see a soft-deleted user * — a login that revives one, say — needs no opt-out. (On the REQUEST store, * where the filter is applied, `.withDeleted()` is the opt-out.) * * Reading a plugin's OWN tables here is a supported use: they are declared * through `extendSchema` like any other, so `getTable(name)` finds them and * this store reads them. */ readonly store?: DataStore; /** * The client address, resolved through the app's `security.trustedProxies` * policy — the SAME value the rate limiter, the geo-block and every audit row * use (`resolveClientAddress`). Use this, never `headers['x-forwarded-for']`. * * `x-forwarded-for` is a request header: any client can write it. Reading it * raw means a caller picks the IP that lands in your `sessions.ipAddress` * column, which is the one field a breach investigation leans on. Three * first-party routes did exactly that until SEC-8 was extended down to this * surface. The resolution here ignores the header entirely unless a trusted * proxy is declared, and then believes only the hops that are one. * * `undefined` when the socket address is unavailable (a unix socket, an * in-process test harness that constructs the request by hand). */ readonly remoteAddr?: string | undefined; } /** * One HTTP endpoint a plugin contributes under the framework's * `/_voltro/inspect/*` introspection surface. Plugins use this to * surface tooling / dashboards that don't fit the rpc wire (e.g. * Server-Sent-Event streams of plugin-internal state, on-demand * health probes, plugin-specific debug dumps). * * Path convention: `/_voltro/inspect/plugins//`. * The plugin slug (derived from `plugin.name` with `@scope/` + * `plugin-` stripped, KEBAB-CASE — `plugin-cdc-out` → `cdc-out`, * instance suffix `#x` → `--x`) is prepended by the framework so * plugin endpoints never collide with the framework's own inspect * endpoints OR with another plugin's. Kebab (not the camelCase rpc * alias) because it's a URL every dashboard fetches. The dashboard * lists every plugin's endpoints grouped by plugin in the manifest. * * Plugin endpoints inherit the SAME auth resolver the framework's * own inspect endpoints use (`VOLTRO_INSPECT_TOKEN` by default; * customisable by passing an `authResolver` at the http-handler * layer). The plugin can NOT bypass auth — that's the framework's * job, not the plugin's. */ /** What a plugin HTTP route returns. `body` may be bytes (e.g. a served * blob) or text; `headers` carries redirects (302 `location`) + cache * policy. */ declare interface PluginHttpRouteResult { readonly status: number; readonly body?: string | Uint8Array; readonly contentType?: string; readonly headers?: Record; /** Stream the response (SSE) instead of sending `body`. See * {@link PluginHttpRouteStream}. */ readonly stream?: PluginHttpRouteStream; /** Stream a BINARY response (a download, an export) instead of sending * `body` — see {@link PluginHttpRouteByteStream}. Never buffered by the * serve layer; never compressed (flush timing + Content-Length are the * contract). Takes precedence over `body`; do not set both `stream` and * `byteStream`. */ readonly byteStream?: PluginHttpRouteByteStream; } /** * A long-lived Server-Sent-Events body. When a route result carries this, the * serve layer streams the response until the client disconnects instead of * sending a buffered body — `body` is ignored. * * `subscribe` receives an `emit` that takes ONE already-SSE-framed chunk (e.g. * `` `event: snapshot\ndata: ${json}\n\n` ``) and MUST return an unsubscribe * function. The serve layer runs that unsubscribe when the client goes away, so * whatever the route opened (a dispatcher subscription, an interval) is released * — a stream route that leaks its subscription leaks it per connection. */ declare interface PluginHttpRouteStream { readonly subscribe: (emit: (chunk: string) => void) => () => void; /** Keep-alive comment interval in ms (default 15000; `0` disables). Without * it an idle SSE connection is dropped by proxies after ~30–60s. */ readonly keepAliveMs?: number; } /** * A relationship (ReBAC) guard — "may this subject perform ACTION on THIS row?" * * The scope guard above answers "what may this subject do at all"; this answers * "on which row". Both live in the same `guards:` array on purpose, because the * alternative is what apps actually built: a hand-maintained map from rpc tag → * policy rule, installed as an interceptor. That map is FAIL-OPEN BY OMISSION — * add an rpc, forget the entry, and it is silently unguarded. A declaration on * the descriptor cannot be forgotten for an rpc that exists, because it IS the * rpc. * * Browser-safe by the same construction as `GuardSpec`: strings plus a PURE * `resource` extractor. Resolution — reading relationship tuples, applying the * policy's `implies` closure — happens server-side through the registered * policy resolver. With no resolver registered the check FAILS CLOSED: an * unanswerable authorization question is a denial, never a pass. * * Because it is data, the same declaration compiles into the capability * manifest the client reads, so a UI gate and the server check cannot drift. */ declare interface PolicyGuardSpec { /** The action to authorize, as named in the resource policy's `actions`. */ readonly action: string; /** Which registered resource policy governs the check. */ readonly resourceType: string; /** PURE `input → resource id`. Returning undefined DENIES — an unidentifiable * resource is not a reason to skip the check. */ readonly resource: (input: Input) => string | undefined; } /** A descriptor kind that can be projected to REST (streams are not, in v1). */ export declare type PublicApiDescriptor = QueryProcedureDescriptor | MutationProcedureDescriptor | ActionProcedureDescriptor; /** A descriptor paired with its bound executor, for batch projection at boot. */ export declare interface PublicApiEntry { readonly descriptor: PublicApiDescriptor; readonly invoke: PublicApiInvoke; /** Live-subscription binding, required for a `stream: 'sse'` query. Absent → * the route serves the first snapshot as JSON instead of streaming. */ readonly subscribe?: PublicApiSubscribe; } /** The bound executor a public route runs — the real rpc handler, invoked * under the request's (apiKey) subject. `ctx` carries that subject (resolved * by the REST desugar's `resolveSubject`) so the serve binding runs the * handler through the SAME AuthMiddleware/RBAC/tenant/audit as the rpc path. */ export declare type PublicApiInvoke = (input: unknown, ctx: RestRouteContext) => Promise | unknown; /** * Synthesize the public REST route for ONE descriptor. The descriptor's input * schema is wrapped into the REST `{ query }` (GET) / `{ body }` (other) shape * the desugar decodes, then handed UNWRAPPED to `invoke`. Output is the * descriptor's output schema verbatim (a query is drained to its first snapshot * by the serve layer — same as POST /rpc — unless `stream: 'sse'` streams it). * `scopes` become * `requireScope` guards on top of the handler's own RBAC. */ export declare const publicApiRoute: (descriptor: PublicApiDescriptor, invoke: PublicApiInvoke, subscribe?: PublicApiSubscribe) => RestRouteDescriptor, unknown>; declare interface PublicApiSpec { /** Derived by kind when omitted: query→GET, mutation/action→POST. */ readonly method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; /** Derived from the tag + version when omitted: `//`. */ readonly path?: string; /** Path version segment; multiple versions coexist as separate descriptors. */ readonly version?: string; /** Additional API-key scopes required ON TOP of the handler's RBAC. */ readonly scopes?: ReadonlyArray; /** Per-endpoint rate limit (rides @voltro/plugin-ratelimit when mounted). */ readonly rateLimit?: { readonly limit: number; readonly window: string; }; /** Honor `Idempotency-Key` for mutating methods (existing HTTP idempotency). */ readonly idempotent?: boolean; /** Replacement hint → `Deprecation: true` header. */ readonly deprecated?: string; /** ISO date → `Sunset:` header, `410` past the date. */ readonly sunset?: string; readonly summary?: string; readonly description?: string; /** Streaming queries over request/response: 'snapshot' (default — first * snapshot, like POST /rpc) | 'sse' (Server-Sent Events of snapshot+deltas). */ readonly stream?: 'snapshot' | 'sse'; } /** * Open a live subscription for a `stream: 'sse'` query — the streaming * counterpart to {@link PublicApiInvoke}. `emit` takes ONE subscription event * (the initial `snapshot`, then each `delta`); the returned unsubscribe runs * when the HTTP client disconnects. Supplied by the boot layer, which owns the * dispatcher; this module only frames the events as SSE. */ export declare type PublicApiSubscribe = (input: unknown, ctx: RestRouteContext, emit: (event: { readonly _tag: string; readonly [k: string]: unknown; }) => void) => () => void; /** * Server-side snapshot caching for a query (see `defineQuery`). When set, * the dispatcher caches the initial snapshot result and auto-invalidates it * when a mutation writes any table the query depends on. */ declare interface QueryCacheConfig { /** Fresh window — seconds (number) or a duration string (`'30s'`, * `'5m'`, `'1h'`). */ readonly ttl: number | string; /** Stale-while-revalidate window past `ttl` (same units). While stale, * the cached value is served immediately and refreshed in the * background. */ readonly swr?: number | string | undefined; /** * Cross-subject safety — REQUIRED, no default, because guessing wrong leaks * rows. * * - `'subject'` keys by the caller's subject id. Always safe, and recomputes * per PERSON — for a figure that is identical for everyone in an org, that * is one identical computation per employee. * - `'tenant'` keys by the caller's `tenantId`: one entry per org, none * shared across orgs. The right answer for anything derived from * `subject.tenantId` — a rollup, a dashboard figure, a count. * - `'global'` shares ONE entry across every caller. Legal ONLY when the * resolved query is caller-independent (reference data). * * Rubric: does the resolved predicate depend on the caller? On the PERSON → * `subject`; on their ORG only → `tenant`; not at all → `global`. * * `'tenant'` exists because the other two were the only options and neither * fits an org-wide figure: `subject` recomputes it per person, and `global` * is not a cache but a cross-tenant leak. A deployment reported computing the * same nine-table statistic up to 18 times for 18 employees rather than take * the second option, which was the correct call. * * A caller with NO tenant (anonymous) BYPASSES a `'tenant'` cache rather than * falling back — falling back to `global` would be the leak this option * exists to avoid, and falling back to `subject` would silently change the * cardinality of a cache the author sized per org. */ readonly scope: 'subject' | 'tenant' | 'global'; } declare interface QueryProcedureDescriptor { readonly kind: 'query'; readonly name: Name; readonly input: Input; readonly output: Output; readonly error: Error; /** Which DataStore table(s) this query reads. Drives auto-optimistic patch * routing — mutations that target ANY of these tables patch caches keyed * to queries with a matching `source`. A computed query re-runs when ANY * listed table changes (pass an array to depend on several). Optional: * queries without a source never receive auto-patches (joins, aggregates). */ readonly source: string | ReadonlyArray | undefined; /** Server-side snapshot cache config. Absent → never cached (the * default; the dispatcher already keeps live subscriptions fresh). */ readonly cache: QueryCacheConfig | undefined; /** Declarative authorization guard(s) — enforced before the executor runs, * failing with a typed `ScopeError`. Absent → no framework-level authz. */ readonly guards: DeclaredAccess | undefined; /** The declared reason this procedure needs NO authorization check — * `openAccess: ''`. Mutually exclusive with `guards`; together they are * the only two shapes `security.defaultDeny` accepts. */ readonly openAccess: string | undefined; /** Opt this query into a public REST endpoint (innovation/11). */ readonly publicApi: PublicApiSpec | undefined; /** Opt this query into the auto-synthesized agent toolset (innovation/07). */ readonly exposeAsTool: ExposeAsTool | undefined; /** True when the procedure is kept OFF the wire — no client-group entry and no * route in dev or serve. See `internal` on the definer's options. */ /** * Replace a PLUGIN route that answers to this same tag. * * Without it, a user route and a plugin route sharing a tag is a hard error, * and correctly so — two handlers behind one name is not a thing a caller can * reason about. But refusing is the wrong answer when the app deliberately * wants its own version: the two escapes available otherwise are to rename * your procedure (so the split runs along "who built it" rather than along a * domain boundary) or to `alias` the whole plugin away (same, one level up). * For a frontend developer that is the worst possible partition. * * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose * surface is richer than theirs, add `archive`/`unarchive` beside it — which * already composes, since the collision check compares FULL tags and not * prefixes — and replace `markRead`, because theirs maintains archive state. * * Explicit, never inferred. Silently letting the app win would mean a plugin * upgrade that adds a route could shadow an app procedure with no diff to * read; declaring it makes the intent reviewable and puts the override in the * file that performs it. */ readonly overridesPlugin: boolean | undefined; readonly internal: boolean | undefined; } /** * Passes when the subject holds ANY of `scopes` (or `admin:full`). * * For a route whose permitted callers differ by REQUEST SHAPE rather than by * route — issuing an api key for yourself, for the org, or on behalf of someone * else are three different rights on one endpoint. The guard establishes "may * mint something"; the handler decides which, because only the body says which * was asked for. Splitting them into three routes instead would move a decision * that depends on the body into the URL, where a client could pick. */ export declare const requireAnyScope: (scopes: ReadonlyArray) => RestGuard; /** * Guard that requires the resolved subject to carry `scope`. Anonymous / * scope-less subjects are rejected with `403`. The blanket admin scope * (`ADMIN_SCOPE` = `'admin:full'`) satisfies any required scope — the SAME * bypass the rpc-handler `requireScope`/`hasScope` (`./scopes`) honours, so * a subject granted admin is admin on both surfaces. */ export declare const requireScope: (scope: string) => RestGuard; /** A BINARY streaming response (download/export). The serve layer pipes the * ReadableStream without buffering — a body larger than the heap is fine. * The LAZY thunk form defers opening the source until the response streams. */ export declare interface RestByteResponse { readonly __voltroRestBytes: true; readonly byteStream: { readonly stream: ReadableStream | (() => ReadableStream); readonly contentLength?: number; readonly contentDisposition?: string; }; readonly contentType?: string; } export declare type RestGuard = (ctx: RestRouteContext) => RestGuardRejection | undefined | Promise; /** * A guard runs after input decode + ctx resolution, before the handler. * Returning a `RestGuardRejection` short-circuits with that status; any * other return (or `undefined`) lets the request proceed. */ export declare interface RestGuardRejection { readonly status: number; readonly message: string; } export declare type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; /** * Per-call context handed to a REST route handler. Resolved by the serve * pipeline: `subject` from the framework's auth resolver, `headers` * lowercased, `store` the framework `DataStore` injected the same way * plugins receive it via `bindDataStore` (kept untyped here so protocol * stays free of the database dep). */ export declare interface RestRouteContext { readonly subject: Subject; readonly headers: Readonly>; /** Framework `DataStore`. Untyped in protocol (same discipline as * `PluginHttpRoute` / `bindDataStore`); cast to the concrete store at * the call site. `undefined` only in unit tests that don't inject one. */ readonly store: unknown; } export declare interface RestRouteDescriptor { readonly method: RestMethod; /** Absolute path, e.g. `/v1/customers`. Matches that path exactly. */ readonly path: string; /** Input schema. Shape is `{ query?, params?, body? }` — the desugar * parses the query string, path params, and JSON body into that shape * before decoding. Omit for routes that take no input. The schema's * decoded `Type` flows to the handler's `input`; its `Encoded` is left * open (`any`) so any `Schema.Struct(...)` is accepted. */ readonly input?: Schema.Schema; /** Output schema. The handler returns the decoded `Type`; the desugar * encodes it to JSON for the `200` response. */ readonly output: Schema.Schema; readonly handler: (input: I, ctx: RestRouteContext) => Promise | O; readonly summary?: string; readonly description?: string; readonly example?: RestRouteExample; /** Replacement hint. Sets a `Deprecation: true` response header. */ readonly deprecated?: string; /** ISO date. Sets a `Sunset:` header; past the date the route returns * `410 Gone` with a replacement pointer. */ readonly sunset?: string; /** * Opt-in API version. `version: 'v2'` + `path: '/customers'` mounts the route * at `/v2/customers` — the same `/vN/` convention the `publicApi:` projection * has always used (`derivePublicPath`) and the built-in `/v1/api-keys` * surface follows. * * OPT-IN on purpose: a route without `version` keeps its literal `path` * untouched. An automatic prefix would silently move every deployed route — * a second breaking change hiding inside a naming feature. * * Two versions of one resource are TWO descriptors: the old version is * ordinary code — visible, testable, deletable — carrying `deprecated` (the * replacement pointer) and `sunset` (the date it starts answering `410`, * whose body then also names this `version`). There is no transformation * DSL, and the rpc SOCKET is deliberately outside this: the generated client * is versioned with the server it was generated from (a stale browser tab * runs the previous client until reload — that skew window exists and is * documented, it is not solved by URL versioning). */ readonly version?: `v${number}`; readonly guards?: ReadonlyArray; /** * This route STREAMS (Server-Sent Events) rather than resolving one value — its * handler returns `sse(...)`. Declared on the descriptor, not left implicit in * the handler, so anything that INSPECTS routes without running them (the * OpenAPI generator, the inspect manifest) can tell a stream from a buffered * response. A spec that documents a stream as `application/json` is worse than * no spec, because clients are generated from it. */ readonly streaming?: boolean; /** Per-route body cap override (bytes) — see PluginHttpRoute.maxBodyBytes. */ readonly maxBodyBytes?: number; /** GET only: derive a weak ETag from the encoded response and answer a * matching `If-None-Match` with 304. The tag is content-derived (an md5 * of the JSON), so it is correct across content-encodings — the * transport's compression varies the bytes, not the representation. */ readonly etag?: boolean; } export declare interface RestRouteExample { readonly request?: unknown; readonly response?: unknown; } /** * Desugar a list of REST descriptors into `PluginHttpRoute[]` ready to * spread into a plugin/app's `httpRoutes`. `bindings` is supplied by the * serve pipeline (auth resolver + bound DataStore); omit it in unit tests * to get an anonymous subject and an undefined store. */ export declare const restRoutesToHttpRoutes: (routes: ReadonlyArray>, bindings?: RestServeBindings) => ReadonlyArray; /** * Default ctx resolver used when the serve pipeline doesn't inject one * (e.g. unit tests). Real mounts pass a resolver that reads the framework's * auth subject + the bound DataStore. */ export declare interface RestServeBindings { /** Resolve the calling subject from the request headers. */ readonly resolveSubject?: (headers: Readonly>) => Subject | Promise; /** The bound framework DataStore. */ readonly store?: unknown; /** * HTTP idempotency — when set, a mutating request (POST/PUT/PATCH/DELETE) * carrying the `header` is deduplicated: the first call runs + caches its * response; a replay within `ttlMs` returns the cached response * (`Idempotency-Replayed: true`); an in-flight duplicate gets `409`. Scoped * per (tenant, method, path). The serve path injects the store + config from * `app.config.ts`'s `idempotency` field. */ readonly idempotency?: { readonly store: IdempotencyStore; readonly header: string; readonly ttlMs: number; }; } /** * What a REST handler returns to stream its response instead of resolving one * value: `sse((emit) => unsubscribe)`. The route's schema `output` is not used * for a stream (each frame is framed as it is produced), and everything the * desugar does BEFORE the handler — method gate, sunset, input decode, guards, * subject resolution — still applies. */ export declare interface RestStreamResponse { readonly __voltroRestStream: true; readonly stream: PluginHttpRouteStream; } /** * Build a streaming (Server-Sent Events) handler result. `subscribe` gets an * `emit` for one already-framed SSE chunk and MUST return an unsubscribe, which * the serve layer runs on client disconnect. * * handler: () => sse((emit) => { * const stop = watch((row) => emit(sseFrame('change', row))) * return stop * }) */ export declare const sse: (subscribe: (emit: (chunk: string) => void) => () => void, options?: { readonly keepAliveMs?: number; }) => RestStreamResponse; /** Frame ONE SSE event. Newlines in `data` are split across `data:` lines, as * the protocol requires — a raw `\n` inside one would truncate the event. */ export declare const sseFrame: (event: string, data: unknown) => string; declare const Subject: Schema.Union<[Schema.Struct<{ type: Schema.Literal<["user"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["apiKey"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["serviceAccount"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["anonymous"]>; id: typeof Schema.Null; tenantId: Schema.NullOr; /** * Set when this caller PRESENTED a credential and it was rejected — an * expired token above all. Absent when they presented none. * * The two are the same Subject and must not be the same ANSWER. A deployment * measured the cost: a user's tab outlived their IdP's token lifetime, the * strategy logged `supabase jwt expired`, the caller fell through to * anonymous, and the guard then refused with `missing required scope * 'task:u:o'`. Technically true — an anonymous caller holds no scopes — and * it sent everyone who read it into the permissions system while the problem * was an expired session. They did that round. * * It stays a FALLBACK rather than a hard failure on purpose: a stale cookie * must not break an `openAccess` procedure that needs no session at all. The * fact travels, and only a guard that actually refuses spends it. */ credentialRejected: Schema.optional; }>, Schema.Struct<{ type: Schema.Literal<["system"]>; id: typeof Schema.String; tenantId: typeof Schema.Null; scopes: Schema.optional>; metadata: Schema.optional>; }>]>; declare type Subject = typeof Subject.Type; declare type Target = TargetSpec | ReadonlyArray>; declare type TargetSpec = InsertTarget | UpdateTarget | DeleteTarget; declare interface UpdateTarget> extends NestedTargetFields { readonly table: string; readonly op: 'update'; /** Identify the row(s) to patch. Default: `input.id`. Return an ARRAY to patch * MANY rows/items in one mutation (a bulk edit — where the per-item * parallel-write race lived). */ readonly identify?: ((input: Input) => string | ReadonlyArray) | undefined; /** Build the patch for a FLAT target (`current` is the OUTPUT row). Default: * merges input over current. For a NESTED (`path`) target use `shapeItem`. */ readonly shape?: ((input: Input, current: Row) => Row) | undefined; /** NESTED (`path`) update: build the item patch. `current` is the existing * ITEM (not the mutation output), so no cast is needed. */ readonly shapeItem?: ((input: Input, current: Item) => Item) | undefined; } export { }