@vxil/feature-configs 0.5.0 → 0.6.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/dist/apiState.d.ts +14 -14
- package/dist/apiState.js +5 -5
- package/dist/canonicalJson.d.ts +7 -0
- package/dist/canonicalJson.js +16 -0
- package/dist/index.d.ts +63 -2
- package/dist/index.js +119 -20
- package/package.json +1 -1
- package/src/apiState.ts +30 -26
- package/src/canonicalJson.ts +15 -0
- package/src/index.ts +132 -24
package/dist/apiState.d.ts
CHANGED
|
@@ -80,12 +80,14 @@ export interface LiveWebhookSubscription {
|
|
|
80
80
|
}
|
|
81
81
|
export interface WebhookSubscriptionPlan {
|
|
82
82
|
create: DeclaredWebhookSubscription[];
|
|
83
|
-
/** the prefix SET differs —
|
|
84
|
-
*
|
|
85
|
-
|
|
83
|
+
/** the prefix SET differs — `PATCH /v1/webhooks/subscriptions/:subId
|
|
84
|
+
* { event_prefixes }` (2026-09-25) changes it in place: the sub_id and the
|
|
85
|
+
* cursor are kept, so this is an ordinary update, not destructive-shaped
|
|
86
|
+
* (before 2026-09-25 it was delete+recreate under --allow-destructive). */
|
|
87
|
+
update: Array<{
|
|
86
88
|
sub_id: string;
|
|
87
89
|
target_url: string;
|
|
88
|
-
|
|
90
|
+
event_prefixes: string[];
|
|
89
91
|
}>;
|
|
90
92
|
unchanged: string[];
|
|
91
93
|
/** live rows the config does not declare, EXCLUDING the function-delivery
|
|
@@ -149,14 +151,12 @@ export interface ApiStateSummary {
|
|
|
149
151
|
export declare function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary;
|
|
150
152
|
/** The `api_state` object a config-write response carries (`PUT /v1/config/
|
|
151
153
|
* :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). */
|
|
154
|
+
* handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
|
|
155
|
+
* `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
|
|
156
|
+
* INDEFINITE reason — the feature worker has not seen the config yet, or a
|
|
157
|
+
* transient failure (a push asks again through POST /v1/apply after its
|
|
158
|
+
* wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
|
|
159
|
+
* `note` = converged nothing on purpose (a disabled feature). */
|
|
160
160
|
export interface ApiStateResponseField extends ApiStateSummary {
|
|
161
161
|
ok: boolean;
|
|
162
162
|
dry_run?: boolean;
|
|
@@ -170,8 +170,8 @@ export interface ApiStateResponseField extends ApiStateSummary {
|
|
|
170
170
|
export declare const API_STATE_DISABLED_NOTE = "feature disabled \u2014 declaration kept, not converged";
|
|
171
171
|
/** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
|
|
172
172
|
* 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. */
|
|
173
|
+
* re-POST every declared name the cap hid (review finding 5). The server's
|
|
174
|
+
* converge and the CLI's read-only plan refuse with this message instead. */
|
|
175
175
|
export declare function rlPolicyListCapError(liveCount: number): string | null;
|
|
176
176
|
export declare const RL_POLICY_LIST_CAP = 100;
|
|
177
177
|
/** `+2 created · ~1 updated · =3 unchanged · 1 undeclared (left in place)` —
|
package/dist/apiState.js
CHANGED
|
@@ -93,7 +93,7 @@ function sameStringSet(a, b) {
|
|
|
93
93
|
return A.size === B.size && [...A].every((x) => B.has(x));
|
|
94
94
|
}
|
|
95
95
|
export function planWebhookSubscriptions(declared, live) {
|
|
96
|
-
const plan = { create: [],
|
|
96
|
+
const plan = { create: [], update: [], unchanged: [], undeclared: [] };
|
|
97
97
|
const declaredUrls = new Set(declared.map((d) => d.target_url));
|
|
98
98
|
const matched = new Set();
|
|
99
99
|
for (const d of declared) {
|
|
@@ -105,14 +105,14 @@ export function planWebhookSubscriptions(declared, live) {
|
|
|
105
105
|
}
|
|
106
106
|
// Prefer an EXACT prefix-set match among duplicates (a hand-made row equal
|
|
107
107
|
// to the declaration is adopted as-is — no churn); else the first row is
|
|
108
|
-
// the one
|
|
108
|
+
// the one updated in place and the rest fall through as undeclared duplicates.
|
|
109
109
|
const exact = rows.find((l) => sameStringSet(l.event_prefixes ?? [], want));
|
|
110
110
|
const chosen = exact ?? rows[0];
|
|
111
111
|
matched.add(chosen.sub_id);
|
|
112
112
|
if (exact)
|
|
113
113
|
plan.unchanged.push(d.target_url);
|
|
114
114
|
else
|
|
115
|
-
plan.
|
|
115
|
+
plan.update.push({ sub_id: chosen.sub_id, target_url: d.target_url, event_prefixes: [...want] });
|
|
116
116
|
}
|
|
117
117
|
for (const l of live) {
|
|
118
118
|
if (matched.has(l.sub_id))
|
|
@@ -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,7 +2,14 @@ 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>;
|
|
7
|
+
/** F33 (2026-09-25): a function binding's `retry.maxAttempts` ceiling, and the
|
|
8
|
+
* binding kinds that may carry `retry` — the platform-delivered event lanes
|
|
9
|
+
* (an http invoke returns its own status; a cron tick's retry would overlap
|
|
10
|
+
* the next tick). Read by the schema, the deploy clamp and the CLI. */
|
|
11
|
+
export declare const FN_RETRY_MAX_ATTEMPTS = 5;
|
|
12
|
+
export declare const FN_RETRY_BINDING_KINDS: ReadonlySet<string>;
|
|
6
13
|
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
7
14
|
* of which is restricted to internal platform machinery). */
|
|
8
15
|
export declare function isReservedCreditType(creditType: string): boolean;
|
|
@@ -38,6 +45,10 @@ export declare function validateNotificationOverrides(templates: {
|
|
|
38
45
|
text?: string;
|
|
39
46
|
}>>;
|
|
40
47
|
} | undefined): string[];
|
|
48
|
+
/** `notifications.ses.snsTopicArn` grammar: a plain SNS topic ARN (the
|
|
49
|
+
* `aws-cn` / `aws-us-gov` partitions included). Mirrored by SNS_TOPIC_ARN_RE
|
|
50
|
+
* in the notifications worker's snsIntake.ts. */
|
|
51
|
+
export declare const SNS_TOPIC_ARN_PATTERN = "^arn:aws(-[a-z]+)?:sns:[a-z0-9-]+:\\d{12}:[A-Za-z0-9_-]{1,256}$";
|
|
41
52
|
/** AWS region grammar for `notifications.ses.region` (`us-east-1`,
|
|
42
53
|
* `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
|
|
43
54
|
* one or more lowercase words, a single digit. Pinned as a pattern rather than
|
|
@@ -56,6 +67,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
|
|
|
56
67
|
region: import("@sinclair/typebox").TString;
|
|
57
68
|
accessKeyIdRef: import("@sinclair/typebox").TString;
|
|
58
69
|
secretAccessKeyRef: import("@sinclair/typebox").TString;
|
|
70
|
+
snsTopicArn: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
59
71
|
}>>;
|
|
60
72
|
defaultLocale: import("@sinclair/typebox").TString;
|
|
61
73
|
retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
@@ -104,6 +116,49 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
|
|
|
104
116
|
export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
105
117
|
/** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
106
118
|
export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
|
|
119
|
+
/** Generation-lifecycle config defaults (jobs.md §11 / §5 ≤15-flag budget). */
|
|
120
|
+
export declare const GENERATION_DEFAULTS: {
|
|
121
|
+
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
122
|
+
readonly maxConcurrent: 20;
|
|
123
|
+
/** default expiry/timeout when the descriptor omits one — 5 min */
|
|
124
|
+
readonly defaultTimeoutMs: number;
|
|
125
|
+
/** hard ceiling a tenant-supplied timeout is clamped to — 1 h */
|
|
126
|
+
readonly maxTimeoutMs: number;
|
|
127
|
+
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
128
|
+
readonly pollMaxAttempts: 60;
|
|
129
|
+
/** MANDATORY per-hold cap on a `reserve_credits.amount` (clamped, never rejected) */
|
|
130
|
+
readonly maxReserveCredits: 1000;
|
|
131
|
+
/** MANDATORY per-tenant ceiling on the sum of un-settled provisional holds */
|
|
132
|
+
readonly maxOutstandingReserveCredits: 100000;
|
|
133
|
+
};
|
|
134
|
+
/** The inclusive [min, max] each `generation` leaf is clamped to — by the
|
|
135
|
+
* schema at config-write time and by jobs-v1 at read time. */
|
|
136
|
+
export declare const GENERATION_BOUNDS: {
|
|
137
|
+
readonly maxConcurrent: {
|
|
138
|
+
readonly min: 1;
|
|
139
|
+
readonly max: 200;
|
|
140
|
+
};
|
|
141
|
+
readonly defaultTimeoutMs: {
|
|
142
|
+
readonly min: 1000;
|
|
143
|
+
readonly max: 3600000;
|
|
144
|
+
};
|
|
145
|
+
readonly maxTimeoutMs: {
|
|
146
|
+
readonly min: 1000;
|
|
147
|
+
readonly max: 3600000;
|
|
148
|
+
};
|
|
149
|
+
readonly pollMaxAttempts: {
|
|
150
|
+
readonly min: 1;
|
|
151
|
+
readonly max: 1000;
|
|
152
|
+
};
|
|
153
|
+
readonly maxReserveCredits: {
|
|
154
|
+
readonly min: 1;
|
|
155
|
+
readonly max: 1000000;
|
|
156
|
+
};
|
|
157
|
+
readonly maxOutstandingReserveCredits: {
|
|
158
|
+
readonly min: 1;
|
|
159
|
+
readonly max: 100000000;
|
|
160
|
+
};
|
|
161
|
+
};
|
|
107
162
|
export declare const JobsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
108
163
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
109
164
|
retry: import("@sinclair/typebox").TObject<{
|
|
@@ -614,11 +669,14 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
614
669
|
trialDays: import("@sinclair/typebox").TInteger;
|
|
615
670
|
}>;
|
|
616
671
|
ledger: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
617
|
-
productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
672
|
+
productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
|
|
618
673
|
creditType: import("@sinclair/typebox").TString;
|
|
619
674
|
amount: import("@sinclair/typebox").TInteger;
|
|
620
675
|
period: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"once">, import("@sinclair/typebox").TLiteral<"monthly">, import("@sinclair/typebox").TLiteral<"annual">]>;
|
|
621
|
-
}
|
|
676
|
+
}>, import("@sinclair/typebox").TObject<{
|
|
677
|
+
tier: import("@sinclair/typebox").TString;
|
|
678
|
+
durationDays: import("@sinclair/typebox").TInteger;
|
|
679
|
+
}>]>>;
|
|
622
680
|
tierMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
623
681
|
entitlements: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
624
682
|
quotas: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TInteger>;
|
|
@@ -657,6 +715,9 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
|
|
|
657
715
|
source: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
658
716
|
collection: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
659
717
|
event: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
718
|
+
retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
719
|
+
maxAttempts: import("@sinclair/typebox").TInteger;
|
|
720
|
+
}>>;
|
|
660
721
|
}>>>;
|
|
661
722
|
scriptRef: import("@sinclair/typebox").TString;
|
|
662
723
|
scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
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')) {
|
|
@@ -33,6 +36,12 @@ if (!FormatRegistry.Has('email')) {
|
|
|
33
36
|
// config-write gate AND payments-v1 import) so the runtime choke point and the
|
|
34
37
|
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
35
38
|
export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
|
|
39
|
+
/** F33 (2026-09-25): a function binding's `retry.maxAttempts` ceiling, and the
|
|
40
|
+
* binding kinds that may carry `retry` — the platform-delivered event lanes
|
|
41
|
+
* (an http invoke returns its own status; a cron tick's retry would overlap
|
|
42
|
+
* the next tick). Read by the schema, the deploy clamp and the CLI. */
|
|
43
|
+
export const FN_RETRY_MAX_ATTEMPTS = 5;
|
|
44
|
+
export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
|
|
36
45
|
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
37
46
|
* of which is restricted to internal platform machinery). */
|
|
38
47
|
export function isReservedCreditType(creditType) {
|
|
@@ -145,6 +154,10 @@ export function validateNotificationOverrides(templates) {
|
|
|
145
154
|
}
|
|
146
155
|
return errs;
|
|
147
156
|
}
|
|
157
|
+
/** `notifications.ses.snsTopicArn` grammar: a plain SNS topic ARN (the
|
|
158
|
+
* `aws-cn` / `aws-us-gov` partitions included). Mirrored by SNS_TOPIC_ARN_RE
|
|
159
|
+
* in the notifications worker's snsIntake.ts. */
|
|
160
|
+
export const SNS_TOPIC_ARN_PATTERN = '^arn:aws(-[a-z]+)?:sns:[a-z0-9-]+:\\d{12}:[A-Za-z0-9_-]{1,256}$';
|
|
148
161
|
/** AWS region grammar for `notifications.ses.region` (`us-east-1`,
|
|
149
162
|
* `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
|
|
150
163
|
* one or more lowercase words, a single digit. Pinned as a pattern rather than
|
|
@@ -187,6 +200,13 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
187
200
|
region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
|
|
188
201
|
accessKeyIdRef: Type.String({ minLength: 1 }),
|
|
189
202
|
secretAccessKeyRef: Type.String({ minLength: 1 }),
|
|
203
|
+
// Bounce/complaint intake (2026-09-25): the SNS topic SES publishes its
|
|
204
|
+
// bounce/complaint events to; its HTTPS subscription posts to the worker's
|
|
205
|
+
// `/v1/notifications/webhooks/ses/<tenant>/<tag>` route, which accepts a
|
|
206
|
+
// message ONLY when its TopicArn equals this value (fail closed: unset ⇒
|
|
207
|
+
// the lane answers 404). Plain config, not a secret — the AWS signature is
|
|
208
|
+
// the proof. Inside the Optional bag ⇒ zero leaf cost.
|
|
209
|
+
snsTopicArn: Type.Optional(Type.String({ pattern: SNS_TOPIC_ARN_PATTERN, maxLength: 320 })),
|
|
190
210
|
})),
|
|
191
211
|
defaultLocale: Type.String({ default: 'en-US' }),
|
|
192
212
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
@@ -269,6 +289,41 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
269
289
|
freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
|
|
270
290
|
})),
|
|
271
291
|
});
|
|
292
|
+
// ── jobs `generation` block: the ONE declaration of its defaults and bounds ──
|
|
293
|
+
// (roadmap §4.10, 2026-09-25 — "dropping the jobs-v1 GENERATION_DEFAULTS
|
|
294
|
+
// copy"). The JobsConfigSchema `generation` leaf reads these for its
|
|
295
|
+
// `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
|
|
296
|
+
// imports them (jobs-v1 already depends on @vxil/feature-configs; this package
|
|
297
|
+
// has no @vxil/types dependency, so the shared value lives here — the
|
|
298
|
+
// RESERVED_CREDIT_TYPES precedent). A future edit changes one object; the
|
|
299
|
+
// feature-configs unit test pins schema ↔ constant, and the CI gate
|
|
300
|
+
// tests/ci/src/generation-defaults-single-source.test.ts pins that no second
|
|
301
|
+
// object-literal declaration of the constant reappears anywhere.
|
|
302
|
+
/** Generation-lifecycle config defaults (jobs.md §11 / §5 ≤15-flag budget). */
|
|
303
|
+
export const GENERATION_DEFAULTS = {
|
|
304
|
+
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
305
|
+
maxConcurrent: 20,
|
|
306
|
+
/** default expiry/timeout when the descriptor omits one — 5 min */
|
|
307
|
+
defaultTimeoutMs: 5 * 60_000,
|
|
308
|
+
/** hard ceiling a tenant-supplied timeout is clamped to — 1 h */
|
|
309
|
+
maxTimeoutMs: 60 * 60_000,
|
|
310
|
+
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
311
|
+
pollMaxAttempts: 60,
|
|
312
|
+
/** MANDATORY per-hold cap on a `reserve_credits.amount` (clamped, never rejected) */
|
|
313
|
+
maxReserveCredits: 1_000,
|
|
314
|
+
/** MANDATORY per-tenant ceiling on the sum of un-settled provisional holds */
|
|
315
|
+
maxOutstandingReserveCredits: 100_000,
|
|
316
|
+
};
|
|
317
|
+
/** The inclusive [min, max] each `generation` leaf is clamped to — by the
|
|
318
|
+
* schema at config-write time and by jobs-v1 at read time. */
|
|
319
|
+
export const GENERATION_BOUNDS = {
|
|
320
|
+
maxConcurrent: { min: 1, max: 200 },
|
|
321
|
+
defaultTimeoutMs: { min: 1_000, max: 3_600_000 },
|
|
322
|
+
maxTimeoutMs: { min: 1_000, max: 3_600_000 },
|
|
323
|
+
pollMaxAttempts: { min: 1, max: 1_000 },
|
|
324
|
+
maxReserveCredits: { min: 1, max: 1_000_000 },
|
|
325
|
+
maxOutstandingReserveCredits: { min: 1, max: 100_000_000 },
|
|
326
|
+
};
|
|
272
327
|
export const JobsConfigSchema = Type.Object({
|
|
273
328
|
enabled: Type.Boolean({ default: true }),
|
|
274
329
|
retry: Type.Object({ defaultMaxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }) }, { default: {} }),
|
|
@@ -284,31 +339,32 @@ export const JobsConfigSchema = Type.Object({
|
|
|
284
339
|
dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
|
|
285
340
|
schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
|
|
286
341
|
concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
|
|
287
|
-
// 2.F6 generation lifecycle knobs (jobs.md §11)
|
|
288
|
-
// GENERATION_DEFAULTS
|
|
289
|
-
// resolveGenerationConfig
|
|
290
|
-
//
|
|
342
|
+
// 2.F6 generation lifecycle knobs (jobs.md §11). The defaults and bounds are
|
|
343
|
+
// declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
|
|
344
|
+
// (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
|
|
345
|
+
// pre-fold manifest to the same numbers — a hand-mirrored copy used to live
|
|
346
|
+
// in workers/jobs-v1/src/generation.ts.
|
|
291
347
|
generation: Type.Object({
|
|
292
348
|
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
293
|
-
maxConcurrent: Type.Integer({ default:
|
|
349
|
+
maxConcurrent: Type.Integer({ default: GENERATION_DEFAULTS.maxConcurrent, minimum: GENERATION_BOUNDS.maxConcurrent.min, maximum: GENERATION_BOUNDS.maxConcurrent.max }),
|
|
294
350
|
/** default expiry/timeout when the descriptor omits one */
|
|
295
|
-
defaultTimeoutMs: Type.Integer({ default:
|
|
351
|
+
defaultTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.defaultTimeoutMs, minimum: GENERATION_BOUNDS.defaultTimeoutMs.min, maximum: GENERATION_BOUNDS.defaultTimeoutMs.max }),
|
|
296
352
|
/** hard ceiling a tenant-supplied timeout is clamped to */
|
|
297
|
-
maxTimeoutMs: Type.Integer({ default:
|
|
353
|
+
maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
|
|
298
354
|
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
299
|
-
pollMaxAttempts: Type.Integer({ default:
|
|
355
|
+
pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
|
|
300
356
|
/** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
|
|
301
357
|
* §11.8). Every requested amount is CLAMPED to this (never rejected) — a
|
|
302
358
|
* conservative default so an untrusted deployed function that carries a
|
|
303
359
|
* reserve block can never hold more than a bounded amount per run without
|
|
304
360
|
* any tenant action. */
|
|
305
|
-
maxReserveCredits: Type.Integer({ default:
|
|
361
|
+
maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
|
|
306
362
|
/** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
|
|
307
363
|
* reserve holds across all in-flight generation runs (jobs.md §11.8): a
|
|
308
364
|
* reserve whose amount would push the tenant's outstanding-holds total over
|
|
309
365
|
* this is rejected 429, so a runaway function cannot hold every user at
|
|
310
366
|
* once. Defaulted so no tenant action is required to be safe. */
|
|
311
|
-
maxOutstandingReserveCredits: Type.Integer({ default:
|
|
367
|
+
maxOutstandingReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxOutstandingReserveCredits, minimum: GENERATION_BOUNDS.maxOutstandingReserveCredits.min, maximum: GENERATION_BOUNDS.maxOutstandingReserveCredits.max }),
|
|
312
368
|
}, { default: {} }),
|
|
313
369
|
});
|
|
314
370
|
// One social-provider's BYO credential block. The *Ref fields are POINTERS into
|
|
@@ -700,9 +756,9 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
700
756
|
})),
|
|
701
757
|
// DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
|
|
702
758
|
// subscriptions as config. Keyed by `target_url` — the only stable identity a
|
|
703
|
-
// subscription has (there is no name column
|
|
704
|
-
//
|
|
705
|
-
//
|
|
759
|
+
// subscription has (there is no name column). A changed prefix set is an
|
|
760
|
+
// in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
|
|
761
|
+
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
|
|
706
762
|
// onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
|
|
707
763
|
// reported (deleted only under --allow-destructive). Rows on the platform's
|
|
708
764
|
// signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
|
|
@@ -1188,12 +1244,27 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1188
1244
|
// analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
|
|
1189
1245
|
// MAPS (one typed leaf each), so catalog size never inflates the flag count.
|
|
1190
1246
|
ledger: Type.Optional(Type.Object({
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1247
|
+
// product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
|
|
1248
|
+
// `boosts` Record-of-Union precedent) with two rule shapes:
|
|
1249
|
+
// { creditType, amount, period } a credit grant (the original rule)
|
|
1250
|
+
// { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
|
|
1251
|
+
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1252
|
+
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1253
|
+
// STACKS on the user's live purchases of the same tier and is ENDED by
|
|
1254
|
+
// that charge's full refund / chargeback. No defaults in either shape,
|
|
1255
|
+
// so an existing manifest is byte-identical after Value.Default.
|
|
1256
|
+
productMap: Type.Record(Type.String(), Type.Union([
|
|
1257
|
+
Type.Object({
|
|
1258
|
+
creditType: Type.String({ minLength: 1 }),
|
|
1259
|
+
amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
|
|
1260
|
+
period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
|
|
1261
|
+
Type.Literal('annual')]),
|
|
1262
|
+
}),
|
|
1263
|
+
Type.Object({
|
|
1264
|
+
tier: Type.String({ minLength: 1 }),
|
|
1265
|
+
durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
|
|
1266
|
+
}),
|
|
1267
|
+
])),
|
|
1197
1268
|
tierMap: Type.Record(Type.String(), Type.Object({
|
|
1198
1269
|
entitlements: Type.Array(Type.String()),
|
|
1199
1270
|
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
|
|
@@ -1316,6 +1387,15 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1316
1387
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1317
1388
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1318
1389
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
1390
|
+
// F33 (2026-09-25): the per-binding opt-in to re-delivery on
|
|
1391
|
+
// queue / webhook / cmsHook / authHook (the cross-field rule
|
|
1392
|
+
// rejects it on http / cron). Absent = the ACK-200 default. The
|
|
1393
|
+
// receiver answers a failed attempt as an enveloped 503 (ladder)
|
|
1394
|
+
// and the last one as a terminal 409 (dead + job.dead_lettered);
|
|
1395
|
+
// effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
|
|
1396
|
+
retry: Type.Optional(Type.Object({
|
|
1397
|
+
maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
|
|
1398
|
+
})),
|
|
1319
1399
|
}), { maxItems: 8 })),
|
|
1320
1400
|
scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
|
|
1321
1401
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
@@ -1743,10 +1823,17 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1743
1823
|
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
1744
1824
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
1745
1825
|
const ledgerErrs = [];
|
|
1826
|
+
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
1746
1827
|
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
1747
1828
|
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
1748
1829
|
ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1749
1830
|
}
|
|
1831
|
+
// (2026-09-25 F35+) an entitlement rule must name a declared tier — the
|
|
1832
|
+
// write-time mirror of the runtime's unknown-tier refusal (a purchase for
|
|
1833
|
+
// a tier nobody declared would land the delivery `error`).
|
|
1834
|
+
if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
|
|
1835
|
+
ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
|
|
1836
|
+
}
|
|
1750
1837
|
}
|
|
1751
1838
|
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1752
1839
|
(rule.grants ?? []).forEach((g, i) => {
|
|
@@ -1926,6 +2013,12 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1926
2013
|
if (b.kind === 'cron' && !b.schedule) {
|
|
1927
2014
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
1928
2015
|
}
|
|
2016
|
+
// F33: retry is an opt-in for the platform-delivered event lanes only —
|
|
2017
|
+
// an http invoke returns its real status to its caller, and a cron
|
|
2018
|
+
// tick's retry would overlap the next tick.
|
|
2019
|
+
if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
|
|
2020
|
+
errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
|
|
2021
|
+
}
|
|
1929
2022
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
1930
2023
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
1931
2024
|
}
|
|
@@ -1956,8 +2049,14 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1956
2049
|
for (const [id, agent] of Object.entries(v.agents ?? {})) {
|
|
1957
2050
|
const allow = agent.actions?.allow ?? {};
|
|
1958
2051
|
const guestAllow = agent.guardrails?.guestToolAllow ?? [];
|
|
1959
|
-
if (agent.guardrails?.allowGuest === true)
|
|
2052
|
+
if (agent.guardrails?.allowGuest === true) {
|
|
1960
2053
|
anyGuestAgent = true;
|
|
2054
|
+
// Guests share ONE tenant-wide bucket that spends the tenant's own AI
|
|
2055
|
+
// key: an unlimited (0) daily cap is never allowed alongside them.
|
|
2056
|
+
if (agent.guardrails.rateLimitPerUserPerDay === 0) {
|
|
2057
|
+
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)`);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
1961
2060
|
if (knownMcpTools) {
|
|
1962
2061
|
for (const tool of Object.keys(allow)) {
|
|
1963
2062
|
if (!knownMcpTools.has(tool)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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
|
@@ -9,25 +9,29 @@
|
|
|
9
9
|
// by `vxil push` and `POST /v1/apply`:
|
|
10
10
|
//
|
|
11
11
|
// rate-limits.policies[] — keyed by `name` (POST/PUT/DELETE /v1/rate-limits/policies)
|
|
12
|
-
// webhooks.subscriptions[] — keyed by `target_url` (POST/DELETE /v1/webhooks/subscriptions)
|
|
12
|
+
// webhooks.subscriptions[] — keyed by `target_url` (POST/PATCH/DELETE /v1/webhooks/subscriptions)
|
|
13
13
|
// ai.templates[] — keyed by `template` (POST /v1/ai/templates, by content hash)
|
|
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
|
|
26
28
|
// passes allow-destructive — so adopting a block on an existing tenant is a
|
|
27
29
|
// report, never a wipe. A change that can only be applied by delete+recreate
|
|
28
|
-
// (a policy's `key_template
|
|
29
|
-
//
|
|
30
|
-
//
|
|
30
|
+
// (a policy's `key_template`) is likewise destructive-SHAPED: it changes the
|
|
31
|
+
// row's id, which a caller may have cached, so it is reported and applied
|
|
32
|
+
// only under the same ack. A subscription's prefix set used to be one of
|
|
33
|
+
// those; since 2026-09-25 the PATCH route changes it in place (same sub_id,
|
|
34
|
+
// same cursor), so it is an ordinary `update`.
|
|
31
35
|
//
|
|
32
36
|
// Two shapes this file deliberately does NOT cover, with the reason:
|
|
33
37
|
// notifications.templates — templates are CODE with per-locale `overrides`
|
|
@@ -159,9 +163,11 @@ export interface LiveWebhookSubscription {
|
|
|
159
163
|
|
|
160
164
|
export interface WebhookSubscriptionPlan {
|
|
161
165
|
create: DeclaredWebhookSubscription[];
|
|
162
|
-
/** the prefix SET differs —
|
|
163
|
-
*
|
|
164
|
-
|
|
166
|
+
/** the prefix SET differs — `PATCH /v1/webhooks/subscriptions/:subId
|
|
167
|
+
* { event_prefixes }` (2026-09-25) changes it in place: the sub_id and the
|
|
168
|
+
* cursor are kept, so this is an ordinary update, not destructive-shaped
|
|
169
|
+
* (before 2026-09-25 it was delete+recreate under --allow-destructive). */
|
|
170
|
+
update: Array<{ sub_id: string; target_url: string; event_prefixes: string[] }>;
|
|
165
171
|
unchanged: string[];
|
|
166
172
|
/** live rows the config does not declare, EXCLUDING the function-delivery
|
|
167
173
|
* lanes (those derive from the functions manifest) — left in place by default */
|
|
@@ -176,7 +182,7 @@ function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
|
|
|
176
182
|
export function planWebhookSubscriptions(
|
|
177
183
|
declared: readonly DeclaredWebhookSubscription[], live: readonly LiveWebhookSubscription[],
|
|
178
184
|
): WebhookSubscriptionPlan {
|
|
179
|
-
const plan: WebhookSubscriptionPlan = { create: [],
|
|
185
|
+
const plan: WebhookSubscriptionPlan = { create: [], update: [], unchanged: [], undeclared: [] };
|
|
180
186
|
const declaredUrls = new Set(declared.map((d) => d.target_url));
|
|
181
187
|
const matched = new Set<string>();
|
|
182
188
|
for (const d of declared) {
|
|
@@ -185,12 +191,12 @@ export function planWebhookSubscriptions(
|
|
|
185
191
|
if (rows.length === 0) { plan.create.push(d); continue; }
|
|
186
192
|
// Prefer an EXACT prefix-set match among duplicates (a hand-made row equal
|
|
187
193
|
// to the declaration is adopted as-is — no churn); else the first row is
|
|
188
|
-
// the one
|
|
194
|
+
// the one updated in place and the rest fall through as undeclared duplicates.
|
|
189
195
|
const exact = rows.find((l) => sameStringSet(l.event_prefixes ?? [], want));
|
|
190
196
|
const chosen = exact ?? rows[0]!;
|
|
191
197
|
matched.add(chosen.sub_id);
|
|
192
198
|
if (exact) plan.unchanged.push(d.target_url);
|
|
193
|
-
else plan.
|
|
199
|
+
else plan.update.push({ sub_id: chosen.sub_id, target_url: d.target_url, event_prefixes: [...want] });
|
|
194
200
|
}
|
|
195
201
|
for (const l of live) {
|
|
196
202
|
if (matched.has(l.sub_id)) continue;
|
|
@@ -285,14 +291,12 @@ export function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary {
|
|
|
285
291
|
|
|
286
292
|
/** The `api_state` object a config-write response carries (`PUT /v1/config/
|
|
287
293
|
* :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). */
|
|
294
|
+
* handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
|
|
295
|
+
* `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
|
|
296
|
+
* INDEFINITE reason — the feature worker has not seen the config yet, or a
|
|
297
|
+
* transient failure (a push asks again through POST /v1/apply after its
|
|
298
|
+
* wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
|
|
299
|
+
* `note` = converged nothing on purpose (a disabled feature). */
|
|
296
300
|
export interface ApiStateResponseField extends ApiStateSummary {
|
|
297
301
|
ok: boolean;
|
|
298
302
|
dry_run?: boolean;
|
|
@@ -308,8 +312,8 @@ export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, n
|
|
|
308
312
|
|
|
309
313
|
/** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
|
|
310
314
|
* 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. */
|
|
315
|
+
* re-POST every declared name the cap hid (review finding 5). The server's
|
|
316
|
+
* converge and the CLI's read-only plan refuse with this message instead. */
|
|
313
317
|
export function rlPolicyListCapError(liveCount: number): string | null {
|
|
314
318
|
if (liveCount < RL_POLICY_LIST_CAP) return null;
|
|
315
319
|
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).
|
|
@@ -37,6 +40,13 @@ if (!FormatRegistry.Has('email')) {
|
|
|
37
40
|
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
38
41
|
export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
|
|
39
42
|
|
|
43
|
+
/** F33 (2026-09-25): a function binding's `retry.maxAttempts` ceiling, and the
|
|
44
|
+
* binding kinds that may carry `retry` — the platform-delivered event lanes
|
|
45
|
+
* (an http invoke returns its own status; a cron tick's retry would overlap
|
|
46
|
+
* the next tick). Read by the schema, the deploy clamp and the CLI. */
|
|
47
|
+
export const FN_RETRY_MAX_ATTEMPTS = 5;
|
|
48
|
+
export const FN_RETRY_BINDING_KINDS: ReadonlySet<string> = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
|
|
49
|
+
|
|
40
50
|
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
41
51
|
* of which is restricted to internal platform machinery). */
|
|
42
52
|
export function isReservedCreditType(creditType: string): boolean {
|
|
@@ -158,6 +168,11 @@ export function validateNotificationOverrides(
|
|
|
158
168
|
return errs;
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
/** `notifications.ses.snsTopicArn` grammar: a plain SNS topic ARN (the
|
|
172
|
+
* `aws-cn` / `aws-us-gov` partitions included). Mirrored by SNS_TOPIC_ARN_RE
|
|
173
|
+
* in the notifications worker's snsIntake.ts. */
|
|
174
|
+
export const SNS_TOPIC_ARN_PATTERN = '^arn:aws(-[a-z]+)?:sns:[a-z0-9-]+:\\d{12}:[A-Za-z0-9_-]{1,256}$';
|
|
175
|
+
|
|
161
176
|
/** AWS region grammar for `notifications.ses.region` (`us-east-1`,
|
|
162
177
|
* `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
|
|
163
178
|
* one or more lowercase words, a single digit. Pinned as a pattern rather than
|
|
@@ -201,6 +216,13 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
201
216
|
region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
|
|
202
217
|
accessKeyIdRef: Type.String({ minLength: 1 }),
|
|
203
218
|
secretAccessKeyRef: Type.String({ minLength: 1 }),
|
|
219
|
+
// Bounce/complaint intake (2026-09-25): the SNS topic SES publishes its
|
|
220
|
+
// bounce/complaint events to; its HTTPS subscription posts to the worker's
|
|
221
|
+
// `/v1/notifications/webhooks/ses/<tenant>/<tag>` route, which accepts a
|
|
222
|
+
// message ONLY when its TopicArn equals this value (fail closed: unset ⇒
|
|
223
|
+
// the lane answers 404). Plain config, not a secret — the AWS signature is
|
|
224
|
+
// the proof. Inside the Optional bag ⇒ zero leaf cost.
|
|
225
|
+
snsTopicArn: Type.Optional(Type.String({ pattern: SNS_TOPIC_ARN_PATTERN, maxLength: 320 })),
|
|
204
226
|
})),
|
|
205
227
|
defaultLocale: Type.String({ default: 'en-US' }),
|
|
206
228
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
@@ -312,6 +334,44 @@ export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
|
312
334
|
/** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
313
335
|
export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
|
|
314
336
|
|
|
337
|
+
// ── jobs `generation` block: the ONE declaration of its defaults and bounds ──
|
|
338
|
+
// (roadmap §4.10, 2026-09-25 — "dropping the jobs-v1 GENERATION_DEFAULTS
|
|
339
|
+
// copy"). The JobsConfigSchema `generation` leaf reads these for its
|
|
340
|
+
// `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
|
|
341
|
+
// imports them (jobs-v1 already depends on @vxil/feature-configs; this package
|
|
342
|
+
// has no @vxil/types dependency, so the shared value lives here — the
|
|
343
|
+
// RESERVED_CREDIT_TYPES precedent). A future edit changes one object; the
|
|
344
|
+
// feature-configs unit test pins schema ↔ constant, and the CI gate
|
|
345
|
+
// tests/ci/src/generation-defaults-single-source.test.ts pins that no second
|
|
346
|
+
// object-literal declaration of the constant reappears anywhere.
|
|
347
|
+
|
|
348
|
+
/** Generation-lifecycle config defaults (jobs.md §11 / §5 ≤15-flag budget). */
|
|
349
|
+
export const GENERATION_DEFAULTS = {
|
|
350
|
+
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
351
|
+
maxConcurrent: 20,
|
|
352
|
+
/** default expiry/timeout when the descriptor omits one — 5 min */
|
|
353
|
+
defaultTimeoutMs: 5 * 60_000,
|
|
354
|
+
/** hard ceiling a tenant-supplied timeout is clamped to — 1 h */
|
|
355
|
+
maxTimeoutMs: 60 * 60_000,
|
|
356
|
+
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
357
|
+
pollMaxAttempts: 60,
|
|
358
|
+
/** MANDATORY per-hold cap on a `reserve_credits.amount` (clamped, never rejected) */
|
|
359
|
+
maxReserveCredits: 1_000,
|
|
360
|
+
/** MANDATORY per-tenant ceiling on the sum of un-settled provisional holds */
|
|
361
|
+
maxOutstandingReserveCredits: 100_000,
|
|
362
|
+
} as const;
|
|
363
|
+
|
|
364
|
+
/** The inclusive [min, max] each `generation` leaf is clamped to — by the
|
|
365
|
+
* schema at config-write time and by jobs-v1 at read time. */
|
|
366
|
+
export const GENERATION_BOUNDS = {
|
|
367
|
+
maxConcurrent: { min: 1, max: 200 },
|
|
368
|
+
defaultTimeoutMs: { min: 1_000, max: 3_600_000 },
|
|
369
|
+
maxTimeoutMs: { min: 1_000, max: 3_600_000 },
|
|
370
|
+
pollMaxAttempts: { min: 1, max: 1_000 },
|
|
371
|
+
maxReserveCredits: { min: 1, max: 1_000_000 },
|
|
372
|
+
maxOutstandingReserveCredits: { min: 1, max: 100_000_000 },
|
|
373
|
+
} as const satisfies Record<keyof typeof GENERATION_DEFAULTS, { min: number; max: number }>;
|
|
374
|
+
|
|
315
375
|
export const JobsConfigSchema = Type.Object({
|
|
316
376
|
enabled: Type.Boolean({ default: true }),
|
|
317
377
|
retry: Type.Object(
|
|
@@ -339,32 +399,33 @@ export const JobsConfigSchema = Type.Object({
|
|
|
339
399
|
{ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
|
|
340
400
|
{ default: {} },
|
|
341
401
|
),
|
|
342
|
-
// 2.F6 generation lifecycle knobs (jobs.md §11)
|
|
343
|
-
// GENERATION_DEFAULTS
|
|
344
|
-
// resolveGenerationConfig
|
|
345
|
-
//
|
|
402
|
+
// 2.F6 generation lifecycle knobs (jobs.md §11). The defaults and bounds are
|
|
403
|
+
// declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
|
|
404
|
+
// (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
|
|
405
|
+
// pre-fold manifest to the same numbers — a hand-mirrored copy used to live
|
|
406
|
+
// in workers/jobs-v1/src/generation.ts.
|
|
346
407
|
generation: Type.Object(
|
|
347
408
|
{
|
|
348
409
|
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
349
|
-
maxConcurrent: Type.Integer({ default:
|
|
410
|
+
maxConcurrent: Type.Integer({ default: GENERATION_DEFAULTS.maxConcurrent, minimum: GENERATION_BOUNDS.maxConcurrent.min, maximum: GENERATION_BOUNDS.maxConcurrent.max }),
|
|
350
411
|
/** default expiry/timeout when the descriptor omits one */
|
|
351
|
-
defaultTimeoutMs: Type.Integer({ default:
|
|
412
|
+
defaultTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.defaultTimeoutMs, minimum: GENERATION_BOUNDS.defaultTimeoutMs.min, maximum: GENERATION_BOUNDS.defaultTimeoutMs.max }),
|
|
352
413
|
/** hard ceiling a tenant-supplied timeout is clamped to */
|
|
353
|
-
maxTimeoutMs: Type.Integer({ default:
|
|
414
|
+
maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
|
|
354
415
|
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
355
|
-
pollMaxAttempts: Type.Integer({ default:
|
|
416
|
+
pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
|
|
356
417
|
/** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
|
|
357
418
|
* §11.8). Every requested amount is CLAMPED to this (never rejected) — a
|
|
358
419
|
* conservative default so an untrusted deployed function that carries a
|
|
359
420
|
* reserve block can never hold more than a bounded amount per run without
|
|
360
421
|
* any tenant action. */
|
|
361
|
-
maxReserveCredits: Type.Integer({ default:
|
|
422
|
+
maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
|
|
362
423
|
/** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
|
|
363
424
|
* reserve holds across all in-flight generation runs (jobs.md §11.8): a
|
|
364
425
|
* reserve whose amount would push the tenant's outstanding-holds total over
|
|
365
426
|
* this is rejected 429, so a runaway function cannot hold every user at
|
|
366
427
|
* once. Defaulted so no tenant action is required to be safe. */
|
|
367
|
-
maxOutstandingReserveCredits: Type.Integer({ default:
|
|
428
|
+
maxOutstandingReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxOutstandingReserveCredits, minimum: GENERATION_BOUNDS.maxOutstandingReserveCredits.min, maximum: GENERATION_BOUNDS.maxOutstandingReserveCredits.max }),
|
|
368
429
|
},
|
|
369
430
|
{ default: {} },
|
|
370
431
|
),
|
|
@@ -834,9 +895,9 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
834
895
|
})),
|
|
835
896
|
// DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
|
|
836
897
|
// subscriptions as config. Keyed by `target_url` — the only stable identity a
|
|
837
|
-
// subscription has (there is no name column
|
|
838
|
-
//
|
|
839
|
-
//
|
|
898
|
+
// subscription has (there is no name column). A changed prefix set is an
|
|
899
|
+
// in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
|
|
900
|
+
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
|
|
840
901
|
// onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
|
|
841
902
|
// reported (deleted only under --allow-destructive). Rows on the platform's
|
|
842
903
|
// signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
|
|
@@ -1503,12 +1564,27 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1503
1564
|
// analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
|
|
1504
1565
|
// MAPS (one typed leaf each), so catalog size never inflates the flag count.
|
|
1505
1566
|
ledger: Type.Optional(Type.Object({
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1567
|
+
// product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
|
|
1568
|
+
// `boosts` Record-of-Union precedent) with two rule shapes:
|
|
1569
|
+
// { creditType, amount, period } a credit grant (the original rule)
|
|
1570
|
+
// { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
|
|
1571
|
+
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1572
|
+
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1573
|
+
// STACKS on the user's live purchases of the same tier and is ENDED by
|
|
1574
|
+
// that charge's full refund / chargeback. No defaults in either shape,
|
|
1575
|
+
// so an existing manifest is byte-identical after Value.Default.
|
|
1576
|
+
productMap: Type.Record(Type.String(), Type.Union([
|
|
1577
|
+
Type.Object({
|
|
1578
|
+
creditType: Type.String({ minLength: 1 }),
|
|
1579
|
+
amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
|
|
1580
|
+
period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
|
|
1581
|
+
Type.Literal('annual')]),
|
|
1582
|
+
}),
|
|
1583
|
+
Type.Object({
|
|
1584
|
+
tier: Type.String({ minLength: 1 }),
|
|
1585
|
+
durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
|
|
1586
|
+
}),
|
|
1587
|
+
])),
|
|
1512
1588
|
tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
|
|
1513
1589
|
entitlements: Type.Array(Type.String()),
|
|
1514
1590
|
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
|
|
@@ -1652,6 +1728,15 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1652
1728
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1653
1729
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1654
1730
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
1731
|
+
// F33 (2026-09-25): the per-binding opt-in to re-delivery on
|
|
1732
|
+
// queue / webhook / cmsHook / authHook (the cross-field rule
|
|
1733
|
+
// rejects it on http / cron). Absent = the ACK-200 default. The
|
|
1734
|
+
// receiver answers a failed attempt as an enveloped 503 (ladder)
|
|
1735
|
+
// and the last one as a terminal 409 (dead + job.dead_lettered);
|
|
1736
|
+
// effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
|
|
1737
|
+
retry: Type.Optional(Type.Object({
|
|
1738
|
+
maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
|
|
1739
|
+
})),
|
|
1655
1740
|
}),
|
|
1656
1741
|
{ maxItems: 8 },
|
|
1657
1742
|
),
|
|
@@ -2102,7 +2187,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2102
2187
|
const v = withDefaults as {
|
|
2103
2188
|
provider?: string;
|
|
2104
2189
|
resendApiKeyRef?: string;
|
|
2105
|
-
ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string };
|
|
2190
|
+
ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string; snsTopicArn?: string };
|
|
2106
2191
|
templates?: {
|
|
2107
2192
|
allowOverride?: boolean;
|
|
2108
2193
|
overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
|
|
@@ -2134,7 +2219,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2134
2219
|
const v = withDefaults as {
|
|
2135
2220
|
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
2136
2221
|
ledger?: {
|
|
2137
|
-
productMap?: Record<string, { creditType?: string }>;
|
|
2222
|
+
productMap?: Record<string, { creditType?: string; tier?: string }>;
|
|
2138
2223
|
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
|
|
2139
2224
|
priceMap?: Record<string, string>;
|
|
2140
2225
|
};
|
|
@@ -2154,12 +2239,19 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2154
2239
|
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
2155
2240
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
2156
2241
|
const ledgerErrs: string[] = [];
|
|
2242
|
+
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
2157
2243
|
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
2158
2244
|
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
2159
2245
|
ledgerErrs.push(
|
|
2160
2246
|
`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2161
2247
|
);
|
|
2162
2248
|
}
|
|
2249
|
+
// (2026-09-25 F35+) an entitlement rule must name a declared tier — the
|
|
2250
|
+
// write-time mirror of the runtime's unknown-tier refusal (a purchase for
|
|
2251
|
+
// a tier nobody declared would land the delivery `error`).
|
|
2252
|
+
if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
|
|
2253
|
+
ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
|
|
2254
|
+
}
|
|
2163
2255
|
}
|
|
2164
2256
|
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
2165
2257
|
(rule.grants ?? []).forEach((g, i) => {
|
|
@@ -2336,7 +2428,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2336
2428
|
const v = withDefaults as {
|
|
2337
2429
|
functions?: Record<
|
|
2338
2430
|
string,
|
|
2339
|
-
{
|
|
2431
|
+
{
|
|
2432
|
+
scriptRef?: string; scopes?: string[]; signature?: unknown;
|
|
2433
|
+
bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number } }>;
|
|
2434
|
+
}
|
|
2340
2435
|
>;
|
|
2341
2436
|
};
|
|
2342
2437
|
const errs: string[] = [];
|
|
@@ -2351,6 +2446,12 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2351
2446
|
if (b.kind === 'cron' && !b.schedule) {
|
|
2352
2447
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
2353
2448
|
}
|
|
2449
|
+
// F33: retry is an opt-in for the platform-delivered event lanes only —
|
|
2450
|
+
// an http invoke returns its real status to its caller, and a cron
|
|
2451
|
+
// tick's retry would overlap the next tick.
|
|
2452
|
+
if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
|
|
2453
|
+
errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
|
|
2454
|
+
}
|
|
2354
2455
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
2355
2456
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
2356
2457
|
}
|
|
@@ -2377,7 +2478,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2377
2478
|
const v = withDefaults as {
|
|
2378
2479
|
agents?: Record<string, {
|
|
2379
2480
|
actions?: { mode?: string; allow?: Record<string, unknown> };
|
|
2380
|
-
guardrails?: { allowGuest?: boolean; guestToolAllow?: string[] };
|
|
2481
|
+
guardrails?: { allowGuest?: boolean; guestToolAllow?: string[]; rateLimitPerUserPerDay?: number };
|
|
2381
2482
|
}>;
|
|
2382
2483
|
widget?: { requireAuth?: boolean };
|
|
2383
2484
|
};
|
|
@@ -2386,7 +2487,14 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2386
2487
|
for (const [id, agent] of Object.entries(v.agents ?? {})) {
|
|
2387
2488
|
const allow = agent.actions?.allow ?? {};
|
|
2388
2489
|
const guestAllow = agent.guardrails?.guestToolAllow ?? [];
|
|
2389
|
-
if (agent.guardrails?.allowGuest === true)
|
|
2490
|
+
if (agent.guardrails?.allowGuest === true) {
|
|
2491
|
+
anyGuestAgent = true;
|
|
2492
|
+
// Guests share ONE tenant-wide bucket that spends the tenant's own AI
|
|
2493
|
+
// key: an unlimited (0) daily cap is never allowed alongside them.
|
|
2494
|
+
if (agent.guardrails.rateLimitPerUserPerDay === 0) {
|
|
2495
|
+
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)`);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2390
2498
|
if (knownMcpTools) {
|
|
2391
2499
|
for (const tool of Object.keys(allow)) {
|
|
2392
2500
|
if (!knownMcpTools.has(tool)) {
|