@saasicat/spec 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.
Files changed (30) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +59 -0
  3. package/acceptance/README.md +62 -0
  4. package/acceptance/manifest/full-manifest-requires-super-admin.yaml +43 -0
  5. package/acceptance/manifest/public-boot-no-auth.yaml +40 -0
  6. package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +57 -0
  7. package/acceptance/plan-version/publish-does-not-touch-bestand.yaml +62 -0
  8. package/acceptance/promo/first-time-only-blocks-second-redemption.yaml +40 -0
  9. package/acceptance/tenant/suspend-creates-audit-and-blocks-login.yaml +69 -0
  10. package/admin-api.openapi.yaml +1724 -0
  11. package/cli-conventions.md +158 -0
  12. package/index.cjs +18 -0
  13. package/index.d.cts +13 -0
  14. package/index.d.ts +14 -0
  15. package/index.js +17 -0
  16. package/package.json +63 -0
  17. package/prisma-fragments/01-subscription.prisma +216 -0
  18. package/prisma-fragments/02-promo-code.prisma +145 -0
  19. package/prisma-fragments/03-plan-versions.prisma +94 -0
  20. package/prisma-fragments/04-audit-log.prisma +38 -0
  21. package/prisma-fragments/05-bundle-business-type.prisma +206 -0
  22. package/prisma-fragments/06-catalog-entries.prisma +279 -0
  23. package/prisma-fragments/07-promotion.prisma +65 -0
  24. package/prisma-fragments/08-subscription-contract.prisma +92 -0
  25. package/prisma-fragments/09-pending-registration.prisma +96 -0
  26. package/prisma-fragments/README.md +115 -0
  27. package/schemas/admin-manifest.schema.json +328 -0
  28. package/schemas/audit-event.schema.json +73 -0
  29. package/schemas/plan-catalog.schema.json +166 -0
  30. package/schemas/promo-code.schema.json +214 -0
@@ -0,0 +1,92 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: SubscriptionContract + ContractLineItem
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — not a standalone schema. Consumers copy the models into
6
+ // their `schema.prisma` and add FK relations to Tenant/User.
7
+ //
8
+ // V3 rule: billing and entitlement must not depend on live-mutable catalog
9
+ // tables. `SubscriptionContract` and `ContractLineItem` therefore store full
10
+ // snapshots. Catalog FKs are optional and only audit/trace references.
11
+
12
+ enum SubscriptionContractStatus {
13
+ active
14
+ scheduled
15
+ terminated
16
+ superseded
17
+ }
18
+
19
+ enum ContractLineItemKind {
20
+ plan
21
+ bundle
22
+ quota
23
+ feature
24
+ discount
25
+ }
26
+
27
+ model SubscriptionContract {
28
+ id String @id @default(uuid())
29
+ projectKey String
30
+ tenantId String
31
+
32
+ status SubscriptionContractStatus @default(active)
33
+ effectiveFrom DateTime
34
+ effectiveUntil DateTime?
35
+
36
+ // Optional trace back to the offer/catalog. The contract stays valid even
37
+ // when these target objects are archived or physically deleted.
38
+ originalOfferId String?
39
+ originalPlanVersionId String?
40
+ originalBundleVersionIds Json @default("[]")
41
+
42
+ // Full snapshots for runtime, billing, and later display.
43
+ entitlementSnapshot Json?
44
+ priceSnapshot Json
45
+ promotionSnapshots Json @default("[]")
46
+ promoCodeSnapshots Json @default("[]")
47
+ termsSnapshot Json?
48
+
49
+ lineItems ContractLineItem[]
50
+
51
+ createdAt DateTime @default(now())
52
+ updatedAt DateTime @updatedAt
53
+
54
+ // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
55
+
56
+ @@index([tenantId, status, effectiveFrom])
57
+ @@index([projectKey, status])
58
+ @@index([originalOfferId])
59
+ @@map("subscription_contracts")
60
+ }
61
+
62
+ model ContractLineItem {
63
+ id String @id @default(uuid())
64
+ contractId String
65
+
66
+ kind ContractLineItemKind
67
+ sourceKey String
68
+ sourceVersionId String?
69
+
70
+ titleSnapshot String
71
+ descriptionSnapshot String?
72
+
73
+ quantity Int @default(1)
74
+ unit String?
75
+ priceNet Decimal @db.Decimal(10, 2)
76
+ priceGross Decimal @db.Decimal(10, 2)
77
+ billingCycle String // monthly | yearly
78
+
79
+ minimumTermUntil DateTime?
80
+
81
+ featuresSnapshot Json @default("[]")
82
+ quotaEffectsSnapshot Json @default("{}")
83
+ metadata Json?
84
+
85
+ createdAt DateTime @default(now())
86
+
87
+ contract SubscriptionContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
88
+
89
+ @@index([contractId, kind])
90
+ @@index([sourceVersionId])
91
+ @@map("contract_line_items")
92
+ }
@@ -0,0 +1,96 @@
1
+ // =============================================================================
2
+ // SaaS Platform Prisma fragment: PendingRegistration + PaymentEventLog
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // Persistence of the multi-step registration and onboarding flow
8
+ // (`PendingRegistrationService`). A PendingRegistration holds the
9
+ // intermediate state between step 1 (capturing sign-up data) and the final
10
+ // activation (step 4: payment). Only after successful payment does it become
11
+ // User + Tenant + Subscription — until then the record deliberately stays
12
+ // without a foreign key to consumer models.
13
+ //
14
+ // Unlike the other fragments, WITHOUT `@@map`: the model was adopted back from
15
+ // the reference implementation, whose tables already live under the Prisma
16
+ // default names — a subsequent `@@map` would only force a table rename.
17
+ //
18
+ // Contract: saas-platform-types/registration.types.ts (PendingRegistration,
19
+ // PendingRegistrationRepository, PaymentEventLog).
20
+ // Service logic: saas-platform-nest/registration/PendingRegistrationService.
21
+
22
+ // Multi-step registration flow: status of a PendingRegistration
23
+ // between sign-up-data capture (step 1) and final activation
24
+ // after payment (step 4).
25
+ enum RegistrationStatus {
26
+ PENDING_EMAIL_VERIFICATION
27
+ EMAIL_VERIFIED
28
+ PLAN_SELECTED
29
+ CHECKOUT_STARTED
30
+ EXPIRED
31
+ DELETED
32
+ }
33
+
34
+ model PendingRegistration {
35
+ id String @id @default(cuid())
36
+
37
+ tenantName String
38
+ tenantSlug String?
39
+ salutation String?
40
+ firstName String
41
+ lastName String
42
+ email String @unique
43
+ passwordHash String
44
+ locale String @default("de")
45
+
46
+ status RegistrationStatus @default(PENDING_EMAIL_VERIFICATION)
47
+ currentStep Int @default(2)
48
+
49
+ emailVerifiedAt DateTime?
50
+
51
+ otpHash String?
52
+ otpExpiresAt DateTime?
53
+ otpSendCount Int @default(1)
54
+ lastOtpSentAt DateTime?
55
+ // Persistent failed-attempt counter of the OTP verification. From
56
+ // OTP_VERIFY_MAX_ATTEMPTS on, verifyOtp() throws OTP_LOCKED — even with
57
+ // a correct code afterwards. A newly generated OTP resets it to 0
58
+ // (sending is rate-limited separately via otpSendCount).
59
+ otpAttemptCount Int @default(0)
60
+
61
+ selectedPlanId String?
62
+ // Configurator selection (Step 3):
63
+ // - configJson: JSON snapshot of the RegistrationConfigSelection.
64
+ // - billingCycle: 'MONTHLY' | 'YEARLY' — determines the price calculation.
65
+ // - appliedPromoCode: last-applied code (plaintext, only for UI display;
66
+ // validation runs fresh every time via PromoCodesService).
67
+ configJson Json?
68
+ billingCycle String?
69
+ appliedPromoCode String?
70
+ checkoutSessionId String?
71
+ checkoutStartedAt DateTime?
72
+
73
+ expiresAt DateTime
74
+ createdAt DateTime @default(now())
75
+ updatedAt DateTime @updatedAt
76
+
77
+ @@index([status, expiresAt])
78
+ @@index([tenantSlug])
79
+ }
80
+
81
+ // PaymentEventLog — idempotency key for payment webhooks.
82
+ // Prevents a doubly delivered webhook (Stripe at-least-once) from triggering
83
+ // the final activation more than once. `eventId` is the provider event ID
84
+ // (Stripe `event.id`), `tryClaim` uses a @unique INSERT as race protection.
85
+ model PaymentEventLog {
86
+ id String @id @default(cuid())
87
+ eventId String @unique
88
+ provider String
89
+ sessionId String?
90
+ status String
91
+ payload Json?
92
+ processedAt DateTime @default(now())
93
+
94
+ @@index([sessionId])
95
+ @@index([status, processedAt])
96
+ }
@@ -0,0 +1,115 @@
1
+ ---
2
+ doc_title: SaaS Platform Prisma Fragments
3
+ status: living document
4
+ related:
5
+ - ../schemas/plan-catalog.schema.json
6
+ - ../schemas/promo-code.schema.json
7
+ - ../schemas/audit-event.schema.json
8
+ - ../../saas-platform-types/src/subscription.types.ts
9
+ ---
10
+
11
+ # Prisma Fragments
12
+
13
+ Reference snippets that document the **canonical database schema** of the SaaS
14
+ platform. Consumer apps copy the models into their own `schema.prisma` and add
15
+ FK relations to their project-specific `Tenant`/`User` models.
16
+
17
+ ## Files
18
+
19
+ | File | Models |
20
+ | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
21
+ | [`01-subscription.prisma`](01-subscription.prisma) | `Subscription`, `SubscriptionPaymentMethod`, `CheckoutOffer` + Enums |
22
+ | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeValidationLog` + Enums |
23
+ | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
24
+ | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
25
+ | [`05-bundle-business-type.prisma`](05-bundle-business-type.prisma) | `Bundle`, `BundleVersion`, `BusinessType`, `BusinessTypeVersion`, `BusinessTypeBundle` |
26
+ | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | `CapabilityCatalogEntry`, `FeatureCatalogEntry`, `MarketingProjection` |
27
+ | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
28
+ | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
29
+ | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration`, `PaymentEventLog` + `RegistrationStatus` |
30
+
31
+ ## How the consumer uses the fragments
32
+
33
+ Prisma does **not** support real schema merging — there is no include mechanism.
34
+ Consumers must add the models into their own `schema.prisma`. There are two
35
+ pragmatic approaches:
36
+
37
+ ### Variant A — Copy-Paste (recommended)
38
+
39
+ 1. Add the required models from the fragments into your own `schema.prisma`.
40
+ 2. Enable the commented-out FK relations to consumer models (`Tenant`, `User`)
41
+ and adapt them to your own model names.
42
+ 3. Plan/feature keys stay as `String` — the source of truth is the set of plans
43
+ maintained in the SuperAdmin UI (DB) and the feature/quota catalog published
44
+ via Discovery.
45
+
46
+ ### Variant B — Schema stitching via codegen
47
+
48
+ Tools such as [`prisma-import`](https://github.com/ajmnz/prisma-import) allow
49
+ include directives; they generate a merged `schema.prisma`. For today's
50
+ single-repo consumers, Variant A is simpler.
51
+
52
+ ## Conventions
53
+
54
+ ### 1. Keys are strings, not enums
55
+
56
+ `plan` (`Subscription.plan`, `Subscription.pendingPlan`, …) and
57
+ `featureKey` are declared as `String`. The source of truth is the plan master
58
+ records (`plans` table, maintained in the SuperAdmin UI) and the feature catalog
59
+ (`feature_catalog_entries`).
60
+
61
+ If you prefer **Postgres enums**: declare an enum locally and cast the field
62
+ via `@db.<EnumName>`. Not a platform requirement — the platform services only
63
+ read strings.
64
+
65
+ ### 2. FKs to consumer models are documented but commented out
66
+
67
+ Fields such as `tenantId String` and `userId String?` remain as plain
68
+ string columns in the fragments; the corresponding `@relation` is left as a
69
+ comment. The consumer enables them using their own `Tenant`/`User` model names.
70
+
71
+ ### 3. Table names (`@@map`) are canonical
72
+
73
+ `subscriptions`, `plan_versions`, `promo_codes`, `promo_code_redemptions`,
74
+ `promo_code_validation_logs`, `audit_logs`, `bundles`, `bundle_versions`,
75
+ `business_types`, `business_type_versions`, `business_type_bundles`,
76
+ `capability_catalog_entries`, `feature_catalog_entries`,
77
+ `marketing_projections`, `subscription_contracts`, `contract_line_items`.
78
+ Please do **not change** them — otherwise platform migration scripts and the
79
+ `@saasicat/cli` commands that rely on these names will break.
80
+
81
+ ### 4. Decimal precision
82
+
83
+ All monetary amounts are `Decimal(10, 2)` (max ±99,999,999.99 €), promo-code
84
+ values are `Decimal(8, 2)` (percentage or amount). Consumers should not relax
85
+ this precision.
86
+
87
+ ### 5. Partial unique index for drafts
88
+
89
+ `PlanVersion`, `BundleVersion`
90
+ and `BusinessTypeVersion` allow **exactly one** draft per
91
+ identity key (`publishedAt IS NULL`). The Prisma schema cannot express this —
92
+ add it in the SQL migration:
93
+
94
+ ```sql
95
+ CREATE UNIQUE INDEX plan_versions_draft_per_plan
96
+ ON plan_versions (plan_id) WHERE published_at IS NULL;
97
+ ```
98
+
99
+ Likewise for:
100
+
101
+ - `bundle_versions` (per `bundle_id`)
102
+ - `business_type_versions` (per `business_type_id`)
103
+
104
+ ## Design decisions
105
+
106
+ - **No fixed quota columns** (`maxUsers`, `maxStorageGb`, …) — limits
107
+ live generically in `quotas Json`; the allowed keys are declared in code
108
+ via `@DefinesQuota`.
109
+ - **No `SubscriptionPlan`/`FeatureKey` enums** — both fields are declared
110
+ as `String` (see Convention 1).
111
+ - **No add-on tables (#49)** — `subscription_addons`,
112
+ `unit_addon_versions`, `feature_addon_versions` are not a
113
+ sales surface; only plan versions + bundles are sold.
114
+ - **App-specific tables** (e.g. invoice or bank master data)
115
+ belong in the schema of the consuming app, not in the platform.
@@ -0,0 +1,328 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://saasicat.dev/schemas/admin-manifest.schema.json",
4
+ "title": "AdminManifest",
5
+ "description": "UI discovery projection of a SaaS app. Served by the app backend under GET /api/v1/admin/manifest and consumed by the shared UI shell.",
6
+ "type": "object",
7
+ "required": [
8
+ "schemaVersion",
9
+ "project",
10
+ "build",
11
+ "planCatalogSnapshot",
12
+ "capabilities",
13
+ "navigation"
14
+ ],
15
+ "additionalProperties": false,
16
+ "properties": {
17
+ "schemaVersion": {
18
+ "type": "integer",
19
+ "const": 1
20
+ },
21
+
22
+ "project": {
23
+ "type": "object",
24
+ "required": ["key", "displayName"],
25
+ "additionalProperties": false,
26
+ "properties": {
27
+ "key": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,30}$" },
28
+ "displayName": { "type": "string", "minLength": 1, "maxLength": 60 },
29
+ "label": {
30
+ "type": "string",
31
+ "minLength": 1,
32
+ "maxLength": 60,
33
+ "description": "Tag/subtitle (e.g. \"SuperAdmin\"). From saas.yaml#app.label."
34
+ },
35
+ "icon": {
36
+ "type": "string",
37
+ "minLength": 1,
38
+ "maxLength": 8,
39
+ "description": "Short abbreviation for the logo badge (e.g. \"ma\", \"da\"). From saas.yaml#app.icon."
40
+ },
41
+ "logoUrl": { "type": "string" },
42
+ "environment": {
43
+ "type": "string",
44
+ "enum": ["production", "staging", "development"]
45
+ },
46
+ "availableLocales": {
47
+ "type": "array",
48
+ "items": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
49
+ "minItems": 1,
50
+ "uniqueItems": true,
51
+ "description": "Locales allowed by the app catalog (saas.yaml marketing.availableLocales). First = default. SPEC_V2 §6.5."
52
+ },
53
+ "defaultLocale": {
54
+ "type": "string",
55
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$",
56
+ "description": "Default locale; corresponds to availableLocales[0]."
57
+ }
58
+ }
59
+ },
60
+
61
+ "build": {
62
+ "type": "object",
63
+ "required": ["platformPackageVersion", "appVersion", "manifestHash"],
64
+ "additionalProperties": false,
65
+ "properties": {
66
+ "platformPackageVersion": { "type": "string" },
67
+ "appVersion": { "type": "string" },
68
+ "manifestHash": { "type": "string", "pattern": "^sha256-[A-Za-z0-9+/=_-]{32,}$" }
69
+ }
70
+ },
71
+
72
+ "planCatalogSnapshot": {
73
+ "type": "object",
74
+ "required": ["source", "hash", "currency", "vatRate", "plans"],
75
+ "additionalProperties": false,
76
+ "properties": {
77
+ "source": {
78
+ "type": "string",
79
+ "description": "Path to the source, e.g. 'config/saas.yaml'"
80
+ },
81
+ "hash": { "type": "string", "pattern": "^sha256-[A-Za-z0-9+/=_-]{32,}$" },
82
+ "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
83
+ "vatRate": { "type": "number", "minimum": 0, "maximum": 100 },
84
+ "features": {
85
+ "type": "array",
86
+ "items": {
87
+ "type": "object",
88
+ "required": ["key"],
89
+ "additionalProperties": false,
90
+ "properties": {
91
+ "key": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" },
92
+ "label": { "type": "string" },
93
+ "tier": { "type": "string" }
94
+ }
95
+ }
96
+ },
97
+ "plans": {
98
+ "type": "array",
99
+ "items": { "$ref": "#/$defs/PlanDef" },
100
+ "minItems": 1
101
+ }
102
+ }
103
+ },
104
+
105
+ "capabilities": {
106
+ "type": "object",
107
+ "description": "Backend capabilities. Naming convention: domain.action or domain.resource.action. Values are boolean (true = active in the build).",
108
+ "patternProperties": {
109
+ "^[a-z][a-zA-Z0-9]*\\.[a-z][a-zA-Z0-9]*(?:\\.[a-z][a-zA-Z0-9]*)?$": {
110
+ "type": "boolean"
111
+ }
112
+ },
113
+ "additionalProperties": false
114
+ },
115
+
116
+ "navigation": {
117
+ "type": "object",
118
+ "required": ["standardPages"],
119
+ "additionalProperties": false,
120
+ "properties": {
121
+ "standardPages": {
122
+ "type": "object",
123
+ "additionalProperties": false,
124
+ "properties": {
125
+ "dashboard": { "$ref": "#/$defs/StandardPageDef" },
126
+ "tenants": { "$ref": "#/$defs/StandardPageDef" },
127
+ "subscriptions": { "$ref": "#/$defs/StandardPageDef" },
128
+ "promoCodes": { "$ref": "#/$defs/StandardPageDef" },
129
+ "plans": { "$ref": "#/$defs/StandardPageDef" },
130
+ "planVersions": { "$ref": "#/$defs/StandardPageDef" },
131
+ "audit": { "$ref": "#/$defs/StandardPageDef" },
132
+ "users": { "$ref": "#/$defs/StandardPageDef" },
133
+ "pilots": { "$ref": "#/$defs/StandardPageDef" },
134
+ "discovery": { "$ref": "#/$defs/StandardPageDef" },
135
+ "bundles": { "$ref": "#/$defs/StandardPageDef" },
136
+ "businessTypes": { "$ref": "#/$defs/StandardPageDef" },
137
+ "marketingCatalog": { "$ref": "#/$defs/StandardPageDef" },
138
+ "platformEmail": { "$ref": "#/$defs/StandardPageDef" },
139
+ "platformEmailHistory": { "$ref": "#/$defs/StandardPageDef" }
140
+ }
141
+ },
142
+ "projectPages": {
143
+ "type": "array",
144
+ "items": { "$ref": "#/$defs/ProjectPageDef" }
145
+ }
146
+ }
147
+ },
148
+
149
+ "dashboard": {
150
+ "type": "object",
151
+ "additionalProperties": false,
152
+ "properties": {
153
+ "kpiCards": {
154
+ "type": "array",
155
+ "items": { "$ref": "#/$defs/KpiCardDef" }
156
+ }
157
+ }
158
+ },
159
+
160
+ "tenants": {
161
+ "type": "object",
162
+ "additionalProperties": false,
163
+ "properties": {
164
+ "columns": {
165
+ "type": "array",
166
+ "items": { "$ref": "#/$defs/TenantColumnDef" }
167
+ },
168
+ "actions": {
169
+ "type": "array",
170
+ "items": { "$ref": "#/$defs/TenantActionDef" }
171
+ }
172
+ }
173
+ },
174
+
175
+ "audit": {
176
+ "type": "object",
177
+ "additionalProperties": false,
178
+ "properties": {
179
+ "actions": {
180
+ "type": "array",
181
+ "items": {
182
+ "type": "object",
183
+ "required": ["key", "label"],
184
+ "additionalProperties": false,
185
+ "properties": {
186
+ "key": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" },
187
+ "label": { "type": "string" },
188
+ "severity": {
189
+ "type": "string",
190
+ "enum": ["info", "low", "medium", "high"]
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ },
198
+
199
+ "$defs": {
200
+ "CapabilityKey": {
201
+ "type": "string",
202
+ "pattern": "^[a-z][a-zA-Z0-9]*\\.[a-z][a-zA-Z0-9]*(?:\\.[a-z][a-zA-Z0-9]*)?$"
203
+ },
204
+
205
+ "StandardPageDef": {
206
+ "type": "object",
207
+ "required": ["enabled"],
208
+ "additionalProperties": false,
209
+ "properties": {
210
+ "enabled": { "type": "boolean" },
211
+ "requiredCapability": { "$ref": "#/$defs/CapabilityKey" }
212
+ }
213
+ },
214
+
215
+ "ProjectPageDef": {
216
+ "type": "object",
217
+ "required": ["id", "label", "route", "componentKey"],
218
+ "additionalProperties": false,
219
+ "properties": {
220
+ "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]+\\.[a-z][a-zA-Z0-9-]*$" },
221
+ "label": { "type": "string", "minLength": 1 },
222
+ "icon": { "type": "string" },
223
+ "route": { "type": "string", "pattern": "^/[A-Za-z0-9-/]+$" },
224
+ "navSection": { "type": "string" },
225
+ "componentKey": {
226
+ "type": "string",
227
+ "pattern": "^[a-z][a-z0-9-]+\\.[a-z][a-zA-Z0-9-]*$"
228
+ },
229
+ "requiredCapability": { "$ref": "#/$defs/CapabilityKey" },
230
+ "prefetchOnIdle": { "type": "boolean", "default": false }
231
+ }
232
+ },
233
+
234
+ "KpiCardDef": {
235
+ "type": "object",
236
+ "required": ["id", "label", "endpoint", "displayHint"],
237
+ "additionalProperties": false,
238
+ "properties": {
239
+ "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]+\\.[a-z][a-zA-Z0-9.-]*$" },
240
+ "label": { "type": "string", "minLength": 1 },
241
+ "endpoint": {
242
+ "type": "string",
243
+ "pattern": "^/api(/v[0-9]+)?/admin/(extras|dashboard)/"
244
+ },
245
+ "displayHint": {
246
+ "type": "object",
247
+ "required": ["type"],
248
+ "additionalProperties": false,
249
+ "properties": {
250
+ "type": {
251
+ "type": "string",
252
+ "enum": ["value", "value+timestamp", "value+spark8w", "value+delta"]
253
+ },
254
+ "icon": { "type": "string" }
255
+ }
256
+ },
257
+ "slotPriority": { "type": "integer", "minimum": 0, "maximum": 100 },
258
+ "requiredCapability": { "$ref": "#/$defs/CapabilityKey" }
259
+ }
260
+ },
261
+
262
+ "TenantColumnDef": {
263
+ "type": "object",
264
+ "required": ["key", "label", "endpoint"],
265
+ "additionalProperties": false,
266
+ "properties": {
267
+ "key": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" },
268
+ "label": { "type": "string", "minLength": 1 },
269
+ "endpoint": {
270
+ "type": "string",
271
+ "pattern": "^/api(/v[0-9]+)?/admin/extras/[A-Za-z0-9/-]+$",
272
+ "not": { "pattern": "\\{slug\\}|\\{tenantId\\}" },
273
+ "description": "Required: batch-capable endpoint, no tenant slug in the path. UI calls it with ?tenantIds=... See SPEC §4.4.1."
274
+ },
275
+ "requiredCapability": { "$ref": "#/$defs/CapabilityKey" }
276
+ }
277
+ },
278
+
279
+ "TenantActionDef": {
280
+ "type": "object",
281
+ "required": ["id", "label", "actionKey"],
282
+ "additionalProperties": false,
283
+ "properties": {
284
+ "id": {
285
+ "type": "string",
286
+ "pattern": "^[a-z][a-z0-9-]+(?:\\.[a-z][a-zA-Z0-9-]*)+$"
287
+ },
288
+ "label": { "type": "string", "minLength": 1 },
289
+ "actionKey": {
290
+ "$ref": "#/$defs/CapabilityKey",
291
+ "description": "Lookup in the frontend action registry. NO endpoint/method here."
292
+ },
293
+ "requiredCapability": { "$ref": "#/$defs/CapabilityKey" },
294
+ "requiresMfa": { "type": "boolean" },
295
+ "confirmType": {
296
+ "type": "string",
297
+ "enum": ["none", "simple", "typed-slug", "typed-production", "date"]
298
+ }
299
+ }
300
+ },
301
+
302
+ "PlanDef": {
303
+ "type": "object",
304
+ "required": ["id", "quotas", "features"],
305
+ "additionalProperties": false,
306
+ "properties": {
307
+ "id": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" },
308
+ "name": { "type": "string" },
309
+ "tagline": { "type": "string" },
310
+ "marketed": { "type": "boolean", "default": true },
311
+ "popular": { "type": "boolean", "default": false },
312
+ "monthlyNet": { "type": ["number", "null"], "minimum": 0 },
313
+ "yearlyNet": { "type": ["number", "null"], "minimum": 0 },
314
+ "quotas": {
315
+ "type": "object",
316
+ "patternProperties": {
317
+ "^[a-z][A-Za-z0-9]*$": { "type": "integer" }
318
+ },
319
+ "additionalProperties": false
320
+ },
321
+ "features": {
322
+ "type": "array",
323
+ "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }