@ultimat3/query 29.3.2 → 30.0.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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The four ways a download query is refused, in their own module for `audit-errors.ts`'s reason:
3
+ * the TITLES are registered in `errors.ts` with every code this package owns, and a read that
4
+ * declares no download never constructs one of these.
5
+ */
6
+
7
+ import { UltimateError } from '@ultimat3/core';
8
+
9
+ /** `query({ download })` written wrong — refused where it is declared, never on the first request. */
10
+ export class QueryDownloadInvalidError extends UltimateError {
11
+ constructor(problem: string) {
12
+ super({
13
+ code: 'X_QUERY_DOWNLOAD_INVALID',
14
+ cause: `a download query cannot be built: ${problem}`,
15
+ fix: 'query({ input, policy, download: { expiresInMs: 300_000, handle: async ({ input, ctx }) => ({ body, contentType, filename }) } }) // no sql and no rows option beside download',
16
+ meta: { problem },
17
+ });
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Rows asked of a query that answers a file. Every rows projection reaches `sql`, and a download
23
+ * stores one that throws this — so `.live()`, `.page()`, `.as()`, `sourceFor`, `explain` and an MCP
24
+ * tool are refused by ONE line rather than each remembering to check.
25
+ */
26
+ export class QueryDownloadOnlyError extends UltimateError {
27
+ constructor(query: string) {
28
+ super({
29
+ code: 'X_QUERY_DOWNLOAD_ONLY',
30
+ cause: `"${query}" is a download query: it answers a file at a signed URL, and has no sql to read rows with`,
31
+ fix: 'await recording.url(input) # a download query mints its signed URL; await recording(input) is the file in-process',
32
+ meta: { query },
33
+ });
34
+ }
35
+ }
36
+
37
+ /** Why a token was refused. Kept in `meta`, never on the wire beyond the code: 403 is the answer. */
38
+ export type DownloadTokenProblem = 'malformed' | 'unauthenticated' | 'other-query' | 'other-input';
39
+
40
+ /**
41
+ * A `_token` this app did not mint for THIS query and THIS input. One code for every reason, so a
42
+ * caller probing tokens learns nothing about which part of a forgery was wrong.
43
+ */
44
+ export class QueryDownloadTokenInvalidError extends UltimateError {
45
+ constructor(query: string, problem: DownloadTokenProblem) {
46
+ super({
47
+ code: 'X_QUERY_DOWNLOAD_TOKEN_INVALID',
48
+ cause: `the download URL for "${query}" carries a token this app did not mint for it and this input`,
49
+ fix: `await queryClient<Api['queries']>({ baseUrl }).${query}.url(input) # a fresh URL; request it unchanged`,
50
+ meta: { query, problem },
51
+ });
52
+ }
53
+ }
54
+
55
+ /** A genuine token past its expiry: the one refusal a fresh mint repairs, so it has its own code. */
56
+ export class QueryDownloadTokenExpiredError extends UltimateError {
57
+ constructor(query: string, expiresAt: string) {
58
+ super({
59
+ code: 'X_QUERY_DOWNLOAD_TOKEN_EXPIRED',
60
+ cause: `the download URL for "${query}" expired at ${expiresAt}`,
61
+ fix: `await queryClient<Api['queries']>({ baseUrl }).${query}.url(input) # right before the request; or raise download.expiresInMs (at most 3_600_000)`,
62
+ meta: { query, expiresAt },
63
+ });
64
+ }
65
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * A download query's route — the same `GET /_x/query/<kebab>` every read answers on, so the typed
3
+ * client, `x routes`, the manifest and OpenAPI need no second URL rule. Without `_token` it MINTS
4
+ * (`{ url, expiresAt }`, the policy deciding as for any read); with one it SERVES the file: bytes,
5
+ * `content-disposition`, one `Range:` window, and `no-store` — the bytes are one actor's.
6
+ */
7
+
8
+ import { assert, queryPath, useContext } from '@ultimat3/core';
9
+ import type { RateLimitDecision, Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
10
+ import {
11
+ byteWindow,
12
+ jsonResponse,
13
+ type ObjectRequest,
14
+ objectResponse,
15
+ publishRateLimit,
16
+ type RequestedRange,
17
+ } from '@ultimat3/http';
18
+ import { policyCapability } from '@ultimat3/policy';
19
+ import { coerceQuery } from '@ultimat3/schema';
20
+ import type { DownloadFile } from './download';
21
+ import { mintDownloadUrl, serveDownloadFile } from './download';
22
+ import { DOWNLOAD_GRANT_PARAM } from './download-token';
23
+ import { QueryInputInvalidError } from './errors';
24
+ import { rateLimitedByHandler } from './http';
25
+ import { absentArraysOf } from './input-shape';
26
+ import { pageControlsOf } from './page-controls';
27
+ import type { AnyQuery } from './query';
28
+
29
+ /** The route for `target`, whose `download` is declared. `sunsetting` is `toQueryRoute`'s. */
30
+ export function toDownloadRoute(
31
+ target: AnyQuery,
32
+ name: string,
33
+ sunsetting: Readonly<Record<string, string>> | undefined,
34
+ ): Route {
35
+ const handler = async (request: UltimateRequest): Promise<Response> => {
36
+ if (sunsetting !== undefined) {
37
+ for (const [header, value] of Object.entries(sunsetting)) {
38
+ request.ctx.headers.set(header, value);
39
+ }
40
+ }
41
+ const { values, token } = tokenOf(name, request.queryRaw());
42
+ // A page of a file is not a thing: `_first`/`_after` are refused as on a single read.
43
+ const { input: rest, page } = pageControlsOf(name, values);
44
+ if (page !== undefined) {
45
+ throw new QueryInputInvalidError(name, 'a download answers one file — it takes no _first');
46
+ }
47
+ const input = absentArraysOf(target.input, coerceQuery(target.input, rest));
48
+ if (token === undefined) {
49
+ const minted = await mintDownloadUrl(target, input, {
50
+ surface: 'http',
51
+ clientAddress: request.ctx.ip,
52
+ onRateLimit: (decision: RateLimitDecision) =>
53
+ publishRateLimit(request.ctx, decision, useContext().now().getTime()),
54
+ });
55
+ return jsonResponse(minted);
56
+ }
57
+ const file = await serveDownloadFile(target, input, token, { surface: 'http' });
58
+ return fileResponse(request, file);
59
+ };
60
+
61
+ const meta: RouteMeta = {
62
+ name,
63
+ // PUBLIC, and not unguarded: the mint runs this read's policy inside the handler like every
64
+ // read (`enforcedBy`), and a serve is authorized by the sealed token the policy minted — the
65
+ // reason a URL works in a `<video>` or a tab with no session header at all.
66
+ auth: 'public',
67
+ policy: policyCapability(target.policy),
68
+ enforcedBy: 'handler',
69
+ primitive: 'query',
70
+ // One actor's bytes under a URL that names no actor: nothing may keep a copy.
71
+ cache: { mode: 'no-store' },
72
+ tags: ['query', 'download'],
73
+ ...(target.rateLimit === undefined
74
+ ? {}
75
+ : { rateLimitedBy: rateLimitedByHandler(name, target.rateLimit) }),
76
+ };
77
+ return { method: 'GET', path: queryPath(name), handler, meta };
78
+ }
79
+
80
+ /** `_token` out of the search string — one string, or a refusal: two tokens are two grants. */
81
+ function tokenOf(
82
+ name: string,
83
+ values: Readonly<Record<string, string | readonly string[] | undefined>>,
84
+ ): {
85
+ readonly values: Record<string, string | readonly string[] | undefined>;
86
+ readonly token: string | undefined;
87
+ } {
88
+ const rest: Record<string, string | readonly string[] | undefined> = Object.create(null);
89
+ for (const [key, value] of Object.entries(values)) {
90
+ if (key !== DOWNLOAD_GRANT_PARAM) rest[key] = value;
91
+ }
92
+ const token = values[DOWNLOAD_GRANT_PARAM];
93
+ if (token === undefined || typeof token === 'string') return { values: rest, token };
94
+ throw new QueryInputInvalidError(name, `${DOWNLOAD_GRANT_PARAM} was sent ${token.length} times`);
95
+ }
96
+
97
+ /**
98
+ * A lone UTF-16 surrogate has no UTF-8 form, so `encodeURIComponent` throws a bare `URIError` on it.
99
+ * Spelled out because `String#isWellFormed` is ES2024 and the lib is ES2023 (core's cookie rule).
100
+ */
101
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
102
+
103
+ /**
104
+ * What a served file may load: nothing from anywhere, but its own bytes as an image or a video —
105
+ * the tab a browser renders an image or a film in fetches that one URL. `sandbox` alone still let
106
+ * an inline HTML file fetch `<img src="https://elsewhere">` and carry this bearer URL in `Referer`.
107
+ */
108
+ export const DOWNLOAD_CSP = "default-src 'none'; img-src 'self'; media-src 'self'; sandbox";
109
+
110
+ /** A C0 control or DEL — what turns one header value into two. */
111
+ const hasControl = (text: string): boolean =>
112
+ [...text].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f);
113
+
114
+ /**
115
+ * The handler's answer, held to its type before a header is written: a `contentType` or a
116
+ * `filename` carrying a CR/LF would be a second header, and a body that is not bytes is a 500 the
117
+ * handler's author has to see named.
118
+ */
119
+ function assertFile(file: DownloadFile): void {
120
+ const { body, contentType, filename, disposition, size } = file;
121
+ assert(
122
+ body instanceof Uint8Array ||
123
+ body instanceof Blob ||
124
+ body instanceof ReadableStream ||
125
+ typeof body === 'function',
126
+ 'a download handle() answered a body that is not a Uint8Array, a Blob, a ReadableStream or a function',
127
+ "return { body: bytes, contentType: 'video/mp4', filename: 'run.mp4' } // bytes, never a base64 string",
128
+ );
129
+ assert(
130
+ typeof body !== 'function' || size !== undefined,
131
+ 'a download handle() answered a function body with no size, so no Range: can be judged',
132
+ "return { body: (range) => open(range), size: object.size, contentType: 'video/mp4', filename: 'run.mp4' }",
133
+ );
134
+ assert(
135
+ typeof contentType === 'string' && contentType !== '' && !hasControl(contentType),
136
+ 'a download handle() answered a contentType that is empty, not a string, or carries a control character',
137
+ "return { body, contentType: 'video/mp4', filename }",
138
+ );
139
+ assert(
140
+ typeof filename === 'string' &&
141
+ filename !== '' &&
142
+ !hasControl(filename) &&
143
+ !LONE_SURROGATE.test(filename),
144
+ 'a download handle() answered a filename that is empty, not a string, or carries a control character or a lone UTF-16 surrogate',
145
+ "return { body, contentType, filename: 'run.mp4' }",
146
+ );
147
+ assert(
148
+ disposition === undefined || disposition === 'attachment' || disposition === 'inline',
149
+ `a download handle() answered disposition ${String(disposition)}`,
150
+ "disposition: 'attachment' (the default) or 'inline'",
151
+ );
152
+ assert(
153
+ size === undefined || (Number.isSafeInteger(size) && size >= 0),
154
+ `a download handle() answered size ${String(size)}, which is not a whole number of bytes`,
155
+ 'size: stream.byteLength // the stream’s length in bytes, or omit it to serve it whole',
156
+ );
157
+ }
158
+
159
+ /**
160
+ * `attachment; filename="run_1.mp4"; filename*=UTF-8''run%E2%80%931.mp4` — the quoted name ASCII
161
+ * with every other character `_` (a quote or a backslash would end it), the starred one exact.
162
+ */
163
+ export function contentDisposition(file: DownloadFile): string {
164
+ const ascii = file.filename.replace(/[^\u0020-\u007e]|["\\]/g, '_');
165
+ const exact = encodeURIComponent(file.filename).replace(
166
+ /['()*]/g,
167
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
168
+ );
169
+ return `${file.disposition ?? 'attachment'}; filename="${ascii}"; filename*=UTF-8''${exact}`;
170
+ }
171
+
172
+ /** The body's length when it can be known without reading it. */
173
+ function sizeOf(file: DownloadFile): number | undefined {
174
+ if (file.body instanceof Uint8Array) return file.body.byteLength;
175
+ if (file.body instanceof Blob) return file.body.size;
176
+ return file.size;
177
+ }
178
+
179
+ /**
180
+ * A view `Response` accepts. A `Uint8Array` over a `SharedArrayBuffer` is not a body, so only that
181
+ * case is copied; every other view is re-wrapped over its own buffer, no byte moved.
182
+ */
183
+ function bytesBody(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
184
+ const { buffer } = bytes;
185
+ return buffer instanceof ArrayBuffer
186
+ ? new Uint8Array(buffer, bytes.byteOffset, bytes.byteLength)
187
+ : bytes.slice();
188
+ }
189
+
190
+ function bodyOf(file: DownloadFile, range?: RequestedRange): BodyInit | Promise<BodyInit> {
191
+ const { body } = file;
192
+ if (typeof body === 'function') return body(range);
193
+ if (body instanceof Uint8Array) {
194
+ const whole = bytesBody(body);
195
+ return range === undefined ? whole : whole.subarray(range.start, range.end + 1);
196
+ }
197
+ if (body instanceof Blob) {
198
+ return range === undefined ? body : body.slice(range.start, range.end + 1);
199
+ }
200
+ return range === undefined ? body : byteWindow(body, range);
201
+ }
202
+
203
+ /**
204
+ * Status, headers and body for one file and the request's `Range:`, by `objectResponse`'s rules.
205
+ * The headers are the file's own and stand in for its sandbox rule. A file of unknown size has no
206
+ * window to serve, so it answers whole; there is no etag on a file either, so no 304.
207
+ */
208
+ export async function fileResponse(request: ObjectRequest, file: DownloadFile): Promise<Response> {
209
+ assertFile(file);
210
+ const own = {
211
+ 'content-disposition': contentDisposition(file),
212
+ 'x-content-type-options': 'nosniff',
213
+ // Loads nothing but itself, in an opaque origin with no script — and names no URL on the way
214
+ // out: the one this file is served at is a bearer grant until it expires.
215
+ 'content-security-policy': DOWNLOAD_CSP,
216
+ 'referrer-policy': 'no-referrer',
217
+ };
218
+ const size = sizeOf(file);
219
+ if (size === undefined) {
220
+ return new Response(await bodyOf(file), {
221
+ headers: { 'content-type': file.contentType, 'accept-ranges': 'none', ...own },
222
+ });
223
+ }
224
+ const response = await objectResponse(
225
+ request,
226
+ {
227
+ object: { contentType: file.contentType, size },
228
+ open: (range) => bodyOf(file, range),
229
+ },
230
+ { headers: own },
231
+ );
232
+ // Nothing opened it, and nothing else will read it: a stream the handler started is let go.
233
+ if (response.status === 416 && file.body instanceof ReadableStream) {
234
+ void file.body.cancel().catch(() => undefined);
235
+ }
236
+ return response;
237
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * A download's `_token`: who the policy admitted, for which query and input, until when — SEALED
3
+ * with core's `seal()` (AES-256-GCM under the master key, the query's name as the purpose).
4
+ * Sealed, not signed: it carries the minting actor, and the AEAD tag is the signature.
5
+ */
6
+
7
+ import type { Actor, ActorInit, ActorKind } from '@ultimat3/core';
8
+ import {
9
+ agentActor,
10
+ anonymousActor,
11
+ fingerprint,
12
+ isActorKind,
13
+ isJsonObject,
14
+ open,
15
+ SealInvalidError,
16
+ SealKeyUnknownError,
17
+ seal,
18
+ serviceActor,
19
+ userActor,
20
+ } from '@ultimat3/core';
21
+ import {
22
+ type DownloadTokenProblem,
23
+ QueryDownloadTokenExpiredError,
24
+ QueryDownloadTokenInvalidError,
25
+ } from './download-errors';
26
+
27
+ /** The search key the token rides under — `_`-prefixed like the page controls, so no input owns it. */
28
+ export const DOWNLOAD_GRANT_PARAM = '_token';
29
+
30
+ /** Bumped when the claims change shape: an old token is then refused, never misread. */
31
+ const TOKEN_VERSION = 1;
32
+
33
+ /**
34
+ * The minting actor, minus `facts`. Facts are app values resolved per request — often not JSON at
35
+ * all — so they are not carried: the policy decided WITH them at mint, and the handler runs under
36
+ * the actor's identity, tenancy and grants.
37
+ */
38
+ interface ReplayActor {
39
+ readonly k: ActorKind;
40
+ readonly i: string;
41
+ readonly o?: string;
42
+ readonly r: readonly string[];
43
+ readonly s: readonly string[];
44
+ readonly p: readonly string[];
45
+ readonly b?: { readonly actorId: string; readonly actorKind: ActorKind };
46
+ readonly l?: string;
47
+ readonly z?: string;
48
+ }
49
+
50
+ interface Claims {
51
+ readonly v: number;
52
+ /** The query's name — also the seal's purpose, and checked again here. */
53
+ readonly q: string;
54
+ /** `fingerprint()` of the PARSED input: a URL whose input was edited no longer matches. */
55
+ readonly f: string;
56
+ /** Expiry, epoch milliseconds, on the context's clock. */
57
+ readonly e: number;
58
+ readonly a: ReplayActor;
59
+ }
60
+
61
+ const purposeOf = (query: string): string => `ultimate.query.download:${query}`;
62
+
63
+ function replayOf(actor: Actor): ReplayActor {
64
+ return {
65
+ k: actor.kind,
66
+ i: actor.id,
67
+ ...(actor.orgId === undefined ? {} : { o: actor.orgId }),
68
+ r: actor.roles,
69
+ s: actor.scopes,
70
+ p: actor.permissions,
71
+ ...(actor.onBehalfOf === undefined ? {} : { b: actor.onBehalfOf }),
72
+ ...(actor.locale === undefined ? {} : { l: actor.locale }),
73
+ ...(actor.tz === undefined ? {} : { z: actor.tz }),
74
+ };
75
+ }
76
+
77
+ /** Seal the claims for one admitted call. `input` is the PARSED input the policy decided about. */
78
+ export function mintDownloadToken(
79
+ query: string,
80
+ input: unknown,
81
+ actor: Actor,
82
+ expiresAtMs: number,
83
+ ): Promise<string> {
84
+ const claims: Claims = {
85
+ v: TOKEN_VERSION,
86
+ q: query,
87
+ f: fingerprint(input),
88
+ e: expiresAtMs,
89
+ a: replayOf(actor),
90
+ };
91
+ return seal(JSON.stringify(claims), { purpose: purposeOf(query) });
92
+ }
93
+
94
+ /** What an opened token grants: the actor to run as, and the input it was minted for. */
95
+ export interface DownloadGrant {
96
+ readonly actor: Actor;
97
+ /** `fingerprint()` of the parsed input the policy admitted. */
98
+ readonly input: string;
99
+ }
100
+
101
+ /**
102
+ * The grant a token carries — or a refusal, in the order `@ultimat3/storage` checks a signed URL
103
+ * in: the seal (forged, truncated, another query's, a retired key) BEFORE the expiry, so an
104
+ * expired forgery is answered as a forgery. Opened BEFORE the input is parsed, so a holder of no
105
+ * genuine token learns nothing about the read's schema from an `X_INPUT_INVALID`.
106
+ */
107
+ export async function openDownloadToken(
108
+ query: string,
109
+ token: string,
110
+ nowMs: number,
111
+ ): Promise<DownloadGrant> {
112
+ const claims = await claimsOf(query, token);
113
+ if (claims.e <= nowMs) {
114
+ throw new QueryDownloadTokenExpiredError(query, new Date(claims.e).toISOString());
115
+ }
116
+ return { actor: actorOf(query, claims.a), input: claims.f };
117
+ }
118
+
119
+ /** The URL's parsed input against the one the token was minted for: an edited URL is refused. */
120
+ export function assertGrantedInput(query: string, grant: DownloadGrant, input: unknown): void {
121
+ if (grant.input !== fingerprint(input)) throw refused(query, 'other-input');
122
+ }
123
+
124
+ const refused = (query: string, problem: DownloadTokenProblem): QueryDownloadTokenInvalidError =>
125
+ new QueryDownloadTokenInvalidError(query, problem);
126
+
127
+ async function claimsOf(query: string, token: string): Promise<Claims> {
128
+ let text: string;
129
+ try {
130
+ text = new TextDecoder().decode(await open(token, { purpose: purposeOf(query) }));
131
+ } catch (error) {
132
+ // A tag that does not verify, a string that is no sealed value, or a key this process no
133
+ // longer declares: every one is "not a token this app can honour". A MISSING master key is
134
+ // the server's fault, not the URL's, and keeps its own code (`X_SEAL_KEY_MISSING`, a 500).
135
+ if (error instanceof SealInvalidError || error instanceof SealKeyUnknownError) {
136
+ throw refused(query, error instanceof SealKeyUnknownError ? 'unauthenticated' : 'malformed');
137
+ }
138
+ throw error;
139
+ }
140
+ const claims = parseClaims(text);
141
+ if (claims === undefined) throw refused(query, 'malformed');
142
+ if (claims.q !== query) throw refused(query, 'other-query');
143
+ return claims;
144
+ }
145
+
146
+ const isStrings = (value: unknown): value is readonly string[] =>
147
+ Array.isArray(value) && value.every((item) => typeof item === 'string');
148
+
149
+ /** The claims, or `undefined` for any shape this version did not write. Rebuilt, never cast. */
150
+ function parseClaims(text: string): Claims | undefined {
151
+ let raw: unknown;
152
+ try {
153
+ raw = JSON.parse(text);
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ if (!isJsonObject(raw) || raw['v'] !== TOKEN_VERSION) return undefined;
158
+ const { q, f, e } = raw;
159
+ if (typeof q !== 'string' || typeof f !== 'string') return undefined;
160
+ if (typeof e !== 'number' || !Number.isSafeInteger(e)) return undefined;
161
+ const a = replayFrom(raw['a']);
162
+ return a === undefined ? undefined : { v: TOKEN_VERSION, q, f, e, a };
163
+ }
164
+
165
+ const optionalText = (value: unknown): value is string | undefined =>
166
+ value === undefined || typeof value === 'string';
167
+
168
+ function replayFrom(a: unknown): ReplayActor | undefined {
169
+ if (!isJsonObject(a)) return undefined;
170
+ const { k, i, o, r, s, p, b, l, z } = a;
171
+ if (!isActorKind(k) || typeof i !== 'string') return undefined;
172
+ if (!isStrings(r) || !isStrings(s) || !isStrings(p)) return undefined;
173
+ if (!optionalText(o) || !optionalText(l) || !optionalText(z)) return undefined;
174
+ let behalf: ReplayActor['b'];
175
+ if (b !== undefined) {
176
+ if (!isJsonObject(b)) return undefined;
177
+ const { actorId, actorKind } = b;
178
+ if (typeof actorId !== 'string' || !isActorKind(actorKind)) return undefined;
179
+ behalf = { actorId, actorKind };
180
+ }
181
+ return {
182
+ k,
183
+ i,
184
+ r,
185
+ s,
186
+ p,
187
+ ...(o === undefined ? {} : { o }),
188
+ ...(behalf === undefined ? {} : { b: behalf }),
189
+ ...(l === undefined ? {} : { l }),
190
+ ...(z === undefined ? {} : { z }),
191
+ };
192
+ }
193
+
194
+ function actorOf(query: string, replay: ReplayActor): Actor {
195
+ if (replay.k === 'anonymous') return anonymousActor();
196
+ const init: ActorInit = {
197
+ id: replay.i,
198
+ orgId: replay.o,
199
+ roles: replay.r,
200
+ scopes: replay.s,
201
+ permissions: replay.p,
202
+ onBehalfOf: replay.b,
203
+ locale: replay.l,
204
+ tz: replay.z,
205
+ };
206
+ switch (replay.k) {
207
+ case 'user':
208
+ return userActor(init);
209
+ case 'service':
210
+ return serviceActor(init);
211
+ case 'agent':
212
+ return agentActor(init);
213
+ default:
214
+ throw refused(query, 'malformed');
215
+ }
216
+ }