@ultimat3/query 24.0.0 → 25.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.
- package/CLAUDE.md +54 -34
- package/README.md +102 -26
- package/package.json +8 -8
- package/src/audit-errors.ts +43 -0
- package/src/audit-gate.ts +177 -0
- package/src/cache.ts +4 -4
- package/src/client.ts +4 -5
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +39 -25
- package/src/index.ts +27 -55
- package/src/input-shape.ts +1 -1
- package/src/live.ts +19 -3
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +34 -35
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -58
- package/src/rate-limit-gate.ts +71 -0
- package/src/read.ts +97 -14
- package/src/record-answer.ts +9 -5
- package/src/registry.ts +1 -1
- package/src/search.ts +8 -8
- package/src/source.ts +2 -2
- package/src/sql.ts +3 -3
- package/src/stable.ts +0 -3
- package/src/subscribes.ts +3 -3
- package/src/type-pins.ts +62 -0
- package/src/deprecation.ts +0 -76
- package/src/mcp-tool.ts +0 -102
- package/src/naming.ts +0 -22
package/src/live.ts
CHANGED
|
@@ -12,9 +12,11 @@ import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
|
12
12
|
import type { Patch } from './matcher';
|
|
13
13
|
import { assertMatchable } from './matcher';
|
|
14
14
|
import type { QueryPolicy, QuerySubject } from './policy-gate';
|
|
15
|
-
import {
|
|
16
|
-
import type { Query } from './query';
|
|
15
|
+
import { guardQuery } from './policy-gate';
|
|
16
|
+
import type { AnyQuery, Query } from './query';
|
|
17
17
|
import { queryHash } from './query';
|
|
18
|
+
import type { QueryLimitCaller } from './rate-limit-gate';
|
|
19
|
+
import { spendReadLimit } from './rate-limit-gate';
|
|
18
20
|
import { queryName, sourceFor } from './read';
|
|
19
21
|
import { assertNoSealedKey } from './sealed-shape';
|
|
20
22
|
import type { QueryShape, SeekKey } from './shape';
|
|
@@ -103,6 +105,20 @@ export interface ToLiveOptions {
|
|
|
103
105
|
const SHARED_WINDOW_REASON =
|
|
104
106
|
'the shared subject-less window has no subscriber to decide about; authorize() runs per subscriber';
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A live SUBSCRIBE's spend of the read's declared `rateLimit:` — the bucket the HTTP route, a paged
|
|
110
|
+
* read and the MCP tool spend. Its own export because the subscribe is realtime's: the sync node
|
|
111
|
+
* builds one shared window per `(query, input)` with no subscriber (`enforce: false`, which spends
|
|
112
|
+
* nothing) and calls this once per subscriber, beside `authorize`. Never on a re-authorization:
|
|
113
|
+
* the subscription it already holds was paid for.
|
|
114
|
+
*/
|
|
115
|
+
export function spendQueryLimit(
|
|
116
|
+
target: AnyQuery,
|
|
117
|
+
caller: Omit<QueryLimitCaller, 'surface'>,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
return spendReadLimit(queryName(target), target.rateLimit, { ...caller, surface: 'live' });
|
|
120
|
+
}
|
|
121
|
+
|
|
106
122
|
/** Changing the build changes the epoch, which forces reconnects to refetch. */
|
|
107
123
|
export function liveEpoch(): string {
|
|
108
124
|
return Bun.env['X_BUILD_ID'] ?? 'dev';
|
|
@@ -148,7 +164,7 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
|
|
|
148
164
|
limit: shape.limit,
|
|
149
165
|
execute: () => source.execute(),
|
|
150
166
|
authorize: async (subject) => {
|
|
151
|
-
|
|
167
|
+
guardQuery(policy, subject, 'live');
|
|
152
168
|
},
|
|
153
169
|
initialCursor: (rows) => ({
|
|
154
170
|
epoch,
|
package/src/openapi.ts
CHANGED
|
@@ -16,13 +16,17 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { tagKeys } from '@ultimat3/cache';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
isMcpExposed,
|
|
21
|
+
queryPath,
|
|
22
|
+
RECORDS_OPENAPI_HEADER,
|
|
23
|
+
recordEnvelopeSchema,
|
|
24
|
+
} from '@ultimat3/core';
|
|
25
|
+
import { MAX_PAGE_SIZE } from '@ultimat3/entity';
|
|
26
|
+
import { policyCapability } from '@ultimat3/policy';
|
|
20
27
|
import type { SchemaNode } from '@ultimat3/schema';
|
|
21
28
|
import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
|
|
22
|
-
import {
|
|
23
|
-
import { derivePath } from './naming';
|
|
24
|
-
import { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
25
|
-
import { policyCapability } from './policy-gate';
|
|
29
|
+
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
26
30
|
import type { AnyQuery } from './query';
|
|
27
31
|
import { queryName } from './read';
|
|
28
32
|
import { answersRecords } from './record-answer';
|
|
@@ -48,19 +52,22 @@ const ANSWERS: readonly Record<string, unknown>[] = [
|
|
|
48
52
|
{ type: 'array', items: {} },
|
|
49
53
|
{
|
|
50
54
|
type: 'object',
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
required: ['rows', 'nextCursor', 'hasMore', 'endCursor', 'hasNextPage'],
|
|
55
|
+
// The one page shape: the `endCursor`/`hasNextPage` alias pair left in 25.0.0 (O-12).
|
|
56
|
+
required: ['rows', 'nextCursor', 'hasMore'],
|
|
54
57
|
properties: {
|
|
55
58
|
rows: { type: 'array', items: {} },
|
|
56
59
|
nextCursor: {
|
|
57
60
|
type: ['string', 'null'],
|
|
58
|
-
description: `the signed cursor
|
|
61
|
+
description: `the signed cursor for the next page — send it as ${PAGE_AFTER_KEY}; null exactly when hasMore is false`,
|
|
59
62
|
},
|
|
60
63
|
hasMore: { type: 'boolean', description: 'whether another page follows' },
|
|
61
|
-
endCursor: { type: ['string', 'null'], description: 'alias of nextCursor — the same value' },
|
|
62
|
-
hasNextPage: { type: 'boolean', description: 'alias of hasMore — the same value' },
|
|
63
64
|
},
|
|
65
|
+
// `@ultimat3/core`'s `Page` is a union, and so is its wire shape: the two facts cannot
|
|
66
|
+
// disagree in a body this document validates (25.0.0 — the last page used to keep a cursor).
|
|
67
|
+
oneOf: [
|
|
68
|
+
{ properties: { nextCursor: { type: 'string' }, hasMore: { const: true } } },
|
|
69
|
+
{ properties: { nextCursor: { type: 'null' }, hasMore: { const: false } } },
|
|
70
|
+
],
|
|
64
71
|
},
|
|
65
72
|
];
|
|
66
73
|
|
|
@@ -70,7 +77,7 @@ export function queryOpenApiPaths(
|
|
|
70
77
|
): Record<string, OpenApiPathItem> {
|
|
71
78
|
const paths: Record<string, OpenApiPathItem> = {};
|
|
72
79
|
for (const target of [...queries].sort(compareByName)) {
|
|
73
|
-
paths[
|
|
80
|
+
paths[queryPath(queryName(target))] = { get: toQueryOpenApiOperation(target) };
|
|
74
81
|
}
|
|
75
82
|
return paths;
|
|
76
83
|
}
|
|
@@ -117,9 +124,9 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
117
124
|
capability: policyCapability(target.policy),
|
|
118
125
|
live: target.isLive,
|
|
119
126
|
cacheTags: tagKeys(target.cache?.tags ?? []),
|
|
120
|
-
//
|
|
121
|
-
// MCP catalog lists one, under the export name verbatim
|
|
122
|
-
tool:
|
|
127
|
+
// Core's one predicate, the one `@ultimat3/mcp`'s catalog and the action side ask: a tool is
|
|
128
|
+
// advertised only where the MCP catalog lists one, under the export name verbatim.
|
|
129
|
+
tool: isMcpExposed(target.mcp) ? name : null,
|
|
123
130
|
},
|
|
124
131
|
};
|
|
125
132
|
}
|
|
@@ -156,7 +163,7 @@ function singleOperation(target: AnyQuery, name: string): Record<string, unknown
|
|
|
156
163
|
live: target.isLive,
|
|
157
164
|
single: true,
|
|
158
165
|
cacheTags: tagKeys(target.cache?.tags ?? []),
|
|
159
|
-
tool:
|
|
166
|
+
tool: isMcpExposed(target.mcp) ? name : null,
|
|
160
167
|
},
|
|
161
168
|
};
|
|
162
169
|
}
|
|
@@ -177,7 +184,7 @@ const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
|
|
|
177
184
|
name: PAGE_AFTER_KEY,
|
|
178
185
|
in: 'query',
|
|
179
186
|
required: false,
|
|
180
|
-
description: `the nextCursor
|
|
187
|
+
description: `the nextCursor a previous page answered; needs ${PAGE_FIRST_KEY}`,
|
|
181
188
|
schema: { type: 'string' },
|
|
182
189
|
},
|
|
183
190
|
];
|
package/src/page-controls.ts
CHANGED
|
@@ -11,21 +11,17 @@
|
|
|
11
11
|
// the framework's own namespace mark — the route lives under `/_x/` for the same reason — and
|
|
12
12
|
// `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
|
|
13
13
|
|
|
14
|
+
import { MAX_PAGE_SIZE } from '@ultimat3/entity';
|
|
14
15
|
import { QueryInputInvalidError } from './errors';
|
|
15
16
|
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
16
17
|
|
|
17
18
|
export { PAGE_AFTER_KEY, PAGE_FIRST_KEY };
|
|
18
19
|
export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_KEY];
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* an action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
|
|
25
|
-
* request could ask for five million rows. Lives here rather than in `pagination.ts` because the
|
|
26
|
-
* route validates against it BEFORE `paginate` runs — see `pageControlsOf`.
|
|
27
|
-
*/
|
|
28
|
-
export const MAX_PAGE_SIZE = 10_000;
|
|
21
|
+
// The largest page a read will serve is `@ultimat3/entity`'s `MAX_PAGE_SIZE`, imported — a batch
|
|
22
|
+
// and a page are one memory bound, and this package already depends on entity. `first` reaches
|
|
23
|
+
// `paginate` straight from a route parameter, so the route judges it against that bound BEFORE
|
|
24
|
+
// `paginate` runs (`pageControlsOf`), and one request can never ask for five million rows.
|
|
29
25
|
|
|
30
26
|
/** What the route hands `query.page()` — `PaginateArgs` minus the server-side options. */
|
|
31
27
|
export interface PageControls {
|
package/src/page-keys.ts
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
|
|
6
6
|
/** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
|
|
7
7
|
export const PAGE_FIRST_KEY = '_first';
|
|
8
|
-
/** `?_after=<cursor>` — the signed cursor the previous page's `
|
|
8
|
+
/** `?_after=<cursor>` — the signed cursor the previous page's `nextCursor` carried. */
|
|
9
9
|
export const PAGE_AFTER_KEY = '_after';
|
package/src/pagination.ts
CHANGED
|
@@ -9,44 +9,27 @@
|
|
|
9
9
|
* The codec is `@ultimat3/core`'s. This file only decides what a cursor is bound
|
|
10
10
|
* to — `queryHash(name, input)` — so one read's cursor cannot page another.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import type { CursorPayload, Page } from '@ultimat3/core';
|
|
13
|
+
import { assert, CursorInvalidError, decodeCursor, encodeCursor, pageOf } from '@ultimat3/core';
|
|
14
|
+
import { MAX_PAGE_SIZE } from '@ultimat3/entity';
|
|
13
15
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
16
|
import { kindsOf } from './column-kinds';
|
|
15
17
|
import { reviveSortKey, serializeSortValue } from './cursor-value';
|
|
16
|
-
import { CursorInvalidError } from './errors';
|
|
17
|
-
import { MAX_PAGE_SIZE } from './page-controls';
|
|
18
18
|
import type { Query, SourceOptions } from './query';
|
|
19
|
-
import { queryHash, queryName
|
|
19
|
+
import { queryHash, queryName } from './query';
|
|
20
|
+
import { readCall } from './read';
|
|
20
21
|
import type { QueryShape, SeekKey } from './shape';
|
|
21
22
|
import { compareRows, seekKeyOf, totalOrder } from './shape';
|
|
22
23
|
import type { SqlSource } from './source';
|
|
23
24
|
import { isAfterKey } from './source';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* One page
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* One page is `@ultimat3/core`'s `Page` — the framework's ONE page shape (O-12, 25.0.0), the same
|
|
28
|
+
* type `@ultimat3/entity`'s `findMany` answers, re-exported so a `query` file needs one import.
|
|
29
|
+
* `nextCursor` is `null` exactly when `hasMore` is false: before 25.0.0 this page kept a cursor on
|
|
30
|
+
* its LAST page, so a `while (page.nextCursor)` loop fetched an empty page past the end.
|
|
29
31
|
*/
|
|
30
|
-
export
|
|
31
|
-
readonly rows: readonly TRow[];
|
|
32
|
-
/** The signed cursor that continues this listing; `null` on an empty page. */
|
|
33
|
-
readonly nextCursor: string | null;
|
|
34
|
-
/** Whether another page follows. */
|
|
35
|
-
readonly hasMore: boolean;
|
|
36
|
-
/** Alias of `nextCursor`. */
|
|
37
|
-
readonly endCursor: string | null;
|
|
38
|
-
/** Alias of `hasMore`. */
|
|
39
|
-
readonly hasNextPage: boolean;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Builds a `Page` from its two facts — the only constructor, so the aliases cannot drift. */
|
|
43
|
-
export function pageOf<TRow>(
|
|
44
|
-
rows: readonly TRow[],
|
|
45
|
-
nextCursor: string | null,
|
|
46
|
-
hasMore: boolean,
|
|
47
|
-
): Page<TRow> {
|
|
48
|
-
return { rows, nextCursor, hasMore, endCursor: nextCursor, hasNextPage: hasMore };
|
|
49
|
-
}
|
|
32
|
+
export type { Page } from '@ultimat3/core';
|
|
50
33
|
|
|
51
34
|
export interface PaginateArgs extends SourceOptions {
|
|
52
35
|
readonly first: number;
|
|
@@ -72,7 +55,18 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
72
55
|
// The scope is this read plus these arguments: a cursor from anywhere else is
|
|
73
56
|
// already `X_CURSOR_INVALID` by the time it gets here.
|
|
74
57
|
const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
|
|
75
|
-
|
|
58
|
+
// One call: an audited read's record covers the build AND the page it serves — the early return
|
|
59
|
+
// below included, since a caller refused nothing and was answered (`audit-gate.ts`).
|
|
60
|
+
return readCall(target, input, args, (base) => pageFrom<TRow>(base, decoded, args.first, hash));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The page itself, over a source `readCall` already validated, authorized and built. */
|
|
64
|
+
async function pageFrom<TRow extends object>(
|
|
65
|
+
base: SqlSource<object>,
|
|
66
|
+
decoded: CursorPayload | null,
|
|
67
|
+
first: number,
|
|
68
|
+
hash: string,
|
|
69
|
+
): Promise<Page<TRow>> {
|
|
76
70
|
const shape = base.shape();
|
|
77
71
|
// Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
|
|
78
72
|
// key decoded as an ISO string was compared as text against the row's own instant, so page two
|
|
@@ -81,25 +75,30 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
81
75
|
const after = resumed?.seek ?? null;
|
|
82
76
|
const left = rowsLeft(shape.limit, resumed);
|
|
83
77
|
// The whole of a declared limit is already served: there is no row to ask the source for.
|
|
84
|
-
if (left === 0) return pageOf<TRow>([], null
|
|
78
|
+
if (left === 0) return pageOf<TRow>([], null);
|
|
85
79
|
|
|
86
|
-
// `MAX_PAGE_SIZE`
|
|
80
|
+
// `MAX_PAGE_SIZE` is `@ultimat3/entity`'s: `page-controls.ts` checks the same bound at the wire, as
|
|
87
81
|
// a 400, before this `assert` — which is a 500 — can see the number.
|
|
88
|
-
// Fetch one extra row: its presence *is* `
|
|
82
|
+
// Fetch one extra row: its presence *is* `hasMore`, with no count query. Never past what a
|
|
89
83
|
// declared `.limit()` has left — `first` used to REPLACE that limit, so `?_first=10000` walked a
|
|
90
84
|
// "top 3" to the end of the table.
|
|
91
|
-
const window = Math.min(left,
|
|
85
|
+
const window = Math.min(left, first + 1);
|
|
92
86
|
const source: SqlSource<object> = base.seek === undefined ? base : base.seek(after, window);
|
|
93
87
|
const executed = await source.execute();
|
|
94
88
|
const scoped =
|
|
95
89
|
base.seek === undefined ? inTotalOrder(executed, after, shape).slice(0, window) : executed;
|
|
96
90
|
// The source came from this query's own `sql()`, so its rows are TRow.
|
|
97
|
-
const rows = scoped.slice(0,
|
|
91
|
+
const rows = scoped.slice(0, first) as unknown as readonly TRow[];
|
|
98
92
|
const last = rows[rows.length - 1];
|
|
93
|
+
// Read on EVERY page with a row, the last included: a read with no id is refused as unpageable
|
|
94
|
+
// on page one, never only once its listing grows past one page.
|
|
99
95
|
const seek = last === undefined ? null : seekKeyOf(last, shape);
|
|
100
96
|
|
|
97
|
+
// A cursor is minted only when another page follows (the extra row arrived): the position of the
|
|
98
|
+
// last row of the LAST page is not a next page, and handing it back made a
|
|
99
|
+
// `while (page.nextCursor)` loop fetch an empty page past the end.
|
|
101
100
|
const nextCursor =
|
|
102
|
-
seek === null
|
|
101
|
+
seek === null || scoped.length <= first
|
|
103
102
|
? null
|
|
104
103
|
: encodeCursor({
|
|
105
104
|
scope: hash,
|
|
@@ -113,7 +112,7 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
113
112
|
],
|
|
114
113
|
id: String(seek.id),
|
|
115
114
|
});
|
|
116
|
-
return pageOf(rows, nextCursor
|
|
115
|
+
return pageOf(rows, nextCursor);
|
|
117
116
|
}
|
|
118
117
|
|
|
119
118
|
/** A decoded cursor: where the page resumes, and how many rows of a declared limit are spent. */
|
package/src/policy-gate.ts
CHANGED
|
@@ -5,20 +5,20 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
-
import { assertNever
|
|
8
|
+
import { assertNever } from '@ultimat3/core';
|
|
9
9
|
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
|
-
import {
|
|
11
|
-
enforce,
|
|
12
|
-
enforceBeforeInput,
|
|
13
|
-
policyPermissions as flattenedPermissions,
|
|
14
|
-
admitsAnonymous as policyAdmitsAnonymous,
|
|
15
|
-
} from '@ultimat3/policy';
|
|
10
|
+
import { enforce, enforceBeforeInput } from '@ultimat3/policy';
|
|
16
11
|
import { QueryDeniedError } from './errors';
|
|
17
12
|
|
|
18
13
|
/** Policies are opaque here: we evaluate them, we never introspect their rules. */
|
|
19
14
|
export type QueryPolicy = Policy<unknown>;
|
|
20
15
|
|
|
21
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Where a read came from. `'mcp'` is an agent's tool call — named so a declared `rateLimit:`
|
|
18
|
+
* spends for it (`'server'`, in-process app code, does not) — and is judged by the policy exactly
|
|
19
|
+
* as `'server'` always judged it, so no MCP read's authz answer moved.
|
|
20
|
+
*/
|
|
21
|
+
export type QuerySurface = 'server' | 'http' | 'live' | 'mcp';
|
|
22
22
|
|
|
23
23
|
export interface QuerySubject {
|
|
24
24
|
readonly actor: Actor | null;
|
|
@@ -33,7 +33,11 @@ export interface QuerySubject {
|
|
|
33
33
|
readonly query: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function
|
|
36
|
+
export function guardQuery(
|
|
37
|
+
policy: QueryPolicy,
|
|
38
|
+
subject: QuerySubject,
|
|
39
|
+
surface: QuerySurface,
|
|
40
|
+
): void {
|
|
37
41
|
const denial = enforce(policySurface(surface), policy, {
|
|
38
42
|
input: subject.input,
|
|
39
43
|
actor: subject.actor,
|
|
@@ -44,11 +48,11 @@ export function guard(policy: QueryPolicy, subject: QuerySubject, surface: Query
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
/**
|
|
47
|
-
* The actor-only half of `
|
|
51
|
+
* The actor-only half of `guardQuery`, run BEFORE the input is parsed — the twin of
|
|
48
52
|
* `@ultimat3/action`'s. A reader the policy refuses whatever they send is answered 403 (or 401),
|
|
49
|
-
* never `X_INPUT_INVALID` with the read's input schema in it. Undecided passes to `
|
|
53
|
+
* never `X_INPUT_INVALID` with the read's input schema in it. Undecided passes to `guardQuery`.
|
|
50
54
|
*/
|
|
51
|
-
export function
|
|
55
|
+
export function guardQueryBeforeInput(
|
|
52
56
|
policy: QueryPolicy,
|
|
53
57
|
subject: Omit<QuerySubject, 'input' | 'row'>,
|
|
54
58
|
surface: QuerySurface,
|
|
@@ -68,45 +72,9 @@ function policySurface(surface: QuerySurface): PolicySurface {
|
|
|
68
72
|
case 'live':
|
|
69
73
|
return 'live';
|
|
70
74
|
case 'server':
|
|
75
|
+
case 'mcp':
|
|
71
76
|
return 'job';
|
|
72
77
|
default:
|
|
73
78
|
return assertNever(surface);
|
|
74
79
|
}
|
|
75
80
|
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Core models "nobody" as an anonymous actor; policy models it as `null`, which is
|
|
79
|
-
* what turns a missing session into `X_UNAUTHENTICATED` instead of a bare denial.
|
|
80
|
-
*/
|
|
81
|
-
export function actorOf(ctx: Ctx): Actor | null {
|
|
82
|
-
return isAnonymous(ctx.actor) ? null : ctx.actor;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** The capability a read requires, for manifests and the `/_x` dashboard. A DISPLAY label. */
|
|
86
|
-
export function policyCapability(policy: QueryPolicy): string {
|
|
87
|
-
return policy.label;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Every permission the policy tree references, flattened and deduped — and the only field a
|
|
92
|
-
* compliance report may match a grant against. `label` renders a composite as
|
|
93
|
-
* `or(feed:read, org:administer)`, which is a sentence and never equals a permission string, so
|
|
94
|
-
* matching on it reported every read guarded by a composite as enforcing nothing. The mirror of
|
|
95
|
-
* `@ultimat3/action`'s, because `x policy list` reads both lists the same way.
|
|
96
|
-
*/
|
|
97
|
-
export function policyPermissions(policy: QueryPolicy): readonly string[] {
|
|
98
|
-
return flattenedPermissions(policy);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Whether a policy admits an ANONYMOUS caller — `@ultimat3/policy`'s answer, re-exported here so
|
|
103
|
-
* `http.ts` reads it through this file like every other authz question. `toQueryRoute` derives
|
|
104
|
-
* `meta.auth` from it, never from `policy.kind === 'allow'`: that read looked at the ROOT
|
|
105
|
-
* combinator only, so `or(allow(), can('x:y'))` was 401'd by the pipeline before `runQuery` ran
|
|
106
|
-
* while the MCP tool and a direct server read allowed it. `true` is `meta.auth` only — the read is
|
|
107
|
-
* still `no-store` and `runQuery` still evaluates the policy per caller. Declared once in
|
|
108
|
-
* `policy.ts`, exactly as `policyPermissions` is: the answer is a property of the combinators.
|
|
109
|
-
*/
|
|
110
|
-
export function admitsAnonymous(policy: QueryPolicy): boolean {
|
|
111
|
-
return policyAdmitsAnonymous(policy);
|
|
112
|
-
}
|
package/src/query.ts
CHANGED
|
@@ -8,20 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CacheTag } from '@ultimat3/cache';
|
|
10
10
|
import { tagKeys } from '@ultimat3/cache';
|
|
11
|
-
import type { Actor, Ctx } from '@ultimat3/core';
|
|
11
|
+
import type { Actor, Ctx, Deprecation, McpExposureDeclaration } from '@ultimat3/core';
|
|
12
12
|
import { fingerprint } from '@ultimat3/core';
|
|
13
|
+
import type { RateLimitDecision } from '@ultimat3/http';
|
|
14
|
+
import { policyCapability, policyPermissions } from '@ultimat3/policy';
|
|
13
15
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
16
|
import type { QueryCacheScope } from './cache';
|
|
15
17
|
import type { QueryClientMethodOf, QueryClientOptions } from './client';
|
|
16
|
-
import type { Deprecation } from './deprecation';
|
|
17
18
|
import { QueryCacheTtlInvalidError, QuerySingleInvalidError } from './errors';
|
|
18
19
|
import { facadeFor } from './facade';
|
|
19
20
|
import { assertEncodableInput } from './input-shape';
|
|
20
21
|
import type { LiveQuery, ToLiveOptions } from './live';
|
|
21
|
-
import type { QueryToolDescriptor } from './mcp-tool';
|
|
22
22
|
import type { Page, PaginateArgs } from './pagination';
|
|
23
23
|
import type { QueryPolicy, QuerySurface } from './policy-gate';
|
|
24
|
-
import { policyCapability, policyPermissions } from './policy-gate';
|
|
25
24
|
import { hasDef, queryName, runQuery, stashDef } from './read';
|
|
26
25
|
import type { SqlSource } from './source';
|
|
27
26
|
import { assertSubscribes } from './subscribes';
|
|
@@ -44,52 +43,6 @@ export interface QueryCache {
|
|
|
44
43
|
readonly scope?: QueryCacheScope;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
export interface QueryMcp {
|
|
48
|
-
/** Opt-in: a read reaches an agent only when it says so. Silence exposes nothing. */
|
|
49
|
-
readonly expose: boolean;
|
|
50
|
-
/** Contract text, not UI text — see `ActionMcp.description` for why it stays outside `t()`. */
|
|
51
|
-
readonly description?: string;
|
|
52
|
-
/**
|
|
53
|
-
* Roles that may SEE the projected tool — a catalog audience, not an authz rule; the `policy`
|
|
54
|
-
* still decides every call. A caller whose role is not named gets ToolNotFound, never
|
|
55
|
-
* Forbidden. See `ActionMcp.visibleTo`, which this mirrors exactly.
|
|
56
|
-
*/
|
|
57
|
-
readonly visibleTo?: readonly string[];
|
|
58
|
-
/**
|
|
59
|
-
* The list whitelist an MCP meta surface composes over (`@ultimat3/mcp` `McpListParams`):
|
|
60
|
-
* `filters` field → operators, sortable `sort` fields, pickable `fields`, `maxLimit`. Flat keys:
|
|
61
|
-
* the query's own `input` declares `status_eq`, `sort`, `fields`, `cursor`, `limit` and
|
|
62
|
-
* implements them; `manage_resource` refuses anything outside the whitelist before it runs.
|
|
63
|
-
*/
|
|
64
|
-
readonly listParams?: QueryListParams;
|
|
65
|
-
/** The tool's display name in an MCP client. See `ActionMcp.title`. */
|
|
66
|
-
readonly title?: string;
|
|
67
|
-
/**
|
|
68
|
-
* MCP tool annotations, overriding the derived `readOnlyHint: true` key by key — `openWorldHint:
|
|
69
|
-
* true` for a read that reaches outside the app. See `ActionMcp.annotations`.
|
|
70
|
-
*/
|
|
71
|
-
readonly annotations?: QueryMcpAnnotations;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Structural twin of `@ultimat3/action`'s `McpAnnotationHints` (siblings cannot import). */
|
|
75
|
-
export interface QueryMcpAnnotations {
|
|
76
|
-
readonly readOnlyHint?: boolean;
|
|
77
|
-
readonly destructiveHint?: boolean;
|
|
78
|
-
readonly idempotentHint?: boolean;
|
|
79
|
-
readonly openWorldHint?: boolean;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** One comparison a list filter accepts. A filter key is `<field><op>`: `status_eq`. */
|
|
83
|
-
export type QueryListFilterOp = '_eq' | '_in' | '_gt' | '_lt' | '_cont';
|
|
84
|
-
|
|
85
|
-
/** Structural twin of `@ultimat3/mcp`'s `McpListParams` (tier 3 cannot import tier 4). */
|
|
86
|
-
export interface QueryListParams {
|
|
87
|
-
readonly filters?: Readonly<Record<string, readonly QueryListFilterOp[]>>;
|
|
88
|
-
readonly sort?: readonly string[];
|
|
89
|
-
readonly fields?: readonly string[];
|
|
90
|
-
readonly maxLimit?: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
46
|
/**
|
|
94
47
|
* The symmetric twin of `ActionRateLimit`, and the field whose absence meant a read could not be
|
|
95
48
|
* throttled at all: every `GET /_x/query/*` fell through to the `default` bucket (120 burst, 2/s
|
|
@@ -147,7 +100,7 @@ export interface QueryDef<
|
|
|
147
100
|
readonly rows?: StandardSchemaV1<unknown, TRow>;
|
|
148
101
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
149
102
|
readonly cache?: QueryCache;
|
|
150
|
-
readonly mcp?:
|
|
103
|
+
readonly mcp?: McpExposureDeclaration;
|
|
151
104
|
/** Burst and refill for THIS read's route, in the limiter's own vocabulary. */
|
|
152
105
|
readonly rateLimit?: QueryRateLimit;
|
|
153
106
|
/**
|
|
@@ -158,6 +111,13 @@ export interface QueryDef<
|
|
|
158
111
|
* router feature; see the README.
|
|
159
112
|
*/
|
|
160
113
|
readonly deprecated?: Deprecation;
|
|
114
|
+
/**
|
|
115
|
+
* Record every call through the installed `AuditSink` (`@ultimat3/core`'s, the one an action's
|
|
116
|
+
* `audit: true` writes to) — allowed, denied and failed, and a memo or cache hit too: an audit of
|
|
117
|
+
* a read is who saw what, not what was computed. Off unless declared; no sink installed is
|
|
118
|
+
* `X_QUERY_AUDIT_SINK_MISSING` before the input is parsed.
|
|
119
|
+
*/
|
|
120
|
+
readonly audit?: boolean;
|
|
161
121
|
}
|
|
162
122
|
|
|
163
123
|
export interface QueryOptions {
|
|
@@ -176,6 +136,13 @@ export interface QueryOptions {
|
|
|
176
136
|
* stays on the one read path instead of forking a second one.
|
|
177
137
|
*/
|
|
178
138
|
readonly actor?: Actor | null;
|
|
139
|
+
/**
|
|
140
|
+
* The reader's connection address — the only rate-limit subject an anonymous reader has. The
|
|
141
|
+
* HTTP projection passes the request's; elsewhere every anonymous reader shares one bucket.
|
|
142
|
+
*/
|
|
143
|
+
readonly clientAddress?: string | null;
|
|
144
|
+
/** The declared `rateLimit:` decision, before a refusal: how HTTP answers `RateLimit-*`. */
|
|
145
|
+
readonly onRateLimit?: (decision: RateLimitDecision) => void;
|
|
179
146
|
}
|
|
180
147
|
|
|
181
148
|
export interface SourceOptions extends QueryOptions {
|
|
@@ -239,9 +206,10 @@ export interface AnyQueryDef {
|
|
|
239
206
|
readonly rows?: StandardSchemaV1;
|
|
240
207
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
241
208
|
readonly cache?: QueryCache;
|
|
242
|
-
readonly mcp?:
|
|
209
|
+
readonly mcp?: McpExposureDeclaration;
|
|
243
210
|
readonly rateLimit?: QueryRateLimit;
|
|
244
211
|
readonly deprecated?: Deprecation;
|
|
212
|
+
readonly audit?: boolean;
|
|
245
213
|
}
|
|
246
214
|
|
|
247
215
|
export interface AnyQuery {
|
|
@@ -259,7 +227,7 @@ export interface AnyQuery {
|
|
|
259
227
|
/** Lifted for the route: whether an answer is a record envelope is decided at projection. */
|
|
260
228
|
readonly rows?: StandardSchemaV1;
|
|
261
229
|
readonly cache?: QueryCache;
|
|
262
|
-
readonly mcp?:
|
|
230
|
+
readonly mcp?: McpExposureDeclaration;
|
|
263
231
|
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
264
232
|
readonly rateLimit?: QueryRateLimit;
|
|
265
233
|
readonly deprecated?: Deprecation;
|
|
@@ -271,7 +239,6 @@ export interface AnyQuery {
|
|
|
271
239
|
/** One bounded page plus the signed cursor that continues it. There is no `offset`. */
|
|
272
240
|
page(input: unknown, args: PaginateArgs): Promise<Page<object>>;
|
|
273
241
|
live(input: unknown, options?: ToLiveOptions): Promise<LiveQuery>;
|
|
274
|
-
tool(): QueryToolDescriptor;
|
|
275
242
|
}
|
|
276
243
|
|
|
277
244
|
export interface Query<
|
|
@@ -297,8 +264,6 @@ export interface Query<
|
|
|
297
264
|
* so it lives here and not on the schema-erased `AnyQuery` view.
|
|
298
265
|
*/
|
|
299
266
|
client(options: QueryClientOptions): QueryClientMethodOf<TInput, TRow, TSingle>;
|
|
300
|
-
/** Narrowed by the declaration: a list read's tool answers rows, a single read's one row. */
|
|
301
|
-
tool(): QueryToolDescriptor<TSingle>;
|
|
302
267
|
}
|
|
303
268
|
|
|
304
269
|
/** The fluent half of a query: lifted declaration plus one method per projection. */
|
|
@@ -320,7 +285,6 @@ export type QueryFacade<
|
|
|
320
285
|
| 'as'
|
|
321
286
|
| 'page'
|
|
322
287
|
| 'live'
|
|
323
|
-
| 'tool'
|
|
324
288
|
| 'client'
|
|
325
289
|
>;
|
|
326
290
|
|
|
@@ -374,7 +338,7 @@ export function isQuery(value: unknown): value is AnyQuery {
|
|
|
374
338
|
/**
|
|
375
339
|
* Stamp the export name onto the query the app declared, rather than handing back a
|
|
376
340
|
* differently-named copy of it. `import { liveFeed } from './live'` is then the query
|
|
377
|
-
* that projects — `liveFeed.
|
|
341
|
+
* that projects — `liveFeed.client()` after boot, with nothing to remember. The same
|
|
378
342
|
* rule `nameAction` follows; naming twice is the one case that still needs a twin.
|
|
379
343
|
*/
|
|
380
344
|
export function nameQuery<Q extends AnyQuery>(target: Q, name: string): Q {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a read's declared `rateLimit:` is spent: once, in the read path's front half, so the HTTP
|
|
3
|
+
* route, a paged read, the MCP tool and a live subscribe draw on ONE bucket. The store is
|
|
4
|
+
* `@ultimat3/http`'s installed one — the slot `@ultimat3/action`'s `invoke` spends from too.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Actor } from '@ultimat3/core';
|
|
8
|
+
import { isAnonymous, systemClock } from '@ultimat3/core';
|
|
9
|
+
import type { RateLimitDecision } from '@ultimat3/http';
|
|
10
|
+
// The bucket maths, the subject precedence and the refusal are `@ultimat3/http`'s (tier 2): a copy
|
|
11
|
+
// here would be a second answer to "what does this limit mean" and "whose bucket is it".
|
|
12
|
+
import { installedRateLimitStore, rateLimited, rateLimitSpends, toBucket } from '@ultimat3/http';
|
|
13
|
+
import type { QuerySurface } from './policy-gate';
|
|
14
|
+
import type { QueryRateLimit } from './query';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Namespaced, never the bare query name: the HTTP stage still spends its own buckets under
|
|
18
|
+
* `<route name>|<subject>`, and one key taken against two bucket shapes corrupts both counters.
|
|
19
|
+
*/
|
|
20
|
+
const QUERY_SCOPE = 'query';
|
|
21
|
+
|
|
22
|
+
export interface QueryLimitCaller {
|
|
23
|
+
/** Who is reading — `ctx.actor` on a read, the socket's actor on a live subscribe. */
|
|
24
|
+
readonly actor: Actor;
|
|
25
|
+
readonly surface: QuerySurface;
|
|
26
|
+
/** The connection address — the only subject an anonymous reader has. */
|
|
27
|
+
readonly clientAddress?: string | null | undefined;
|
|
28
|
+
/** The decision, before a refusal is thrown: how HTTP puts `RateLimit-*` on the answer. */
|
|
29
|
+
readonly onRateLimit?: ((decision: RateLimitDecision) => void) | undefined;
|
|
30
|
+
/** Epoch ms from the caller's clock. Absent, the system clock. */
|
|
31
|
+
readonly nowMs?: number | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Spend one token of a read's declared limit for this caller, or throw `X_RATE_LIMITED`. Called by
|
|
36
|
+
* `read.ts`'s `buildSource` for every enforced build, and through `spendQueryLimit` (`live.ts`) by
|
|
37
|
+
* the one surface this package does not run: realtime's per-subscriber live subscribe.
|
|
38
|
+
*
|
|
39
|
+
* `surface: 'server'` spends nothing — app code reading in-process (a page's render, a service),
|
|
40
|
+
* the rule `@ultimat3/action` applies to its own `'server'` surface. A refused attempt spends
|
|
41
|
+
* nothing either: the token bucket only decrements on an admission.
|
|
42
|
+
*/
|
|
43
|
+
export async function spendReadLimit(
|
|
44
|
+
name: string,
|
|
45
|
+
limit: QueryRateLimit | undefined,
|
|
46
|
+
caller: QueryLimitCaller,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
if (limit === undefined || caller.surface === 'server') return;
|
|
49
|
+
const actor = isAnonymous(caller.actor) ? null : caller.actor;
|
|
50
|
+
const spends = rateLimitSpends(
|
|
51
|
+
{
|
|
52
|
+
actorId: actor?.id ?? null,
|
|
53
|
+
orgId: actor?.orgId ?? null,
|
|
54
|
+
ip: caller.clientAddress ?? null,
|
|
55
|
+
routeName: `${QUERY_SCOPE}:${name}`,
|
|
56
|
+
},
|
|
57
|
+
// The tenant allowance is the HTTP pipeline's; counting it here too would charge twice.
|
|
58
|
+
{ route: name, tenant: null },
|
|
59
|
+
);
|
|
60
|
+
const nowMs = caller.nowMs ?? systemClock.now().getTime();
|
|
61
|
+
for (const spend of spends) {
|
|
62
|
+
const decision = await installedRateLimitStore().take(
|
|
63
|
+
spend.key,
|
|
64
|
+
toBucket(name, limit),
|
|
65
|
+
1,
|
|
66
|
+
nowMs,
|
|
67
|
+
);
|
|
68
|
+
caller.onRateLimit?.(decision);
|
|
69
|
+
if (!decision.allowed) throw rateLimited(spend.key, decision.retryAfterSeconds);
|
|
70
|
+
}
|
|
71
|
+
}
|