@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/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +347 -0
- package/dist/apikey.d.ts +88 -0
- package/dist/apikey.js +32 -0
- package/dist/auth-DCE6m7Bo.js +64 -0
- package/dist/index.d.ts +2564 -0
- package/dist/index.js +396 -0
- package/dist/jwt.d.ts +230 -0
- package/dist/jwt.js +142 -0
- package/dist/rest.d.ts +450 -0
- package/dist/rest.js +142 -0
- package/dist/serverErrorBus-C1KVL0dr.js +0 -0
- package/dist/session.d.ts +262 -0
- package/dist/session.js +106 -0
- package/package.json +67 -0
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 { }
|