@ultimat3/query 29.3.2 → 29.4.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/CLAUDE.md +19 -0
- package/README.md +67 -0
- package/package.json +7 -7
- package/src/client.ts +48 -51
- package/src/download-errors.ts +65 -0
- package/src/download-http.ts +226 -0
- package/src/download-token.ts +216 -0
- package/src/download.ts +242 -0
- package/src/errors.ts +4 -0
- package/src/http.ts +3 -1
- package/src/index.ts +26 -1
- package/src/input-shape.ts +4 -0
- package/src/openapi.ts +64 -0
- package/src/query.ts +39 -1
- package/src/read.ts +40 -7
- package/src/search-string.ts +47 -0
- package/src/sql.ts +2 -1
- package/src/type-pins.ts +45 -0
|
@@ -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
|
+
}
|
package/src/download.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `query({ input, policy, download })` — a read whose answer is a FILE, served at a short-lived
|
|
3
|
+
* signed URL. Still a `query`: the same input parse, the same policy, the same declared limit, the
|
|
4
|
+
* same audit, one name in the registry and the manifest. Only the answer differs, so the rows
|
|
5
|
+
* projections (`live`, `page`, `as`, MCP, `explain`) are refused by the `sql` this file stores.
|
|
6
|
+
*
|
|
7
|
+
* Two calls make a download. `url(input)` runs the read's front half as the caller and seals a
|
|
8
|
+
* token naming the caller, the input and an expiry. `GET <url>` opens the token, re-parses the
|
|
9
|
+
* input from the URL and runs `handle` as the minting actor — the policy decided at mint, and the
|
|
10
|
+
* token is what it decided. That is what lets a `<video src>` or a link handed to someone without
|
|
11
|
+
* the session's headers fetch the bytes, and what keeps a URL from outliving `expiresInMs`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Ctx, Deprecation } from '@ultimat3/core';
|
|
15
|
+
import { queryPath, useContext } from '@ultimat3/core';
|
|
16
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
17
|
+
import type { DownloadUrl, QueryCallOptions, QueryClientOptions } from './client';
|
|
18
|
+
import { downloadClientFor } from './client';
|
|
19
|
+
import { QueryDownloadInvalidError, QueryDownloadOnlyError } from './download-errors';
|
|
20
|
+
import {
|
|
21
|
+
assertGrantedInput,
|
|
22
|
+
DOWNLOAD_GRANT_PARAM,
|
|
23
|
+
mintDownloadToken,
|
|
24
|
+
openDownloadToken,
|
|
25
|
+
} from './download-token';
|
|
26
|
+
import type { QueryPolicy } from './policy-gate';
|
|
27
|
+
import type { AnyQuery, AnyQueryDef, QueryDescriptor, QueryOptions, QueryRateLimit } from './query';
|
|
28
|
+
import { admittedCall, defOf, queryName, stashDef } from './read';
|
|
29
|
+
import { searchOf } from './search-string';
|
|
30
|
+
|
|
31
|
+
export type { DownloadUrl } from './client';
|
|
32
|
+
|
|
33
|
+
/** How long a minted URL works when the declaration names nothing: long enough to start a player. */
|
|
34
|
+
export const DEFAULT_DOWNLOAD_TTL_MS = 300_000;
|
|
35
|
+
/** The floor and ceiling of `expiresInMs`. A URL is a bearer capability; an hour is the most. */
|
|
36
|
+
export const MIN_DOWNLOAD_TTL_MS = 1_000;
|
|
37
|
+
export const MAX_DOWNLOAD_TTL_MS = 3_600_000;
|
|
38
|
+
|
|
39
|
+
/** The written reason the token's serve skips the policy — `admit`'s escape, never a boolean. */
|
|
40
|
+
const TOKEN_IS_THE_GRANT =
|
|
41
|
+
'a download token is sealed only by url(), after this query admitted the minting actor; the token is that decision';
|
|
42
|
+
|
|
43
|
+
/** What `handle` answers. Bytes, never a base64 string — that is the +33 % this exists to remove. */
|
|
44
|
+
export interface DownloadFile {
|
|
45
|
+
/**
|
|
46
|
+
* A `Uint8Array` or a `Blob` (a `Bun.file`, a slice) is range-served from its own length; a
|
|
47
|
+
* `ReadableStream` is range-served only with `size`, else whole with `accept-ranges: none`.
|
|
48
|
+
*/
|
|
49
|
+
readonly body: Uint8Array | Blob | ReadableStream<Uint8Array>;
|
|
50
|
+
readonly contentType: string;
|
|
51
|
+
/** The name a browser saves it under — `content-disposition`'s `filename` and `filename*`. */
|
|
52
|
+
readonly filename: string;
|
|
53
|
+
/** `attachment` (default) saves it; `inline` lets a tab render it. `<video>` ignores both. */
|
|
54
|
+
readonly disposition?: 'attachment' | 'inline' | undefined;
|
|
55
|
+
/** The stream's length in bytes. Ignored for a `Uint8Array` or a `Blob`, which know theirs. */
|
|
56
|
+
readonly size?: number | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DownloadArgs<TInput extends StandardSchemaV1> {
|
|
60
|
+
readonly input: InferOutput<TInput>;
|
|
61
|
+
readonly ctx: Ctx;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface QueryDownload<TInput extends StandardSchemaV1> {
|
|
65
|
+
/** How long a minted URL works: 1 000 … 3 600 000 ms. Default `DEFAULT_DOWNLOAD_TTL_MS` (5 min). */
|
|
66
|
+
readonly expiresInMs?: number | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* The file, for an input the policy already admitted. Runs as the actor that MINTED the URL;
|
|
69
|
+
* decrypt a sealed object here (`open()` from `@ultimat3/core`) — an AES-GCM envelope opens whole,
|
|
70
|
+
* so a sealed object is buffered, bounded by its disk's `maxGetBytes`.
|
|
71
|
+
*/
|
|
72
|
+
handle(args: DownloadArgs<TInput>): Promise<DownloadFile>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type RowsOnly = 'sql' | 'live' | 'single' | 'subscribes' | 'rows' | 'cache' | 'mcp';
|
|
76
|
+
|
|
77
|
+
/** A download declares no rows option: each is a type error here and `X_QUERY_DOWNLOAD_INVALID` at runtime. */
|
|
78
|
+
export type DownloadQueryDef<TInput extends StandardSchemaV1> = {
|
|
79
|
+
readonly input: TInput;
|
|
80
|
+
readonly policy: QueryPolicy;
|
|
81
|
+
readonly download: QueryDownload<TInput>;
|
|
82
|
+
/** Spent at MINT — one URL is one admitted read, however many `Range:` requests a player makes. */
|
|
83
|
+
readonly rateLimit?: QueryRateLimit;
|
|
84
|
+
readonly deprecated?: Deprecation;
|
|
85
|
+
/** Records the MINT: the decision of who may read what. A ranged GET is not a second read. */
|
|
86
|
+
readonly audit?: boolean;
|
|
87
|
+
} & { readonly [K in RowsOnly]?: never };
|
|
88
|
+
|
|
89
|
+
/** `client.recording.url(input)` — the browser half: the server mints, this prefixes the base. */
|
|
90
|
+
export interface DownloadClientMethod<TInput extends StandardSchemaV1> {
|
|
91
|
+
url(input: InferInput<TInput>, options?: QueryCallOptions): Promise<DownloadUrl>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DownloadQuery<TInput extends StandardSchemaV1 = StandardSchemaV1>
|
|
95
|
+
extends AnyQuery {
|
|
96
|
+
/** The file, in-process: the same parse and policy as `url()`, then `handle` as this caller. */
|
|
97
|
+
(input: InferInput<TInput>, options?: QueryOptions): Promise<DownloadFile>;
|
|
98
|
+
readonly input: TInput;
|
|
99
|
+
readonly download: true;
|
|
100
|
+
/** Mint a signed URL — a PATH, `/_x/query/<kebab>?…&_token=…` — for this caller and this input. */
|
|
101
|
+
url(input: InferInput<TInput>, options?: QueryOptions): Promise<DownloadUrl>;
|
|
102
|
+
client(options: QueryClientOptions): DownloadClientMethod<TInput>;
|
|
103
|
+
named(name: string): DownloadQuery<TInput>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const isFunction = (value: unknown): value is (...args: never[]) => unknown =>
|
|
107
|
+
typeof value === 'function';
|
|
108
|
+
|
|
109
|
+
/** The declaration, judged where it is written: an untyped caller can still pass `sql` beside it. */
|
|
110
|
+
export function assertDownloadDef(def: Readonly<Record<string, unknown>>): void {
|
|
111
|
+
const download = def['download'];
|
|
112
|
+
if (typeof download !== 'object' || download === null) {
|
|
113
|
+
throw new QueryDownloadInvalidError('download is not an object with a handle');
|
|
114
|
+
}
|
|
115
|
+
const rows = (['sql', 'live', 'single', 'subscribes', 'rows', 'cache', 'mcp'] as const).filter(
|
|
116
|
+
(key) => def[key] !== undefined,
|
|
117
|
+
);
|
|
118
|
+
if (rows.length > 0) {
|
|
119
|
+
throw new QueryDownloadInvalidError(`it also declares ${rows.join(', ')}, which answer rows`);
|
|
120
|
+
}
|
|
121
|
+
if (!isFunction(Reflect.get(download, 'handle'))) {
|
|
122
|
+
throw new QueryDownloadInvalidError('download.handle is not a function');
|
|
123
|
+
}
|
|
124
|
+
const ttl: unknown = Reflect.get(download, 'expiresInMs');
|
|
125
|
+
const whole = typeof ttl === 'number' && Number.isSafeInteger(ttl);
|
|
126
|
+
if (ttl !== undefined && !(whole && ttl >= MIN_DOWNLOAD_TTL_MS && ttl <= MAX_DOWNLOAD_TTL_MS)) {
|
|
127
|
+
throw new QueryDownloadInvalidError(
|
|
128
|
+
`download.expiresInMs is ${String(ttl)}, not a whole number of ms from ${MIN_DOWNLOAD_TTL_MS} to ${MAX_DOWNLOAD_TTL_MS}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `describe` is handed in, so this file never value-imports `query.ts` (which imports it). */
|
|
134
|
+
export function buildDownloadQuery<TInput extends StandardSchemaV1>(
|
|
135
|
+
def: DownloadQueryDef<TInput>,
|
|
136
|
+
name: string,
|
|
137
|
+
describe: (target: AnyQuery) => QueryDescriptor,
|
|
138
|
+
): DownloadQuery<TInput> {
|
|
139
|
+
const refuse = (): Promise<never> => Promise.reject(new QueryDownloadOnlyError(queryName(self)));
|
|
140
|
+
const callable = (input: InferInput<TInput>, options: QueryOptions = {}): Promise<DownloadFile> =>
|
|
141
|
+
readDownloadFile(self, input, options);
|
|
142
|
+
const self: DownloadQuery<TInput> = Object.assign(callable, {
|
|
143
|
+
kind: 'query' as const,
|
|
144
|
+
isLive: false,
|
|
145
|
+
download: true as const,
|
|
146
|
+
input: def.input,
|
|
147
|
+
policy: def.policy,
|
|
148
|
+
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
|
|
149
|
+
...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
|
|
150
|
+
describe: (): QueryDescriptor => describe(self),
|
|
151
|
+
named: (next: string): DownloadQuery<TInput> => buildDownloadQuery(def, next, describe),
|
|
152
|
+
as: refuse,
|
|
153
|
+
page: refuse,
|
|
154
|
+
live: refuse,
|
|
155
|
+
url: (input: InferInput<TInput>, options: QueryOptions = {}) =>
|
|
156
|
+
mintDownloadUrl(self, input, options),
|
|
157
|
+
client: (options: QueryClientOptions): DownloadClientMethod<TInput> => ({
|
|
158
|
+
url: downloadClientFor<TInput>(queryName(self), options).url,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
Object.defineProperty(self, 'name', { value: name, configurable: true });
|
|
162
|
+
const stored: AnyQueryDef = {
|
|
163
|
+
input: def.input,
|
|
164
|
+
policy: def.policy,
|
|
165
|
+
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
|
|
166
|
+
...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
|
|
167
|
+
...(def.audit === undefined ? {} : { audit: def.audit }),
|
|
168
|
+
download: def.download,
|
|
169
|
+
// Every rows projection ends here — one refusal, wherever the rows were asked from.
|
|
170
|
+
sql: () => {
|
|
171
|
+
throw new QueryDownloadOnlyError(queryName(self));
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
stashDef(self, stored);
|
|
175
|
+
return self;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handlerOf(target: AnyQuery): QueryDownload<StandardSchemaV1> {
|
|
179
|
+
const download = defOf(target).download;
|
|
180
|
+
if (download === undefined) throw new QueryDownloadOnlyError(queryName(target));
|
|
181
|
+
return download;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The in-process read: admitted as the caller, answered by `handle`. */
|
|
185
|
+
export function readDownloadFile(
|
|
186
|
+
target: AnyQuery,
|
|
187
|
+
raw: unknown,
|
|
188
|
+
options: QueryOptions,
|
|
189
|
+
): Promise<DownloadFile> {
|
|
190
|
+
const download = handlerOf(target);
|
|
191
|
+
return admittedCall(target, raw, options, (input, ctx) => download.handle({ input, ctx }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Admit the caller exactly as a read would, then seal the decision. The URL carries the input as
|
|
196
|
+
* it was HANDED in (the route re-parses it to the same value) and the token binds the PARSED one.
|
|
197
|
+
*/
|
|
198
|
+
export function mintDownloadUrl(
|
|
199
|
+
target: AnyQuery,
|
|
200
|
+
raw: unknown,
|
|
201
|
+
options: QueryOptions,
|
|
202
|
+
): Promise<DownloadUrl> {
|
|
203
|
+
const name = queryName(target);
|
|
204
|
+
const ttl = handlerOf(target).expiresInMs ?? DEFAULT_DOWNLOAD_TTL_MS;
|
|
205
|
+
return admittedCall(target, raw, options, async (input, ctx) => {
|
|
206
|
+
const expiresAtMs = ctx.now().getTime() + ttl;
|
|
207
|
+
const token = await mintDownloadToken(name, input, ctx.actor, expiresAtMs);
|
|
208
|
+
const search = searchOf(raw);
|
|
209
|
+
const tokenParam = `${DOWNLOAD_GRANT_PARAM}=${encodeURIComponent(token)}`;
|
|
210
|
+
return {
|
|
211
|
+
url: `${queryPath(name)}?${search === '' ? '' : `${search}&`}${tokenParam}`,
|
|
212
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The file a token grants: the token opened against THIS query and THIS input on the context's
|
|
219
|
+
* clock, then `handle` as the minting actor. The input is still parsed — `handle` is owed the
|
|
220
|
+
* schema's output — and the policy is not re-run: it ran at mint, with facts this token cannot
|
|
221
|
+
* carry, and the written reason above is what `admit` logs for the skip.
|
|
222
|
+
*/
|
|
223
|
+
export async function serveDownloadFile(
|
|
224
|
+
target: AnyQuery,
|
|
225
|
+
raw: unknown,
|
|
226
|
+
token: string,
|
|
227
|
+
options: QueryOptions,
|
|
228
|
+
): Promise<DownloadFile> {
|
|
229
|
+
const name = queryName(target);
|
|
230
|
+
const download = handlerOf(target);
|
|
231
|
+
const now = (options.ctx ?? useContext()).now().getTime();
|
|
232
|
+
const grant = await openDownloadToken(name, token, now);
|
|
233
|
+
return admittedCall(
|
|
234
|
+
target,
|
|
235
|
+
raw,
|
|
236
|
+
{ ...options, actor: grant.actor, unenforced: TOKEN_IS_THE_GRANT },
|
|
237
|
+
(input, ctx) => {
|
|
238
|
+
assertGrantedInput(name, grant, input);
|
|
239
|
+
return download.handle({ input, ctx });
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -31,6 +31,10 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
31
31
|
X_QUERY_AUDIT_SINK_MISSING: 'a query declares audit: true and no audit sink is installed',
|
|
32
32
|
X_QUERY_AUDIT_SINK_FAILED:
|
|
33
33
|
'an audited read was refused because the audit sink rejected its record',
|
|
34
|
+
X_QUERY_DOWNLOAD_INVALID: 'a query declares a download it cannot serve',
|
|
35
|
+
X_QUERY_DOWNLOAD_ONLY: 'rows were asked of a query that answers a file',
|
|
36
|
+
X_QUERY_DOWNLOAD_TOKEN_INVALID: 'a download URL carries a token this app did not mint for it',
|
|
37
|
+
X_QUERY_DOWNLOAD_TOKEN_EXPIRED: 'a download URL is past its expiry',
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
/**
|
package/src/http.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { RateLimitDecision, Route, RouteMeta, UltimateRequest } from '@ulti
|
|
|
12
12
|
import { publishRateLimit, toBucket } from '@ultimat3/http';
|
|
13
13
|
import { admitsAnonymous, policyCapability } from '@ultimat3/policy';
|
|
14
14
|
import { coerceQuery } from '@ultimat3/schema';
|
|
15
|
+
import { toDownloadRoute } from './download-http';
|
|
15
16
|
import { QueryDeprecationInvalidError, QueryInputInvalidError } from './errors';
|
|
16
17
|
import { absentArraysOf } from './input-shape';
|
|
17
18
|
import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
|
|
@@ -30,6 +31,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
30
31
|
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
31
32
|
// not a surprise on the first read.
|
|
32
33
|
const sunsetting = deprecationHeadersFor(name, target.deprecated);
|
|
34
|
+
if (target.download === true) return toDownloadRoute(target, name, sunsetting);
|
|
33
35
|
const answer = recordAnswerFor(target.rows);
|
|
34
36
|
const answerRow = recordRowAnswerFor(target.rows);
|
|
35
37
|
|
|
@@ -137,7 +139,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
/** Validates the declaration through the limiter's own conversion, then hands the route its flag. */
|
|
140
|
-
function rateLimitedByHandler(name: string, limit: QueryRateLimit): 'handler' {
|
|
142
|
+
export function rateLimitedByHandler(name: string, limit: QueryRateLimit): 'handler' {
|
|
141
143
|
toBucket(name, limit);
|
|
142
144
|
return 'handler';
|
|
143
145
|
}
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export {
|
|
|
47
47
|
requestMemo,
|
|
48
48
|
} from './cache';
|
|
49
49
|
export type {
|
|
50
|
+
DownloadUrl,
|
|
50
51
|
FetchLike,
|
|
51
52
|
QueryCallOptions,
|
|
52
53
|
QueryClient,
|
|
@@ -58,7 +59,7 @@ export type {
|
|
|
58
59
|
QuerySingleClientMethod,
|
|
59
60
|
} from './client';
|
|
60
61
|
/** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
|
|
61
|
-
export { queryClient, queryClientMethodFor } from './client';
|
|
62
|
+
export { downloadClientFor, queryClient, queryClientMethodFor } from './client';
|
|
62
63
|
/**
|
|
63
64
|
* `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
|
|
64
65
|
* `totalOrder` is the one definition of the order it must serve a page in. `compareRows`,
|
|
@@ -67,6 +68,30 @@ export { queryClient, queryClientMethodFor } from './client';
|
|
|
67
68
|
*/
|
|
68
69
|
export type { KindOf } from './column-kinds';
|
|
69
70
|
export { kindsOf } from './column-kinds';
|
|
71
|
+
/**
|
|
72
|
+
* `query({ download })` — a read answered by a FILE at a short-lived signed URL. Still a `query`:
|
|
73
|
+
* one policy, one name, one route (`GET /_x/query/<kebab>`: mint without `_token`, serve with it).
|
|
74
|
+
*/
|
|
75
|
+
export type {
|
|
76
|
+
DownloadArgs,
|
|
77
|
+
DownloadClientMethod,
|
|
78
|
+
DownloadFile,
|
|
79
|
+
DownloadQuery,
|
|
80
|
+
DownloadQueryDef,
|
|
81
|
+
QueryDownload,
|
|
82
|
+
} from './download';
|
|
83
|
+
export {
|
|
84
|
+
DEFAULT_DOWNLOAD_TTL_MS,
|
|
85
|
+
MAX_DOWNLOAD_TTL_MS,
|
|
86
|
+
MIN_DOWNLOAD_TTL_MS,
|
|
87
|
+
} from './download';
|
|
88
|
+
export type { DownloadTokenProblem } from './download-errors';
|
|
89
|
+
export {
|
|
90
|
+
QueryDownloadInvalidError,
|
|
91
|
+
QueryDownloadOnlyError,
|
|
92
|
+
QueryDownloadTokenExpiredError,
|
|
93
|
+
QueryDownloadTokenInvalidError,
|
|
94
|
+
} from './download-errors';
|
|
70
95
|
export {
|
|
71
96
|
CursorValueUnsupportedError,
|
|
72
97
|
MatcherUnsupportedError,
|
package/src/input-shape.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// input cannot be written.
|
|
17
17
|
|
|
18
18
|
import { type SchemaNode, tryIntrospect } from '@ultimat3/schema';
|
|
19
|
+
import { DOWNLOAD_GRANT_PARAM } from './download-token';
|
|
19
20
|
import { QueryInputUnencodableError } from './errors';
|
|
20
21
|
import { PAGE_CONTROL_KEYS } from './page-controls';
|
|
21
22
|
|
|
@@ -34,6 +35,9 @@ function unencodable(node: SchemaNode): string | undefined {
|
|
|
34
35
|
if (PAGE_CONTROL_KEYS.includes(key)) {
|
|
35
36
|
return `${key} is reserved — the route reads it as a page control (see query.page())`;
|
|
36
37
|
}
|
|
38
|
+
if (key === DOWNLOAD_GRANT_PARAM) {
|
|
39
|
+
return `${key} is reserved — a download's route reads it as the signed token (see query.url())`;
|
|
40
|
+
}
|
|
37
41
|
// Required AND nullable: `searchOf` sends nothing for a `null`, and absence is what the far
|
|
38
42
|
// side then sees — so the value the caller explicitly chose fails validation on arrival.
|
|
39
43
|
// Optional or defaulted, absence is already the schema's own answer and nothing is lost.
|
package/src/openapi.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { MAX_PAGE_SIZE } from '@ultimat3/entity';
|
|
|
26
26
|
import { policyCapability } from '@ultimat3/policy';
|
|
27
27
|
import type { SchemaNode } from '@ultimat3/schema';
|
|
28
28
|
import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
|
|
29
|
+
import { DOWNLOAD_GRANT_PARAM } from './download-token';
|
|
29
30
|
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
30
31
|
import type { AnyQuery } from './query';
|
|
31
32
|
import { queryName } from './read';
|
|
@@ -90,6 +91,7 @@ export function queryOpenApiPaths(
|
|
|
90
91
|
*/
|
|
91
92
|
export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknown> {
|
|
92
93
|
const name = queryName(target);
|
|
94
|
+
if (target.download === true) return downloadOperation(target, name);
|
|
93
95
|
if (target.single === true) return singleOperation(target, name);
|
|
94
96
|
return {
|
|
95
97
|
operationId: name,
|
|
@@ -168,6 +170,68 @@ function singleOperation(target: AnyQuery, name: string): Record<string, unknown
|
|
|
168
170
|
};
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A download: one URL, two answers decided by `_token` — the mint (`{ url, expiresAt }`, JSON) and
|
|
175
|
+
* the file (bytes, a `Range:` window as 206). No page controls, no rows, no MCP tool.
|
|
176
|
+
*/
|
|
177
|
+
function downloadOperation(target: AnyQuery, name: string): Record<string, unknown> {
|
|
178
|
+
return {
|
|
179
|
+
operationId: name,
|
|
180
|
+
tags: [QUERY_TAG],
|
|
181
|
+
summary: name,
|
|
182
|
+
...(target.deprecated === undefined ? {} : { deprecated: true }),
|
|
183
|
+
parameters: [
|
|
184
|
+
...inputParameters(target.input),
|
|
185
|
+
{
|
|
186
|
+
name: DOWNLOAD_GRANT_PARAM,
|
|
187
|
+
in: 'query',
|
|
188
|
+
required: false,
|
|
189
|
+
description: 'absent: mint a signed URL for this input; present: the file that URL grants',
|
|
190
|
+
schema: { type: 'string' },
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
responses: {
|
|
194
|
+
'200': {
|
|
195
|
+
description: `without ${DOWNLOAD_GRANT_PARAM}, the minted URL; with it, the file`,
|
|
196
|
+
content: {
|
|
197
|
+
'application/json': { schema: MINTED_URL },
|
|
198
|
+
// The file's own type — whatever `handle` answered — so any media type, as bytes.
|
|
199
|
+
'*/*': { schema: FILE_BYTES },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
'206': {
|
|
203
|
+
description: 'the one byte range the Range header named, with content-range',
|
|
204
|
+
content: { '*/*': { schema: FILE_BYTES } },
|
|
205
|
+
},
|
|
206
|
+
'400': problemResponse('X_INPUT_INVALID'),
|
|
207
|
+
'401': problemResponse(
|
|
208
|
+
'X_UNAUTHENTICATED — a mint with no session, under a policy that needs one',
|
|
209
|
+
),
|
|
210
|
+
'403': problemResponse('policy denied, or X_QUERY_DOWNLOAD_TOKEN_INVALID'),
|
|
211
|
+
'410': problemResponse('X_QUERY_DOWNLOAD_TOKEN_EXPIRED — mint again'),
|
|
212
|
+
'416': { description: 'the Range header starts past the end of the file' },
|
|
213
|
+
},
|
|
214
|
+
'x-ultimate': {
|
|
215
|
+
capability: policyCapability(target.policy),
|
|
216
|
+
live: false,
|
|
217
|
+
download: true,
|
|
218
|
+
cacheTags: [],
|
|
219
|
+
tool: null,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const FILE_BYTES: Readonly<Record<string, unknown>> = { type: 'string', format: 'binary' };
|
|
225
|
+
|
|
226
|
+
const MINTED_URL: Readonly<Record<string, unknown>> = {
|
|
227
|
+
type: 'object',
|
|
228
|
+
required: ['url', 'expiresAt'],
|
|
229
|
+
properties: {
|
|
230
|
+
url: { type: 'string', description: `this path, with the input and ${DOWNLOAD_GRANT_PARAM}` },
|
|
231
|
+
expiresAt: { type: 'string', format: 'date-time' },
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
171
235
|
/** One row. Its members are not published — a query declares no output schema, as `ANSWERS` says. */
|
|
172
236
|
const SINGLE_ANSWER: Readonly<Record<string, unknown>> = { type: 'object' };
|
|
173
237
|
|