@vxil/feature-configs 0.5.0 → 0.5.1
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/dist/apiState.d.ts +8 -10
- package/dist/apiState.js +2 -2
- package/dist/canonicalJson.d.ts +7 -0
- package/dist/canonicalJson.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -1
- package/package.json +1 -1
- package/src/apiState.ts +16 -16
- package/src/canonicalJson.ts +15 -0
- package/src/index.ts +12 -2
package/dist/apiState.d.ts
CHANGED
|
@@ -149,14 +149,12 @@ export interface ApiStateSummary {
|
|
|
149
149
|
export declare function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary;
|
|
150
150
|
/** The `api_state` object a config-write response carries (`PUT /v1/config/
|
|
151
151
|
* :feature` and its dashboard twin, dry-run included — control-plane
|
|
152
|
-
* handlers/apiState.ts apiStateResponseField) and the shape `vxil push`
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* `ok:false`
|
|
157
|
-
*
|
|
158
|
-
* a DEFINITE, named refusal (nothing to retry); `note` = converged nothing on
|
|
159
|
-
* purpose (a disabled feature). */
|
|
152
|
+
* handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
|
|
153
|
+
* `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
|
|
154
|
+
* INDEFINITE reason — the feature worker has not seen the config yet, or a
|
|
155
|
+
* transient failure (a push asks again through POST /v1/apply after its
|
|
156
|
+
* wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
|
|
157
|
+
* `note` = converged nothing on purpose (a disabled feature). */
|
|
160
158
|
export interface ApiStateResponseField extends ApiStateSummary {
|
|
161
159
|
ok: boolean;
|
|
162
160
|
dry_run?: boolean;
|
|
@@ -170,8 +168,8 @@ export interface ApiStateResponseField extends ApiStateSummary {
|
|
|
170
168
|
export declare const API_STATE_DISABLED_NOTE = "feature disabled \u2014 declaration kept, not converged";
|
|
171
169
|
/** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
|
|
172
170
|
* AT the cap is not a complete live set, so planning creates against it would
|
|
173
|
-
* re-POST every declared name the cap hid (review finding 5).
|
|
174
|
-
* refuse with this message instead. */
|
|
171
|
+
* re-POST every declared name the cap hid (review finding 5). The server's
|
|
172
|
+
* converge and the CLI's read-only plan refuse with this message instead. */
|
|
175
173
|
export declare function rlPolicyListCapError(liveCount: number): string | null;
|
|
176
174
|
export declare const RL_POLICY_LIST_CAP = 100;
|
|
177
175
|
/** `+2 created · ~1 updated · =3 unchanged · 1 undeclared (left in place)` —
|
package/dist/apiState.js
CHANGED
|
@@ -165,8 +165,8 @@ export function emptyApiStateSummary(datum) {
|
|
|
165
165
|
export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, not converged';
|
|
166
166
|
/** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
|
|
167
167
|
* AT the cap is not a complete live set, so planning creates against it would
|
|
168
|
-
* re-POST every declared name the cap hid (review finding 5).
|
|
169
|
-
* refuse with this message instead. */
|
|
168
|
+
* re-POST every declared name the cap hid (review finding 5). The server's
|
|
169
|
+
* converge and the CLI's read-only plan refuse with this message instead. */
|
|
170
170
|
export function rlPolicyListCapError(liveCount) {
|
|
171
171
|
if (liveCount < RL_POLICY_LIST_CAP)
|
|
172
172
|
return null;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** JSON.stringify with object keys sorted at every depth (arrays keep their
|
|
2
|
+
* order; undefined-valued keys are dropped, as JSON.stringify drops them).
|
|
3
|
+
* Two values with equal output are equal in CONTENT. A stored manifest comes
|
|
4
|
+
* back from storage with its object keys re-ordered, so every declared-vs-
|
|
5
|
+
* stored config compare (the server's shallowDiff, the CLI's diffManifest)
|
|
6
|
+
* must go through this, never through plain JSON.stringify. */
|
|
7
|
+
export declare function canonicalJson(v: unknown): string | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** JSON.stringify with object keys sorted at every depth (arrays keep their
|
|
2
|
+
* order; undefined-valued keys are dropped, as JSON.stringify drops them).
|
|
3
|
+
* Two values with equal output are equal in CONTENT. A stored manifest comes
|
|
4
|
+
* back from storage with its object keys re-ordered, so every declared-vs-
|
|
5
|
+
* stored config compare (the server's shallowDiff, the CLI's diffManifest)
|
|
6
|
+
* must go through this, never through plain JSON.stringify. */
|
|
7
|
+
export function canonicalJson(v) {
|
|
8
|
+
if (Array.isArray(v))
|
|
9
|
+
return `[${v.map((x) => canonicalJson(x) ?? 'null').join(',')}]`;
|
|
10
|
+
if (v !== null && typeof v === 'object') {
|
|
11
|
+
const o = v;
|
|
12
|
+
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
|
|
13
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(v);
|
|
16
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Static, type TSchema } from '@sinclair/typebox';
|
|
|
2
2
|
export * from './hooks.js';
|
|
3
3
|
export * from './readmodels.js';
|
|
4
4
|
export * from './apiState.js';
|
|
5
|
+
export * from './canonicalJson.js';
|
|
5
6
|
export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
|
|
6
7
|
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
7
8
|
* of which is restricted to internal platform machinery). */
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,9 @@ export * from './readmodels.js';
|
|
|
18
18
|
// declared-vs-live reconciliation, shared by the control-plane apply path and
|
|
19
19
|
// the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
|
|
20
20
|
export * from './apiState.js';
|
|
21
|
+
// Key-order-insensitive JSON: the one declared-vs-stored manifest compare
|
|
22
|
+
// (control-plane shallowDiff, CLI diffManifest).
|
|
23
|
+
export * from './canonicalJson.js';
|
|
21
24
|
// TypeBox validates `format:` only for registered formats — register the ones
|
|
22
25
|
// our schemas use (pragmatic RFC-lite email check; providers do the real one).
|
|
23
26
|
if (!FormatRegistry.Has('email')) {
|
|
@@ -1956,8 +1959,14 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1956
1959
|
for (const [id, agent] of Object.entries(v.agents ?? {})) {
|
|
1957
1960
|
const allow = agent.actions?.allow ?? {};
|
|
1958
1961
|
const guestAllow = agent.guardrails?.guestToolAllow ?? [];
|
|
1959
|
-
if (agent.guardrails?.allowGuest === true)
|
|
1962
|
+
if (agent.guardrails?.allowGuest === true) {
|
|
1960
1963
|
anyGuestAgent = true;
|
|
1964
|
+
// Guests share ONE tenant-wide bucket that spends the tenant's own AI
|
|
1965
|
+
// key: an unlimited (0) daily cap is never allowed alongside them.
|
|
1966
|
+
if (agent.guardrails.rateLimitPerUserPerDay === 0) {
|
|
1967
|
+
errs.push(`/agents/${id}/guardrails/rateLimitPerUserPerDay: 0 (unlimited) is not allowed with allowGuest: true — set a finite daily cap (guests share one tenant-wide bucket)`);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1961
1970
|
if (knownMcpTools) {
|
|
1962
1971
|
for (const tool of Object.keys(allow)) {
|
|
1963
1972
|
if (!knownMcpTools.has(tool)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vxil.com",
|
package/src/apiState.ts
CHANGED
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
//
|
|
15
15
|
// ONE WRITER PER DATUM, AND THE WRITER IS THE REPOSITORY. The planner below is
|
|
16
16
|
// PURE (no I/O, no clock): it takes the declared list and the live rows and
|
|
17
|
-
// returns the exact operation set.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
17
|
+
// returns the exact operation set. ONE executor drives it: the control-plane
|
|
18
|
+
// (handlers/apiState.ts convergeApiState — the feature's own routes with the
|
|
19
|
+
// caller's scopes, or its own table in-process), after every config write and
|
|
20
|
+
// as the api-state step of POST /v1/apply. The CLI (packages/cli/src/
|
|
21
|
+
// apiState.ts) never writes these rows: `vxil push` asks the server, and
|
|
22
|
+
// `vxil plan` / `vxil diff` run this planner READ-ONLY when the server's dry
|
|
23
|
+
// run could not plan. packages/cli/src/apiState.e2e.test.ts drives the real
|
|
24
|
+
// CLI binary against the real convergeApiState.
|
|
23
25
|
//
|
|
24
26
|
// THE NON-DESTRUCTIVE DEFAULT. A live row the config does NOT declare is
|
|
25
27
|
// LEFT IN PLACE and reported as `undeclared` — deleted only when the caller
|
|
@@ -285,14 +287,12 @@ export function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary {
|
|
|
285
287
|
|
|
286
288
|
/** The `api_state` object a config-write response carries (`PUT /v1/config/
|
|
287
289
|
* :feature` and its dashboard twin, dry-run included — control-plane
|
|
288
|
-
* handlers/apiState.ts apiStateResponseField) and the shape `vxil push`
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* `ok:false`
|
|
293
|
-
*
|
|
294
|
-
* a DEFINITE, named refusal (nothing to retry); `note` = converged nothing on
|
|
295
|
-
* purpose (a disabled feature). */
|
|
290
|
+
* handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
|
|
291
|
+
* `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
|
|
292
|
+
* INDEFINITE reason — the feature worker has not seen the config yet, or a
|
|
293
|
+
* transient failure (a push asks again through POST /v1/apply after its
|
|
294
|
+
* wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
|
|
295
|
+
* `note` = converged nothing on purpose (a disabled feature). */
|
|
296
296
|
export interface ApiStateResponseField extends ApiStateSummary {
|
|
297
297
|
ok: boolean;
|
|
298
298
|
dry_run?: boolean;
|
|
@@ -308,8 +308,8 @@ export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, n
|
|
|
308
308
|
|
|
309
309
|
/** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
|
|
310
310
|
* AT the cap is not a complete live set, so planning creates against it would
|
|
311
|
-
* re-POST every declared name the cap hid (review finding 5).
|
|
312
|
-
* refuse with this message instead. */
|
|
311
|
+
* re-POST every declared name the cap hid (review finding 5). The server's
|
|
312
|
+
* converge and the CLI's read-only plan refuse with this message instead. */
|
|
313
313
|
export function rlPolicyListCapError(liveCount: number): string | null {
|
|
314
314
|
if (liveCount < RL_POLICY_LIST_CAP) return null;
|
|
315
315
|
return `the policy list is at its ${RL_POLICY_LIST_CAP}-row hard cap (no cursor) — the live set may be incomplete; delete undeclared policies before converging`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** JSON.stringify with object keys sorted at every depth (arrays keep their
|
|
2
|
+
* order; undefined-valued keys are dropped, as JSON.stringify drops them).
|
|
3
|
+
* Two values with equal output are equal in CONTENT. A stored manifest comes
|
|
4
|
+
* back from storage with its object keys re-ordered, so every declared-vs-
|
|
5
|
+
* stored config compare (the server's shallowDiff, the CLI's diffManifest)
|
|
6
|
+
* must go through this, never through plain JSON.stringify. */
|
|
7
|
+
export function canonicalJson(v: unknown): string | undefined {
|
|
8
|
+
if (Array.isArray(v)) return `[${v.map((x) => canonicalJson(x) ?? 'null').join(',')}]`;
|
|
9
|
+
if (v !== null && typeof v === 'object') {
|
|
10
|
+
const o = v as Record<string, unknown>;
|
|
11
|
+
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
|
|
12
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
|
|
13
|
+
}
|
|
14
|
+
return JSON.stringify(v);
|
|
15
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,9 @@ export * from './readmodels.js';
|
|
|
19
19
|
// declared-vs-live reconciliation, shared by the control-plane apply path and
|
|
20
20
|
// the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
|
|
21
21
|
export * from './apiState.js';
|
|
22
|
+
// Key-order-insensitive JSON: the one declared-vs-stored manifest compare
|
|
23
|
+
// (control-plane shallowDiff, CLI diffManifest).
|
|
24
|
+
export * from './canonicalJson.js';
|
|
22
25
|
|
|
23
26
|
// TypeBox validates `format:` only for registered formats — register the ones
|
|
24
27
|
// our schemas use (pragmatic RFC-lite email check; providers do the real one).
|
|
@@ -2377,7 +2380,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2377
2380
|
const v = withDefaults as {
|
|
2378
2381
|
agents?: Record<string, {
|
|
2379
2382
|
actions?: { mode?: string; allow?: Record<string, unknown> };
|
|
2380
|
-
guardrails?: { allowGuest?: boolean; guestToolAllow?: string[] };
|
|
2383
|
+
guardrails?: { allowGuest?: boolean; guestToolAllow?: string[]; rateLimitPerUserPerDay?: number };
|
|
2381
2384
|
}>;
|
|
2382
2385
|
widget?: { requireAuth?: boolean };
|
|
2383
2386
|
};
|
|
@@ -2386,7 +2389,14 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2386
2389
|
for (const [id, agent] of Object.entries(v.agents ?? {})) {
|
|
2387
2390
|
const allow = agent.actions?.allow ?? {};
|
|
2388
2391
|
const guestAllow = agent.guardrails?.guestToolAllow ?? [];
|
|
2389
|
-
if (agent.guardrails?.allowGuest === true)
|
|
2392
|
+
if (agent.guardrails?.allowGuest === true) {
|
|
2393
|
+
anyGuestAgent = true;
|
|
2394
|
+
// Guests share ONE tenant-wide bucket that spends the tenant's own AI
|
|
2395
|
+
// key: an unlimited (0) daily cap is never allowed alongside them.
|
|
2396
|
+
if (agent.guardrails.rateLimitPerUserPerDay === 0) {
|
|
2397
|
+
errs.push(`/agents/${id}/guardrails/rateLimitPerUserPerDay: 0 (unlimited) is not allowed with allowGuest: true — set a finite daily cap (guests share one tenant-wide bucket)`);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2390
2400
|
if (knownMcpTools) {
|
|
2391
2401
|
for (const tool of Object.keys(allow)) {
|
|
2392
2402
|
if (!knownMcpTools.has(tool)) {
|