@ultimat3/query 22.9.1 → 22.11.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 +2 -1
- package/package.json +7 -7
- package/src/errors.ts +2 -1
- package/src/index.ts +2 -0
- package/src/policy-gate.ts +18 -0
- package/src/query.ts +15 -0
- package/src/read.ts +11 -2
package/CLAUDE.md
CHANGED
|
@@ -38,7 +38,8 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
38
38
|
|
|
39
39
|
## Invariants — the read path
|
|
40
40
|
|
|
41
|
-
- Every surface goes through `sourceFor`:
|
|
41
|
+
- Every surface goes through `sourceFor`: the policy's actor half (`guardBeforeInput`), parse input,
|
|
42
|
+
evaluate policy, build the source. A second
|
|
42
43
|
read path is the one unforgivable change here.
|
|
43
44
|
- **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
|
|
44
45
|
`@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.11.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",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "22.
|
|
40
|
-
"@ultimat3/core": "22.
|
|
41
|
-
"@ultimat3/entity": "22.
|
|
42
|
-
"@ultimat3/http": "22.
|
|
43
|
-
"@ultimat3/policy": "22.
|
|
44
|
-
"@ultimat3/schema": "22.
|
|
39
|
+
"@ultimat3/cache": "22.11.0",
|
|
40
|
+
"@ultimat3/core": "22.11.0",
|
|
41
|
+
"@ultimat3/entity": "22.11.0",
|
|
42
|
+
"@ultimat3/http": "22.11.0",
|
|
43
|
+
"@ultimat3/policy": "22.11.0",
|
|
44
|
+
"@ultimat3/schema": "22.11.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/errors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Every failure @ultimat3/query can produce, one subclass per stable code. */
|
|
2
|
-
import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
2
|
+
import { assertNever, deniedCallerFix, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
3
3
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
4
4
|
|
|
5
5
|
// No `docs:` on the classes below.
|
|
@@ -87,6 +87,7 @@ export class QueryDeniedError extends UltimateError {
|
|
|
87
87
|
code,
|
|
88
88
|
cause: `${query} denied: ${denialReason(denial)}`,
|
|
89
89
|
fix: `x policy explain ${query} --json # shows which clause decided and why`,
|
|
90
|
+
callerFix: deniedCallerFix(),
|
|
90
91
|
});
|
|
91
92
|
this.denial = denial;
|
|
92
93
|
}
|
package/src/index.ts
CHANGED
|
@@ -129,6 +129,7 @@ export {
|
|
|
129
129
|
actorOf,
|
|
130
130
|
admitsAnonymous,
|
|
131
131
|
guard,
|
|
132
|
+
guardBeforeInput,
|
|
132
133
|
policyCapability,
|
|
133
134
|
policyPermissions,
|
|
134
135
|
} from './policy-gate';
|
|
@@ -142,6 +143,7 @@ export type {
|
|
|
142
143
|
QueryListFilterOp,
|
|
143
144
|
QueryListParams,
|
|
144
145
|
QueryMcp,
|
|
146
|
+
QueryMcpAnnotations,
|
|
145
147
|
QueryOptions,
|
|
146
148
|
QueryRateLimit,
|
|
147
149
|
SourceOptions,
|
package/src/policy-gate.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { assertNever, isAnonymous } from '@ultimat3/core';
|
|
|
9
9
|
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
10
|
import {
|
|
11
11
|
enforce,
|
|
12
|
+
enforceBeforeInput,
|
|
12
13
|
policyPermissions as flattenedPermissions,
|
|
13
14
|
admitsAnonymous as policyAdmitsAnonymous,
|
|
14
15
|
} from '@ultimat3/policy';
|
|
@@ -42,6 +43,23 @@ export function guard(policy: QueryPolicy, subject: QuerySubject, surface: Query
|
|
|
42
43
|
if (denial !== undefined) throw new QueryDeniedError(subject.query, denial);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The actor-only half of `guard`, run BEFORE the input is parsed — the twin of
|
|
48
|
+
* `@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 `guard`.
|
|
50
|
+
*/
|
|
51
|
+
export function guardBeforeInput(
|
|
52
|
+
policy: QueryPolicy,
|
|
53
|
+
subject: Omit<QuerySubject, 'input' | 'row'>,
|
|
54
|
+
surface: QuerySurface,
|
|
55
|
+
): void {
|
|
56
|
+
const denial = enforceBeforeInput(policySurface(surface), policy, {
|
|
57
|
+
actor: subject.actor,
|
|
58
|
+
ctx: subject.ctx,
|
|
59
|
+
});
|
|
60
|
+
if (denial !== undefined) throw new QueryDeniedError(subject.query, denial);
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
/** A direct server read is the job surface: no request, no socket to close. */
|
|
46
64
|
function policySurface(surface: QuerySurface): PolicySurface {
|
|
47
65
|
switch (surface) {
|
package/src/query.ts
CHANGED
|
@@ -62,6 +62,21 @@ export interface QueryMcp {
|
|
|
62
62
|
* implements them; `manage_resource` refuses anything outside the whitelist before it runs.
|
|
63
63
|
*/
|
|
64
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;
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
/** One comparison a list filter accepts. A filter key is `<field><op>`: `status_eq`. */
|
package/src/read.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
readThrough,
|
|
30
30
|
} from './cache';
|
|
31
31
|
import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
|
|
32
|
-
import { actorOf, guard } from './policy-gate';
|
|
32
|
+
import { actorOf, guard, guardBeforeInput } from './policy-gate';
|
|
33
33
|
import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
|
|
34
34
|
import type { SqlSource } from './source';
|
|
35
35
|
|
|
@@ -204,8 +204,17 @@ async function buildSource(
|
|
|
204
204
|
): Promise<SqlSource<object>> {
|
|
205
205
|
const def = defOf(target);
|
|
206
206
|
const name = queryName(target);
|
|
207
|
-
const input = await validate(def.input, raw, name);
|
|
208
207
|
const unenforced = options.unenforced;
|
|
208
|
+
// The actor half of the policy first, exactly as `invoke` does it: a reader refused whatever
|
|
209
|
+
// they send learns nothing about this read's input schema from an `X_INPUT_INVALID`.
|
|
210
|
+
if (unenforced === undefined) {
|
|
211
|
+
guardBeforeInput(
|
|
212
|
+
def.policy,
|
|
213
|
+
{ actor: actorOf(ctx), ctx, query: name },
|
|
214
|
+
options.surface ?? 'server',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const input = await validate(def.input, raw, name);
|
|
209
218
|
if (unenforced === undefined) {
|
|
210
219
|
guard(
|
|
211
220
|
def.policy,
|