@vxil/feature-configs 0.6.0 → 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/index.d.ts +21 -0
- package/dist/index.js +104 -5
- package/package.json +1 -1
- package/src/index.ts +112 -7
package/dist/index.d.ts
CHANGED
|
@@ -656,6 +656,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
656
656
|
secretApiKeyRef: import("@sinclair/typebox").TString;
|
|
657
657
|
webhookSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
658
658
|
acceptSandbox: import("@sinclair/typebox").TBoolean;
|
|
659
|
+
sandboxUsers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
659
660
|
}>>;
|
|
660
661
|
paypal: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
661
662
|
clientIdRef: import("@sinclair/typebox").TString;
|
|
@@ -685,6 +686,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
685
686
|
creditType: import("@sinclair/typebox").TString;
|
|
686
687
|
amount: import("@sinclair/typebox").TInteger;
|
|
687
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">]>>;
|
|
688
690
|
}>>>;
|
|
689
691
|
}>>;
|
|
690
692
|
priceMap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
@@ -718,11 +720,17 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
|
|
|
718
720
|
retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
719
721
|
maxAttempts: import("@sinclair/typebox").TInteger;
|
|
720
722
|
}>>;
|
|
723
|
+
overlap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"allow">, import("@sinclair/typebox").TLiteral<"skip">]>>;
|
|
721
724
|
}>>>;
|
|
722
725
|
scriptRef: import("@sinclair/typebox").TString;
|
|
723
726
|
scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
724
727
|
secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
725
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
|
+
}>>;
|
|
726
734
|
limits: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
727
735
|
cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
728
736
|
timeoutMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
@@ -790,6 +798,19 @@ export declare const CONFIG_FLAG_CAP = 15;
|
|
|
790
798
|
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
791
799
|
export declare const AUTH_HOOK_EVENTS: readonly ["user.created", "session.created", "session.revoked", "signin.failure"];
|
|
792
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;
|
|
793
814
|
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
794
815
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
795
816
|
export declare const TEST_RECIPIENT_EMAIL_RE: RegExp;
|
package/dist/index.js
CHANGED
|
@@ -1116,8 +1116,9 @@ export const AiConfigSchema = Type.Object({
|
|
|
1116
1116
|
// stamps on the latest version, and POSTs a new version ONLY when the content
|
|
1117
1117
|
// differs — so a push is idempotent and versions stay monotonic per name.
|
|
1118
1118
|
// Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
|
|
1119
|
-
// name). Stored templates the config does not declare are reported
|
|
1120
|
-
//
|
|
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
|
|
1121
1122
|
// manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
|
|
1122
1123
|
templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
|
|
1123
1124
|
});
|
|
@@ -1220,6 +1221,17 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1220
1221
|
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1221
1222
|
// environments by signing secret / API base, so only RC carries this knob.
|
|
1222
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 })),
|
|
1223
1235
|
})),
|
|
1224
1236
|
paypal: Type.Optional(Type.Object({
|
|
1225
1237
|
clientIdRef: Type.String(),
|
|
@@ -1250,9 +1262,12 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1250
1262
|
// { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
|
|
1251
1263
|
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1252
1264
|
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1253
|
-
// STACKS
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
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.
|
|
1256
1271
|
productMap: Type.Record(Type.String(), Type.Union([
|
|
1257
1272
|
Type.Object({
|
|
1258
1273
|
creditType: Type.String({ minLength: 1 }),
|
|
@@ -1273,6 +1288,14 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1273
1288
|
creditType: Type.String({ minLength: 1 }),
|
|
1274
1289
|
amount: Type.Integer({ minimum: 1 }), // #128
|
|
1275
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')])),
|
|
1276
1299
|
}))),
|
|
1277
1300
|
})),
|
|
1278
1301
|
// provider price/plan id → tier. A real subscription webhook carries the
|
|
@@ -1396,11 +1419,30 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1396
1419
|
retry: Type.Optional(Type.Object({
|
|
1397
1420
|
maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
|
|
1398
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')])),
|
|
1399
1426
|
}), { maxItems: 8 })),
|
|
1400
1427
|
scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
|
|
1401
1428
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1402
1429
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1403
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
|
+
})),
|
|
1404
1446
|
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1405
1447
|
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1406
1448
|
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
@@ -1565,6 +1607,24 @@ export const CONFIG_FLAG_CAP = 15;
|
|
|
1565
1607
|
* closed union `packages/config` types as AuthHookEvent; the control-plane
|
|
1566
1608
|
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
1567
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
|
+
}
|
|
1568
1628
|
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1569
1629
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1570
1630
|
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -1840,8 +1900,39 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1840
1900
|
if (g.creditType && isReservedCreditType(g.creditType)) {
|
|
1841
1901
|
ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1842
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
|
+
}
|
|
1843
1928
|
});
|
|
1844
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
|
+
}
|
|
1845
1936
|
// Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
|
|
1846
1937
|
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
1847
1938
|
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
@@ -2013,6 +2104,9 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
2013
2104
|
if (b.kind === 'cron' && !b.schedule) {
|
|
2014
2105
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
2015
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
|
+
}
|
|
2016
2110
|
// F33: retry is an opt-in for the platform-delivered event lanes only —
|
|
2017
2111
|
// an http invoke returns its real status to its caller, and a cron
|
|
2018
2112
|
// tick's retry would overlap the next tick.
|
|
@@ -2022,6 +2116,11 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
2022
2116
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
2023
2117
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
2024
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}`);
|
|
2025
2124
|
// authHook: a CLOSED event union (F4-30) — reject typos at write time
|
|
2026
2125
|
// so a binding never silently subscribes to nothing.
|
|
2027
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/index.ts
CHANGED
|
@@ -1405,8 +1405,9 @@ export const AiConfigSchema = Type.Object({
|
|
|
1405
1405
|
// stamps on the latest version, and POSTs a new version ONLY when the content
|
|
1406
1406
|
// differs — so a push is idempotent and versions stay monotonic per name.
|
|
1407
1407
|
// Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
|
|
1408
|
-
// name). Stored templates the config does not declare are reported
|
|
1409
|
-
//
|
|
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
|
|
1410
1411
|
// manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
|
|
1411
1412
|
templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
|
|
1412
1413
|
});
|
|
@@ -1537,6 +1538,17 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1537
1538
|
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1538
1539
|
// environments by signing secret / API base, so only RC carries this knob.
|
|
1539
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 })),
|
|
1540
1552
|
})),
|
|
1541
1553
|
paypal: Type.Optional(Type.Object({
|
|
1542
1554
|
clientIdRef: Type.String(),
|
|
@@ -1570,9 +1582,12 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1570
1582
|
// { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
|
|
1571
1583
|
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1572
1584
|
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1573
|
-
// STACKS
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
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.
|
|
1576
1591
|
productMap: Type.Record(Type.String(), Type.Union([
|
|
1577
1592
|
Type.Object({
|
|
1578
1593
|
creditType: Type.String({ minLength: 1 }),
|
|
@@ -1593,6 +1608,14 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1593
1608
|
creditType: Type.String({ minLength: 1 }),
|
|
1594
1609
|
amount: Type.Integer({ minimum: 1 }), // #128
|
|
1595
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')])),
|
|
1596
1619
|
}))),
|
|
1597
1620
|
})),
|
|
1598
1621
|
// provider price/plan id → tier. A real subscription webhook carries the
|
|
@@ -1737,6 +1760,10 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1737
1760
|
retry: Type.Optional(Type.Object({
|
|
1738
1761
|
maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
|
|
1739
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')])),
|
|
1740
1767
|
}),
|
|
1741
1768
|
{ maxItems: 8 },
|
|
1742
1769
|
),
|
|
@@ -1745,6 +1772,23 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1745
1772
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1746
1773
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1747
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
|
+
),
|
|
1748
1792
|
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1749
1793
|
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1750
1794
|
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
@@ -1952,6 +1996,25 @@ export const CONFIG_FLAG_CAP = 15;
|
|
|
1952
1996
|
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
|
|
1953
1997
|
export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
|
|
1954
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
|
+
|
|
1955
2018
|
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1956
2019
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1957
2020
|
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -2220,7 +2283,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2220
2283
|
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
2221
2284
|
ledger?: {
|
|
2222
2285
|
productMap?: Record<string, { creditType?: string; tier?: string }>;
|
|
2223
|
-
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
|
|
2286
|
+
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string; period?: string; mode?: string }> }>;
|
|
2224
2287
|
priceMap?: Record<string, string>;
|
|
2225
2288
|
};
|
|
2226
2289
|
};
|
|
@@ -2260,8 +2323,43 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2260
2323
|
`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2261
2324
|
);
|
|
2262
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
|
+
}
|
|
2263
2340
|
});
|
|
2264
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
|
+
}
|
|
2353
|
+
});
|
|
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
|
+
}
|
|
2265
2363
|
// Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
|
|
2266
2364
|
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
2267
2365
|
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
@@ -2430,7 +2528,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2430
2528
|
string,
|
|
2431
2529
|
{
|
|
2432
2530
|
scriptRef?: string; scopes?: string[]; signature?: unknown;
|
|
2433
|
-
bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number } }>;
|
|
2531
|
+
bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number }; overlap?: string }>;
|
|
2434
2532
|
}
|
|
2435
2533
|
>;
|
|
2436
2534
|
};
|
|
@@ -2446,6 +2544,9 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2446
2544
|
if (b.kind === 'cron' && !b.schedule) {
|
|
2447
2545
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
2448
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
|
+
}
|
|
2449
2550
|
// F33: retry is an opt-in for the platform-delivered event lanes only —
|
|
2450
2551
|
// an http invoke returns its real status to its caller, and a cron
|
|
2451
2552
|
// tick's retry would overlap the next tick.
|
|
@@ -2455,6 +2556,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2455
2556
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
2456
2557
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
2457
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}`);
|
|
2458
2563
|
// authHook: a CLOSED event union (F4-30) — reject typos at write time
|
|
2459
2564
|
// so a binding never silently subscribes to nothing.
|
|
2460
2565
|
if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {
|