@vxil/feature-configs 0.5.1 → 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 +6 -4
- package/dist/apiState.js +3 -3
- package/dist/index.d.ts +62 -2
- package/dist/index.js +109 -19
- package/package.json +1 -1
- package/src/apiState.ts +14 -10
- package/src/index.ts +120 -22
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
|
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))
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export * from './readmodels.js';
|
|
|
4
4
|
export * from './apiState.js';
|
|
5
5
|
export * from './canonicalJson.js';
|
|
6
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>;
|
|
7
13
|
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
8
14
|
* of which is restricted to internal platform machinery). */
|
|
9
15
|
export declare function isReservedCreditType(creditType: string): boolean;
|
|
@@ -39,6 +45,10 @@ export declare function validateNotificationOverrides(templates: {
|
|
|
39
45
|
text?: string;
|
|
40
46
|
}>>;
|
|
41
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}$";
|
|
42
52
|
/** AWS region grammar for `notifications.ses.region` (`us-east-1`,
|
|
43
53
|
* `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
|
|
44
54
|
* one or more lowercase words, a single digit. Pinned as a pattern rather than
|
|
@@ -57,6 +67,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
|
|
|
57
67
|
region: import("@sinclair/typebox").TString;
|
|
58
68
|
accessKeyIdRef: import("@sinclair/typebox").TString;
|
|
59
69
|
secretAccessKeyRef: import("@sinclair/typebox").TString;
|
|
70
|
+
snsTopicArn: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
60
71
|
}>>;
|
|
61
72
|
defaultLocale: import("@sinclair/typebox").TString;
|
|
62
73
|
retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
@@ -105,6 +116,49 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
|
|
|
105
116
|
export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
106
117
|
/** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
107
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
|
+
};
|
|
108
162
|
export declare const JobsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
109
163
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
110
164
|
retry: import("@sinclair/typebox").TObject<{
|
|
@@ -615,11 +669,14 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
615
669
|
trialDays: import("@sinclair/typebox").TInteger;
|
|
616
670
|
}>;
|
|
617
671
|
ledger: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
618
|
-
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<{
|
|
619
673
|
creditType: import("@sinclair/typebox").TString;
|
|
620
674
|
amount: import("@sinclair/typebox").TInteger;
|
|
621
675
|
period: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"once">, import("@sinclair/typebox").TLiteral<"monthly">, import("@sinclair/typebox").TLiteral<"annual">]>;
|
|
622
|
-
}
|
|
676
|
+
}>, import("@sinclair/typebox").TObject<{
|
|
677
|
+
tier: import("@sinclair/typebox").TString;
|
|
678
|
+
durationDays: import("@sinclair/typebox").TInteger;
|
|
679
|
+
}>]>>;
|
|
623
680
|
tierMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
624
681
|
entitlements: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
625
682
|
quotas: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TInteger>;
|
|
@@ -658,6 +715,9 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
|
|
|
658
715
|
source: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
659
716
|
collection: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
660
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
|
+
}>>;
|
|
661
721
|
}>>>;
|
|
662
722
|
scriptRef: import("@sinclair/typebox").TString;
|
|
663
723
|
scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,12 @@ if (!FormatRegistry.Has('email')) {
|
|
|
36
36
|
// config-write gate AND payments-v1 import) so the runtime choke point and the
|
|
37
37
|
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
38
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']);
|
|
39
45
|
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
40
46
|
* of which is restricted to internal platform machinery). */
|
|
41
47
|
export function isReservedCreditType(creditType) {
|
|
@@ -148,6 +154,10 @@ export function validateNotificationOverrides(templates) {
|
|
|
148
154
|
}
|
|
149
155
|
return errs;
|
|
150
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}$';
|
|
151
161
|
/** AWS region grammar for `notifications.ses.region` (`us-east-1`,
|
|
152
162
|
* `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
|
|
153
163
|
* one or more lowercase words, a single digit. Pinned as a pattern rather than
|
|
@@ -190,6 +200,13 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
190
200
|
region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
|
|
191
201
|
accessKeyIdRef: Type.String({ minLength: 1 }),
|
|
192
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 })),
|
|
193
210
|
})),
|
|
194
211
|
defaultLocale: Type.String({ default: 'en-US' }),
|
|
195
212
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
@@ -272,6 +289,41 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
272
289
|
freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
|
|
273
290
|
})),
|
|
274
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
|
+
};
|
|
275
327
|
export const JobsConfigSchema = Type.Object({
|
|
276
328
|
enabled: Type.Boolean({ default: true }),
|
|
277
329
|
retry: Type.Object({ defaultMaxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }) }, { default: {} }),
|
|
@@ -287,31 +339,32 @@ export const JobsConfigSchema = Type.Object({
|
|
|
287
339
|
dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
|
|
288
340
|
schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
|
|
289
341
|
concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
|
|
290
|
-
// 2.F6 generation lifecycle knobs (jobs.md §11)
|
|
291
|
-
// GENERATION_DEFAULTS
|
|
292
|
-
// resolveGenerationConfig
|
|
293
|
-
//
|
|
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.
|
|
294
347
|
generation: Type.Object({
|
|
295
348
|
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
296
|
-
maxConcurrent: Type.Integer({ default:
|
|
349
|
+
maxConcurrent: Type.Integer({ default: GENERATION_DEFAULTS.maxConcurrent, minimum: GENERATION_BOUNDS.maxConcurrent.min, maximum: GENERATION_BOUNDS.maxConcurrent.max }),
|
|
297
350
|
/** default expiry/timeout when the descriptor omits one */
|
|
298
|
-
defaultTimeoutMs: Type.Integer({ default:
|
|
351
|
+
defaultTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.defaultTimeoutMs, minimum: GENERATION_BOUNDS.defaultTimeoutMs.min, maximum: GENERATION_BOUNDS.defaultTimeoutMs.max }),
|
|
299
352
|
/** hard ceiling a tenant-supplied timeout is clamped to */
|
|
300
|
-
maxTimeoutMs: Type.Integer({ default:
|
|
353
|
+
maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
|
|
301
354
|
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
302
|
-
pollMaxAttempts: Type.Integer({ default:
|
|
355
|
+
pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
|
|
303
356
|
/** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
|
|
304
357
|
* §11.8). Every requested amount is CLAMPED to this (never rejected) — a
|
|
305
358
|
* conservative default so an untrusted deployed function that carries a
|
|
306
359
|
* reserve block can never hold more than a bounded amount per run without
|
|
307
360
|
* any tenant action. */
|
|
308
|
-
maxReserveCredits: Type.Integer({ default:
|
|
361
|
+
maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
|
|
309
362
|
/** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
|
|
310
363
|
* reserve holds across all in-flight generation runs (jobs.md §11.8): a
|
|
311
364
|
* reserve whose amount would push the tenant's outstanding-holds total over
|
|
312
365
|
* this is rejected 429, so a runaway function cannot hold every user at
|
|
313
366
|
* once. Defaulted so no tenant action is required to be safe. */
|
|
314
|
-
maxOutstandingReserveCredits: Type.Integer({ default:
|
|
367
|
+
maxOutstandingReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxOutstandingReserveCredits, minimum: GENERATION_BOUNDS.maxOutstandingReserveCredits.min, maximum: GENERATION_BOUNDS.maxOutstandingReserveCredits.max }),
|
|
315
368
|
}, { default: {} }),
|
|
316
369
|
});
|
|
317
370
|
// One social-provider's BYO credential block. The *Ref fields are POINTERS into
|
|
@@ -703,9 +756,9 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
703
756
|
})),
|
|
704
757
|
// DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
|
|
705
758
|
// subscriptions as config. Keyed by `target_url` — the only stable identity a
|
|
706
|
-
// subscription has (there is no name column
|
|
707
|
-
//
|
|
708
|
-
//
|
|
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
|
|
709
762
|
// onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
|
|
710
763
|
// reported (deleted only under --allow-destructive). Rows on the platform's
|
|
711
764
|
// signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
|
|
@@ -1191,12 +1244,27 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1191
1244
|
// analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
|
|
1192
1245
|
// MAPS (one typed leaf each), so catalog size never inflates the flag count.
|
|
1193
1246
|
ledger: Type.Optional(Type.Object({
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
+
])),
|
|
1200
1268
|
tierMap: Type.Record(Type.String(), Type.Object({
|
|
1201
1269
|
entitlements: Type.Array(Type.String()),
|
|
1202
1270
|
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
|
|
@@ -1319,6 +1387,15 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1319
1387
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1320
1388
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1321
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
|
+
})),
|
|
1322
1399
|
}), { maxItems: 8 })),
|
|
1323
1400
|
scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
|
|
1324
1401
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
@@ -1746,10 +1823,17 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1746
1823
|
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
1747
1824
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
1748
1825
|
const ledgerErrs = [];
|
|
1826
|
+
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
1749
1827
|
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
1750
1828
|
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
1751
1829
|
ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1752
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
|
+
}
|
|
1753
1837
|
}
|
|
1754
1838
|
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1755
1839
|
(rule.grants ?? []).forEach((g, i) => {
|
|
@@ -1929,6 +2013,12 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1929
2013
|
if (b.kind === 'cron' && !b.schedule) {
|
|
1930
2014
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
1931
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
|
+
}
|
|
1932
2022
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
1933
2023
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
1934
2024
|
}
|
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,7 +9,7 @@
|
|
|
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
|
|
@@ -27,9 +27,11 @@
|
|
|
27
27
|
// LEFT IN PLACE and reported as `undeclared` — deleted only when the caller
|
|
28
28
|
// passes allow-destructive — so adopting a block on an existing tenant is a
|
|
29
29
|
// report, never a wipe. A change that can only be applied by delete+recreate
|
|
30
|
-
// (a policy's `key_template
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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`.
|
|
33
35
|
//
|
|
34
36
|
// Two shapes this file deliberately does NOT cover, with the reason:
|
|
35
37
|
// notifications.templates — templates are CODE with per-locale `overrides`
|
|
@@ -161,9 +163,11 @@ export interface LiveWebhookSubscription {
|
|
|
161
163
|
|
|
162
164
|
export interface WebhookSubscriptionPlan {
|
|
163
165
|
create: DeclaredWebhookSubscription[];
|
|
164
|
-
/** the prefix SET differs —
|
|
165
|
-
*
|
|
166
|
-
|
|
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[] }>;
|
|
167
171
|
unchanged: string[];
|
|
168
172
|
/** live rows the config does not declare, EXCLUDING the function-delivery
|
|
169
173
|
* lanes (those derive from the functions manifest) — left in place by default */
|
|
@@ -178,7 +182,7 @@ function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
|
|
|
178
182
|
export function planWebhookSubscriptions(
|
|
179
183
|
declared: readonly DeclaredWebhookSubscription[], live: readonly LiveWebhookSubscription[],
|
|
180
184
|
): WebhookSubscriptionPlan {
|
|
181
|
-
const plan: WebhookSubscriptionPlan = { create: [],
|
|
185
|
+
const plan: WebhookSubscriptionPlan = { create: [], update: [], unchanged: [], undeclared: [] };
|
|
182
186
|
const declaredUrls = new Set(declared.map((d) => d.target_url));
|
|
183
187
|
const matched = new Set<string>();
|
|
184
188
|
for (const d of declared) {
|
|
@@ -187,12 +191,12 @@ export function planWebhookSubscriptions(
|
|
|
187
191
|
if (rows.length === 0) { plan.create.push(d); continue; }
|
|
188
192
|
// Prefer an EXACT prefix-set match among duplicates (a hand-made row equal
|
|
189
193
|
// to the declaration is adopted as-is — no churn); else the first row is
|
|
190
|
-
// the one
|
|
194
|
+
// the one updated in place and the rest fall through as undeclared duplicates.
|
|
191
195
|
const exact = rows.find((l) => sameStringSet(l.event_prefixes ?? [], want));
|
|
192
196
|
const chosen = exact ?? rows[0]!;
|
|
193
197
|
matched.add(chosen.sub_id);
|
|
194
198
|
if (exact) plan.unchanged.push(d.target_url);
|
|
195
|
-
else plan.
|
|
199
|
+
else plan.update.push({ sub_id: chosen.sub_id, target_url: d.target_url, event_prefixes: [...want] });
|
|
196
200
|
}
|
|
197
201
|
for (const l of live) {
|
|
198
202
|
if (matched.has(l.sub_id)) continue;
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,13 @@ if (!FormatRegistry.Has('email')) {
|
|
|
40
40
|
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
41
41
|
export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
|
|
42
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
|
+
|
|
43
50
|
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
44
51
|
* of which is restricted to internal platform machinery). */
|
|
45
52
|
export function isReservedCreditType(creditType: string): boolean {
|
|
@@ -161,6 +168,11 @@ export function validateNotificationOverrides(
|
|
|
161
168
|
return errs;
|
|
162
169
|
}
|
|
163
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
|
+
|
|
164
176
|
/** AWS region grammar for `notifications.ses.region` (`us-east-1`,
|
|
165
177
|
* `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
|
|
166
178
|
* one or more lowercase words, a single digit. Pinned as a pattern rather than
|
|
@@ -204,6 +216,13 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
204
216
|
region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
|
|
205
217
|
accessKeyIdRef: Type.String({ minLength: 1 }),
|
|
206
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 })),
|
|
207
226
|
})),
|
|
208
227
|
defaultLocale: Type.String({ default: 'en-US' }),
|
|
209
228
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
@@ -315,6 +334,44 @@ export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
|
315
334
|
/** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
316
335
|
export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
|
|
317
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
|
+
|
|
318
375
|
export const JobsConfigSchema = Type.Object({
|
|
319
376
|
enabled: Type.Boolean({ default: true }),
|
|
320
377
|
retry: Type.Object(
|
|
@@ -342,32 +399,33 @@ export const JobsConfigSchema = Type.Object({
|
|
|
342
399
|
{ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
|
|
343
400
|
{ default: {} },
|
|
344
401
|
),
|
|
345
|
-
// 2.F6 generation lifecycle knobs (jobs.md §11)
|
|
346
|
-
// GENERATION_DEFAULTS
|
|
347
|
-
// resolveGenerationConfig
|
|
348
|
-
//
|
|
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.
|
|
349
407
|
generation: Type.Object(
|
|
350
408
|
{
|
|
351
409
|
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
352
|
-
maxConcurrent: Type.Integer({ default:
|
|
410
|
+
maxConcurrent: Type.Integer({ default: GENERATION_DEFAULTS.maxConcurrent, minimum: GENERATION_BOUNDS.maxConcurrent.min, maximum: GENERATION_BOUNDS.maxConcurrent.max }),
|
|
353
411
|
/** default expiry/timeout when the descriptor omits one */
|
|
354
|
-
defaultTimeoutMs: Type.Integer({ default:
|
|
412
|
+
defaultTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.defaultTimeoutMs, minimum: GENERATION_BOUNDS.defaultTimeoutMs.min, maximum: GENERATION_BOUNDS.defaultTimeoutMs.max }),
|
|
355
413
|
/** hard ceiling a tenant-supplied timeout is clamped to */
|
|
356
|
-
maxTimeoutMs: Type.Integer({ default:
|
|
414
|
+
maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
|
|
357
415
|
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
358
|
-
pollMaxAttempts: Type.Integer({ default:
|
|
416
|
+
pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
|
|
359
417
|
/** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
|
|
360
418
|
* §11.8). Every requested amount is CLAMPED to this (never rejected) — a
|
|
361
419
|
* conservative default so an untrusted deployed function that carries a
|
|
362
420
|
* reserve block can never hold more than a bounded amount per run without
|
|
363
421
|
* any tenant action. */
|
|
364
|
-
maxReserveCredits: Type.Integer({ default:
|
|
422
|
+
maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
|
|
365
423
|
/** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
|
|
366
424
|
* reserve holds across all in-flight generation runs (jobs.md §11.8): a
|
|
367
425
|
* reserve whose amount would push the tenant's outstanding-holds total over
|
|
368
426
|
* this is rejected 429, so a runaway function cannot hold every user at
|
|
369
427
|
* once. Defaulted so no tenant action is required to be safe. */
|
|
370
|
-
maxOutstandingReserveCredits: Type.Integer({ default:
|
|
428
|
+
maxOutstandingReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxOutstandingReserveCredits, minimum: GENERATION_BOUNDS.maxOutstandingReserveCredits.min, maximum: GENERATION_BOUNDS.maxOutstandingReserveCredits.max }),
|
|
371
429
|
},
|
|
372
430
|
{ default: {} },
|
|
373
431
|
),
|
|
@@ -837,9 +895,9 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
837
895
|
})),
|
|
838
896
|
// DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
|
|
839
897
|
// subscriptions as config. Keyed by `target_url` — the only stable identity a
|
|
840
|
-
// subscription has (there is no name column
|
|
841
|
-
//
|
|
842
|
-
//
|
|
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
|
|
843
901
|
// onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
|
|
844
902
|
// reported (deleted only under --allow-destructive). Rows on the platform's
|
|
845
903
|
// signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
|
|
@@ -1506,12 +1564,27 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1506
1564
|
// analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
|
|
1507
1565
|
// MAPS (one typed leaf each), so catalog size never inflates the flag count.
|
|
1508
1566
|
ledger: Type.Optional(Type.Object({
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
+
])),
|
|
1515
1588
|
tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
|
|
1516
1589
|
entitlements: Type.Array(Type.String()),
|
|
1517
1590
|
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
|
|
@@ -1655,6 +1728,15 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1655
1728
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1656
1729
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1657
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
|
+
})),
|
|
1658
1740
|
}),
|
|
1659
1741
|
{ maxItems: 8 },
|
|
1660
1742
|
),
|
|
@@ -2105,7 +2187,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2105
2187
|
const v = withDefaults as {
|
|
2106
2188
|
provider?: string;
|
|
2107
2189
|
resendApiKeyRef?: string;
|
|
2108
|
-
ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string };
|
|
2190
|
+
ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string; snsTopicArn?: string };
|
|
2109
2191
|
templates?: {
|
|
2110
2192
|
allowOverride?: boolean;
|
|
2111
2193
|
overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
|
|
@@ -2137,7 +2219,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2137
2219
|
const v = withDefaults as {
|
|
2138
2220
|
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
2139
2221
|
ledger?: {
|
|
2140
|
-
productMap?: Record<string, { creditType?: string }>;
|
|
2222
|
+
productMap?: Record<string, { creditType?: string; tier?: string }>;
|
|
2141
2223
|
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
|
|
2142
2224
|
priceMap?: Record<string, string>;
|
|
2143
2225
|
};
|
|
@@ -2157,12 +2239,19 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2157
2239
|
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
2158
2240
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
2159
2241
|
const ledgerErrs: string[] = [];
|
|
2242
|
+
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
2160
2243
|
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
2161
2244
|
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
2162
2245
|
ledgerErrs.push(
|
|
2163
2246
|
`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2164
2247
|
);
|
|
2165
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
|
+
}
|
|
2166
2255
|
}
|
|
2167
2256
|
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
2168
2257
|
(rule.grants ?? []).forEach((g, i) => {
|
|
@@ -2339,7 +2428,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2339
2428
|
const v = withDefaults as {
|
|
2340
2429
|
functions?: Record<
|
|
2341
2430
|
string,
|
|
2342
|
-
{
|
|
2431
|
+
{
|
|
2432
|
+
scriptRef?: string; scopes?: string[]; signature?: unknown;
|
|
2433
|
+
bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number } }>;
|
|
2434
|
+
}
|
|
2343
2435
|
>;
|
|
2344
2436
|
};
|
|
2345
2437
|
const errs: string[] = [];
|
|
@@ -2354,6 +2446,12 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2354
2446
|
if (b.kind === 'cron' && !b.schedule) {
|
|
2355
2447
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
2356
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
|
+
}
|
|
2357
2455
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
2358
2456
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
2359
2457
|
}
|