@voltro/protocol 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/jwt.js ADDED
@@ -0,0 +1,142 @@
1
+ import { readCookie as e } from "./session.js";
2
+ import { createRemoteJWKSet as t, errors as n, jwtVerify as r } from "jose";
3
+ //#region src/jwt.ts
4
+ var i = 3600 * 1e3, a = /* @__PURE__ */ new Map(), o = (e, n) => {
5
+ let r = a.get(e);
6
+ if (r && Date.now() - r.fetchedAt < r.ttlMs) return r.fetcher;
7
+ let i = t(new URL(e), {
8
+ cacheMaxAge: n,
9
+ cooldownDuration: 3e4
10
+ });
11
+ return a.set(e, {
12
+ fetcher: i,
13
+ fetchedAt: Date.now(),
14
+ ttlMs: n
15
+ }), i;
16
+ }, s = () => {
17
+ a.clear();
18
+ }, c = class extends Error {
19
+ code;
20
+ constructor(e, t) {
21
+ super(e), this.code = t, this.name = "JwtVerifyError";
22
+ }
23
+ }, l = ["ES256", "RS256"], u = async (e, t, n = {}) => {
24
+ let a = o(t, n.jwksCacheTtlMs ?? i);
25
+ try {
26
+ let { payload: t } = await r(e, a, {
27
+ ...n.issuer === void 0 ? {} : { issuer: n.issuer },
28
+ ...n.audience === void 0 ? {} : { audience: n.audience },
29
+ clockTolerance: n.clockToleranceS ?? 60,
30
+ algorithms: [...n.algorithms ?? l]
31
+ });
32
+ return t;
33
+ } catch (e) {
34
+ throw _(e);
35
+ }
36
+ }, d = ["HS256"], f = async (e, t, n = {}) => {
37
+ let i = new TextEncoder().encode(t);
38
+ try {
39
+ let { payload: t } = await r(e, i, {
40
+ ...n.issuer === void 0 ? {} : { issuer: n.issuer },
41
+ ...n.audience === void 0 ? {} : { audience: n.audience },
42
+ clockTolerance: n.clockToleranceS ?? 60,
43
+ algorithms: [...n.algorithms ?? d]
44
+ });
45
+ return t;
46
+ } catch (e) {
47
+ throw _(e);
48
+ }
49
+ }, p = (e, t) => {
50
+ let n = e;
51
+ for (let e of t.split(".")) {
52
+ if (typeof n != "object" || !n) return;
53
+ n = n[e];
54
+ }
55
+ return n;
56
+ }, m = /* @__PURE__ */ new Map(), h = async (e) => {
57
+ let t = m.get(e);
58
+ if (t) return t;
59
+ let n = fetch(e).then(async (t) => {
60
+ if (!t.ok) throw Error(`OIDC discovery fetch failed: ${t.status} ${t.statusText}`);
61
+ let n = await t.json();
62
+ if (typeof n.jwks_uri != "string") throw Error(`OIDC discovery document missing jwks_uri at ${e}`);
63
+ return n;
64
+ }).catch((t) => {
65
+ throw m.delete(e), t;
66
+ });
67
+ return m.set(e, n), n;
68
+ }, g = () => {
69
+ m.clear();
70
+ }, _ = (e) => {
71
+ if (e instanceof n.JWTExpired) return new c("jwt expired", "expired");
72
+ if (e instanceof n.JWTClaimValidationFailed) {
73
+ let t = e.claim;
74
+ return t === "iss" ? new c("issuer mismatch", "issuer_mismatch") : t === "aud" ? new c("audience mismatch", "audience_mismatch") : t === "nbf" ? new c("not yet valid", "not_yet_valid") : new c(`claim '${t ?? "?"}' failed`, "other");
75
+ }
76
+ if (e instanceof n.JWSSignatureVerificationFailed) return new c("signature verification failed", "signature_mismatch");
77
+ if (e instanceof n.JWSInvalid || e instanceof n.JWTInvalid) return new c("malformed jwt", "malformed");
78
+ if (e instanceof n.JOSEAlgNotAllowed) return new c("jwt algorithm not allowed", "unsupported_algorithm");
79
+ let t = e?.message ?? String(e);
80
+ return /fetch|network|enotfound|econnrefused/i.test(t) ? new c(`jwks unreachable: ${t}`, "jwks_unreachable") : new c(`jwt verify failed: ${t}`, "other");
81
+ }, v = (t, n) => {
82
+ let r = t.authorization ?? t.Authorization;
83
+ if (r?.startsWith("Bearer ")) {
84
+ let e = r.slice(7).trim();
85
+ if (e) return e;
86
+ }
87
+ if (n) {
88
+ let r = e(t.cookie, n);
89
+ if (r) return r;
90
+ }
91
+ }, y = (e) => {
92
+ let t = e.cookieName === void 0 ? null : e.cookieName;
93
+ return {
94
+ id: e.id,
95
+ resolve: async (n) => {
96
+ let r = v(n.headers, t);
97
+ if (!r) return { kind: "skip" };
98
+ try {
99
+ let t = e.jwtSecret === void 0 ? await u(r, e.jwksUrl, {
100
+ ...e.issuer === void 0 ? {} : { issuer: e.issuer },
101
+ ...e.audience === void 0 ? {} : { audience: e.audience },
102
+ ...e.algorithms === void 0 ? {} : { algorithms: e.algorithms }
103
+ }) : await f(r, e.jwtSecret, {
104
+ ...e.issuer === void 0 ? {} : { issuer: e.issuer },
105
+ ...e.audience === void 0 ? {} : { audience: e.audience },
106
+ ...e.algorithms === void 0 ? {} : { algorithms: e.algorithms }
107
+ }), n = e.tenantIdFromClaims(t);
108
+ if (!n) return {
109
+ kind: "failed",
110
+ reason: `${e.id} token has no tenant claim and no defaultTenantId configured`
111
+ };
112
+ let i = e.subjectIdFromClaims ? e.subjectIdFromClaims(t) : String(t.sub ?? "");
113
+ return i ? {
114
+ kind: "matched",
115
+ subject: {
116
+ type: "user",
117
+ id: i,
118
+ tenantId: n,
119
+ ...e.scopesFromClaims === void 0 ? {} : { scopes: e.scopesFromClaims(t) },
120
+ metadata: {
121
+ provider: e.id,
122
+ claims: t
123
+ }
124
+ }
125
+ } : {
126
+ kind: "failed",
127
+ reason: `${e.id} token has no sub claim`
128
+ };
129
+ } catch (t) {
130
+ return t instanceof c ? {
131
+ kind: "failed",
132
+ reason: `${e.id} jwt ${t.code}: ${t.message}`
133
+ } : {
134
+ kind: "failed",
135
+ reason: `${e.id} verify error: ${t.message}`
136
+ };
137
+ }
138
+ }
139
+ };
140
+ };
141
+ //#endregion
142
+ export { c as JwtVerifyError, s as _resetJwksCache, g as _resetOidcDiscoveryCache, h as discoverOidc, v as extractBearerOrCookie, y as jwtBearerStrategy, p as readClaimPath, e as readCookie, u as verifyJwt, f as verifyJwtWithSecret };
package/dist/rest.d.ts ADDED
@@ -0,0 +1,450 @@
1
+ import { Schema } from 'effect';
2
+
3
+ declare interface ActionProcedureDescriptor<Name extends string, Input extends Schema.Schema.Any, Output extends Schema.Schema.Any, Error extends Schema.Schema.All> {
4
+ readonly kind: 'action';
5
+ readonly name: Name;
6
+ readonly input: Input;
7
+ readonly output: Output;
8
+ readonly error: Error;
9
+ /** Opt this action into a public REST endpoint (innovation/11). */
10
+ readonly publicApi: PublicApiSpec | undefined;
11
+ /** Opt this action into the auto-synthesized agent toolset (innovation/07). */
12
+ readonly exposeAsTool: ExposeAsTool | undefined;
13
+ }
14
+
15
+ /**
16
+ * Project every descriptor carrying a `publicApi` annotation into a REST
17
+ * route, in stable (path) order. The boot layer feeds these straight into
18
+ * `restRoutesToHttpRoutes` alongside any hand-authored `restRoutes`.
19
+ */
20
+ export declare const collectPublicApiRoutes: (entries: ReadonlyArray<PublicApiEntry>) => ReadonlyArray<RestRouteDescriptor<Record<string, unknown>, unknown>>;
21
+
22
+ /**
23
+ * Identity helper that fixes the descriptor's types (like `defineAction` /
24
+ * `definePlugin`). Returns its argument unchanged at runtime; exists for
25
+ * the type-level enforcement at the declaration site.
26
+ */
27
+ export declare const defineRestRoute: <I, O>(descriptor: RestRouteDescriptor<I, O>) => RestRouteDescriptor<I, O>;
28
+
29
+ declare interface DeleteTarget<Input = unknown> {
30
+ readonly table: string;
31
+ readonly op: 'delete';
32
+ /** Identify the row to remove. Default: `input.id`. */
33
+ readonly identify?: ((input: Input) => string) | undefined;
34
+ }
35
+
36
+ /** query→GET, mutation/action→POST, unless the spec overrides. */
37
+ export declare const derivePublicMethod: (kind: PublicApiDescriptor["kind"], spec: PublicApiSpec) => RestMethod;
38
+
39
+ /** `/<version>/<tag-with-dots-as-slashes>` unless the spec gives an explicit
40
+ * path. `orders.create` @ v1 → `/v1/orders/create`. */
41
+ export declare const derivePublicPath: (tag: string, spec: PublicApiSpec) => string;
42
+
43
+ /**
44
+ * Dispatch a request across REST routes that SHARE a path. Every REST route
45
+ * mounts as `*` and gates its own method, returning a precise 405 BEFORE any
46
+ * side effect when the method isn't its own — so probing the group in order and
47
+ * taking the FIRST non-405 result yields the route that owns the request's
48
+ * method. This is what lets the standard GET + POST on one resource path
49
+ * coexist: the serve pipeline mounts ONE dispatcher per path (the underlying
50
+ * router rejects two mounts on the same `(method, path)`). A single-route group
51
+ * returns that route's result directly (its own 405 included); when no route in
52
+ * the group owns the method, the last 405 stands.
53
+ *
54
+ * `group` must be non-empty (the serve pipeline only builds a dispatcher for a
55
+ * path that has at least one route).
56
+ */
57
+ export declare const dispatchSharedPath: (group: ReadonlyArray<PluginHttpRoute>, req: PluginHttpRouteRequest) => Promise<PluginHttpRouteResult>;
58
+
59
+ /** Normalize the `exposeAsTool` shorthand. `true` is only valid when the
60
+ * descriptor carries a top-level `description`; callers pass that in. */
61
+ declare type ExposeAsTool = boolean | ExposeAsToolSpec;
62
+
63
+ declare interface ExposeAsToolSpec {
64
+ /** Shown to the model — REQUIRED to expose (a tool with no description is
65
+ * unusable). What the tool does + when to call it. */
66
+ readonly description: string;
67
+ /** Require human confirmation of the concrete call before it executes.
68
+ * Default posture: writes (mutation/action) confirm, reads don't. */
69
+ readonly confirm?: boolean;
70
+ /** Cap how many times the agent may call this tool per run. */
71
+ readonly maxPerRun?: number;
72
+ }
73
+
74
+ declare interface IdempotencyRecord {
75
+ readonly scope: string;
76
+ readonly key: string;
77
+ readonly status: 'in_flight' | 'completed';
78
+ readonly response: IdempotencyResponse | null;
79
+ /** epoch ms */
80
+ readonly createdAt: number;
81
+ }
82
+
83
+ declare interface IdempotencyResponse {
84
+ readonly status: number;
85
+ readonly body: unknown;
86
+ }
87
+
88
+ declare interface IdempotencyStore {
89
+ /** Current record for (scope, key), or null. Read-side (inspect/dashboard). */
90
+ readonly get: (scope: string, key: string) => Promise<IdempotencyRecord | null>;
91
+ /**
92
+ * Atomically claim (scope, key) IF absent OR stale (older than `ttlMs`):
93
+ * write a fresh `in_flight` row and return `'claimed'`. Otherwise return the
94
+ * EXISTING (still-fresh) record. The atomicity here is the whole game — two
95
+ * concurrent same-key requests both hit this, exactly one gets `'claimed'`,
96
+ * the loser reads the winner's record. In SQL this is one
97
+ * `INSERT … ON CONFLICT DO UPDATE … WHERE existing.createdAt < now - ttl`.
98
+ */
99
+ readonly claim: (scope: string, key: string, now: number, ttlMs: number) => Promise<'claimed' | IdempotencyRecord>;
100
+ /** Flip the claim to `completed` and store the response. */
101
+ readonly complete: (scope: string, key: string, response: IdempotencyResponse, now: number) => Promise<void>;
102
+ /** Drop the claim (handler errored → a retry may re-process). */
103
+ readonly release: (scope: string, key: string) => Promise<void>;
104
+ }
105
+
106
+ declare interface InsertTarget<Input = unknown, Row = unknown> {
107
+ readonly table: string;
108
+ readonly op: 'insert';
109
+ readonly order?: 'prepend' | 'append' | undefined;
110
+ /**
111
+ * Build the optimistic row from the mutation input. The framework
112
+ * injects `id` (from `optimisticId`) and the `optimistic: true` flag
113
+ * around the return — your `shape` returns ONLY the user-controllable
114
+ * row body. Default when omitted: `{ ...input, id, optimistic: true }`.
115
+ *
116
+ * Return type intentionally excludes `id`: it's server-generated;
117
+ * the optimistic id placeholder is the framework's responsibility.
118
+ * Excluding `optimistic` (also framework-injected) follows the same
119
+ * principle — `Omit<Row, 'id' | 'optimistic'>` lets you describe
120
+ * EVERYTHING ELSE without re-stating the bookkeeping fields.
121
+ *
122
+ * The `optimisticId` parameter is exposed for rare cases where the
123
+ * shape function needs to reference it (e.g., setting a foreign key
124
+ * on a child row spread in the same optimistic insert).
125
+ */
126
+ readonly shape?: ((input: Input, optimisticId: string) => Omit<Row, 'id' | 'optimistic'>) | undefined;
127
+ }
128
+
129
+ /** Extract `:name` path params by aligning the route PATTERN with the request
130
+ * path segment-by-segment (`/projects/:project/deploy` + `/projects/acme/deploy`
131
+ * → `{ project: 'acme' }`). `PluginHttpRouteRequest` carries no matched params,
132
+ * so the desugar re-derives them here — without this, `input.params` is always
133
+ * empty and every `:param` route 400s on decode. */
134
+ export declare const matchPathParams: (pattern: string, path: string) => Record<string, string>;
135
+
136
+ declare interface MutationProcedureDescriptor<Name extends string, Input extends Schema.Schema.Any, Output extends Schema.Schema.Any, Error extends Schema.Schema.All> {
137
+ readonly kind: 'mutation';
138
+ readonly name: Name;
139
+ readonly input: Input;
140
+ readonly output: Output;
141
+ readonly error: Error;
142
+ /** Declarative target(s) — drives auto-optimistic on the client AND
143
+ * surfaces which tables this mutation touches (debug, future
144
+ * query-invalidation analytics). Mutations without a target run
145
+ * normally but skip auto-optimistic. */
146
+ readonly target: Target | undefined;
147
+ /** Opt this mutation into a public REST endpoint (innovation/11). */
148
+ readonly publicApi: PublicApiSpec | undefined;
149
+ /** Opt this mutation into the auto-synthesized agent toolset (innovation/07). */
150
+ readonly exposeAsTool: ExposeAsTool | undefined;
151
+ }
152
+
153
+ /** A public raw-HTTP route a plugin serves on the framework listener. */
154
+ declare interface PluginHttpRoute {
155
+ /** HTTP method, or `'*'` for any (the handler decides). */
156
+ readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'DELETE';
157
+ /** Absolute path prefix, e.g. `/_voltro/storage`. Matches the path AND
158
+ * any sub-path (`/_voltro/storage/abc123`). */
159
+ readonly path: string;
160
+ readonly handle: (req: PluginHttpRouteRequest) => Promise<PluginHttpRouteResult>;
161
+ }
162
+
163
+ declare interface PluginHttpRouteRequest {
164
+ readonly method: string;
165
+ /** Path WITHOUT query string. */
166
+ readonly path: string;
167
+ /** Query string WITHOUT the leading `?` (empty when absent). Parse with
168
+ * `new URLSearchParams(req.query)`. */
169
+ readonly query: string;
170
+ readonly headers: Record<string, string>;
171
+ readonly rawBody: Uint8Array;
172
+ }
173
+
174
+ /**
175
+ * One HTTP endpoint a plugin contributes under the framework's
176
+ * `/_voltro/inspect/*` introspection surface. Plugins use this to
177
+ * surface tooling / dashboards that don't fit the rpc wire (e.g.
178
+ * Server-Sent-Event streams of plugin-internal state, on-demand
179
+ * health probes, plugin-specific debug dumps).
180
+ *
181
+ * Path convention: `/_voltro/inspect/plugins/<plugin-slug>/<route.path>`.
182
+ * The plugin slug (derived from `plugin.name` with `@scope/` +
183
+ * `plugin-` stripped, KEBAB-CASE — `plugin-cdc-out` → `cdc-out`,
184
+ * instance suffix `#x` → `--x`) is prepended by the framework so
185
+ * plugin endpoints never collide with the framework's own inspect
186
+ * endpoints OR with another plugin's. Kebab (not the camelCase rpc
187
+ * alias) because it's a URL every dashboard fetches. The dashboard
188
+ * lists every plugin's endpoints grouped by plugin in the manifest.
189
+ *
190
+ * Plugin endpoints inherit the SAME auth resolver the framework's
191
+ * own inspect endpoints use (`VOLTRO_INSPECT_TOKEN` by default;
192
+ * customisable by passing an `authResolver` at the http-handler
193
+ * layer). The plugin can NOT bypass auth — that's the framework's
194
+ * job, not the plugin's.
195
+ */
196
+ /** What a plugin HTTP route returns. `body` may be bytes (e.g. a served
197
+ * blob) or text; `headers` carries redirects (302 `location`) + cache
198
+ * policy. */
199
+ declare interface PluginHttpRouteResult {
200
+ readonly status: number;
201
+ readonly body?: string | Uint8Array;
202
+ readonly contentType?: string;
203
+ readonly headers?: Record<string, string>;
204
+ }
205
+
206
+ /** A descriptor kind that can be projected to REST (streams are not, in v1). */
207
+ export declare type PublicApiDescriptor = QueryProcedureDescriptor<string, Schema.Schema.Any, Schema.Schema.Any, Schema.Schema.All> | MutationProcedureDescriptor<string, Schema.Schema.Any, Schema.Schema.Any, Schema.Schema.All> | ActionProcedureDescriptor<string, Schema.Schema.Any, Schema.Schema.Any, Schema.Schema.All>;
208
+
209
+ /** A descriptor paired with its bound executor, for batch projection at boot. */
210
+ export declare interface PublicApiEntry {
211
+ readonly descriptor: PublicApiDescriptor;
212
+ readonly invoke: PublicApiInvoke;
213
+ }
214
+
215
+ /** The bound executor a public route runs — the real rpc handler, invoked
216
+ * under the request's (apiKey) subject. `ctx` carries that subject (resolved
217
+ * by the REST desugar's `resolveSubject`) so the serve binding runs the
218
+ * handler through the SAME AuthMiddleware/RBAC/tenant/audit as the rpc path. */
219
+ export declare type PublicApiInvoke = (input: unknown, ctx: RestRouteContext) => Promise<unknown> | unknown;
220
+
221
+ /**
222
+ * Synthesize the public REST route for ONE descriptor. The descriptor's input
223
+ * schema is wrapped into the REST `{ query }` (GET) / `{ body }` (other) shape
224
+ * the desugar decodes, then handed UNWRAPPED to `invoke`. Output is the
225
+ * descriptor's output schema verbatim (a streaming query is drained to its
226
+ * first snapshot by the serve layer — same as POST /rpc). `scopes` become
227
+ * `requireScope` guards on top of the handler's own RBAC.
228
+ */
229
+ export declare const publicApiRoute: (descriptor: PublicApiDescriptor, invoke: PublicApiInvoke) => RestRouteDescriptor<Record<string, unknown>, unknown>;
230
+
231
+ declare interface PublicApiSpec {
232
+ /** Derived by kind when omitted: query→GET, mutation/action→POST. */
233
+ readonly method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
234
+ /** Derived from the tag + version when omitted: `/<version>/<tag-as-path>`. */
235
+ readonly path?: string;
236
+ /** Path version segment; multiple versions coexist as separate descriptors. */
237
+ readonly version?: string;
238
+ /** Additional API-key scopes required ON TOP of the handler's RBAC. */
239
+ readonly scopes?: ReadonlyArray<string>;
240
+ /** Per-endpoint rate limit (rides @voltro/plugin-ratelimit when mounted). */
241
+ readonly rateLimit?: {
242
+ readonly limit: number;
243
+ readonly window: string;
244
+ };
245
+ /** Honor `Idempotency-Key` for mutating methods (existing HTTP idempotency). */
246
+ readonly idempotent?: boolean;
247
+ /** Replacement hint → `Deprecation: true` header. */
248
+ readonly deprecated?: string;
249
+ /** ISO date → `Sunset:` header, `410` past the date. */
250
+ readonly sunset?: string;
251
+ readonly summary?: string;
252
+ readonly description?: string;
253
+ /** Streaming queries over request/response: 'snapshot' (default — first
254
+ * snapshot, like POST /rpc) | 'sse' (Server-Sent Events of snapshot+deltas). */
255
+ readonly stream?: 'snapshot' | 'sse';
256
+ }
257
+
258
+ /**
259
+ * Server-side snapshot caching for a query (see `defineQuery`). When set,
260
+ * the dispatcher caches the initial snapshot result and auto-invalidates it
261
+ * when a mutation writes any table the query depends on.
262
+ */
263
+ declare interface QueryCacheConfig {
264
+ /** Fresh window — seconds (number) or a duration string (`'30s'`,
265
+ * `'5m'`, `'1h'`). */
266
+ readonly ttl: number | string;
267
+ /** Stale-while-revalidate window past `ttl` (same units). While stale,
268
+ * the cached value is served immediately and refreshed in the
269
+ * background. */
270
+ readonly swr?: number | string | undefined;
271
+ /**
272
+ * Cross-subject safety — REQUIRED, no default, because guessing wrong
273
+ * leaks rows. `'subject'` keys the cache by the caller's subject id
274
+ * (safe for tenant- or row-filtered queries). `'global'` shares ONE
275
+ * entry across every caller — legal ONLY when the resolved query is
276
+ * subject-independent (reference data). Rubric: does the resolved
277
+ * predicate depend on the caller? Yes → `subject`; no → `global`.
278
+ */
279
+ readonly scope: 'subject' | 'global';
280
+ }
281
+
282
+ declare interface QueryProcedureDescriptor<Name extends string, Input extends Schema.Schema.Any, Output extends Schema.Schema.Any, Error extends Schema.Schema.All> {
283
+ readonly kind: 'query';
284
+ readonly name: Name;
285
+ readonly input: Input;
286
+ readonly output: Output;
287
+ readonly error: Error;
288
+ /** Which DataStore table(s) this query reads. Drives auto-optimistic patch
289
+ * routing — mutations that target ANY of these tables patch caches keyed
290
+ * to queries with a matching `source`. A computed query re-runs when ANY
291
+ * listed table changes (pass an array to depend on several). Optional:
292
+ * queries without a source never receive auto-patches (joins, aggregates). */
293
+ readonly source: string | ReadonlyArray<string> | undefined;
294
+ /** Server-side snapshot cache config. Absent → never cached (the
295
+ * default; the dispatcher already keeps live subscriptions fresh). */
296
+ readonly cache: QueryCacheConfig | undefined;
297
+ /** Opt this query into a public REST endpoint (innovation/11). */
298
+ readonly publicApi: PublicApiSpec | undefined;
299
+ /** Opt this query into the auto-synthesized agent toolset (innovation/07). */
300
+ readonly exposeAsTool: ExposeAsTool | undefined;
301
+ }
302
+
303
+ /**
304
+ * Guard that requires the resolved subject to carry `scope`. Anonymous /
305
+ * scope-less subjects are rejected with `403`. The blanket admin scope
306
+ * (`ADMIN_SCOPE` = `'admin:full'`) satisfies any required scope — the SAME
307
+ * bypass the rpc-handler `requireScope`/`hasScope` (`./scopes`) honours, so
308
+ * a subject granted admin is admin on both surfaces.
309
+ */
310
+ export declare const requireScope: (scope: string) => RestGuard;
311
+
312
+ export declare type RestGuard = (ctx: RestRouteContext) => RestGuardRejection | undefined | Promise<RestGuardRejection | undefined>;
313
+
314
+ /**
315
+ * A guard runs after input decode + ctx resolution, before the handler.
316
+ * Returning a `RestGuardRejection` short-circuits with that status; any
317
+ * other return (or `undefined`) lets the request proceed.
318
+ */
319
+ export declare interface RestGuardRejection {
320
+ readonly status: number;
321
+ readonly message: string;
322
+ }
323
+
324
+ export declare type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
325
+
326
+ /**
327
+ * Per-call context handed to a REST route handler. Resolved by the serve
328
+ * pipeline: `subject` from the framework's auth resolver, `headers`
329
+ * lowercased, `store` the framework `DataStore` injected the same way
330
+ * plugins receive it via `bindDataStore` (kept untyped here so protocol
331
+ * stays free of the database dep).
332
+ */
333
+ export declare interface RestRouteContext {
334
+ readonly subject: Subject;
335
+ readonly headers: Readonly<Record<string, string>>;
336
+ /** Framework `DataStore`. Untyped in protocol (same discipline as
337
+ * `PluginHttpRoute` / `bindDataStore`); cast to the concrete store at
338
+ * the call site. `undefined` only in unit tests that don't inject one. */
339
+ readonly store: unknown;
340
+ }
341
+
342
+ export declare interface RestRouteDescriptor<I, O> {
343
+ readonly method: RestMethod;
344
+ /** Absolute path, e.g. `/v1/customers`. Matches that path exactly. */
345
+ readonly path: string;
346
+ /** Input schema. Shape is `{ query?, params?, body? }` — the desugar
347
+ * parses the query string, path params, and JSON body into that shape
348
+ * before decoding. Omit for routes that take no input. The schema's
349
+ * decoded `Type` flows to the handler's `input`; its `Encoded` is left
350
+ * open (`any`) so any `Schema.Struct(...)` is accepted. */
351
+ readonly input?: Schema.Schema<I, any, never>;
352
+ /** Output schema. The handler returns the decoded `Type`; the desugar
353
+ * encodes it to JSON for the `200` response. */
354
+ readonly output: Schema.Schema<O, any, never>;
355
+ readonly handler: (input: I, ctx: RestRouteContext) => Promise<O> | O;
356
+ readonly summary?: string;
357
+ readonly description?: string;
358
+ readonly example?: RestRouteExample;
359
+ /** Replacement hint. Sets a `Deprecation: true` response header. */
360
+ readonly deprecated?: string;
361
+ /** ISO date. Sets a `Sunset:` header; past the date the route returns
362
+ * `410 Gone` with a replacement pointer. */
363
+ readonly sunset?: string;
364
+ readonly guards?: ReadonlyArray<RestGuard>;
365
+ }
366
+
367
+ export declare interface RestRouteExample {
368
+ readonly request?: unknown;
369
+ readonly response?: unknown;
370
+ }
371
+
372
+ /**
373
+ * Desugar a list of REST descriptors into `PluginHttpRoute[]` ready to
374
+ * spread into a plugin/app's `httpRoutes`. `bindings` is supplied by the
375
+ * serve pipeline (auth resolver + bound DataStore); omit it in unit tests
376
+ * to get an anonymous subject and an undefined store.
377
+ */
378
+ export declare const restRoutesToHttpRoutes: (routes: ReadonlyArray<RestRouteDescriptor<any, any>>, bindings?: RestServeBindings) => ReadonlyArray<PluginHttpRoute>;
379
+
380
+ /**
381
+ * Default ctx resolver used when the serve pipeline doesn't inject one
382
+ * (e.g. unit tests). Real mounts pass a resolver that reads the framework's
383
+ * auth subject + the bound DataStore.
384
+ */
385
+ export declare interface RestServeBindings {
386
+ /** Resolve the calling subject from the request headers. */
387
+ readonly resolveSubject?: (headers: Readonly<Record<string, string>>) => Subject | Promise<Subject>;
388
+ /** The bound framework DataStore. */
389
+ readonly store?: unknown;
390
+ /**
391
+ * HTTP idempotency — when set, a mutating request (POST/PUT/PATCH/DELETE)
392
+ * carrying the `header` is deduplicated: the first call runs + caches its
393
+ * response; a replay within `ttlMs` returns the cached response
394
+ * (`Idempotency-Replayed: true`); an in-flight duplicate gets `409`. Scoped
395
+ * per (tenant, method, path). The serve path injects the store + config from
396
+ * `app.config.ts`'s `idempotency` field.
397
+ */
398
+ readonly idempotency?: {
399
+ readonly store: IdempotencyStore;
400
+ readonly header: string;
401
+ readonly ttlMs: number;
402
+ };
403
+ }
404
+
405
+ declare const Subject: Schema.Union<[Schema.Struct<{
406
+ type: Schema.Literal<["user"]>;
407
+ id: typeof Schema.String;
408
+ tenantId: typeof Schema.String;
409
+ scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
410
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
411
+ }>, Schema.Struct<{
412
+ type: Schema.Literal<["apiKey"]>;
413
+ id: typeof Schema.String;
414
+ tenantId: typeof Schema.String;
415
+ scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
416
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
417
+ }>, Schema.Struct<{
418
+ type: Schema.Literal<["serviceAccount"]>;
419
+ id: typeof Schema.String;
420
+ tenantId: typeof Schema.String;
421
+ scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
422
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
423
+ }>, Schema.Struct<{
424
+ type: Schema.Literal<["anonymous"]>;
425
+ id: typeof Schema.Null;
426
+ tenantId: Schema.NullOr<typeof Schema.String>;
427
+ }>, Schema.Struct<{
428
+ type: Schema.Literal<["system"]>;
429
+ id: typeof Schema.String;
430
+ tenantId: typeof Schema.Null;
431
+ scopes: Schema.optional<Schema.Array$<typeof Schema.String>>;
432
+ metadata: Schema.optional<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
433
+ }>]>;
434
+
435
+ declare type Subject = typeof Subject.Type;
436
+
437
+ declare type Target<Input = unknown, Row = unknown> = TargetSpec<Input, Row> | ReadonlyArray<TargetSpec<Input, Row>>;
438
+
439
+ declare type TargetSpec<Input = unknown, Row = unknown> = InsertTarget<Input, Row> | UpdateTarget<Input, Row> | DeleteTarget<Input>;
440
+
441
+ declare interface UpdateTarget<Input = unknown, Row = unknown> {
442
+ readonly table: string;
443
+ readonly op: 'update';
444
+ /** Identify the row to patch. Default: `input.id`. */
445
+ readonly identify?: ((input: Input) => string) | undefined;
446
+ /** Build the patch. Default: merges input over current. */
447
+ readonly shape?: ((input: Input, current: Row) => Row) | undefined;
448
+ }
449
+
450
+ export { }