@vxil/feature-configs 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +19 -6
- package/dist/index.js +166 -16
- package/package.json +3 -2
- package/src/index.ts +173 -22
- package/src/hooks.test.ts +0 -451
- package/src/index.test.ts +0 -1255
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
|
|
|
6
6
|
* of which is restricted to internal platform machinery). */
|
|
7
7
|
export declare function isReservedCreditType(creditType: string): boolean;
|
|
8
8
|
export declare const NOTIFICATION_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, readonly string[]>>;
|
|
9
|
+
/** Placeholders that are SLOTS in the BODY parts only — the renderer splits
|
|
10
|
+
* `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
|
|
11
|
+
* the plain interpolator, where the same token is just a data key that does not
|
|
12
|
+
* exist and renders EMPTY. Rejected in `subject` for exactly the reason the
|
|
13
|
+
* whole allow-list exists: a silent empty render in a live email. */
|
|
14
|
+
export declare const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS: Readonly<Record<string, readonly string[]>>;
|
|
9
15
|
/** Per-override caps (D4). Bodies are emails, not documents. */
|
|
10
16
|
export declare const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
11
17
|
export declare const NOTIF_OVERRIDE_BODY_MAX = 20000;
|
|
@@ -54,7 +60,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
|
|
|
54
60
|
templates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
55
61
|
allowOverride: import("@sinclair/typebox").TBoolean;
|
|
56
62
|
overrides: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
57
|
-
subject: import("@sinclair/typebox").TString
|
|
63
|
+
subject: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
58
64
|
html: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
59
65
|
text: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
60
66
|
}>>>>;
|
|
@@ -203,9 +209,11 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
203
209
|
emailVerification: import("@sinclair/typebox").TObject<{
|
|
204
210
|
required: import("@sinclair/typebox").TBoolean;
|
|
205
211
|
}>;
|
|
206
|
-
magicLink: import("@sinclair/typebox").TObject<{
|
|
212
|
+
magicLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
207
213
|
tokenTtlMinutes: import("@sinclair/typebox").TInteger;
|
|
208
|
-
|
|
214
|
+
resendCooldownSec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
215
|
+
}>>;
|
|
216
|
+
registryAdopt: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
209
217
|
otp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
210
218
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
211
219
|
codeTtlMinutes: import("@sinclair/typebox").TInteger;
|
|
@@ -275,6 +283,11 @@ export declare const WebhooksConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
275
283
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
276
284
|
maxSubscriptions: import("@sinclair/typebox").TInteger;
|
|
277
285
|
maxSources: import("@sinclair/typebox").TInteger;
|
|
286
|
+
alerts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
287
|
+
enabled: import("@sinclair/typebox").TBoolean;
|
|
288
|
+
minLevel: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"warn">, import("@sinclair/typebox").TLiteral<"error">]>;
|
|
289
|
+
digestMinutes: import("@sinclair/typebox").TInteger;
|
|
290
|
+
}>>;
|
|
278
291
|
}>;
|
|
279
292
|
export type WebhooksConfig = Static<typeof WebhooksConfigSchema>;
|
|
280
293
|
export declare const CommentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
@@ -554,6 +567,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
554
567
|
enforcePeriodEnd: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
555
568
|
slackHours: import("@sinclair/typebox").TInteger;
|
|
556
569
|
}>>;
|
|
570
|
+
unmappedProduct: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"error">, import("@sinclair/typebox").TLiteral<"ignore">]>>;
|
|
557
571
|
}>>;
|
|
558
572
|
}>;
|
|
559
573
|
export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
|
@@ -579,9 +593,8 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
|
|
|
579
593
|
secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
580
594
|
egressAllow: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
581
595
|
limits: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
582
|
-
cpuMs: import("@sinclair/typebox").TInteger
|
|
583
|
-
timeoutMs: import("@sinclair/typebox").TInteger
|
|
584
|
-
memoryMb: import("@sinclair/typebox").TInteger;
|
|
596
|
+
cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
597
|
+
timeoutMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
585
598
|
}>>;
|
|
586
599
|
enabled: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
587
600
|
signature: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
package/dist/index.js
CHANGED
|
@@ -44,7 +44,19 @@ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = {
|
|
|
44
44
|
'magic-link': ['url', 'expires_minutes'],
|
|
45
45
|
'otp-code': ['code', 'expires_minutes'],
|
|
46
46
|
welcome: ['app_name', 'first_name', 'first_name_greeting'],
|
|
47
|
-
|
|
47
|
+
// `cta` (G-78) is a SLOT, not a data key: an override that writes `{{cta}}`
|
|
48
|
+
// chooses where the escaped CTA anchor built from cta_label + cta_url lands.
|
|
49
|
+
// Absent from the built-in bodies (which append it), so the worker parity
|
|
50
|
+
// suite pins it explicitly — see templates.test.ts.
|
|
51
|
+
transactional: ['subject', 'paragraph', 'cta_label', 'cta_url', 'cta'],
|
|
52
|
+
};
|
|
53
|
+
/** Placeholders that are SLOTS in the BODY parts only — the renderer splits
|
|
54
|
+
* `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
|
|
55
|
+
* the plain interpolator, where the same token is just a data key that does not
|
|
56
|
+
* exist and renders EMPTY. Rejected in `subject` for exactly the reason the
|
|
57
|
+
* whole allow-list exists: a silent empty render in a live email. */
|
|
58
|
+
export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS = {
|
|
59
|
+
transactional: ['cta'],
|
|
48
60
|
};
|
|
49
61
|
/** Per-override caps (D4). Bodies are emails, not documents. */
|
|
50
62
|
export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
@@ -96,14 +108,31 @@ export function validateNotificationOverrides(templates) {
|
|
|
96
108
|
continue;
|
|
97
109
|
}
|
|
98
110
|
const variant = byLocale[locale];
|
|
111
|
+
if (typeof variant !== 'object' || variant === null) {
|
|
112
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: must be an object with at least one of subject, html, text`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// G-78 — every part is OPTIONAL (a body-only translation inherits the
|
|
116
|
+
// built-in subject), but an ALL-EMPTY variant is the new "inert config"
|
|
117
|
+
// and fails LOUD rather than silently rendering the built-in.
|
|
118
|
+
const nonEmpty = ['subject', 'html', 'text']
|
|
119
|
+
.filter((p) => typeof variant[p] === 'string' && variant[p] !== '');
|
|
120
|
+
if (nonEmpty.length === 0) {
|
|
121
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: set at least one of subject, html, text`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// body-only SLOTS are not subject placeholders (see the map's doc comment)
|
|
125
|
+
const slots = NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS[templateId] ?? [];
|
|
126
|
+
const subjectAllowed = allowed.filter((k) => !slots.includes(k));
|
|
99
127
|
for (const part of ['subject', 'html', 'text']) {
|
|
100
128
|
const body = variant[part];
|
|
101
129
|
if (typeof body !== 'string')
|
|
102
130
|
continue;
|
|
131
|
+
const ok = part === 'subject' ? subjectAllowed : allowed;
|
|
103
132
|
for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
|
|
104
133
|
const key = m[1];
|
|
105
|
-
if (!
|
|
106
|
-
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${
|
|
134
|
+
if (!ok.includes(key)) {
|
|
135
|
+
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${ok.join(', ')})`);
|
|
107
136
|
}
|
|
108
137
|
}
|
|
109
138
|
}
|
|
@@ -161,15 +190,23 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
161
190
|
allowOverride: Type.Boolean({ default: false }),
|
|
162
191
|
// D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
163
192
|
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
164
|
-
// moves the count). Shape: { [templateId]: { [locale]: { subject
|
|
193
|
+
// moves the count). Shape: { [templateId]: { [locale]: { subject?,
|
|
165
194
|
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
166
195
|
// built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
|
|
167
196
|
// sanitisation are identical and there is no raw-output syntax.
|
|
168
197
|
// validateFeatureConfig rejects: an unknown template id, an unknown
|
|
169
198
|
// placeholder, a malformed locale tag, an over-size body, and (fail
|
|
170
199
|
// LOUD, never silently inert) overrides present with allowOverride:false.
|
|
171
|
-
overrides: Type.Optional(Type.Record(Type.String(),
|
|
172
|
-
|
|
200
|
+
overrides: Type.Optional(Type.Record(Type.String(),
|
|
201
|
+
// G-78: `subject` is OPTIONAL like html/text — a PARTIAL override
|
|
202
|
+
// inherits each omitted part from the built-in, so a body-only
|
|
203
|
+
// translation no longer forces you to restate the subject (and a
|
|
204
|
+
// subject-only 'ar' override no longer pairs an Arabic subject with the
|
|
205
|
+
// English built-in body). A strict widening: every config valid before
|
|
206
|
+
// this change is still valid. validateNotificationOverrides rejects the
|
|
207
|
+
// one shape this opens up — a variant with NO non-empty part.
|
|
208
|
+
Type.Record(Type.String(), Type.Object({
|
|
209
|
+
subject: Type.Optional(Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX })),
|
|
173
210
|
html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
174
211
|
text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
175
212
|
})))),
|
|
@@ -368,7 +405,35 @@ export const AuthConfigSchema = Type.Object({
|
|
|
368
405
|
// verify-email token flow it would bound was never built). Same precedent as
|
|
369
406
|
// the removed `redirects` block below. Its leaf funds the OTP wave.
|
|
370
407
|
emailVerification: Type.Object({ required: Type.Boolean({ default: false }) }, { default: {} }),
|
|
371
|
-
|
|
408
|
+
// P1-16 (2026-09-18): magicLink is now an OPTIONAL bag (= ONE leaf however
|
|
409
|
+
// many knobs it holds — the methods/session/password precedent) so the
|
|
410
|
+
// request cooldown could land without spending a second leaf. It KEEPS
|
|
411
|
+
// `default: {}`, so Value.Default still materializes
|
|
412
|
+
// `magicLink.tokenTtlMinutes` exactly as before — every persisted manifest
|
|
413
|
+
// folds byte-identically.
|
|
414
|
+
magicLink: Type.Optional(Type.Object({
|
|
415
|
+
tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
|
|
416
|
+
// P1-16: the per-(tenant, identifier) magic-link REQUEST cooldown — the
|
|
417
|
+
// `otp.resendCooldownSec` twin, same bounds so the two knobs read the
|
|
418
|
+
// same. A second request for the same address inside the window is a 429
|
|
419
|
+
// `magic_link_rate_limited` with a Retry-After header. Type.Optional with
|
|
420
|
+
// NO default (the `otp.testRecipients` precedent below) so existing
|
|
421
|
+
// manifests fold byte-identically; the 60s fallback is applied by the
|
|
422
|
+
// auth feature at read time. 0 disables the cooldown.
|
|
423
|
+
resendCooldownSec: Type.Optional(Type.Integer({ minimum: 0, maximum: 600 })),
|
|
424
|
+
}, { default: {} })),
|
|
425
|
+
// P1-5 identity continuity (2026-09-18) — OPT-IN registry adoption. When
|
|
426
|
+
// true, a sign-in by a method that PROVES control of the address — magic
|
|
427
|
+
// link, email OTP, or OAuth with a provider-verified address — reuses the id
|
|
428
|
+
// of the one matching pre-registered end-user (POST /v1/users) that has no
|
|
429
|
+
// account yet, instead of minting a fresh `user_<ulid>` and leaving the
|
|
430
|
+
// tenant with two records for one person. Password SIGN-UP never adopts: it
|
|
431
|
+
// proves nothing about the address (auth.md §8.1 PROVEN_ADOPT_METHODS).
|
|
432
|
+
// OFF by default, and Type.Optional with NO default: adoption means whoever
|
|
433
|
+
// proves control of a pre-registered address becomes that record — a change
|
|
434
|
+
// of security semantics for a tenant that bulk-imports contacts, whose
|
|
435
|
+
// addresses vxil never verified.
|
|
436
|
+
registryAdopt: Type.Optional(Type.Boolean()),
|
|
372
437
|
// Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
|
|
373
438
|
// OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
|
|
374
439
|
// otp?.enabled === true).
|
|
@@ -495,6 +560,27 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
495
560
|
enabled: Type.Boolean({ default: true }),
|
|
496
561
|
maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
497
562
|
maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
563
|
+
// FAILURE-ALERT DIGEST (P0-2). Outbound subscriptions are the real-time
|
|
564
|
+
// channel; this is the "nobody is consuming them yet" fallback — a periodic
|
|
565
|
+
// e-mail summary of the tenant's FAILURE-class audit events (the level:
|
|
566
|
+
// 'failure' rows of the generated event catalog). Read by
|
|
567
|
+
// workers/control-plane/src/alertDigest.ts on the minute cron.
|
|
568
|
+
//
|
|
569
|
+
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this deviation is
|
|
570
|
+
// journaled here the way RateLimitsConfigSchema journals its own: an
|
|
571
|
+
// arbitrary `to` would turn vxil's own sending identity into a relay for
|
|
572
|
+
// tenant-authored content and open a PII egress path out of the audit trail.
|
|
573
|
+
// The digest goes to the OWNER-role dashboard accounts of the project (cap
|
|
574
|
+
// 3), who already receive platform mail (invites, magic links, resets), so no
|
|
575
|
+
// new consent surface is created. An arbitrary recipient is a separate
|
|
576
|
+
// decision with its own abuse review.
|
|
577
|
+
//
|
|
578
|
+
// An Optional object bag counts as ONE leaf (the countLeaves rule).
|
|
579
|
+
alerts: Type.Optional(Type.Object({
|
|
580
|
+
enabled: Type.Boolean({ default: false }),
|
|
581
|
+
minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
|
|
582
|
+
digestMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 1440 }),
|
|
583
|
+
})),
|
|
498
584
|
});
|
|
499
585
|
// comments feature (wishlist): threaded discussion on tenant-defined topics.
|
|
500
586
|
export const CommentsConfigSchema = Type.Object({
|
|
@@ -813,7 +899,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
813
899
|
cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }), // 0 = off
|
|
814
900
|
limits: Type.Object({
|
|
815
901
|
tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
|
|
816
|
-
consumeCredits: Type.Boolean({ default: false }), // reserve→settle (
|
|
902
|
+
consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
|
|
817
903
|
}, { default: {} }),
|
|
818
904
|
streaming: Type.Object({
|
|
819
905
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -902,10 +988,16 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
902
988
|
projectId: Type.String(),
|
|
903
989
|
publicSdkKey: Type.String(),
|
|
904
990
|
secretApiKeyRef: Type.String(),
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
// webhook-forgery vector
|
|
991
|
+
// Per-tenant webhook secret ref (public.tenant_secrets). Inbound RevenueCat
|
|
992
|
+
// webhooks are verified against THIS ref and nothing else: there is no
|
|
993
|
+
// platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
|
|
994
|
+
// (that fallback WAS the multi-tenant RC webhook-forgery vector; it is now
|
|
995
|
+
// frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
|
|
996
|
+
// permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
|
|
997
|
+
// Optional at the SCHEMA level only — leaving it unset does not disable
|
|
998
|
+
// verification, it fails CLOSED: every delivery is 401 bad_signature with a
|
|
999
|
+
// `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
|
|
1000
|
+
// webhookSecretRef.
|
|
909
1001
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
910
1002
|
// Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
|
|
911
1003
|
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
@@ -971,7 +1063,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
971
1063
|
// Opt-in period-end enforcement (money-path operations wave, decision D2
|
|
972
1064
|
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
973
1065
|
// current_period_end passed with no provider event stays entitled forever
|
|
974
|
-
// (the provider is the only clock). PRESENT = the
|
|
1066
|
+
// (the provider is the only clock). PRESENT = the nightly reconcile sweep
|
|
975
1067
|
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
976
1068
|
// has passed with no renewal to status 'lapsed' (+ refold, +
|
|
977
1069
|
// payments.subscription.lapsed); a later provider renewal event revives it
|
|
@@ -980,6 +1072,29 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
980
1072
|
enforcePeriodEnd: Type.Optional(Type.Object({
|
|
981
1073
|
slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
|
|
982
1074
|
})),
|
|
1075
|
+
// (2026-09 P2) What to do with a one-off purchase whose `product_id` is NOT
|
|
1076
|
+
// in productMap. 'error' (the DEFAULT and today's behaviour) stamps the
|
|
1077
|
+
// delivery outcome `error`, emits payments.webhook_event.failed and lands a
|
|
1078
|
+
// `stranded_webhook` reconcile finding. 'ignore' stamps it `ignored`, with
|
|
1079
|
+
// no failure event and no stranded finding.
|
|
1080
|
+
//
|
|
1081
|
+
// WHY IT EXISTS: the trigger is `event.productId && !grantRule`, and Paddle
|
|
1082
|
+
// carries items[0].price.product_id on EVERY transaction.completed —
|
|
1083
|
+
// INCLUDING subscription-renewal transactions, whose money folds through the
|
|
1084
|
+
// subscription.* events instead. A Paddle tenant that drives everything
|
|
1085
|
+
// through tierMap/priceMap therefore got one `error` row + one failure event
|
|
1086
|
+
// + one stranded finding PER RENEWAL, forever. 'ignore' is the one-line
|
|
1087
|
+
// answer. The charge row is still recorded either way — only the ledger
|
|
1088
|
+
// grant is declined — and an `ignored` row stays REPROCESSABLE, so mapping
|
|
1089
|
+
// the product later and re-running the delivery folds it.
|
|
1090
|
+
//
|
|
1091
|
+
// NO SCHEMA DEFAULT ON PURPOSE: the config-write pipeline runs
|
|
1092
|
+
// Value.Default, which MATERIALIZES an Optional-with-default into every
|
|
1093
|
+
// persisted manifest — that would rewrite every payments manifest carrying a
|
|
1094
|
+
// `ledger` bag and make `vxil push --dry-run` / `vxil diff` report drift for
|
|
1095
|
+
// every payments tenant. The READER owns the default
|
|
1096
|
+
// (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
|
|
1097
|
+
unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
|
|
983
1098
|
})),
|
|
984
1099
|
// NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
|
|
985
1100
|
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
@@ -1048,10 +1163,45 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1048
1163
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1049
1164
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1050
1165
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
1166
|
+
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1167
|
+
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1168
|
+
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
1169
|
+
// UNBOUNDED here on purpose: `deployFunction` validates the WHOLE merged
|
|
1170
|
+
// manifest, `Value.Clean` strips unknown keys but never clamps
|
|
1171
|
+
// out-of-range ones, so a min/max here would let ONE legacy entry 422
|
|
1172
|
+
// every future deploy for that tenant. The bounds (cpuMs 5..300000,
|
|
1173
|
+
// timeoutMs 1000..120000) are enforced at the ONE write path,
|
|
1174
|
+
// `normalizeLimits` in the deploy handler, which also sanitizes pre-existing
|
|
1175
|
+
// entries on merge.
|
|
1176
|
+
// cpuMs — TIGHTENS the per-dispatch isolate CPU cap (never widens
|
|
1177
|
+
// FN_MAX_CPU_MS) and RAISES (never lowers) the metering
|
|
1178
|
+
// reserve, whose floor stays defaultLimits.cpuMs. THE
|
|
1179
|
+
// CONSEQUENCE, stated because the knob's name hides it:
|
|
1180
|
+
// the reserve is the payments HOLD, and the pro-rated
|
|
1181
|
+
// settle clamps the measured wall-ms into [floor, reserve]
|
|
1182
|
+
// — it can never commit more than it holds — so raising
|
|
1183
|
+
// the reserve raises the settle CEILING. An invoke whose
|
|
1184
|
+
// wall time exceeds defaultLimits.cpuMs (50) then settles
|
|
1185
|
+
// at its measured wall-ms instead of being capped at 50:
|
|
1186
|
+
// on a METERED tenant, declaring cpuMs raises the bill for
|
|
1187
|
+
// long-running functions (cpuMs: 1000 turns an 800 ms
|
|
1188
|
+
// invoke from 50 into 800). It does NOT move anyone's
|
|
1189
|
+
// availability — the ACCOUNT-global daily capacity budget
|
|
1190
|
+
// counts the platform's own estimate, never this value
|
|
1191
|
+
// (functions-v1 capacity.ts `capacityReserveCpuMs`).
|
|
1192
|
+
// timeoutMs — the wall budget for ONE outbound fetch, delivered to the
|
|
1193
|
+
// egress guard as an outbound parameter. NOT an invocation
|
|
1194
|
+
// budget: the INVOCATION is bounded by the platform's own
|
|
1195
|
+
// FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
|
|
1196
|
+
// bigger per-fetch budget widens with you (audit FN-3).
|
|
1197
|
+
// (F8-54's standing "strip the twins" note is DISCHARGED here: `memoryMb`
|
|
1198
|
+
// is deleted — memory is fixed by the managed runtime and is not a
|
|
1199
|
+
// per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
|
|
1200
|
+
// Value.Clean strips it from an old config, so such a config still loads
|
|
1201
|
+
// and `vxil plan --explain` marks the key DROPPED.)
|
|
1051
1202
|
limits: Type.Optional(Type.Object({
|
|
1052
|
-
cpuMs: Type.Integer(),
|
|
1053
|
-
timeoutMs: Type.Integer(),
|
|
1054
|
-
memoryMb: Type.Integer(),
|
|
1203
|
+
cpuMs: Type.Optional(Type.Integer()),
|
|
1204
|
+
timeoutMs: Type.Optional(Type.Integer()),
|
|
1055
1205
|
})),
|
|
1056
1206
|
enabled: Type.Optional(Type.Boolean()),
|
|
1057
1207
|
// Level-1 typed I/O (cli-sdk design §4.5): the declared input/output
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vxil.com",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
27
|
-
"src"
|
|
27
|
+
"src",
|
|
28
|
+
"!src/**/*.test.ts"
|
|
28
29
|
],
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"@sinclair/typebox": "^0.34.0"
|