@vxil/feature-configs 0.1.0 → 0.2.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 +46 -2
- package/dist/index.js +206 -8
- package/package.json +11 -3
- package/src/index.test.ts +133 -2
- package/src/index.ts +242 -13
package/dist/index.d.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { type Static, type TSchema } from '@sinclair/typebox';
|
|
2
2
|
export * from './hooks.js';
|
|
3
3
|
export * from './readmodels.js';
|
|
4
|
+
export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
|
|
5
|
+
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
6
|
+
* of which is restricted to internal platform machinery). */
|
|
7
|
+
export declare function isReservedCreditType(creditType: string): boolean;
|
|
4
8
|
export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
5
9
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
6
10
|
fromEmail: import("@sinclair/typebox").TString;
|
|
7
11
|
fromName: import("@sinclair/typebox").TString;
|
|
8
12
|
replyTo: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
9
13
|
resendApiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
14
|
+
webhookSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
10
15
|
provider: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"resend">, import("@sinclair/typebox").TLiteral<"mock">]>;
|
|
11
16
|
defaultLocale: import("@sinclair/typebox").TString;
|
|
12
|
-
retry: import("@sinclair/typebox").TObject<{
|
|
17
|
+
retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
13
18
|
maxAttempts: import("@sinclair/typebox").TInteger;
|
|
14
19
|
backoff: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"exponential">, import("@sinclair/typebox").TLiteral<"linear">]>;
|
|
15
|
-
}
|
|
20
|
+
}>>;
|
|
16
21
|
suppression: import("@sinclair/typebox").TObject<{
|
|
17
22
|
softBounceThreshold: import("@sinclair/typebox").TInteger;
|
|
18
23
|
}>;
|
|
@@ -25,8 +30,31 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
|
|
|
25
30
|
}>;
|
|
26
31
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
27
32
|
inboxEnabled: import("@sinclair/typebox").TBoolean;
|
|
33
|
+
/** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
34
|
+
* disabled; per-campaign quiet_hours / freq_cap overrides live on the
|
|
35
|
+
* notifications.campaigns ROW (tenant data), not here. Folded into the
|
|
36
|
+
* canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
|
|
37
|
+
* worker-local extension, so campaigns 403'd via the real config path). */
|
|
38
|
+
broadcast: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
39
|
+
enabled: import("@sinclair/typebox").TBoolean;
|
|
40
|
+
/** tenant-default per-recipient quiet window (defer-not-drop); a
|
|
41
|
+
* per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
|
|
42
|
+
* (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
|
|
43
|
+
* pattern-pinned to what the worker's parseHhMm actually parses — a
|
|
44
|
+
* looser string ('10pm') would validate but FAIL OPEN at runtime
|
|
45
|
+
* (quiet window silently ignored). */
|
|
46
|
+
defaultQuietHours: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
47
|
+
tz: import("@sinclair/typebox").TString;
|
|
48
|
+
start: import("@sinclair/typebox").TString;
|
|
49
|
+
end: import("@sinclair/typebox").TString;
|
|
50
|
+
}>>;
|
|
51
|
+
/** default rolling per-user-per-day campaign-send cap across campaigns */
|
|
52
|
+
freqCapPerUserPerDay: import("@sinclair/typebox").TInteger;
|
|
53
|
+
}>>;
|
|
28
54
|
}>;
|
|
29
55
|
export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
56
|
+
/** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
57
|
+
export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
|
|
30
58
|
export declare const JobsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
31
59
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
32
60
|
retry: import("@sinclair/typebox").TObject<{
|
|
@@ -585,3 +613,19 @@ export declare const MCP_MAX_PROMPTS = 32;
|
|
|
585
613
|
* validation below and the control-plane deploy clamp. */
|
|
586
614
|
export declare const DENY_FUNCTION_SCOPES: Set<string>;
|
|
587
615
|
export declare function validateFeatureConfig(feature: string, raw: unknown): ConfigValidation;
|
|
616
|
+
export declare const FEATURE_KEYS: readonly ["notifications", "jobs", "auth", "rate-limits", "files", "webhooks", "comments", "cms", "mcp", "realtime", "presence", "orgs", "activity-feed", "vector-search", "ai", "rag", "payments", "functions", "copilot"];
|
|
617
|
+
export type FeatureKey = (typeof FEATURE_KEYS)[number];
|
|
618
|
+
/** The hand-authored "when to use" intent a machine schema cannot convey.
|
|
619
|
+
* REQUIRED for every feature (Record key-closure): a feature added to
|
|
620
|
+
* FEATURE_KEYS without a hint is a compile error naming the missing key. */
|
|
621
|
+
export interface PlannerHint {
|
|
622
|
+
/** One or two sentences: when an app needs this feature. */
|
|
623
|
+
whenToUse: string;
|
|
624
|
+
/** Soft priors — words/needs that suggest the feature (NOT a matcher). */
|
|
625
|
+
signals: string[];
|
|
626
|
+
/** When NOT to pick it (disambiguation vs neighbors). */
|
|
627
|
+
notFor: string;
|
|
628
|
+
/** Features that must be enabled alongside (runtime substrate deps). */
|
|
629
|
+
dependsOn: FeatureKey[];
|
|
630
|
+
}
|
|
631
|
+
export declare const FEATURE_HINTS: Record<FeatureKey, PlannerHint>;
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,21 @@ export * from './readmodels.js';
|
|
|
18
18
|
if (!FormatRegistry.Has('email')) {
|
|
19
19
|
FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
|
|
20
20
|
}
|
|
21
|
+
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth (audit F2) ─────
|
|
22
|
+
// `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
|
|
23
|
+
// functions "recover-by-price" meter). It must NEVER be grantable or consumable
|
|
24
|
+
// by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
|
|
25
|
+
// tenant that self-grants `fn_cpu_ms` (directly, or by routing a product/tier
|
|
26
|
+
// through the webhook/subscription grant reducers) runs vxil-billed function CPU
|
|
27
|
+
// for free. Defined HERE (the typebox-only shared package both the control-plane
|
|
28
|
+
// config-write gate AND payments-v1 import) so the runtime choke point and the
|
|
29
|
+
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
30
|
+
export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
|
|
31
|
+
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
32
|
+
* of which is restricted to internal platform machinery). */
|
|
33
|
+
export function isReservedCreditType(creditType) {
|
|
34
|
+
return RESERVED_CREDIT_TYPES.has(creditType);
|
|
35
|
+
}
|
|
21
36
|
export const NotificationsConfigSchema = Type.Object({
|
|
22
37
|
enabled: Type.Boolean({ default: true }),
|
|
23
38
|
fromEmail: Type.String({ format: 'email' }),
|
|
@@ -27,19 +42,32 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
27
42
|
// need no email account, so the mock path is zero-config. A cross-field
|
|
28
43
|
// check in validateFeatureConfig requires it only when provider === 'resend'.
|
|
29
44
|
resendApiKeyRef: Type.Optional(Type.String()),
|
|
45
|
+
// Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
|
|
46
|
+
// envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
|
|
47
|
+
// When set, inbound Resend webhooks are verified with THIS tenant's secret
|
|
48
|
+
// instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
|
|
49
|
+
// to the tenant so a signed event for tenant A can never validate at tenant
|
|
50
|
+
// B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
|
|
51
|
+
webhookSecretRef: Type.Optional(Type.String()),
|
|
30
52
|
// 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
|
|
31
53
|
provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
|
|
32
54
|
default: 'resend',
|
|
33
55
|
}),
|
|
34
56
|
defaultLocale: Type.String({ default: 'en-US' }),
|
|
35
57
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
36
|
-
// and then recurse into the leaf defaults
|
|
37
|
-
retry
|
|
58
|
+
// and then recurse into the leaf defaults.
|
|
59
|
+
// `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
|
|
60
|
+
// Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
|
|
61
|
+
// It KEEPS `default: {}`, which Value.Default still materializes — so every
|
|
62
|
+
// persisted manifest carries retry.{maxAttempts,backoff} exactly as before
|
|
63
|
+
// (zero behavioral delta); only the TS type is now optional (workers read
|
|
64
|
+
// via retryOf()'s fallback).
|
|
65
|
+
retry: Type.Optional(Type.Object({
|
|
38
66
|
maxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }),
|
|
39
67
|
backoff: Type.Union([Type.Literal('exponential'), Type.Literal('linear')], {
|
|
40
68
|
default: 'exponential',
|
|
41
69
|
}),
|
|
42
|
-
}, { default: {} }),
|
|
70
|
+
}, { default: {} })),
|
|
43
71
|
suppression: Type.Object({ softBounceThreshold: Type.Integer({ default: 3 }) }, { default: {} }),
|
|
44
72
|
rateLimit: Type.Object({
|
|
45
73
|
perDay: Type.Integer({ default: 100000 }),
|
|
@@ -48,6 +76,27 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
48
76
|
templates: Type.Object({ allowOverride: Type.Boolean({ default: false }) }, { default: {} }),
|
|
49
77
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
50
78
|
inboxEnabled: Type.Boolean({ default: false }),
|
|
79
|
+
/** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
80
|
+
* disabled; per-campaign quiet_hours / freq_cap overrides live on the
|
|
81
|
+
* notifications.campaigns ROW (tenant data), not here. Folded into the
|
|
82
|
+
* canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
|
|
83
|
+
* worker-local extension, so campaigns 403'd via the real config path). */
|
|
84
|
+
broadcast: Type.Optional(Type.Object({
|
|
85
|
+
enabled: Type.Boolean({ default: false }),
|
|
86
|
+
/** tenant-default per-recipient quiet window (defer-not-drop); a
|
|
87
|
+
* per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
|
|
88
|
+
* (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
|
|
89
|
+
* pattern-pinned to what the worker's parseHhMm actually parses — a
|
|
90
|
+
* looser string ('10pm') would validate but FAIL OPEN at runtime
|
|
91
|
+
* (quiet window silently ignored). */
|
|
92
|
+
defaultQuietHours: Type.Optional(Type.Object({
|
|
93
|
+
tz: Type.String({ minLength: 1, maxLength: 64 }),
|
|
94
|
+
start: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
|
|
95
|
+
end: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
|
|
96
|
+
})),
|
|
97
|
+
/** default rolling per-user-per-day campaign-send cap across campaigns */
|
|
98
|
+
freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
|
|
99
|
+
})),
|
|
51
100
|
});
|
|
52
101
|
export const JobsConfigSchema = Type.Object({
|
|
53
102
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -208,7 +257,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
208
257
|
downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
|
|
209
258
|
quotas: Type.Object({
|
|
210
259
|
// maximum caps are a defense-in-depth ceiling on tenant-editable storage —
|
|
211
|
-
//
|
|
260
|
+
// object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
|
|
212
261
|
// 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
213
262
|
// (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
|
|
214
263
|
// tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
|
|
@@ -338,7 +387,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
338
387
|
enabled: Type.Optional(Type.Boolean()),
|
|
339
388
|
}))),
|
|
340
389
|
// Realtime CDC bridge (cms-rel E): cms writes auto-publish a change event to
|
|
341
|
-
// a realtime channel — config-only rewiring of
|
|
390
|
+
// a realtime channel — config-only rewiring of database-change-event-style subs.
|
|
342
391
|
// Fire-and-forget via waitUntil; at-most-once (guaranteed delivery stays
|
|
343
392
|
// webhooks-out / functions cms-hook). ONE Type.Record leaf.
|
|
344
393
|
cdc: Type.Optional(Type.Record(Type.String({ maxLength: 64 }), Type.Object({
|
|
@@ -473,10 +522,10 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
473
522
|
// synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
|
|
474
523
|
export const VectorSearchConfigSchema = Type.Object({
|
|
475
524
|
enabled: Type.Boolean({ default: true }),
|
|
476
|
-
// 'auto' resolves to
|
|
525
|
+
// 'auto' resolves to the default managed vector backend for the tier (#147).
|
|
477
526
|
backend: Type.Union([Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')], { default: 'auto' }),
|
|
478
527
|
// 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
|
|
479
|
-
// BYO key from
|
|
528
|
+
// BYO key from tenant secrets via apiKeyRef (encrypted at rest).
|
|
480
529
|
embed: Type.Object({
|
|
481
530
|
provider: Type.Union([
|
|
482
531
|
Type.Literal('byov'),
|
|
@@ -709,7 +758,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
709
758
|
webhooks: Type.Object({ forwardToTenantUrl: Type.Optional(Type.String({ format: 'uri' })) }, { default: {} }),
|
|
710
759
|
});
|
|
711
760
|
// functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
|
|
712
|
-
// functions on
|
|
761
|
+
// functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
|
|
713
762
|
// scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
|
|
714
763
|
// bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
|
|
715
764
|
// never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
|
|
@@ -1020,6 +1069,25 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1020
1069
|
errors: [`/${String(key)}: required when provider is '${v.provider}' (omit it for provider 'mock')`],
|
|
1021
1070
|
};
|
|
1022
1071
|
}
|
|
1072
|
+
// A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
|
|
1073
|
+
// the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
|
|
1074
|
+
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
1075
|
+
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
1076
|
+
const ledgerErrs = [];
|
|
1077
|
+
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
1078
|
+
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
1079
|
+
ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1083
|
+
(rule.grants ?? []).forEach((g, i) => {
|
|
1084
|
+
if (g.creditType && isReservedCreditType(g.creditType)) {
|
|
1085
|
+
ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
if (ledgerErrs.length)
|
|
1090
|
+
return { ok: false, errors: ledgerErrs.slice(0, 10) };
|
|
1023
1091
|
}
|
|
1024
1092
|
// Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
|
|
1025
1093
|
// AST-validated against the closed sandbox allow-list HERE, at config-write
|
|
@@ -1189,3 +1257,133 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1189
1257
|
}
|
|
1190
1258
|
return { ok: true, errors: [], value: withDefaults };
|
|
1191
1259
|
}
|
|
1260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1261
|
+
// PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
|
|
1262
|
+
// FEATURE_KEYS is the literal feature list the planner catalog is projected
|
|
1263
|
+
// from; the planner-catalog CI gate asserts set-equality with
|
|
1264
|
+
// Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
|
|
1265
|
+
// list (and FEATURE_HINTS below — a tsc error via the Record key-closure)
|
|
1266
|
+
// turns the gate red. Coverage is derived-and-gated; intent (whenToUse) is
|
|
1267
|
+
// human-authored but gated-for-presence.
|
|
1268
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1269
|
+
export const FEATURE_KEYS = [
|
|
1270
|
+
'notifications', 'jobs', 'auth', 'rate-limits', 'files', 'webhooks',
|
|
1271
|
+
'comments', 'cms', 'mcp', 'realtime', 'presence', 'orgs', 'activity-feed',
|
|
1272
|
+
'vector-search', 'ai', 'rag', 'payments', 'functions', 'copilot',
|
|
1273
|
+
];
|
|
1274
|
+
export const FEATURE_HINTS = {
|
|
1275
|
+
notifications: {
|
|
1276
|
+
whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
|
|
1277
|
+
signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
|
|
1278
|
+
notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
|
|
1279
|
+
dependsOn: [],
|
|
1280
|
+
},
|
|
1281
|
+
jobs: {
|
|
1282
|
+
whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
|
|
1283
|
+
signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
|
|
1284
|
+
notFor: 'Simple request-response logic that completes inline.',
|
|
1285
|
+
dependsOn: [],
|
|
1286
|
+
},
|
|
1287
|
+
auth: {
|
|
1288
|
+
whenToUse: 'End-users sign in/up to the app: passwords, magic links, OTP, social login, sessions. Almost every app with per-user data wants it.',
|
|
1289
|
+
signals: ['sign in', 'login', 'account', 'register', 'user', 'password', 'profile', 'member', 'seller', 'buyer'],
|
|
1290
|
+
notFor: 'Pure-public read-only content with no user state.',
|
|
1291
|
+
dependsOn: [],
|
|
1292
|
+
},
|
|
1293
|
+
'rate-limits': {
|
|
1294
|
+
whenToUse: 'Throttling abuse-prone or costly operations: public forms, expensive endpoints, per-user quotas.',
|
|
1295
|
+
signals: ['rate limit', 'throttle', 'abuse', 'quota', 'spam'],
|
|
1296
|
+
notFor: 'General correctness — the platform already meters requests globally.',
|
|
1297
|
+
dependsOn: [],
|
|
1298
|
+
},
|
|
1299
|
+
files: {
|
|
1300
|
+
whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
|
|
1301
|
+
signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
|
|
1302
|
+
notFor: 'Structured records (cms) or text content authored in-app.',
|
|
1303
|
+
dependsOn: [],
|
|
1304
|
+
},
|
|
1305
|
+
webhooks: {
|
|
1306
|
+
whenToUse: 'Receiving events FROM external services (Stripe/GitHub/etc. callbacks) or fanning tenant events out TO external URLs.',
|
|
1307
|
+
signals: ['webhook', 'callback', 'external event', 'integration', 'sync with'],
|
|
1308
|
+
notFor: 'In-app eventing between vxil features (the audit bus covers that).',
|
|
1309
|
+
dependsOn: ['jobs'],
|
|
1310
|
+
},
|
|
1311
|
+
comments: {
|
|
1312
|
+
whenToUse: 'Threaded discussion, reviews, replies, or reactions attached to any topic/record; also direct messages between users.',
|
|
1313
|
+
signals: ['comment', 'review', 'discussion', 'reply', 'thread', 'DM', 'message'],
|
|
1314
|
+
notFor: 'A social following timeline (activity-feed) or live chat transport (realtime).',
|
|
1315
|
+
dependsOn: [],
|
|
1316
|
+
},
|
|
1317
|
+
cms: {
|
|
1318
|
+
whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
|
|
1319
|
+
signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
|
|
1320
|
+
notFor: 'End-user identity (auth) or file bytes (files).',
|
|
1321
|
+
dependsOn: [],
|
|
1322
|
+
},
|
|
1323
|
+
mcp: {
|
|
1324
|
+
whenToUse: 'Exposing the tenant backend as typed tools an AI agent drives (Claude/Cursor etc.).',
|
|
1325
|
+
signals: ['agent', 'MCP', 'AI tools', 'tool-calling'],
|
|
1326
|
+
notFor: 'In-app AI text generation (ai) or chat over documents (rag).',
|
|
1327
|
+
dependsOn: [],
|
|
1328
|
+
},
|
|
1329
|
+
realtime: {
|
|
1330
|
+
whenToUse: 'Live in-page updates over channels: chat rooms, live boards, collaborative views, instant refresh when data changes.',
|
|
1331
|
+
signals: ['live', 'realtime', 'chat', 'instantly', 'websocket', 'multiplayer'],
|
|
1332
|
+
notFor: 'Email/inbox notifications (notifications) or historical timelines (activity-feed).',
|
|
1333
|
+
dependsOn: [],
|
|
1334
|
+
},
|
|
1335
|
+
presence: {
|
|
1336
|
+
whenToUse: 'Showing who is online/typing/active on a channel right now.',
|
|
1337
|
+
signals: ['online', 'who is here', 'typing', 'active users', 'presence'],
|
|
1338
|
+
notFor: 'Message delivery itself (realtime carries the messages).',
|
|
1339
|
+
dependsOn: ['realtime'],
|
|
1340
|
+
},
|
|
1341
|
+
orgs: {
|
|
1342
|
+
whenToUse: 'End-users grouped into teams/workspaces with roles and per-resource permissions (multi-member accounts, RBAC).',
|
|
1343
|
+
signals: ['team', 'workspace', 'organization', 'role', 'invite member', 'permission'],
|
|
1344
|
+
notFor: 'Simple per-user ownership (cms ownerField covers that without orgs).',
|
|
1345
|
+
dependsOn: ['auth'],
|
|
1346
|
+
},
|
|
1347
|
+
'activity-feed': {
|
|
1348
|
+
whenToUse: 'Social timelines: follow/unfollow, personal feeds, notification feeds of who-did-what.',
|
|
1349
|
+
signals: ['feed', 'timeline', 'follow', 'social', 'activity', 'news feed'],
|
|
1350
|
+
notFor: 'Live transport (realtime) or email (notifications).',
|
|
1351
|
+
dependsOn: [],
|
|
1352
|
+
},
|
|
1353
|
+
'vector-search': {
|
|
1354
|
+
whenToUse: 'Semantic/similarity search over content: find-similar, meaning-based search boxes.',
|
|
1355
|
+
signals: ['search', 'semantic', 'similar', 'find by meaning'],
|
|
1356
|
+
notFor: 'Exact filters/sorts over records (the cms query DSL covers those).',
|
|
1357
|
+
dependsOn: [],
|
|
1358
|
+
},
|
|
1359
|
+
ai: {
|
|
1360
|
+
whenToUse: 'Calling LLMs from the app: generate/summarize/classify text, prompt templates, streaming completions (BYO provider key).',
|
|
1361
|
+
signals: ['generate', 'summarize', 'AI', 'GPT', 'classify', 'rewrite', 'draft'],
|
|
1362
|
+
notFor: 'Answers grounded in the tenant’s own documents (rag) or an embedded agent (copilot).',
|
|
1363
|
+
dependsOn: [],
|
|
1364
|
+
},
|
|
1365
|
+
rag: {
|
|
1366
|
+
whenToUse: 'Question-answering grounded in the tenant’s own content with citations: “ask your docs/notes/knowledge base”.',
|
|
1367
|
+
signals: ['ask questions', 'chatbot over', 'knowledge base', 'Q&A', 'answers from documents'],
|
|
1368
|
+
notFor: 'Free-form generation with no grounding (ai).',
|
|
1369
|
+
dependsOn: ['vector-search', 'ai', 'files'],
|
|
1370
|
+
},
|
|
1371
|
+
payments: {
|
|
1372
|
+
whenToUse: 'A payments integration: the tenant connects their OWN Stripe/Paddle/PayPal/RevenueCat account for checkout sessions, subscriptions, entitlements, and usage credits. Funds never touch vxil.',
|
|
1373
|
+
signals: ['pay', 'subscription', 'checkout', 'billing', 'sale', 'order', 'price', 'monetize', 'credits'],
|
|
1374
|
+
notFor: 'Anything implying vxil processes money — it is an integration with the tenant’s own provider.',
|
|
1375
|
+
dependsOn: [],
|
|
1376
|
+
},
|
|
1377
|
+
functions: {
|
|
1378
|
+
whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
|
|
1379
|
+
signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
|
|
1380
|
+
notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
|
|
1381
|
+
dependsOn: [],
|
|
1382
|
+
},
|
|
1383
|
+
copilot: {
|
|
1384
|
+
whenToUse: 'An embeddable in-app AI assistant that retrieves tenant content and proposes/confirms actions against the tenant’s own API.',
|
|
1385
|
+
signals: ['assistant', 'copilot', 'in-app AI helper', 'agent widget'],
|
|
1386
|
+
notFor: 'Plain text generation (ai) or doc Q&A without actions (rag).',
|
|
1387
|
+
dependsOn: ['ai', 'vector-search', 'rag', 'mcp'],
|
|
1388
|
+
},
|
|
1389
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "TypeBox config schemas for every vxil feature — the typed manifests vxil.config.ts is built on. INTERNAL workspace package: bundled into the published `vxil` package (via @vxil/config), not published separately.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vxil.com",
|
|
@@ -11,8 +11,16 @@
|
|
|
11
11
|
},
|
|
12
12
|
"type": "module",
|
|
13
13
|
"exports": {
|
|
14
|
-
".":
|
|
15
|
-
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./hooks": {
|
|
20
|
+
"types": "./dist/hooks.d.ts",
|
|
21
|
+
"import": "./dist/hooks.js",
|
|
22
|
+
"default": "./dist/hooks.js"
|
|
23
|
+
}
|
|
16
24
|
},
|
|
17
25
|
"files": [
|
|
18
26
|
"dist",
|
package/src/index.test.ts
CHANGED
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
CopilotConfigSchema,
|
|
5
5
|
FEATURE_SCHEMAS,
|
|
6
6
|
NotificationsConfigSchema,
|
|
7
|
+
PaymentsConfigSchema,
|
|
8
|
+
RESERVED_CREDIT_TYPES,
|
|
7
9
|
countLeaves,
|
|
10
|
+
isReservedCreditType,
|
|
8
11
|
setKnownMcpTools,
|
|
9
12
|
validateFeatureConfig,
|
|
10
13
|
} from './index.js';
|
|
@@ -14,8 +17,11 @@ import {
|
|
|
14
17
|
import { TOOLS } from '../../../workers/mcp-v1/src/tools.js';
|
|
15
18
|
|
|
16
19
|
describe('15-flag cap analyzer (architecture §6)', () => {
|
|
17
|
-
it('notifications schema has
|
|
18
|
-
|
|
20
|
+
it('notifications schema has 15 leaves — at the cap (retry + broadcast are Optional bags, M21)', () => {
|
|
21
|
+
// enabled, fromEmail, fromName, replyTo, resendApiKeyRef, webhookSecretRef,
|
|
22
|
+
// provider, defaultLocale, retry(bag=1), suppression.softBounceThreshold,
|
|
23
|
+
// rateLimit(2), templates.allowOverride, inboxEnabled, broadcast(bag=1) = 15.
|
|
24
|
+
expect(countLeaves(NotificationsConfigSchema)).toBe(15);
|
|
19
25
|
expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
20
26
|
});
|
|
21
27
|
|
|
@@ -129,6 +135,74 @@ describe('validateFeatureConfig', () => {
|
|
|
129
135
|
expect(r.ok).toBe(false);
|
|
130
136
|
});
|
|
131
137
|
|
|
138
|
+
it('broadcast SURVIVES Value.Clean and persists via the real config path (M21/#4 regression)', () => {
|
|
139
|
+
// The HIGH this guards: `broadcast` used to live only in a worker-local
|
|
140
|
+
// schema, so this exact pipeline (Default → Clean → Check) silently
|
|
141
|
+
// DROPPED it and createCampaign 403'd on every real-config tenant.
|
|
142
|
+
const r = validateFeatureConfig('notifications', {
|
|
143
|
+
...minimal,
|
|
144
|
+
broadcast: { enabled: true },
|
|
145
|
+
});
|
|
146
|
+
expect(r.ok).toBe(true);
|
|
147
|
+
const v = r.value as {
|
|
148
|
+
broadcast?: { enabled: boolean; freqCapPerUserPerDay: number };
|
|
149
|
+
};
|
|
150
|
+
expect(v.broadcast).toBeDefined();
|
|
151
|
+
expect(v.broadcast!.enabled).toBe(true);
|
|
152
|
+
// leaf default materialized inside the present bag
|
|
153
|
+
expect(v.broadcast!.freqCapPerUserPerDay).toBe(5);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('broadcast.defaultQuietHours round-trips; absent broadcast stays absent (defaults-off deny)', () => {
|
|
157
|
+
const withQh = validateFeatureConfig('notifications', {
|
|
158
|
+
...minimal,
|
|
159
|
+
broadcast: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
defaultQuietHours: { tz: 'UTC', start: '22:00', end: '08:00' },
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
expect(withQh.ok).toBe(true);
|
|
165
|
+
const v = withQh.value as {
|
|
166
|
+
broadcast?: { defaultQuietHours?: { tz: string; start: string; end: string } };
|
|
167
|
+
};
|
|
168
|
+
expect(v.broadcast!.defaultQuietHours).toEqual({ tz: 'UTC', start: '22:00', end: '08:00' });
|
|
169
|
+
|
|
170
|
+
const absent = validateFeatureConfig('notifications', minimal);
|
|
171
|
+
expect(absent.ok).toBe(true);
|
|
172
|
+
expect((absent.value as { broadcast?: unknown }).broadcast).toBeUndefined();
|
|
173
|
+
// while retry (Optional bag WITH default:{}) still materializes — the
|
|
174
|
+
// shape-preserving half of the M21 leaf-budget fund
|
|
175
|
+
expect(((absent.value as { retry?: { maxAttempts: number } }).retry ?? {}).maxAttempts).toBe(5);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rejects a quiet-hours window parseHhMm cannot parse (would fail OPEN at run time)', () => {
|
|
179
|
+
const bad = validateFeatureConfig('notifications', {
|
|
180
|
+
...minimal,
|
|
181
|
+
broadcast: {
|
|
182
|
+
enabled: true,
|
|
183
|
+
defaultQuietHours: { tz: 'UTC', start: '10pm', end: '08:00' },
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
expect(bad.ok).toBe(false);
|
|
187
|
+
expect(bad.errors.join(' ')).toContain('start');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rejects a malformed broadcast bag (unknown sub-keys stripped; bad types fail)', () => {
|
|
191
|
+
const badType = validateFeatureConfig('notifications', {
|
|
192
|
+
...minimal,
|
|
193
|
+
broadcast: { enabled: 'yes' },
|
|
194
|
+
});
|
|
195
|
+
expect(badType.ok).toBe(false);
|
|
196
|
+
|
|
197
|
+
const sneaky = validateFeatureConfig('notifications', {
|
|
198
|
+
...minimal,
|
|
199
|
+
broadcast: { enabled: true, maxAudiencePerRun: 100000 },
|
|
200
|
+
});
|
|
201
|
+
expect(sneaky.ok).toBe(true); // unknown key CLEANED, not persisted
|
|
202
|
+
expect(Object.keys((sneaky.value as { broadcast: object }).broadcast))
|
|
203
|
+
.not.toContain('maxAudiencePerRun');
|
|
204
|
+
});
|
|
205
|
+
|
|
132
206
|
it('rejects an unknown feature', () => {
|
|
133
207
|
const r = validateFeatureConfig('telepathy', { enabled: true });
|
|
134
208
|
expect(r.ok).toBe(false);
|
|
@@ -737,3 +811,60 @@ describe('cms-rel config: readModels + cdc (cms-relational-depth §3/§4)', () =
|
|
|
737
811
|
expect(validateFeatureConfig('cms', { cdc: bag }).ok).toBe(false);
|
|
738
812
|
});
|
|
739
813
|
});
|
|
814
|
+
|
|
815
|
+
// ── payments: reserved (vxil-COGS) credit type cannot be mapped in ledger (F2) ─
|
|
816
|
+
// A tenant that maps a product/tier grant to `fn_cpu_ms` and then triggers a
|
|
817
|
+
// webhook/subscription would credit the vxil-COGS meter type through the grant
|
|
818
|
+
// reducers → free vxil-billed functions. Rejected at config-write time so the
|
|
819
|
+
// tenant gets a clear `vxil push` error instead of a silent runtime skip.
|
|
820
|
+
describe('payments: reserved credit_type rejected in ledger grant maps (F2)', () => {
|
|
821
|
+
it('RESERVED_CREDIT_TYPES / isReservedCreditType are the shared SSOT', () => {
|
|
822
|
+
expect(RESERVED_CREDIT_TYPES.has('fn_cpu_ms')).toBe(true);
|
|
823
|
+
expect(isReservedCreditType('fn_cpu_ms')).toBe(true);
|
|
824
|
+
expect(isReservedCreditType('tokens')).toBe(false);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('rejects fn_cpu_ms in productMap.creditType', () => {
|
|
828
|
+
const v = validateFeatureConfig('payments', {
|
|
829
|
+
ledger: {
|
|
830
|
+
productMap: { prod_free_cpu: { creditType: 'fn_cpu_ms', amount: 1000, period: 'once' } },
|
|
831
|
+
tierMap: {},
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
expect(v.ok).toBe(false);
|
|
835
|
+
expect(v.errors.some((e) => e.includes('/ledger/productMap/prod_free_cpu/creditType') && e.includes('reserved'))).toBe(true);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('rejects fn_cpu_ms in a tierMap grant', () => {
|
|
839
|
+
const v = validateFeatureConfig('payments', {
|
|
840
|
+
ledger: {
|
|
841
|
+
productMap: {},
|
|
842
|
+
tierMap: {
|
|
843
|
+
pro: {
|
|
844
|
+
entitlements: [], quotas: {},
|
|
845
|
+
grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' },
|
|
846
|
+
{ creditType: 'fn_cpu_ms', amount: 999_999, period: 'monthly' }],
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
expect(v.ok).toBe(false);
|
|
852
|
+
expect(v.errors.some((e) => e.includes('/ledger/tierMap/pro/grants/1/creditType') && e.includes('reserved'))).toBe(true);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('accepts an ORDINARY credit_type through the same maps (no false-positive)', () => {
|
|
856
|
+
const v = validateFeatureConfig('payments', {
|
|
857
|
+
ledger: {
|
|
858
|
+
productMap: { pack_100: { creditType: 'tokens', amount: 100, period: 'once' } },
|
|
859
|
+
tierMap: { pro: { entitlements: ['pro'], quotas: {}, grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' }] } },
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it('the reserved-type refinement is a VALIDATION rule, not a leaf — cap unaffected', () => {
|
|
866
|
+
// The refinement adds cross-field checks, never a schema property, so the
|
|
867
|
+
// payments leaf count is unchanged and comfortably under the cap.
|
|
868
|
+
expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
869
|
+
});
|
|
870
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,23 @@ if (!FormatRegistry.Has('email')) {
|
|
|
21
21
|
FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth (audit F2) ─────
|
|
25
|
+
// `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
|
|
26
|
+
// functions "recover-by-price" meter). It must NEVER be grantable or consumable
|
|
27
|
+
// by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
|
|
28
|
+
// tenant that self-grants `fn_cpu_ms` (directly, or by routing a product/tier
|
|
29
|
+
// through the webhook/subscription grant reducers) runs vxil-billed function CPU
|
|
30
|
+
// for free. Defined HERE (the typebox-only shared package both the control-plane
|
|
31
|
+
// config-write gate AND payments-v1 import) so the runtime choke point and the
|
|
32
|
+
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
33
|
+
export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
|
|
34
|
+
|
|
35
|
+
/** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
|
|
36
|
+
* of which is restricted to internal platform machinery). */
|
|
37
|
+
export function isReservedCreditType(creditType: string): boolean {
|
|
38
|
+
return RESERVED_CREDIT_TYPES.has(creditType);
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
export const NotificationsConfigSchema = Type.Object({
|
|
25
42
|
enabled: Type.Boolean({ default: true }),
|
|
26
43
|
fromEmail: Type.String({ format: 'email' }),
|
|
@@ -30,14 +47,27 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
30
47
|
// need no email account, so the mock path is zero-config. A cross-field
|
|
31
48
|
// check in validateFeatureConfig requires it only when provider === 'resend'.
|
|
32
49
|
resendApiKeyRef: Type.Optional(Type.String()),
|
|
50
|
+
// Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
|
|
51
|
+
// envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
|
|
52
|
+
// When set, inbound Resend webhooks are verified with THIS tenant's secret
|
|
53
|
+
// instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
|
|
54
|
+
// to the tenant so a signed event for tenant A can never validate at tenant
|
|
55
|
+
// B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
|
|
56
|
+
webhookSecretRef: Type.Optional(Type.String()),
|
|
33
57
|
// 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
|
|
34
58
|
provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
|
|
35
59
|
default: 'resend',
|
|
36
60
|
}),
|
|
37
61
|
defaultLocale: Type.String({ default: 'en-US' }),
|
|
38
62
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
39
|
-
// and then recurse into the leaf defaults
|
|
40
|
-
retry
|
|
63
|
+
// and then recurse into the leaf defaults.
|
|
64
|
+
// `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
|
|
65
|
+
// Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
|
|
66
|
+
// It KEEPS `default: {}`, which Value.Default still materializes — so every
|
|
67
|
+
// persisted manifest carries retry.{maxAttempts,backoff} exactly as before
|
|
68
|
+
// (zero behavioral delta); only the TS type is now optional (workers read
|
|
69
|
+
// via retryOf()'s fallback).
|
|
70
|
+
retry: Type.Optional(Type.Object(
|
|
41
71
|
{
|
|
42
72
|
maxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }),
|
|
43
73
|
backoff: Type.Union([Type.Literal('exponential'), Type.Literal('linear')], {
|
|
@@ -45,7 +75,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
45
75
|
}),
|
|
46
76
|
},
|
|
47
77
|
{ default: {} },
|
|
48
|
-
),
|
|
78
|
+
)),
|
|
49
79
|
suppression: Type.Object(
|
|
50
80
|
{ softBounceThreshold: Type.Integer({ default: 3 }) },
|
|
51
81
|
{ default: {} },
|
|
@@ -63,13 +93,37 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
63
93
|
),
|
|
64
94
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
65
95
|
inboxEnabled: Type.Boolean({ default: false }),
|
|
96
|
+
/** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
97
|
+
* disabled; per-campaign quiet_hours / freq_cap overrides live on the
|
|
98
|
+
* notifications.campaigns ROW (tenant data), not here. Folded into the
|
|
99
|
+
* canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
|
|
100
|
+
* worker-local extension, so campaigns 403'd via the real config path). */
|
|
101
|
+
broadcast: Type.Optional(Type.Object({
|
|
102
|
+
enabled: Type.Boolean({ default: false }),
|
|
103
|
+
/** tenant-default per-recipient quiet window (defer-not-drop); a
|
|
104
|
+
* per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
|
|
105
|
+
* (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
|
|
106
|
+
* pattern-pinned to what the worker's parseHhMm actually parses — a
|
|
107
|
+
* looser string ('10pm') would validate but FAIL OPEN at runtime
|
|
108
|
+
* (quiet window silently ignored). */
|
|
109
|
+
defaultQuietHours: Type.Optional(Type.Object({
|
|
110
|
+
tz: Type.String({ minLength: 1, maxLength: 64 }),
|
|
111
|
+
start: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
|
|
112
|
+
end: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
|
|
113
|
+
})),
|
|
114
|
+
/** default rolling per-user-per-day campaign-send cap across campaigns */
|
|
115
|
+
freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
|
|
116
|
+
})),
|
|
66
117
|
});
|
|
67
|
-
// Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef,
|
|
68
|
-
// defaultLocale, retry
|
|
118
|
+
// Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef,
|
|
119
|
+
// webhookSecretRef, provider, defaultLocale, retry (Optional bag = 1),
|
|
69
120
|
// suppression.softBounceThreshold, rateLimit.perDay, rateLimit.perTenantSec,
|
|
70
|
-
// templates.allowOverride, inboxEnabled
|
|
121
|
+
// templates.allowOverride, inboxEnabled, broadcast (Optional bag = 1) → 15.
|
|
122
|
+
// Cap = 15 — AT the cap; the next flag must collapse something.
|
|
71
123
|
|
|
72
124
|
export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
125
|
+
/** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
126
|
+
export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
|
|
73
127
|
|
|
74
128
|
export const JobsConfigSchema = Type.Object({
|
|
75
129
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -288,7 +342,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
288
342
|
quotas: Type.Object(
|
|
289
343
|
{
|
|
290
344
|
// maximum caps are a defense-in-depth ceiling on tenant-editable storage —
|
|
291
|
-
//
|
|
345
|
+
// object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
|
|
292
346
|
// 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
293
347
|
// (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
|
|
294
348
|
// tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
|
|
@@ -458,7 +512,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
458
512
|
}),
|
|
459
513
|
)),
|
|
460
514
|
// Realtime CDC bridge (cms-rel E): cms writes auto-publish a change event to
|
|
461
|
-
// a realtime channel — config-only rewiring of
|
|
515
|
+
// a realtime channel — config-only rewiring of database-change-event-style subs.
|
|
462
516
|
// Fire-and-forget via waitUntil; at-most-once (guaranteed delivery stays
|
|
463
517
|
// webhooks-out / functions cms-hook). ONE Type.Record leaf.
|
|
464
518
|
cdc: Type.Optional(Type.Record(
|
|
@@ -681,13 +735,13 @@ export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
|
|
|
681
735
|
// synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
|
|
682
736
|
export const VectorSearchConfigSchema = Type.Object({
|
|
683
737
|
enabled: Type.Boolean({ default: true }),
|
|
684
|
-
// 'auto' resolves to
|
|
738
|
+
// 'auto' resolves to the default managed vector backend for the tier (#147).
|
|
685
739
|
backend: Type.Union(
|
|
686
740
|
[Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')],
|
|
687
741
|
{ default: 'auto' },
|
|
688
742
|
),
|
|
689
743
|
// 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
|
|
690
|
-
// BYO key from
|
|
744
|
+
// BYO key from tenant secrets via apiKeyRef (encrypted at rest).
|
|
691
745
|
embed: Type.Object(
|
|
692
746
|
{
|
|
693
747
|
provider: Type.Union(
|
|
@@ -1005,7 +1059,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1005
1059
|
export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
|
1006
1060
|
|
|
1007
1061
|
// functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
|
|
1008
|
-
// functions on
|
|
1062
|
+
// functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
|
|
1009
1063
|
// scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
|
|
1010
1064
|
// bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
|
|
1011
1065
|
// never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
|
|
@@ -1381,8 +1435,14 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1381
1435
|
// 'mock' provider (the deterministic default) stays zero-config so the whole
|
|
1382
1436
|
// ledger path is testable without real keys (features/payments.md §0/§6).
|
|
1383
1437
|
if (feature === 'payments') {
|
|
1384
|
-
const v = withDefaults as {
|
|
1385
|
-
|
|
1438
|
+
const v = withDefaults as {
|
|
1439
|
+
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
1440
|
+
ledger?: {
|
|
1441
|
+
productMap?: Record<string, { creditType?: string }>;
|
|
1442
|
+
tierMap?: Record<string, { grants?: Array<{ creditType?: string }> }>;
|
|
1443
|
+
};
|
|
1444
|
+
};
|
|
1445
|
+
const needsBlock: Record<string, 'stripe' | 'paddle' | 'revenuecat'> = {
|
|
1386
1446
|
stripe: 'stripe', paddle: 'paddle', revenuecat: 'revenuecat',
|
|
1387
1447
|
};
|
|
1388
1448
|
const key = v.provider ? needsBlock[v.provider] : undefined;
|
|
@@ -1392,6 +1452,28 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1392
1452
|
errors: [`/${String(key)}: required when provider is '${v.provider}' (omit it for provider 'mock')`],
|
|
1393
1453
|
};
|
|
1394
1454
|
}
|
|
1455
|
+
// A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
|
|
1456
|
+
// the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
|
|
1457
|
+
// user, running vxil-billed functions for free (audit F2). Rejected at write
|
|
1458
|
+
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
1459
|
+
const ledgerErrs: string[] = [];
|
|
1460
|
+
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
1461
|
+
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
1462
|
+
ledgerErrs.push(
|
|
1463
|
+
`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1468
|
+
(rule.grants ?? []).forEach((g, i) => {
|
|
1469
|
+
if (g.creditType && isReservedCreditType(g.creditType)) {
|
|
1470
|
+
ledgerErrs.push(
|
|
1471
|
+
`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
if (ledgerErrs.length) return { ok: false, errors: ledgerErrs.slice(0, 10) };
|
|
1395
1477
|
}
|
|
1396
1478
|
// Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
|
|
1397
1479
|
// AST-validated against the closed sandbox allow-list HERE, at config-write
|
|
@@ -1570,3 +1652,150 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1570
1652
|
}
|
|
1571
1653
|
return { ok: true, errors: [], value: withDefaults };
|
|
1572
1654
|
}
|
|
1655
|
+
|
|
1656
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1657
|
+
// PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
|
|
1658
|
+
// FEATURE_KEYS is the literal feature list the planner catalog is projected
|
|
1659
|
+
// from; the planner-catalog CI gate asserts set-equality with
|
|
1660
|
+
// Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
|
|
1661
|
+
// list (and FEATURE_HINTS below — a tsc error via the Record key-closure)
|
|
1662
|
+
// turns the gate red. Coverage is derived-and-gated; intent (whenToUse) is
|
|
1663
|
+
// human-authored but gated-for-presence.
|
|
1664
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1665
|
+
export const FEATURE_KEYS = [
|
|
1666
|
+
'notifications', 'jobs', 'auth', 'rate-limits', 'files', 'webhooks',
|
|
1667
|
+
'comments', 'cms', 'mcp', 'realtime', 'presence', 'orgs', 'activity-feed',
|
|
1668
|
+
'vector-search', 'ai', 'rag', 'payments', 'functions', 'copilot',
|
|
1669
|
+
] as const;
|
|
1670
|
+
export type FeatureKey = (typeof FEATURE_KEYS)[number];
|
|
1671
|
+
|
|
1672
|
+
/** The hand-authored "when to use" intent a machine schema cannot convey.
|
|
1673
|
+
* REQUIRED for every feature (Record key-closure): a feature added to
|
|
1674
|
+
* FEATURE_KEYS without a hint is a compile error naming the missing key. */
|
|
1675
|
+
export interface PlannerHint {
|
|
1676
|
+
/** One or two sentences: when an app needs this feature. */
|
|
1677
|
+
whenToUse: string;
|
|
1678
|
+
/** Soft priors — words/needs that suggest the feature (NOT a matcher). */
|
|
1679
|
+
signals: string[];
|
|
1680
|
+
/** When NOT to pick it (disambiguation vs neighbors). */
|
|
1681
|
+
notFor: string;
|
|
1682
|
+
/** Features that must be enabled alongside (runtime substrate deps). */
|
|
1683
|
+
dependsOn: FeatureKey[];
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
|
|
1687
|
+
notifications: {
|
|
1688
|
+
whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
|
|
1689
|
+
signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
|
|
1690
|
+
notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
|
|
1691
|
+
dependsOn: [],
|
|
1692
|
+
},
|
|
1693
|
+
jobs: {
|
|
1694
|
+
whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
|
|
1695
|
+
signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
|
|
1696
|
+
notFor: 'Simple request-response logic that completes inline.',
|
|
1697
|
+
dependsOn: [],
|
|
1698
|
+
},
|
|
1699
|
+
auth: {
|
|
1700
|
+
whenToUse: 'End-users sign in/up to the app: passwords, magic links, OTP, social login, sessions. Almost every app with per-user data wants it.',
|
|
1701
|
+
signals: ['sign in', 'login', 'account', 'register', 'user', 'password', 'profile', 'member', 'seller', 'buyer'],
|
|
1702
|
+
notFor: 'Pure-public read-only content with no user state.',
|
|
1703
|
+
dependsOn: [],
|
|
1704
|
+
},
|
|
1705
|
+
'rate-limits': {
|
|
1706
|
+
whenToUse: 'Throttling abuse-prone or costly operations: public forms, expensive endpoints, per-user quotas.',
|
|
1707
|
+
signals: ['rate limit', 'throttle', 'abuse', 'quota', 'spam'],
|
|
1708
|
+
notFor: 'General correctness — the platform already meters requests globally.',
|
|
1709
|
+
dependsOn: [],
|
|
1710
|
+
},
|
|
1711
|
+
files: {
|
|
1712
|
+
whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
|
|
1713
|
+
signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
|
|
1714
|
+
notFor: 'Structured records (cms) or text content authored in-app.',
|
|
1715
|
+
dependsOn: [],
|
|
1716
|
+
},
|
|
1717
|
+
webhooks: {
|
|
1718
|
+
whenToUse: 'Receiving events FROM external services (Stripe/GitHub/etc. callbacks) or fanning tenant events out TO external URLs.',
|
|
1719
|
+
signals: ['webhook', 'callback', 'external event', 'integration', 'sync with'],
|
|
1720
|
+
notFor: 'In-app eventing between vxil features (the audit bus covers that).',
|
|
1721
|
+
dependsOn: ['jobs'],
|
|
1722
|
+
},
|
|
1723
|
+
comments: {
|
|
1724
|
+
whenToUse: 'Threaded discussion, reviews, replies, or reactions attached to any topic/record; also direct messages between users.',
|
|
1725
|
+
signals: ['comment', 'review', 'discussion', 'reply', 'thread', 'DM', 'message'],
|
|
1726
|
+
notFor: 'A social following timeline (activity-feed) or live chat transport (realtime).',
|
|
1727
|
+
dependsOn: [],
|
|
1728
|
+
},
|
|
1729
|
+
cms: {
|
|
1730
|
+
whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
|
|
1731
|
+
signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
|
|
1732
|
+
notFor: 'End-user identity (auth) or file bytes (files).',
|
|
1733
|
+
dependsOn: [],
|
|
1734
|
+
},
|
|
1735
|
+
mcp: {
|
|
1736
|
+
whenToUse: 'Exposing the tenant backend as typed tools an AI agent drives (Claude/Cursor etc.).',
|
|
1737
|
+
signals: ['agent', 'MCP', 'AI tools', 'tool-calling'],
|
|
1738
|
+
notFor: 'In-app AI text generation (ai) or chat over documents (rag).',
|
|
1739
|
+
dependsOn: [],
|
|
1740
|
+
},
|
|
1741
|
+
realtime: {
|
|
1742
|
+
whenToUse: 'Live in-page updates over channels: chat rooms, live boards, collaborative views, instant refresh when data changes.',
|
|
1743
|
+
signals: ['live', 'realtime', 'chat', 'instantly', 'websocket', 'multiplayer'],
|
|
1744
|
+
notFor: 'Email/inbox notifications (notifications) or historical timelines (activity-feed).',
|
|
1745
|
+
dependsOn: [],
|
|
1746
|
+
},
|
|
1747
|
+
presence: {
|
|
1748
|
+
whenToUse: 'Showing who is online/typing/active on a channel right now.',
|
|
1749
|
+
signals: ['online', 'who is here', 'typing', 'active users', 'presence'],
|
|
1750
|
+
notFor: 'Message delivery itself (realtime carries the messages).',
|
|
1751
|
+
dependsOn: ['realtime'],
|
|
1752
|
+
},
|
|
1753
|
+
orgs: {
|
|
1754
|
+
whenToUse: 'End-users grouped into teams/workspaces with roles and per-resource permissions (multi-member accounts, RBAC).',
|
|
1755
|
+
signals: ['team', 'workspace', 'organization', 'role', 'invite member', 'permission'],
|
|
1756
|
+
notFor: 'Simple per-user ownership (cms ownerField covers that without orgs).',
|
|
1757
|
+
dependsOn: ['auth'],
|
|
1758
|
+
},
|
|
1759
|
+
'activity-feed': {
|
|
1760
|
+
whenToUse: 'Social timelines: follow/unfollow, personal feeds, notification feeds of who-did-what.',
|
|
1761
|
+
signals: ['feed', 'timeline', 'follow', 'social', 'activity', 'news feed'],
|
|
1762
|
+
notFor: 'Live transport (realtime) or email (notifications).',
|
|
1763
|
+
dependsOn: [],
|
|
1764
|
+
},
|
|
1765
|
+
'vector-search': {
|
|
1766
|
+
whenToUse: 'Semantic/similarity search over content: find-similar, meaning-based search boxes.',
|
|
1767
|
+
signals: ['search', 'semantic', 'similar', 'find by meaning'],
|
|
1768
|
+
notFor: 'Exact filters/sorts over records (the cms query DSL covers those).',
|
|
1769
|
+
dependsOn: [],
|
|
1770
|
+
},
|
|
1771
|
+
ai: {
|
|
1772
|
+
whenToUse: 'Calling LLMs from the app: generate/summarize/classify text, prompt templates, streaming completions (BYO provider key).',
|
|
1773
|
+
signals: ['generate', 'summarize', 'AI', 'GPT', 'classify', 'rewrite', 'draft'],
|
|
1774
|
+
notFor: 'Answers grounded in the tenant’s own documents (rag) or an embedded agent (copilot).',
|
|
1775
|
+
dependsOn: [],
|
|
1776
|
+
},
|
|
1777
|
+
rag: {
|
|
1778
|
+
whenToUse: 'Question-answering grounded in the tenant’s own content with citations: “ask your docs/notes/knowledge base”.',
|
|
1779
|
+
signals: ['ask questions', 'chatbot over', 'knowledge base', 'Q&A', 'answers from documents'],
|
|
1780
|
+
notFor: 'Free-form generation with no grounding (ai).',
|
|
1781
|
+
dependsOn: ['vector-search', 'ai', 'files'],
|
|
1782
|
+
},
|
|
1783
|
+
payments: {
|
|
1784
|
+
whenToUse: 'A payments integration: the tenant connects their OWN Stripe/Paddle/PayPal/RevenueCat account for checkout sessions, subscriptions, entitlements, and usage credits. Funds never touch vxil.',
|
|
1785
|
+
signals: ['pay', 'subscription', 'checkout', 'billing', 'sale', 'order', 'price', 'monetize', 'credits'],
|
|
1786
|
+
notFor: 'Anything implying vxil processes money — it is an integration with the tenant’s own provider.',
|
|
1787
|
+
dependsOn: [],
|
|
1788
|
+
},
|
|
1789
|
+
functions: {
|
|
1790
|
+
whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
|
|
1791
|
+
signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
|
|
1792
|
+
notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
|
|
1793
|
+
dependsOn: [],
|
|
1794
|
+
},
|
|
1795
|
+
copilot: {
|
|
1796
|
+
whenToUse: 'An embeddable in-app AI assistant that retrieves tenant content and proposes/confirms actions against the tenant’s own API.',
|
|
1797
|
+
signals: ['assistant', 'copilot', 'in-app AI helper', 'agent widget'],
|
|
1798
|
+
notFor: 'Plain text generation (ai) or doc Q&A without actions (rag).',
|
|
1799
|
+
dependsOn: ['ai', 'vector-search', 'rag', 'mcp'],
|
|
1800
|
+
},
|
|
1801
|
+
};
|