@vxil/feature-configs 0.5.1 → 0.7.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 +83 -2
- package/dist/index.js +210 -21
- package/package.json +1 -1
- package/src/apiState.ts +14 -10
- package/src/index.ts +228 -25
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<{
|
|
@@ -602,6 +656,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
602
656
|
secretApiKeyRef: import("@sinclair/typebox").TString;
|
|
603
657
|
webhookSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
604
658
|
acceptSandbox: import("@sinclair/typebox").TBoolean;
|
|
659
|
+
sandboxUsers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
605
660
|
}>>;
|
|
606
661
|
paypal: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
607
662
|
clientIdRef: import("@sinclair/typebox").TString;
|
|
@@ -615,11 +670,14 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
615
670
|
trialDays: import("@sinclair/typebox").TInteger;
|
|
616
671
|
}>;
|
|
617
672
|
ledger: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
618
|
-
productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
673
|
+
productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
|
|
619
674
|
creditType: import("@sinclair/typebox").TString;
|
|
620
675
|
amount: import("@sinclair/typebox").TInteger;
|
|
621
676
|
period: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"once">, import("@sinclair/typebox").TLiteral<"monthly">, import("@sinclair/typebox").TLiteral<"annual">]>;
|
|
622
|
-
}
|
|
677
|
+
}>, import("@sinclair/typebox").TObject<{
|
|
678
|
+
tier: import("@sinclair/typebox").TString;
|
|
679
|
+
durationDays: import("@sinclair/typebox").TInteger;
|
|
680
|
+
}>]>>;
|
|
623
681
|
tierMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
624
682
|
entitlements: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
625
683
|
quotas: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TInteger>;
|
|
@@ -628,6 +686,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
628
686
|
creditType: import("@sinclair/typebox").TString;
|
|
629
687
|
amount: import("@sinclair/typebox").TInteger;
|
|
630
688
|
period: import("@sinclair/typebox").TString;
|
|
689
|
+
mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"add">, import("@sinclair/typebox").TLiteral<"reset">]>>;
|
|
631
690
|
}>>>;
|
|
632
691
|
}>>;
|
|
633
692
|
priceMap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
@@ -658,11 +717,20 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
|
|
|
658
717
|
source: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
659
718
|
collection: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
660
719
|
event: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
720
|
+
retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
721
|
+
maxAttempts: import("@sinclair/typebox").TInteger;
|
|
722
|
+
}>>;
|
|
723
|
+
overlap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"allow">, import("@sinclair/typebox").TLiteral<"skip">]>>;
|
|
661
724
|
}>>>;
|
|
662
725
|
scriptRef: import("@sinclair/typebox").TString;
|
|
663
726
|
scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
664
727
|
secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
665
728
|
egressAllow: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
729
|
+
runtime: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
730
|
+
compatibilityDate: import("@sinclair/typebox").TString;
|
|
731
|
+
compatibilityFlags: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
732
|
+
cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
|
|
733
|
+
}>>;
|
|
666
734
|
limits: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
667
735
|
cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
668
736
|
timeoutMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
@@ -730,6 +798,19 @@ export declare const CONFIG_FLAG_CAP = 15;
|
|
|
730
798
|
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
731
799
|
export declare const AUTH_HOOK_EVENTS: readonly ["user.created", "session.created", "session.revoked", "signin.failure"];
|
|
732
800
|
export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
|
|
801
|
+
/** The events a `cmsHook` function binding may name — the closed union
|
|
802
|
+
* `packages/config` types on the `cmsHook` trigger, and the keys of the
|
|
803
|
+
* functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
|
|
804
|
+
* beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
|
|
805
|
+
* else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
|
|
806
|
+
* to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
|
|
807
|
+
* 'beforeDelete' silently subscribed the function to creates AND updates.
|
|
808
|
+
* tests/ci/src/fn-binding-contracts.test.ts pins every copy to this list. */
|
|
809
|
+
export declare const CMS_HOOK_EVENTS: readonly ["beforeCreate", "beforeUpdate", "beforeWrite"];
|
|
810
|
+
export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
|
|
811
|
+
/** The ONE refusal text for an unknown cmsHook binding event (the config
|
|
812
|
+
* validator and the deploy route's early 422 both use it). */
|
|
813
|
+
export declare function cmsHookEventError(event: unknown): string | null;
|
|
733
814
|
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
734
815
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
735
816
|
export declare const TEST_RECIPIENT_EMAIL_RE: RegExp;
|
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
|
|
@@ -1063,8 +1116,9 @@ export const AiConfigSchema = Type.Object({
|
|
|
1063
1116
|
// stamps on the latest version, and POSTs a new version ONLY when the content
|
|
1064
1117
|
// differs — so a push is idempotent and versions stay monotonic per name.
|
|
1065
1118
|
// Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
|
|
1066
|
-
// name). Stored templates the config does not declare are reported
|
|
1067
|
-
//
|
|
1119
|
+
// name). Stored templates the config does not declare are reported and left
|
|
1120
|
+
// in place — RETIRED (soft: hidden from list + render, history kept) only
|
|
1121
|
+
// under --allow-destructive (cvskit F67, 2026-10-01). Bounded to 50 entries: the
|
|
1068
1122
|
// manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
|
|
1069
1123
|
templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
|
|
1070
1124
|
});
|
|
@@ -1167,6 +1221,17 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1167
1221
|
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1168
1222
|
// environments by signing secret / API base, so only RC carries this knob.
|
|
1169
1223
|
acceptSandbox: Type.Boolean({ default: false }),
|
|
1224
|
+
// (2026-10-01 §4.13 A18) Store-review purchases on a PRODUCTION tenant:
|
|
1225
|
+
// the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
|
|
1226
|
+
// whose subject (and, for a TRANSFER, every source user) is listed here
|
|
1227
|
+
// folds — recorded `environment: 'sandbox'` on the delivery, the
|
|
1228
|
+
// subscription and the charge, so it stays out of revenue — while every
|
|
1229
|
+
// other sandbox event is still `rejected_environment`. The narrow,
|
|
1230
|
+
// production-safe alternative to `acceptSandbox` (which folds EVERY
|
|
1231
|
+
// TestFlight purchase and is refused by the CLI's production promotion
|
|
1232
|
+
// gate). No default on purpose (Value.Default would materialize it into
|
|
1233
|
+
// every RC manifest); absent = an empty list.
|
|
1234
|
+
sandboxUsers: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 20 })),
|
|
1170
1235
|
})),
|
|
1171
1236
|
paypal: Type.Optional(Type.Object({
|
|
1172
1237
|
clientIdRef: Type.String(),
|
|
@@ -1191,12 +1256,30 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1191
1256
|
// analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
|
|
1192
1257
|
// MAPS (one typed leaf each), so catalog size never inflates the flag count.
|
|
1193
1258
|
ledger: Type.Optional(Type.Object({
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1259
|
+
// product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
|
|
1260
|
+
// `boosts` Record-of-Union precedent) with two rule shapes:
|
|
1261
|
+
// { creditType, amount, period } a credit grant (the original rule)
|
|
1262
|
+
// { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
|
|
1263
|
+
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1264
|
+
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1265
|
+
// STACKS behind the user's live same-tier manual rows that have an end
|
|
1266
|
+
// (earlier passes AND comp grants since 2026-09-25 F38 — never a row
|
|
1267
|
+
// linked to the same charge, an open-ended grant or a provider
|
|
1268
|
+
// subscription) and is ENDED by that charge's full refund / chargeback.
|
|
1269
|
+
// No defaults in either shape, so an existing manifest is
|
|
1270
|
+
// byte-identical after Value.Default.
|
|
1271
|
+
productMap: Type.Record(Type.String(), Type.Union([
|
|
1272
|
+
Type.Object({
|
|
1273
|
+
creditType: Type.String({ minLength: 1 }),
|
|
1274
|
+
amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
|
|
1275
|
+
period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
|
|
1276
|
+
Type.Literal('annual')]),
|
|
1277
|
+
}),
|
|
1278
|
+
Type.Object({
|
|
1279
|
+
tier: Type.String({ minLength: 1 }),
|
|
1280
|
+
durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
|
|
1281
|
+
}),
|
|
1282
|
+
])),
|
|
1200
1283
|
tierMap: Type.Record(Type.String(), Type.Object({
|
|
1201
1284
|
entitlements: Type.Array(Type.String()),
|
|
1202
1285
|
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
|
|
@@ -1205,6 +1288,14 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1205
1288
|
creditType: Type.String({ minLength: 1 }),
|
|
1206
1289
|
amount: Type.Integer({ minimum: 1 }), // #128
|
|
1207
1290
|
period: Type.String(),
|
|
1291
|
+
// (2026-10-01 §4.13 W9) 'add' (the reader's default, today's
|
|
1292
|
+
// behaviour) ADDS `amount` each period; 'reset' makes the period's
|
|
1293
|
+
// grant REPLACE what is left: the unspent available balance of
|
|
1294
|
+
// `creditType` is written off as one `expire` ledger row and `amount`
|
|
1295
|
+
// granted, so the user starts every period with exactly `amount`
|
|
1296
|
+
// (credits held by an in-flight job stay held). No schema default (it
|
|
1297
|
+
// would materialize into every manifest carrying grants).
|
|
1298
|
+
mode: Type.Optional(Type.Union([Type.Literal('add'), Type.Literal('reset')])),
|
|
1208
1299
|
}))),
|
|
1209
1300
|
})),
|
|
1210
1301
|
// provider price/plan id → tier. A real subscription webhook carries the
|
|
@@ -1319,11 +1410,39 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1319
1410
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1320
1411
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1321
1412
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
1413
|
+
// F33 (2026-09-25): the per-binding opt-in to re-delivery on
|
|
1414
|
+
// queue / webhook / cmsHook / authHook (the cross-field rule
|
|
1415
|
+
// rejects it on http / cron). Absent = the ACK-200 default. The
|
|
1416
|
+
// receiver answers a failed attempt as an enveloped 503 (ladder)
|
|
1417
|
+
// and the last one as a terminal 409 (dead + job.dead_lettered);
|
|
1418
|
+
// effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
|
|
1419
|
+
retry: Type.Optional(Type.Object({
|
|
1420
|
+
maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
|
|
1421
|
+
})),
|
|
1422
|
+
// W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
|
|
1423
|
+
// while the previous tick's run is still open (queued / running /
|
|
1424
|
+
// retrying / waiting / delayed). Absent = 'allow' (every tick runs).
|
|
1425
|
+
overlap: Type.Optional(Type.Union([Type.Literal('allow'), Type.Literal('skip')])),
|
|
1322
1426
|
}), { maxItems: 8 })),
|
|
1323
1427
|
scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
|
|
1324
1428
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1325
1429
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1326
1430
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
1431
|
+
// R1 (2026-10-01): the runtime settings this function's script was
|
|
1432
|
+
// UPLOADED with — server-SET by the deploy pipeline (never authored),
|
|
1433
|
+
// read back by the rollback re-upload so a restored script runs under
|
|
1434
|
+
// the settings it was deployed and tested with, not today's. Absent =
|
|
1435
|
+
// the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
|
|
1436
|
+
// strings on purpose (the deploy writes them from the one constant).
|
|
1437
|
+
// cpuMs (F2, 2026-10-01): the per-invoke CPU limit the script was
|
|
1438
|
+
// uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
|
|
1439
|
+
// cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
|
|
1440
|
+
// rewrites it after a tier change. Absent = the platform default.
|
|
1441
|
+
runtime: Type.Optional(Type.Object({
|
|
1442
|
+
compatibilityDate: Type.String(),
|
|
1443
|
+
compatibilityFlags: Type.Optional(Type.Array(Type.String())),
|
|
1444
|
+
cpuMs: Type.Optional(Type.Number()),
|
|
1445
|
+
})),
|
|
1327
1446
|
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1328
1447
|
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1329
1448
|
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
@@ -1488,6 +1607,24 @@ export const CONFIG_FLAG_CAP = 15;
|
|
|
1488
1607
|
* closed union `packages/config` types as AuthHookEvent; the control-plane
|
|
1489
1608
|
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
1490
1609
|
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'];
|
|
1610
|
+
/** The events a `cmsHook` function binding may name — the closed union
|
|
1611
|
+
* `packages/config` types on the `cmsHook` trigger, and the keys of the
|
|
1612
|
+
* functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
|
|
1613
|
+
* beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
|
|
1614
|
+
* else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
|
|
1615
|
+
* to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
|
|
1616
|
+
* 'beforeDelete' silently subscribed the function to creates AND updates.
|
|
1617
|
+
* tests/ci/src/fn-binding-contracts.test.ts pins every copy to this list. */
|
|
1618
|
+
export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'];
|
|
1619
|
+
/** The ONE refusal text for an unknown cmsHook binding event (the config
|
|
1620
|
+
* validator and the deploy route's early 422 both use it). */
|
|
1621
|
+
export function cmsHookEventError(event) {
|
|
1622
|
+
if (event === undefined)
|
|
1623
|
+
return null;
|
|
1624
|
+
if (typeof event === 'string' && CMS_HOOK_EVENTS.includes(event))
|
|
1625
|
+
return null;
|
|
1626
|
+
return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
|
|
1627
|
+
}
|
|
1491
1628
|
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1492
1629
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1493
1630
|
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -1746,18 +1883,56 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1746
1883
|
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
1747
1884
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
1748
1885
|
const ledgerErrs = [];
|
|
1886
|
+
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
1749
1887
|
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
1750
1888
|
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
1751
1889
|
ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1752
1890
|
}
|
|
1891
|
+
// (2026-09-25 F35+) an entitlement rule must name a declared tier — the
|
|
1892
|
+
// write-time mirror of the runtime's unknown-tier refusal (a purchase for
|
|
1893
|
+
// a tier nobody declared would land the delivery `error`).
|
|
1894
|
+
if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
|
|
1895
|
+
ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
|
|
1896
|
+
}
|
|
1753
1897
|
}
|
|
1754
1898
|
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1755
1899
|
(rule.grants ?? []).forEach((g, i) => {
|
|
1756
1900
|
if (g.creditType && isReservedCreditType(g.creditType)) {
|
|
1757
1901
|
ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1758
1902
|
}
|
|
1903
|
+
// (W9) 'reset' replaces the balance EACH PERIOD — a one-time grant has
|
|
1904
|
+
// no period to reset, and a second grant of the same credit type in the
|
|
1905
|
+
// same tier would be wiped (or wipe it) depending on array order.
|
|
1906
|
+
if (g.mode === 'reset') {
|
|
1907
|
+
if (g.period === 'once') {
|
|
1908
|
+
ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: 'reset' needs a recurring period (monthly or annual), not 'once'`);
|
|
1909
|
+
}
|
|
1910
|
+
const twin = (rule.grants ?? []).findIndex((o, j) => j !== i && o.creditType === g.creditType);
|
|
1911
|
+
if (twin >= 0) {
|
|
1912
|
+
ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: a 'reset' grant must be the only grant of '${g.creditType}' in the tier (grants/${twin} also grants it)`);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
// (2026-10-01 review) A 'reset' grant writes off the WHOLE available balance
|
|
1918
|
+
// of its credit type each period — purchased credits of that type included.
|
|
1919
|
+
// A credit pack of a reset type would lose paid value at the next renewal,
|
|
1920
|
+
// so it is refused here: keep packs on their own type and spend in order
|
|
1921
|
+
// (`credit_types: ['plan', 'topup']`).
|
|
1922
|
+
const resetBy = new Map();
|
|
1923
|
+
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1924
|
+
(rule.grants ?? []).forEach((g, i) => {
|
|
1925
|
+
if (g.mode === 'reset' && g.creditType && !resetBy.has(g.creditType)) {
|
|
1926
|
+
resetBy.set(g.creditType, `/ledger/tierMap/${tier}/grants/${i}`);
|
|
1927
|
+
}
|
|
1759
1928
|
});
|
|
1760
1929
|
}
|
|
1930
|
+
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
1931
|
+
const by = rule.creditType ? resetBy.get(rule.creditType) : undefined;
|
|
1932
|
+
if (by) {
|
|
1933
|
+
ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is reset each period by ${by} (mode 'reset' writes off purchased credits of that type) — sell packs on their own credit type and spend with credit_types`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1761
1936
|
// Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
|
|
1762
1937
|
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
1763
1938
|
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
@@ -1929,9 +2104,23 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1929
2104
|
if (b.kind === 'cron' && !b.schedule) {
|
|
1930
2105
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
1931
2106
|
}
|
|
2107
|
+
if (b.overlap !== undefined && b.kind !== 'cron') {
|
|
2108
|
+
errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
|
|
2109
|
+
}
|
|
2110
|
+
// F33: retry is an opt-in for the platform-delivered event lanes only —
|
|
2111
|
+
// an http invoke returns its real status to its caller, and a cron
|
|
2112
|
+
// tick's retry would overlap the next tick.
|
|
2113
|
+
if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
|
|
2114
|
+
errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
|
|
2115
|
+
}
|
|
1932
2116
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
1933
2117
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
1934
2118
|
}
|
|
2119
|
+
// E-CMSHOOK: the CLOSED cmsHook event union — an unknown name is a 422
|
|
2120
|
+
// at write time, never a silent created+updated subscription.
|
|
2121
|
+
const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
|
|
2122
|
+
if (cmsEventErr)
|
|
2123
|
+
errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
|
|
1935
2124
|
// authHook: a CLOSED event union (F4-30) — reject typos at write time
|
|
1936
2125
|
// so a binding never silently subscribes to nothing.
|
|
1937
2126
|
if (b.kind === 'authHook' && b.event !== undefined && !AUTH_HOOK_EVENTS.includes(b.event)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
|
@@ -1347,8 +1405,9 @@ export const AiConfigSchema = Type.Object({
|
|
|
1347
1405
|
// stamps on the latest version, and POSTs a new version ONLY when the content
|
|
1348
1406
|
// differs — so a push is idempotent and versions stay monotonic per name.
|
|
1349
1407
|
// Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
|
|
1350
|
-
// name). Stored templates the config does not declare are reported
|
|
1351
|
-
//
|
|
1408
|
+
// name). Stored templates the config does not declare are reported and left
|
|
1409
|
+
// in place — RETIRED (soft: hidden from list + render, history kept) only
|
|
1410
|
+
// under --allow-destructive (cvskit F67, 2026-10-01). Bounded to 50 entries: the
|
|
1352
1411
|
// manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
|
|
1353
1412
|
templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
|
|
1354
1413
|
});
|
|
@@ -1479,6 +1538,17 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1479
1538
|
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1480
1539
|
// environments by signing secret / API base, so only RC carries this knob.
|
|
1481
1540
|
acceptSandbox: Type.Boolean({ default: false }),
|
|
1541
|
+
// (2026-10-01 §4.13 A18) Store-review purchases on a PRODUCTION tenant:
|
|
1542
|
+
// the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
|
|
1543
|
+
// whose subject (and, for a TRANSFER, every source user) is listed here
|
|
1544
|
+
// folds — recorded `environment: 'sandbox'` on the delivery, the
|
|
1545
|
+
// subscription and the charge, so it stays out of revenue — while every
|
|
1546
|
+
// other sandbox event is still `rejected_environment`. The narrow,
|
|
1547
|
+
// production-safe alternative to `acceptSandbox` (which folds EVERY
|
|
1548
|
+
// TestFlight purchase and is refused by the CLI's production promotion
|
|
1549
|
+
// gate). No default on purpose (Value.Default would materialize it into
|
|
1550
|
+
// every RC manifest); absent = an empty list.
|
|
1551
|
+
sandboxUsers: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 20 })),
|
|
1482
1552
|
})),
|
|
1483
1553
|
paypal: Type.Optional(Type.Object({
|
|
1484
1554
|
clientIdRef: Type.String(),
|
|
@@ -1506,12 +1576,30 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1506
1576
|
// analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
|
|
1507
1577
|
// MAPS (one typed leaf each), so catalog size never inflates the flag count.
|
|
1508
1578
|
ledger: Type.Optional(Type.Object({
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1579
|
+
// product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
|
|
1580
|
+
// `boosts` Record-of-Union precedent) with two rule shapes:
|
|
1581
|
+
// { creditType, amount, period } a credit grant (the original rule)
|
|
1582
|
+
// { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
|
|
1583
|
+
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1584
|
+
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1585
|
+
// STACKS behind the user's live same-tier manual rows that have an end
|
|
1586
|
+
// (earlier passes AND comp grants since 2026-09-25 F38 — never a row
|
|
1587
|
+
// linked to the same charge, an open-ended grant or a provider
|
|
1588
|
+
// subscription) and is ENDED by that charge's full refund / chargeback.
|
|
1589
|
+
// No defaults in either shape, so an existing manifest is
|
|
1590
|
+
// byte-identical after Value.Default.
|
|
1591
|
+
productMap: Type.Record(Type.String(), Type.Union([
|
|
1592
|
+
Type.Object({
|
|
1593
|
+
creditType: Type.String({ minLength: 1 }),
|
|
1594
|
+
amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
|
|
1595
|
+
period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
|
|
1596
|
+
Type.Literal('annual')]),
|
|
1597
|
+
}),
|
|
1598
|
+
Type.Object({
|
|
1599
|
+
tier: Type.String({ minLength: 1 }),
|
|
1600
|
+
durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
|
|
1601
|
+
}),
|
|
1602
|
+
])),
|
|
1515
1603
|
tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
|
|
1516
1604
|
entitlements: Type.Array(Type.String()),
|
|
1517
1605
|
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
|
|
@@ -1520,6 +1608,14 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1520
1608
|
creditType: Type.String({ minLength: 1 }),
|
|
1521
1609
|
amount: Type.Integer({ minimum: 1 }), // #128
|
|
1522
1610
|
period: Type.String(),
|
|
1611
|
+
// (2026-10-01 §4.13 W9) 'add' (the reader's default, today's
|
|
1612
|
+
// behaviour) ADDS `amount` each period; 'reset' makes the period's
|
|
1613
|
+
// grant REPLACE what is left: the unspent available balance of
|
|
1614
|
+
// `creditType` is written off as one `expire` ledger row and `amount`
|
|
1615
|
+
// granted, so the user starts every period with exactly `amount`
|
|
1616
|
+
// (credits held by an in-flight job stay held). No schema default (it
|
|
1617
|
+
// would materialize into every manifest carrying grants).
|
|
1618
|
+
mode: Type.Optional(Type.Union([Type.Literal('add'), Type.Literal('reset')])),
|
|
1523
1619
|
}))),
|
|
1524
1620
|
})),
|
|
1525
1621
|
// provider price/plan id → tier. A real subscription webhook carries the
|
|
@@ -1655,6 +1751,19 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1655
1751
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1656
1752
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1657
1753
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
1754
|
+
// F33 (2026-09-25): the per-binding opt-in to re-delivery on
|
|
1755
|
+
// queue / webhook / cmsHook / authHook (the cross-field rule
|
|
1756
|
+
// rejects it on http / cron). Absent = the ACK-200 default. The
|
|
1757
|
+
// receiver answers a failed attempt as an enveloped 503 (ladder)
|
|
1758
|
+
// and the last one as a terminal 409 (dead + job.dead_lettered);
|
|
1759
|
+
// effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
|
|
1760
|
+
retry: Type.Optional(Type.Object({
|
|
1761
|
+
maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
|
|
1762
|
+
})),
|
|
1763
|
+
// W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
|
|
1764
|
+
// while the previous tick's run is still open (queued / running /
|
|
1765
|
+
// retrying / waiting / delayed). Absent = 'allow' (every tick runs).
|
|
1766
|
+
overlap: Type.Optional(Type.Union([Type.Literal('allow'), Type.Literal('skip')])),
|
|
1658
1767
|
}),
|
|
1659
1768
|
{ maxItems: 8 },
|
|
1660
1769
|
),
|
|
@@ -1663,6 +1772,23 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1663
1772
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1664
1773
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1665
1774
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
1775
|
+
// R1 (2026-10-01): the runtime settings this function's script was
|
|
1776
|
+
// UPLOADED with — server-SET by the deploy pipeline (never authored),
|
|
1777
|
+
// read back by the rollback re-upload so a restored script runs under
|
|
1778
|
+
// the settings it was deployed and tested with, not today's. Absent =
|
|
1779
|
+
// the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
|
|
1780
|
+
// strings on purpose (the deploy writes them from the one constant).
|
|
1781
|
+
// cpuMs (F2, 2026-10-01): the per-invoke CPU limit the script was
|
|
1782
|
+
// uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
|
|
1783
|
+
// cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
|
|
1784
|
+
// rewrites it after a tier change. Absent = the platform default.
|
|
1785
|
+
runtime: Type.Optional(
|
|
1786
|
+
Type.Object({
|
|
1787
|
+
compatibilityDate: Type.String(),
|
|
1788
|
+
compatibilityFlags: Type.Optional(Type.Array(Type.String())),
|
|
1789
|
+
cpuMs: Type.Optional(Type.Number()),
|
|
1790
|
+
}),
|
|
1791
|
+
),
|
|
1666
1792
|
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1667
1793
|
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1668
1794
|
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
@@ -1870,6 +1996,25 @@ export const CONFIG_FLAG_CAP = 15;
|
|
|
1870
1996
|
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
|
|
1871
1997
|
export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
|
|
1872
1998
|
|
|
1999
|
+
/** The events a `cmsHook` function binding may name — the closed union
|
|
2000
|
+
* `packages/config` types on the `cmsHook` trigger, and the keys of the
|
|
2001
|
+
* functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
|
|
2002
|
+
* beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
|
|
2003
|
+
* else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
|
|
2004
|
+
* to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
|
|
2005
|
+
* 'beforeDelete' silently subscribed the function to creates AND updates.
|
|
2006
|
+
* tests/ci/src/fn-binding-contracts.test.ts pins every copy to this list. */
|
|
2007
|
+
export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'] as const;
|
|
2008
|
+
export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
|
|
2009
|
+
|
|
2010
|
+
/** The ONE refusal text for an unknown cmsHook binding event (the config
|
|
2011
|
+
* validator and the deploy route's early 422 both use it). */
|
|
2012
|
+
export function cmsHookEventError(event: unknown): string | null {
|
|
2013
|
+
if (event === undefined) return null;
|
|
2014
|
+
if (typeof event === 'string' && (CMS_HOOK_EVENTS as readonly string[]).includes(event)) return null;
|
|
2015
|
+
return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1873
2018
|
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1874
2019
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1875
2020
|
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -2105,7 +2250,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2105
2250
|
const v = withDefaults as {
|
|
2106
2251
|
provider?: string;
|
|
2107
2252
|
resendApiKeyRef?: string;
|
|
2108
|
-
ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string };
|
|
2253
|
+
ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string; snsTopicArn?: string };
|
|
2109
2254
|
templates?: {
|
|
2110
2255
|
allowOverride?: boolean;
|
|
2111
2256
|
overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
|
|
@@ -2137,8 +2282,8 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2137
2282
|
const v = withDefaults as {
|
|
2138
2283
|
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
2139
2284
|
ledger?: {
|
|
2140
|
-
productMap?: Record<string, { creditType?: string }>;
|
|
2141
|
-
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
|
|
2285
|
+
productMap?: Record<string, { creditType?: string; tier?: string }>;
|
|
2286
|
+
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string; period?: string; mode?: string }> }>;
|
|
2142
2287
|
priceMap?: Record<string, string>;
|
|
2143
2288
|
};
|
|
2144
2289
|
};
|
|
@@ -2157,12 +2302,19 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2157
2302
|
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
2158
2303
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
2159
2304
|
const ledgerErrs: string[] = [];
|
|
2305
|
+
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
2160
2306
|
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
2161
2307
|
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
2162
2308
|
ledgerErrs.push(
|
|
2163
2309
|
`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2164
2310
|
);
|
|
2165
2311
|
}
|
|
2312
|
+
// (2026-09-25 F35+) an entitlement rule must name a declared tier — the
|
|
2313
|
+
// write-time mirror of the runtime's unknown-tier refusal (a purchase for
|
|
2314
|
+
// a tier nobody declared would land the delivery `error`).
|
|
2315
|
+
if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
|
|
2316
|
+
ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
|
|
2317
|
+
}
|
|
2166
2318
|
}
|
|
2167
2319
|
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
2168
2320
|
(rule.grants ?? []).forEach((g, i) => {
|
|
@@ -2171,8 +2323,43 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2171
2323
|
`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2172
2324
|
);
|
|
2173
2325
|
}
|
|
2326
|
+
// (W9) 'reset' replaces the balance EACH PERIOD — a one-time grant has
|
|
2327
|
+
// no period to reset, and a second grant of the same credit type in the
|
|
2328
|
+
// same tier would be wiped (or wipe it) depending on array order.
|
|
2329
|
+
if (g.mode === 'reset') {
|
|
2330
|
+
if (g.period === 'once') {
|
|
2331
|
+
ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: 'reset' needs a recurring period (monthly or annual), not 'once'`);
|
|
2332
|
+
}
|
|
2333
|
+
const twin = (rule.grants ?? []).findIndex((o, j) => j !== i && o.creditType === g.creditType);
|
|
2334
|
+
if (twin >= 0) {
|
|
2335
|
+
ledgerErrs.push(
|
|
2336
|
+
`/ledger/tierMap/${tier}/grants/${i}/mode: a 'reset' grant must be the only grant of '${g.creditType}' in the tier (grants/${twin} also grants it)`,
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
// (2026-10-01 review) A 'reset' grant writes off the WHOLE available balance
|
|
2343
|
+
// of its credit type each period — purchased credits of that type included.
|
|
2344
|
+
// A credit pack of a reset type would lose paid value at the next renewal,
|
|
2345
|
+
// so it is refused here: keep packs on their own type and spend in order
|
|
2346
|
+
// (`credit_types: ['plan', 'topup']`).
|
|
2347
|
+
const resetBy = new Map<string, string>();
|
|
2348
|
+
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
2349
|
+
(rule.grants ?? []).forEach((g, i) => {
|
|
2350
|
+
if (g.mode === 'reset' && g.creditType && !resetBy.has(g.creditType)) {
|
|
2351
|
+
resetBy.set(g.creditType, `/ledger/tierMap/${tier}/grants/${i}`);
|
|
2352
|
+
}
|
|
2174
2353
|
});
|
|
2175
2354
|
}
|
|
2355
|
+
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
2356
|
+
const by = rule.creditType ? resetBy.get(rule.creditType) : undefined;
|
|
2357
|
+
if (by) {
|
|
2358
|
+
ledgerErrs.push(
|
|
2359
|
+
`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is reset each period by ${by} (mode 'reset' writes off purchased credits of that type) — sell packs on their own credit type and spend with credit_types`,
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2176
2363
|
// Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
|
|
2177
2364
|
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
2178
2365
|
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
@@ -2339,7 +2526,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2339
2526
|
const v = withDefaults as {
|
|
2340
2527
|
functions?: Record<
|
|
2341
2528
|
string,
|
|
2342
|
-
{
|
|
2529
|
+
{
|
|
2530
|
+
scriptRef?: string; scopes?: string[]; signature?: unknown;
|
|
2531
|
+
bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number }; overlap?: string }>;
|
|
2532
|
+
}
|
|
2343
2533
|
>;
|
|
2344
2534
|
};
|
|
2345
2535
|
const errs: string[] = [];
|
|
@@ -2354,9 +2544,22 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2354
2544
|
if (b.kind === 'cron' && !b.schedule) {
|
|
2355
2545
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
2356
2546
|
}
|
|
2547
|
+
if (b.overlap !== undefined && b.kind !== 'cron') {
|
|
2548
|
+
errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
|
|
2549
|
+
}
|
|
2550
|
+
// F33: retry is an opt-in for the platform-delivered event lanes only —
|
|
2551
|
+
// an http invoke returns its real status to its caller, and a cron
|
|
2552
|
+
// tick's retry would overlap the next tick.
|
|
2553
|
+
if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
|
|
2554
|
+
errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
|
|
2555
|
+
}
|
|
2357
2556
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
2358
2557
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
2359
2558
|
}
|
|
2559
|
+
// E-CMSHOOK: the CLOSED cmsHook event union — an unknown name is a 422
|
|
2560
|
+
// at write time, never a silent created+updated subscription.
|
|
2561
|
+
const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
|
|
2562
|
+
if (cmsEventErr) errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
|
|
2360
2563
|
// authHook: a CLOSED event union (F4-30) — reject typos at write time
|
|
2361
2564
|
// so a binding never silently subscribes to nothing.
|
|
2362
2565
|
if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {
|