@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 CHANGED
@@ -38,6 +38,11 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
38
38
  | `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
39
39
  | `audit-gate.ts` | `audit: true` — **the only** file that calls an `AuditSink` (core's): one record per call, an audited source per `execute()` |
40
40
  | `audit-errors.ts` | `X_QUERY_AUDIT_SINK_MISSING` / `X_QUERY_AUDIT_SINK_FAILED` (titles in `errors.ts`) |
41
+ | `download.ts` | `query({ download })`: the build, the in-process file, the mint, the serve — through `admittedCall` |
42
+ | `download-token.ts` | the `_token`: claims `seal()`ed under the master key, opened against one query, one input, the clock |
43
+ | `download-http.ts` | the download route: mint JSON without `_token`, the file with it (`Range:`, `content-disposition`) |
44
+ | `download-errors.ts` | the four `X_QUERY_DOWNLOAD_*` classes (titles in `errors.ts`) |
45
+ | `search-string.ts` | `searchOf`: the input as a search string — the client's request and a minted URL, one encoding |
41
46
 
42
47
  ## Invariants — the read path
43
48
 
@@ -113,6 +118,20 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
113
118
  `toLiveQuery`): a change row carries none, so the window would be read once and never patched.
114
119
  The refusal names the column, never the filter's value.
115
120
 
121
+ ## Invariants — download
122
+
123
+ - **A download is a `query`, never a ninth primitive** (`docs/history/primitive-factories.md`):
124
+ `download:` replaces `sql:` and every rows option is refused beside it (`X_QUERY_DOWNLOAD_INVALID`).
125
+ - **Its stored `sql` throws `X_QUERY_DOWNLOAD_ONLY`** — the ONE refusal every rows projection meets.
126
+ Never add a per-projection `download` check.
127
+ - **Mint and in-process call go through `admittedCall`** (`read.ts`, the front half `buildSource`
128
+ shares). The serve re-parses through it as `unenforced` with a written reason: the token IS the
129
+ policy's decision. Never re-run the policy at serve — the token carries no `facts`.
130
+ - **The token is SEALED, never signed**: it carries the actor. Seal before expiry before input —
131
+ an expired forgery is a forgery. `X_SEAL_KEY_MISSING` stays a 500 (the server's fault).
132
+ - **`audit: true` records the mint, `rateLimit:` spends at the mint** — a player's `Range:` GETs
133
+ are one admitted read.
134
+
116
135
  ## Invariants — audit
117
136
 
118
137
  - **`audit: true` observes the read path, never forks it**: `buildSource` fills the trace (`input`,
package/README.md CHANGED
@@ -216,6 +216,11 @@ forgetting the policy, and the reason is what tells the next reader which of the
216
216
  | `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
217
217
  | `column-kinds.ts` | `kindsOf(relation)` — the declared kind of each column, which every comparison is decided by |
218
218
  | `sealed-shape.ts` | a live read may not filter or order on a `.sealed()` column — refused at subscribe |
219
+ | `download.ts` | `query({ download })` — the declaration, the in-process file, the mint and the serve |
220
+ | `download-token.ts` | the `_token`: claims sealed with core's `seal()`, opened against one query, one input, the clock |
221
+ | `download-http.ts` | the route's two answers — mint JSON or the file: `content-disposition`, one `Range:` window, `no-store` |
222
+ | `download-errors.ts` | `X_QUERY_DOWNLOAD_INVALID` / `_ONLY` / `_TOKEN_INVALID` / `_TOKEN_EXPIRED` |
223
+ | `search-string.ts` | the input as a search string — what the client sends and a minted URL carries |
219
224
 
220
225
  ## Live queries
221
226
 
@@ -665,12 +670,70 @@ before the parse; a sink refusing a **denied/failed** record is logged (`audit.s
665
670
  original error still reaches the caller; a sink refusing an **allowed** record withholds the rows,
666
671
  `X_QUERY_AUDIT_SINK_FAILED` — a read commits nothing, so a retry is safe.
667
672
 
673
+ ## `download:` — a file at a short-lived signed URL
674
+
675
+ A read whose answer is BYTES — a recording, an export, an attachment — is still a `query`: one
676
+ input, one policy, one name, one route. `download:` replaces `sql:`; every rows option (`sql`,
677
+ `live`, `single`, `rows`, `subscribes`, `cache`, `mcp`) beside it is a type error and
678
+ `X_QUERY_DOWNLOAD_INVALID`.
679
+
680
+ ```ts
681
+ import { open } from '@ultimat3/core';
682
+ import { can } from '@ultimat3/policy';
683
+ import { query, t } from '@ultimat3/query';
684
+ import { disk } from '@ultimat3/storage';
685
+
686
+ export const runRecording = query({
687
+ input: t.object({ runId: t.uuid, index: t.number.int().min(0) }),
688
+ policy: can('recording:watch'),
689
+ download: {
690
+ expiresInMs: 600_000, // 1 000 … 3 600 000; default 300 000
691
+ async handle({ input }) {
692
+ const key = `runs/${input.runId}/recordings/${input.index}`;
693
+ const { bytes } = await disk('debug').get(key);
694
+ // A SEALED object opens whole (one AES-GCM envelope), then is served as plain bytes.
695
+ const plain = await open(new TextDecoder().decode(bytes), { purpose: `run-debug:${key}` });
696
+ return { body: plain, contentType: 'video/mp4', filename: `run-${input.runId}.mp4` };
697
+ },
698
+ },
699
+ });
700
+ ```
701
+
702
+ | Call | Answers |
703
+ |---|---|
704
+ | `client.runRecording.url(input)` (browser, `queryClient`/`.client()`) | `{ url, expiresAt }` — `url` absolute on the client's `baseUrl` |
705
+ | `await runRecording.url(input, { ctx })` (server) | the same, `url` a path: `/_x/query/run-recording?runId=…&index=0&_token=…` |
706
+ | `GET <url>` | the file — to ANY requester holding the URL until `expiresAt`; no session needed |
707
+ | `await runRecording(input, { ctx })` | the `DownloadFile` in-process, as the caller |
708
+
709
+ | Rule | |
710
+ |---|---|
711
+ | The route | `GET /_x/query/<kebab>` — the read's own. No `_token`: MINT (`application/json`). `_token`: SERVE (the bytes). `auth: 'public'`, `enforcedBy: 'handler'`, `cache: no-store` |
712
+ | Policy | evaluated at MINT, exactly as for a rows read: actor half, the declared `rateLimit:`, the parse, the predicate. A refusal is the policy's own 401/403 |
713
+ | The token | `seal()`ed (`@ultimat3/core`, AES-256-GCM under the app master key, purpose `ultimate.query.download:<name>`) claims: the query, `fingerprint()` of the PARSED input, the expiry on `ctx.now()`, the minting actor minus `facts`. The ACTOR is ciphertext — no id, role or grant readable; the query's path and its input are plain in the URL, as on any read |
714
+ | The serve | the seal first (forged, truncated, another query's, a retired key → `X_QUERY_DOWNLOAD_TOKEN_INVALID`, 403), then the expiry (`X_QUERY_DOWNLOAD_TOKEN_EXPIRED`, 410), then the URL's input re-parsed against the bound fingerprint (edited → 403). `handle` runs as the MINTING actor; the policy is not re-run — it decided at mint, with `facts` the token cannot carry |
715
+ | `handle`'s answer | `{ body: Uint8Array \| Blob \| ReadableStream, contentType, filename, disposition?: 'attachment' \| 'inline', size? }` — bytes, never base64. Anything else is a 500 (`X_INVARIANT`) naming the fix |
716
+ | Headers | `content-type`, `content-disposition` (`attachment` default; ASCII `filename` + exact `filename*=UTF-8''…`), `x-content-type-options: nosniff`, `content-security-policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox`, `referrer-policy: no-referrer`, `cache-control: no-store`; `content-length` only when the size is known — a `Uint8Array`, a `Blob`, or a stream with `size` |
717
+ | `Range:` | one `bytes=` window, 206 + `content-range`; past the end 416. A `Uint8Array`/`Blob` always (a `Blob` slices lazily); a `ReadableStream` only with `size`, else `accept-ranges: none` and the whole body |
718
+ | Rows | `.live()`, `.page()`, `.as()`, `sourceFor`, `explain`, an MCP tool: `X_QUERY_DOWNLOAD_ONLY`. `describeSql` lists it with `sql: null` |
719
+ | Audit | `audit: true` records the MINT — the decision of who may read what. A serve, and every `Range:` a player sends, is not a second read |
720
+ | Manifest / OpenAPI | `download: true` on the `QueryFact` (rows ↔ download is breaking); one GET with `_token`, `200` as JSON or `application/octet-stream`, `206`, `410`, `416` |
721
+
722
+ **A sealed object is buffered.** WebCrypto's AES-GCM opens a whole envelope or nothing, so a
723
+ sealed recording is read with `get()` (bounded by the disk's `maxGetBytes`) and opened before a
724
+ byte is served; ranges are then sliced from the plaintext. A PLAIN object can be a `Bun.file`
725
+ or a stream with `size`, served without buffering.
726
+
668
727
  ## Errors
669
728
 
670
729
  | Code | When | Fix |
671
730
  |---|---|---|
672
731
  | `X_QUERY_AUDIT_SINK_MISSING` | `audit: true` and no audit sink installed — refused before the parse | `setAuditSink(sink)` from `@ultimat3/core` at boot |
673
732
  | `X_QUERY_AUDIT_SINK_FAILED` | the sink refused the record of an allowed read; the rows were withheld | fix the sink, then retry the read |
733
+ | `X_QUERY_DOWNLOAD_INVALID` | `download:` beside a rows option, a `handle` that is not a function, `expiresInMs` outside 1 000 … 3 600 000 | declare `download: { handle }` alone |
734
+ | `X_QUERY_DOWNLOAD_ONLY` | rows asked of a download (`.live()`, `.page()`, `.as()`, `sourceFor`) | `await q.url(input)`, or `await q(input)` for the file |
735
+ | `X_QUERY_DOWNLOAD_TOKEN_INVALID` | a `_token` forged, truncated, another query's, under a retired key, or bound to other input — 403 | mint again: `client.<name>.url(input)` |
736
+ | `X_QUERY_DOWNLOAD_TOKEN_EXPIRED` | a genuine `_token` past `expiresAt` — 410 | mint again right before the request |
674
737
  | `X_QUERY_DUPLICATE` | two queries under one name | rename one export |
675
738
  | `X_QUERY_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
676
739
  | `X_QUERY_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
@@ -700,6 +763,10 @@ a job boundary the class is gone and the `code` is what survives — match on th
700
763
  | `QueryAuditSinkMissingError` | `X_QUERY_AUDIT_SINK_MISSING` | `src/audit-errors.ts` |
701
764
  | `QueryDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
702
765
  | `QueryDeprecationInvalidError` | `X_QUERY_DEPRECATION_INVALID` | `src/errors.ts` |
766
+ | `QueryDownloadInvalidError` | `X_QUERY_DOWNLOAD_INVALID` | `src/download-errors.ts` |
767
+ | `QueryDownloadOnlyError` | `X_QUERY_DOWNLOAD_ONLY` | `src/download-errors.ts` |
768
+ | `QueryDownloadTokenExpiredError` | `X_QUERY_DOWNLOAD_TOKEN_EXPIRED` | `src/download-errors.ts` |
769
+ | `QueryDownloadTokenInvalidError` | `X_QUERY_DOWNLOAD_TOKEN_INVALID` | `src/download-errors.ts` |
703
770
  | `QueryDuplicateError` | `X_QUERY_DUPLICATE` | `src/errors.ts` |
704
771
  | `QueryForeignError` | `X_QUERY_FOREIGN` | `src/errors.ts` |
705
772
  | `QueryInputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "29.3.2",
3
+ "version": "29.4.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,11 +37,11 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/cache": "29.3.2",
41
- "@ultimat3/core": "29.3.2",
42
- "@ultimat3/entity": "29.3.2",
43
- "@ultimat3/http": "29.3.2",
44
- "@ultimat3/policy": "29.3.2",
45
- "@ultimat3/schema": "29.3.2"
40
+ "@ultimat3/cache": "29.4.0",
41
+ "@ultimat3/core": "29.4.0",
42
+ "@ultimat3/entity": "29.4.0",
43
+ "@ultimat3/http": "29.4.0",
44
+ "@ultimat3/policy": "29.4.0",
45
+ "@ultimat3/schema": "29.4.0"
46
46
  }
47
47
  }
package/src/client.ts CHANGED
@@ -18,12 +18,13 @@
18
18
 
19
19
  import type { ClientFlight, ClientRetry } from '@ultimat3/core';
20
20
  import type { FetchLike, RecordEnvelope } from '@ultimat3/core/page';
21
- import { clientTransport, isJsonObject, queryPath } from '@ultimat3/core/page';
21
+ import { clientTransport, queryPath } from '@ultimat3/core/page';
22
22
  import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
23
+ import type { DownloadClientMethod, DownloadQuery } from './download';
23
24
  import type { PageControls } from './page-controls';
24
- import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
25
25
  import type { Page } from './pagination';
26
26
  import type { Query } from './query';
27
+ import { searchOf } from './search-string';
27
28
 
28
29
  /** Core's, re-exported under the name this package always exported it by — one declaration. */
29
30
  export type { FetchLike } from '@ultimat3/core/page';
@@ -78,6 +79,15 @@ export interface QueryClientMethod<TInput extends StandardSchemaV1, TRow extends
78
79
  ): Promise<Page<TRow>>;
79
80
  }
80
81
 
82
+ /**
83
+ * A download query's signed URL, as `url()` answers it. `url` is a PATH on the server and absolute
84
+ * from a client with a `baseUrl`; `expiresAt` is an ISO instant — mint again past it.
85
+ */
86
+ export interface DownloadUrl {
87
+ readonly url: string;
88
+ readonly expiresAt: string;
89
+ }
90
+
81
91
  /**
82
92
  * A `single: true` read's method: the route answers the row itself, or 404 `X_NOT_FOUND` — which
83
93
  * the transport rejects with, as it does every problem document. No `page`: the route refuses the
@@ -112,13 +122,11 @@ export type QueryMap = Record<string, QueryLike>;
112
122
 
113
123
  /** `queries.publicPost({ slug })`, with the input schema and the row type both inferred. */
114
124
  export type QueryClient<TQueries extends QueryMap> = {
115
- readonly [K in keyof TQueries]: TQueries[K] extends Query<
116
- infer TInput,
117
- infer TRow,
118
- infer TSingle extends boolean
119
- >
120
- ? QueryClientMethodOf<TInput, TRow, TSingle>
121
- : never;
125
+ readonly [K in keyof TQueries]: TQueries[K] extends DownloadQuery<infer TInput>
126
+ ? DownloadClientMethod<TInput>
127
+ : TQueries[K] extends Query<infer TInput, infer TRow, infer TSingle extends boolean>
128
+ ? QueryClientMethodOf<TInput, TRow, TSingle>
129
+ : never;
122
130
  };
123
131
 
124
132
  /**
@@ -152,11 +160,33 @@ export function queryClient<TQueries extends QueryMap>(
152
160
  return proxy as QueryClient<TQueries>;
153
161
  }
154
162
 
155
- /** One query's method — what `query.client()` returns, and what `queryClient` proxies to. */
163
+ /**
164
+ * One query's method — what `query.client()` returns, and what `queryClient` proxies to. It
165
+ * carries `url` too, the download half; the declaration's TYPE decides which half a caller sees.
166
+ */
156
167
  export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow extends object>(
157
168
  name: string,
158
169
  options: QueryClientOptions,
159
170
  ): QueryClientMethod<TInput, TRow> {
171
+ return methodsOf<TInput, TRow>(name, options);
172
+ }
173
+
174
+ /**
175
+ * A download query's client half: `url(input)` asks the server to mint. A GET with no `_token` IS
176
+ * the mint, so the request is the plain read's and only the answer — `{ url, expiresAt }` — differs.
177
+ */
178
+ export function downloadClientFor<TInput extends StandardSchemaV1>(
179
+ name: string,
180
+ options: QueryClientOptions,
181
+ ): DownloadClientMethod<TInput> {
182
+ return { url: methodsOf<TInput, object>(name, options).url };
183
+ }
184
+
185
+ /** The rows call, `page` and `url` over one base — `url` typed only on a download's method. */
186
+ function methodsOf<TInput extends StandardSchemaV1, TRow extends object>(
187
+ name: string,
188
+ options: QueryClientOptions,
189
+ ) {
160
190
  const base = options.baseUrl.replace(/\/+$/, '');
161
191
  // Erased at the wire seam; the row type is this query's by construction.
162
192
  const rows = (input: InferInput<TInput>, callOptions: QueryCallOptions = {}) =>
@@ -166,7 +196,14 @@ export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow exten
166
196
  args: PageControls,
167
197
  callOptions: QueryCallOptions = {},
168
198
  ) => read(base, options, name, input, callOptions, args) as Promise<Page<TRow>>;
169
- return Object.assign(rows, { page });
199
+ const url = async (
200
+ input: InferInput<TInput>,
201
+ callOptions: QueryCallOptions = {},
202
+ ): Promise<DownloadUrl> => {
203
+ const minted = (await read(base, options, name, input, callOptions)) as DownloadUrl;
204
+ return { ...minted, url: base + minted.url };
205
+ };
206
+ return Object.assign(rows, { page, url });
170
207
  }
171
208
 
172
209
  /**
@@ -199,43 +236,3 @@ function read(
199
236
  ...(options.fetch === undefined ? {} : { fetchImpl: options.fetch }),
200
237
  });
201
238
  }
202
-
203
- /**
204
- * Input as a query string. Keys are sorted so the same input always produces the
205
- * same URL — a GET is a cache key, and an unstable one caches nothing.
206
- *
207
- * The page controls come LAST, after the sorted input and in a fixed order, so a paged URL is the
208
- * plain URL with a suffix — the same dedup key for the same page, and a log line a reader can
209
- * split at `_first=` to recover the read underneath.
210
- *
211
- * An EMPTY array sends nothing, and the route reads a required array nobody sent as `[]`
212
- * (`absentArraysOf`, `input-shape.ts`) — the two halves of one rule, so `{ tags: [] }` arrives.
213
- */
214
- function searchOf(input: unknown, page?: PageControls): string {
215
- const params = new URLSearchParams();
216
- if (isJsonObject(input)) {
217
- for (const key of Object.keys(input).sort()) {
218
- const value = input[key];
219
- if (value === undefined || value === null) continue;
220
- for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
221
- params.append(key, wireText(item));
222
- }
223
- }
224
- }
225
- if (page !== undefined) {
226
- params.append(PAGE_FIRST_KEY, String(page.first));
227
- if (page.after !== undefined) params.append(PAGE_AFTER_KEY, page.after);
228
- }
229
- return params.toString();
230
- }
231
-
232
- /**
233
- * One value as the characters `coerceQuery` reads back. A `Date` is its ISO instant: through
234
- * `JSON.stringify` it arrived wrapped in quotes, which `t.date` refuses on the far side. An
235
- * Invalid Date has no instant, and `toISOString()` throws a bare `RangeError` on it, so it is sent
236
- * as the text it prints and the read's own schema says what is wrong with it.
237
- */
238
- function wireText(item: unknown): string {
239
- if (item instanceof Date) return Number.isNaN(item.getTime()) ? String(item) : item.toISOString();
240
- return typeof item === 'object' ? JSON.stringify(item) : String(item);
241
- }
@@ -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,226 @@
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
+ contentRangeOf,
13
+ jsonResponse,
14
+ parseByteRange,
15
+ publishRateLimit,
16
+ RANGE_UNSATISFIABLE,
17
+ type RequestedRange,
18
+ } from '@ultimat3/http';
19
+ import { policyCapability } from '@ultimat3/policy';
20
+ import { coerceQuery } from '@ultimat3/schema';
21
+ import type { DownloadFile } from './download';
22
+ import { mintDownloadUrl, serveDownloadFile } from './download';
23
+ import { DOWNLOAD_GRANT_PARAM } from './download-token';
24
+ import { QueryInputInvalidError } from './errors';
25
+ import { rateLimitedByHandler } from './http';
26
+ import { absentArraysOf } from './input-shape';
27
+ import { pageControlsOf } from './page-controls';
28
+ import type { AnyQuery } from './query';
29
+
30
+ /** The route for `target`, whose `download` is declared. `sunsetting` is `toQueryRoute`'s. */
31
+ export function toDownloadRoute(
32
+ target: AnyQuery,
33
+ name: string,
34
+ sunsetting: Readonly<Record<string, string>> | undefined,
35
+ ): Route {
36
+ const handler = async (request: UltimateRequest): Promise<Response> => {
37
+ if (sunsetting !== undefined) {
38
+ for (const [header, value] of Object.entries(sunsetting)) {
39
+ request.ctx.headers.set(header, value);
40
+ }
41
+ }
42
+ const { values, token } = tokenOf(name, request.queryRaw());
43
+ // A page of a file is not a thing: `_first`/`_after` are refused as on a single read.
44
+ const { input: rest, page } = pageControlsOf(name, values);
45
+ if (page !== undefined) {
46
+ throw new QueryInputInvalidError(name, 'a download answers one file — it takes no _first');
47
+ }
48
+ const input = absentArraysOf(target.input, coerceQuery(target.input, rest));
49
+ if (token === undefined) {
50
+ const minted = await mintDownloadUrl(target, input, {
51
+ surface: 'http',
52
+ clientAddress: request.ctx.ip,
53
+ onRateLimit: (decision: RateLimitDecision) =>
54
+ publishRateLimit(request.ctx, decision, useContext().now().getTime()),
55
+ });
56
+ return jsonResponse(minted);
57
+ }
58
+ const file = await serveDownloadFile(target, input, token, { surface: 'http' });
59
+ return fileResponse(file, request.header('range'));
60
+ };
61
+
62
+ const meta: RouteMeta = {
63
+ name,
64
+ // PUBLIC, and not unguarded: the mint runs this read's policy inside the handler like every
65
+ // read (`enforcedBy`), and a serve is authorized by the sealed token the policy minted — the
66
+ // reason a URL works in a `<video>` or a tab with no session header at all.
67
+ auth: 'public',
68
+ policy: policyCapability(target.policy),
69
+ enforcedBy: 'handler',
70
+ primitive: 'query',
71
+ // One actor's bytes under a URL that names no actor: nothing may keep a copy.
72
+ cache: { mode: 'no-store' },
73
+ tags: ['query', 'download'],
74
+ ...(target.rateLimit === undefined
75
+ ? {}
76
+ : { rateLimitedBy: rateLimitedByHandler(name, target.rateLimit) }),
77
+ };
78
+ return { method: 'GET', path: queryPath(name), handler, meta };
79
+ }
80
+
81
+ /** `_token` out of the search string — one string, or a refusal: two tokens are two grants. */
82
+ function tokenOf(
83
+ name: string,
84
+ values: Readonly<Record<string, string | readonly string[] | undefined>>,
85
+ ): {
86
+ readonly values: Record<string, string | readonly string[] | undefined>;
87
+ readonly token: string | undefined;
88
+ } {
89
+ const rest: Record<string, string | readonly string[] | undefined> = Object.create(null);
90
+ for (const [key, value] of Object.entries(values)) {
91
+ if (key !== DOWNLOAD_GRANT_PARAM) rest[key] = value;
92
+ }
93
+ const token = values[DOWNLOAD_GRANT_PARAM];
94
+ if (token === undefined || typeof token === 'string') return { values: rest, token };
95
+ throw new QueryInputInvalidError(name, `${DOWNLOAD_GRANT_PARAM} was sent ${token.length} times`);
96
+ }
97
+
98
+ /**
99
+ * A lone UTF-16 surrogate has no UTF-8 form, so `encodeURIComponent` throws a bare `URIError` on it.
100
+ * Spelled out because `String#isWellFormed` is ES2024 and the lib is ES2023 (core's cookie rule).
101
+ */
102
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
103
+
104
+ /**
105
+ * What a served file may load: nothing from anywhere, but its own bytes as an image or a video —
106
+ * the tab a browser renders an image or a film in fetches that one URL. `sandbox` alone still let
107
+ * an inline HTML file fetch `<img src="https://elsewhere">` and carry this bearer URL in `Referer`.
108
+ */
109
+ export const DOWNLOAD_CSP = "default-src 'none'; img-src 'self'; media-src 'self'; sandbox";
110
+
111
+ /** A C0 control or DEL — what turns one header value into two. */
112
+ const hasControl = (text: string): boolean =>
113
+ [...text].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f);
114
+
115
+ /**
116
+ * The handler's answer, held to its type before a header is written: a `contentType` or a
117
+ * `filename` carrying a CR/LF would be a second header, and a body that is not bytes is a 500 the
118
+ * handler's author has to see named.
119
+ */
120
+ function assertFile(file: DownloadFile): void {
121
+ const { body, contentType, filename, disposition, size } = file;
122
+ assert(
123
+ body instanceof Uint8Array || body instanceof Blob || body instanceof ReadableStream,
124
+ 'a download handle() answered a body that is not a Uint8Array, a Blob or a ReadableStream',
125
+ "return { body: bytes, contentType: 'video/mp4', filename: 'run.mp4' } // bytes, never a base64 string",
126
+ );
127
+ assert(
128
+ typeof contentType === 'string' && contentType !== '' && !hasControl(contentType),
129
+ 'a download handle() answered a contentType that is empty, not a string, or carries a control character',
130
+ "return { body, contentType: 'video/mp4', filename }",
131
+ );
132
+ assert(
133
+ typeof filename === 'string' &&
134
+ filename !== '' &&
135
+ !hasControl(filename) &&
136
+ !LONE_SURROGATE.test(filename),
137
+ 'a download handle() answered a filename that is empty, not a string, or carries a control character or a lone UTF-16 surrogate',
138
+ "return { body, contentType, filename: 'run.mp4' }",
139
+ );
140
+ assert(
141
+ disposition === undefined || disposition === 'attachment' || disposition === 'inline',
142
+ `a download handle() answered disposition ${String(disposition)}`,
143
+ "disposition: 'attachment' (the default) or 'inline'",
144
+ );
145
+ assert(
146
+ size === undefined || (Number.isSafeInteger(size) && size >= 0),
147
+ `a download handle() answered size ${String(size)}, which is not a whole number of bytes`,
148
+ 'size: stream.byteLength // the stream’s length in bytes, or omit it to serve it whole',
149
+ );
150
+ }
151
+
152
+ /**
153
+ * `attachment; filename="run_1.mp4"; filename*=UTF-8''run%E2%80%931.mp4` — the quoted name ASCII
154
+ * with every other character `_` (a quote or a backslash would end it), the starred one exact.
155
+ */
156
+ export function contentDisposition(file: DownloadFile): string {
157
+ const ascii = file.filename.replace(/[^\u0020-\u007e]|["\\]/g, '_');
158
+ const exact = encodeURIComponent(file.filename).replace(
159
+ /['()*]/g,
160
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
161
+ );
162
+ return `${file.disposition ?? 'attachment'}; filename="${ascii}"; filename*=UTF-8''${exact}`;
163
+ }
164
+
165
+ /** The body's length when it can be known without reading it. */
166
+ function sizeOf(file: DownloadFile): number | undefined {
167
+ if (file.body instanceof Uint8Array) return file.body.byteLength;
168
+ if (file.body instanceof Blob) return file.body.size;
169
+ return file.size;
170
+ }
171
+
172
+ /**
173
+ * A view `Response` accepts. A `Uint8Array` over a `SharedArrayBuffer` is not a body, so only that
174
+ * case is copied; every other view is re-wrapped over its own buffer, no byte moved.
175
+ */
176
+ function bytesBody(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
177
+ const { buffer } = bytes;
178
+ return buffer instanceof ArrayBuffer
179
+ ? new Uint8Array(buffer, bytes.byteOffset, bytes.byteLength)
180
+ : bytes.slice();
181
+ }
182
+
183
+ function bodyOf(file: DownloadFile, range?: RequestedRange): BodyInit {
184
+ const { body } = file;
185
+ if (body instanceof Uint8Array) {
186
+ const whole = bytesBody(body);
187
+ return range === undefined ? whole : whole.subarray(range.start, range.end + 1);
188
+ }
189
+ if (body instanceof Blob) {
190
+ return range === undefined ? body : body.slice(range.start, range.end + 1);
191
+ }
192
+ return range === undefined ? body : byteWindow(body, range);
193
+ }
194
+
195
+ /** Status, headers and body for one file and the request's `Range:`, if it named one we serve. */
196
+ export function fileResponse(file: DownloadFile, rangeHeader: string | null): Response {
197
+ assertFile(file);
198
+ const headers = new Headers({
199
+ 'content-type': file.contentType,
200
+ 'content-disposition': contentDisposition(file),
201
+ 'x-content-type-options': 'nosniff',
202
+ // Loads nothing but itself, in an opaque origin with no script — and names no URL on the way
203
+ // out: the one this file is served at is a bearer grant until it expires.
204
+ 'content-security-policy': DOWNLOAD_CSP,
205
+ 'referrer-policy': 'no-referrer',
206
+ });
207
+ const size = sizeOf(file);
208
+ if (size === undefined) {
209
+ headers.set('accept-ranges', 'none');
210
+ return new Response(bodyOf(file), { headers });
211
+ }
212
+ headers.set('accept-ranges', 'bytes');
213
+ const range = parseByteRange(rangeHeader, size);
214
+ if (range === RANGE_UNSATISFIABLE) {
215
+ if (file.body instanceof ReadableStream) void file.body.cancel().catch(() => undefined);
216
+ headers.set('content-range', `bytes */${String(size)}`);
217
+ return new Response(null, { status: 416, headers });
218
+ }
219
+ if (range === undefined) {
220
+ headers.set('content-length', String(size));
221
+ return new Response(bodyOf(file), { headers });
222
+ }
223
+ headers.set('content-range', contentRangeOf(range, size));
224
+ headers.set('content-length', String(range.end - range.start + 1));
225
+ return new Response(bodyOf(file, range), { status: 206, headers });
226
+ }