@endora-commerce/contracts 0.100.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/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/actor.d.ts +79 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +41 -0
- package/dist/actor.js.map +1 -0
- package/dist/addresses.d.ts +134 -0
- package/dist/addresses.d.ts.map +1 -0
- package/dist/addresses.js +16 -0
- package/dist/addresses.js.map +1 -0
- package/dist/admin-actions.d.ts +367 -0
- package/dist/admin-actions.d.ts.map +1 -0
- package/dist/admin-actions.js +287 -0
- package/dist/admin-actions.js.map +1 -0
- package/dist/admin-contributions.d.ts +518 -0
- package/dist/admin-contributions.d.ts.map +1 -0
- package/dist/admin-contributions.js +495 -0
- package/dist/admin-contributions.js.map +1 -0
- package/dist/admin-i18n.d.ts +135 -0
- package/dist/admin-i18n.d.ts.map +1 -0
- package/dist/admin-i18n.js +72 -0
- package/dist/admin-i18n.js.map +1 -0
- package/dist/admin-notifications.d.ts +55 -0
- package/dist/admin-notifications.d.ts.map +1 -0
- package/dist/admin-notifications.js +16 -0
- package/dist/admin-notifications.js.map +1 -0
- package/dist/admin-roles.d.ts +125 -0
- package/dist/admin-roles.d.ts.map +1 -0
- package/dist/admin-roles.js +2 -0
- package/dist/admin-roles.js.map +1 -0
- package/dist/admin-users.d.ts +178 -0
- package/dist/admin-users.d.ts.map +1 -0
- package/dist/admin-users.js +14 -0
- package/dist/admin-users.js.map +1 -0
- package/dist/admin.d.ts +243 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +246 -0
- package/dist/admin.js.map +1 -0
- package/dist/analytics.d.ts +123 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +68 -0
- package/dist/analytics.js.map +1 -0
- package/dist/api-keys.d.ts +97 -0
- package/dist/api-keys.d.ts.map +1 -0
- package/dist/api-keys.js +64 -0
- package/dist/api-keys.js.map +1 -0
- package/dist/assets-library.d.ts +684 -0
- package/dist/assets-library.d.ts.map +1 -0
- package/dist/assets-library.js +181 -0
- package/dist/assets-library.js.map +1 -0
- package/dist/audit-logs.d.ts +141 -0
- package/dist/audit-logs.d.ts.map +1 -0
- package/dist/audit-logs.js +31 -0
- package/dist/audit-logs.js.map +1 -0
- package/dist/auth.d.ts +174 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +27 -0
- package/dist/auth.js.map +1 -0
- package/dist/blog.d.ts +669 -0
- package/dist/blog.d.ts.map +1 -0
- package/dist/blog.js +360 -0
- package/dist/blog.js.map +1 -0
- package/dist/capabilities.d.ts +40 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +38 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/carts.d.ts +1367 -0
- package/dist/carts.d.ts.map +1 -0
- package/dist/carts.js +405 -0
- package/dist/carts.js.map +1 -0
- package/dist/catalog.d.ts +2855 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +1543 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cms.d.ts +872 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +468 -0
- package/dist/cms.js.map +1 -0
- package/dist/common.d.ts +82 -0
- package/dist/common.d.ts.map +1 -0
- package/dist/common.js +72 -0
- package/dist/common.js.map +1 -0
- package/dist/comparisons.d.ts +487 -0
- package/dist/comparisons.d.ts.map +1 -0
- package/dist/comparisons.js +221 -0
- package/dist/comparisons.js.map +1 -0
- package/dist/credentials.d.ts +292 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +142 -0
- package/dist/credentials.js.map +1 -0
- package/dist/credit-limits.d.ts +111 -0
- package/dist/credit-limits.d.ts.map +1 -0
- package/dist/credit-limits.js +35 -0
- package/dist/credit-limits.js.map +1 -0
- package/dist/currencies.d.ts +127 -0
- package/dist/currencies.d.ts.map +1 -0
- package/dist/currencies.js +20 -0
- package/dist/currencies.js.map +1 -0
- package/dist/custom-fields.d.ts +345 -0
- package/dist/custom-fields.d.ts.map +1 -0
- package/dist/custom-fields.js +185 -0
- package/dist/custom-fields.js.map +1 -0
- package/dist/customer-accounts.d.ts +690 -0
- package/dist/customer-accounts.d.ts.map +1 -0
- package/dist/customer-accounts.js +41 -0
- package/dist/customer-accounts.js.map +1 -0
- package/dist/customers.d.ts +305 -0
- package/dist/customers.d.ts.map +1 -0
- package/dist/customers.js +158 -0
- package/dist/customers.js.map +1 -0
- package/dist/dictionary.d.ts +580 -0
- package/dist/dictionary.d.ts.map +1 -0
- package/dist/dictionary.js +297 -0
- package/dist/dictionary.js.map +1 -0
- package/dist/email-address.d.ts +62 -0
- package/dist/email-address.d.ts.map +1 -0
- package/dist/email-address.js +64 -0
- package/dist/email-address.js.map +1 -0
- package/dist/email.d.ts +175 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +45 -0
- package/dist/email.js.map +1 -0
- package/dist/envelopes.d.ts +15 -0
- package/dist/envelopes.d.ts.map +1 -0
- package/dist/envelopes.js +16 -0
- package/dist/envelopes.js.map +1 -0
- package/dist/environment-inputs.d.ts +306 -0
- package/dist/environment-inputs.d.ts.map +1 -0
- package/dist/environment-inputs.js +277 -0
- package/dist/environment-inputs.js.map +1 -0
- package/dist/erp-connector.d.ts +52 -0
- package/dist/erp-connector.d.ts.map +1 -0
- package/dist/erp-connector.js +34 -0
- package/dist/erp-connector.js.map +1 -0
- package/dist/errors.d.ts +455 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +532 -0
- package/dist/errors.js.map +1 -0
- package/dist/google-analytics.d.ts +181 -0
- package/dist/google-analytics.d.ts.map +1 -0
- package/dist/google-analytics.js +176 -0
- package/dist/google-analytics.js.map +1 -0
- package/dist/google-tag-manager.d.ts +111 -0
- package/dist/google-tag-manager.d.ts.map +1 -0
- package/dist/google-tag-manager.js +129 -0
- package/dist/google-tag-manager.js.map +1 -0
- package/dist/i18n.d.ts +69 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +59 -0
- package/dist/i18n.js.map +1 -0
- package/dist/import-export.d.ts +63 -0
- package/dist/import-export.d.ts.map +1 -0
- package/dist/import-export.js +37 -0
- package/dist/import-export.js.map +1 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +673 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +412 -0
- package/dist/inventory.js.map +1 -0
- package/dist/invoice-ledger.d.ts +366 -0
- package/dist/invoice-ledger.d.ts.map +1 -0
- package/dist/invoice-ledger.js +114 -0
- package/dist/invoice-ledger.js.map +1 -0
- package/dist/invoices.d.ts +845 -0
- package/dist/invoices.d.ts.map +1 -0
- package/dist/invoices.js +314 -0
- package/dist/invoices.js.map +1 -0
- package/dist/kernel.d.ts +49 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +19 -0
- package/dist/kernel.js.map +1 -0
- package/dist/languages.d.ts +122 -0
- package/dist/languages.d.ts.map +1 -0
- package/dist/languages.js +24 -0
- package/dist/languages.js.map +1 -0
- package/dist/linkedin-ads.d.ts +167 -0
- package/dist/linkedin-ads.d.ts.map +1 -0
- package/dist/linkedin-ads.js +156 -0
- package/dist/linkedin-ads.js.map +1 -0
- package/dist/megamenu.d.ts +556 -0
- package/dist/megamenu.d.ts.map +1 -0
- package/dist/megamenu.js +186 -0
- package/dist/megamenu.js.map +1 -0
- package/dist/meta-ads.d.ts +126 -0
- package/dist/meta-ads.d.ts.map +1 -0
- package/dist/meta-ads.js +112 -0
- package/dist/meta-ads.js.map +1 -0
- package/dist/mfa.d.ts +274 -0
- package/dist/mfa.d.ts.map +1 -0
- package/dist/mfa.js +187 -0
- package/dist/mfa.js.map +1 -0
- package/dist/modules.d.ts +1706 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +1390 -0
- package/dist/modules.js.map +1 -0
- package/dist/newsletter.d.ts +611 -0
- package/dist/newsletter.d.ts.map +1 -0
- package/dist/newsletter.js +345 -0
- package/dist/newsletter.js.map +1 -0
- package/dist/orders.d.ts +1175 -0
- package/dist/orders.d.ts.map +1 -0
- package/dist/orders.js +630 -0
- package/dist/orders.js.map +1 -0
- package/dist/organizations.d.ts +938 -0
- package/dist/organizations.d.ts.map +1 -0
- package/dist/organizations.js +418 -0
- package/dist/organizations.js.map +1 -0
- package/dist/pagination.d.ts +21 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +22 -0
- package/dist/pagination.js.map +1 -0
- package/dist/payment-methods.d.ts +472 -0
- package/dist/payment-methods.d.ts.map +1 -0
- package/dist/payment-methods.js +175 -0
- package/dist/payment-methods.js.map +1 -0
- package/dist/payment-return-url.d.ts +53 -0
- package/dist/payment-return-url.d.ts.map +1 -0
- package/dist/payment-return-url.js +35 -0
- package/dist/payment-return-url.js.map +1 -0
- package/dist/payments.d.ts +386 -0
- package/dist/payments.d.ts.map +1 -0
- package/dist/payments.js +84 -0
- package/dist/payments.js.map +1 -0
- package/dist/pim-connector.d.ts +60 -0
- package/dist/pim-connector.d.ts.map +1 -0
- package/dist/pim-connector.js +43 -0
- package/dist/pim-connector.js.map +1 -0
- package/dist/pim-field-path.d.ts +6 -0
- package/dist/pim-field-path.d.ts.map +1 -0
- package/dist/pim-field-path.js +101 -0
- package/dist/pim-field-path.js.map +1 -0
- package/dist/platform-language.d.ts +20 -0
- package/dist/platform-language.d.ts.map +1 -0
- package/dist/platform-language.js +22 -0
- package/dist/platform-language.js.map +1 -0
- package/dist/price-lists.d.ts +685 -0
- package/dist/price-lists.d.ts.map +1 -0
- package/dist/price-lists.js +330 -0
- package/dist/price-lists.js.map +1 -0
- package/dist/product-feeds.d.ts +2837 -0
- package/dist/product-feeds.d.ts.map +1 -0
- package/dist/product-feeds.js +1504 -0
- package/dist/product-feeds.js.map +1 -0
- package/dist/product-scope-overrides.d.ts +134 -0
- package/dist/product-scope-overrides.d.ts.map +1 -0
- package/dist/product-scope-overrides.js +82 -0
- package/dist/product-scope-overrides.js.map +1 -0
- package/dist/product-value-resolver.d.ts +88 -0
- package/dist/product-value-resolver.d.ts.map +1 -0
- package/dist/product-value-resolver.js +128 -0
- package/dist/product-value-resolver.js.map +1 -0
- package/dist/promotions.d.ts +678 -0
- package/dist/promotions.d.ts.map +1 -0
- package/dist/promotions.js +479 -0
- package/dist/promotions.js.map +1 -0
- package/dist/prompt-actions.d.ts +582 -0
- package/dist/prompt-actions.d.ts.map +1 -0
- package/dist/prompt-actions.js +221 -0
- package/dist/prompt-actions.js.map +1 -0
- package/dist/pwa.d.ts +293 -0
- package/dist/pwa.d.ts.map +1 -0
- package/dist/pwa.js +204 -0
- package/dist/pwa.js.map +1 -0
- package/dist/quick-order.d.ts +340 -0
- package/dist/quick-order.d.ts.map +1 -0
- package/dist/quick-order.js +177 -0
- package/dist/quick-order.js.map +1 -0
- package/dist/quote-requests.d.ts +538 -0
- package/dist/quote-requests.d.ts.map +1 -0
- package/dist/quote-requests.js +308 -0
- package/dist/quote-requests.js.map +1 -0
- package/dist/returns.d.ts +774 -0
- package/dist/returns.d.ts.map +1 -0
- package/dist/returns.js +389 -0
- package/dist/returns.js.map +1 -0
- package/dist/sales-channels.d.ts +392 -0
- package/dist/sales-channels.d.ts.map +1 -0
- package/dist/sales-channels.js +285 -0
- package/dist/sales-channels.js.map +1 -0
- package/dist/scope-notice.d.ts +60 -0
- package/dist/scope-notice.d.ts.map +1 -0
- package/dist/scope-notice.js +56 -0
- package/dist/scope-notice.js.map +1 -0
- package/dist/search.d.ts +321 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +160 -0
- package/dist/search.js.map +1 -0
- package/dist/seo.d.ts +113 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +63 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings.d.ts +453 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +337 -0
- package/dist/settings.js.map +1 -0
- package/dist/shipments.d.ts +140 -0
- package/dist/shipments.d.ts.map +1 -0
- package/dist/shipments.js +14 -0
- package/dist/shipments.js.map +1 -0
- package/dist/shipping-methods.d.ts +350 -0
- package/dist/shipping-methods.d.ts.map +1 -0
- package/dist/shipping-methods.js +99 -0
- package/dist/shipping-methods.js.map +1 -0
- package/dist/shopping-lists.d.ts +122 -0
- package/dist/shopping-lists.d.ts.map +1 -0
- package/dist/shopping-lists.js +92 -0
- package/dist/shopping-lists.js.map +1 -0
- package/dist/taxes.d.ts +106 -0
- package/dist/taxes.d.ts.map +1 -0
- package/dist/taxes.js +80 -0
- package/dist/taxes.js.map +1 -0
- package/dist/text-normalization.d.ts +199 -0
- package/dist/text-normalization.d.ts.map +1 -0
- package/dist/text-normalization.js +205 -0
- package/dist/text-normalization.js.map +1 -0
- package/dist/transactional-emails.d.ts +459 -0
- package/dist/transactional-emails.d.ts.map +1 -0
- package/dist/transactional-emails.js +212 -0
- package/dist/transactional-emails.js.map +1 -0
- package/dist/webhooks.d.ts +69 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +53 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +46 -0
package/dist/modules.js
ADDED
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
// Module Lifecycle — feature 018 contract surface.
|
|
2
|
+
//
|
|
3
|
+
// Defines the on-disk-data shape every module's `manifest.ts` exports plus
|
|
4
|
+
// the registry-record shape persisted in `module_registrations`. The settings
|
|
5
|
+
// portion (per-module groups + settings) is delegated to feature 004's
|
|
6
|
+
// existing `ModuleSettingsManifestSchema`; this module wraps it with the
|
|
7
|
+
// outer module-level metadata (id, name, version, dependencies) and the
|
|
8
|
+
// lifecycle-hook type aliases.
|
|
9
|
+
//
|
|
10
|
+
// Hooks themselves are NOT validated by Zod (functions don't serialise
|
|
11
|
+
// through schemas); the loader attaches them from the manifest module's
|
|
12
|
+
// runtime exports as a separate step.
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { ModuleSettingsManifestSchema, settingCodeRe, } from './settings.js';
|
|
15
|
+
import { KnownIconNameSchema, ModuleActionsManifestSchema } from './admin-actions.js';
|
|
16
|
+
import { modulePermissionDeclarationSchema } from './admin.js';
|
|
17
|
+
import { errorCodeRe } from './errors.js';
|
|
18
|
+
import { capabilityKeyRe } from './capabilities.js';
|
|
19
|
+
import { transactionalEmailManifestEntrySchema } from './transactional-emails.js';
|
|
20
|
+
import { BlockCategorySchema, BlockDefinitionSchema, blockNameRe } from './cms.js';
|
|
21
|
+
import { EnvironmentInputSchema } from './environment-inputs.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Identifier / version regexes
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Module identifier — must equal the manifest file's parent folder name.
|
|
27
|
+
* Two-character ids are allowed (e.g. `_lifecycle` after underscore allowance).
|
|
28
|
+
* Underscore-prefixed ids are reserved for platform-internal modules
|
|
29
|
+
* (constitutional exemption alongside `auth` and `example`).
|
|
30
|
+
*/
|
|
31
|
+
export const moduleIdRe = /^_?[a-z][a-z0-9_]*$/;
|
|
32
|
+
/** Semver-lite — `MAJOR.MINOR.PATCH` plus an optional `-prerelease` suffix. */
|
|
33
|
+
export const moduleVersionRe = /^\d+\.\d+\.\d+(?:-[a-z0-9.]+)?$/;
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Module manifest
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* Per-module Admin UI translation declaration (feature 019).
|
|
39
|
+
* When present, the lifecycle install hook reads
|
|
40
|
+
* `<modulePath>/<bundlesDir>/<lang>.json` for every supported Admin UI
|
|
41
|
+
* language and registers the bundle into `translation_bundles`. Default
|
|
42
|
+
* `bundlesDir` is `'i18n'` — every module that ships translations is
|
|
43
|
+
* expected to follow this convention.
|
|
44
|
+
*/
|
|
45
|
+
export const ModuleI18nManifestSchema = z.object({
|
|
46
|
+
bundlesDir: z.string().min(1).default('i18n'),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Per-module documentation declaration (feature 100 / roadmap F12).
|
|
50
|
+
*
|
|
51
|
+
* The same shape as {@link ModuleI18nManifestSchema} and for the same reason: a
|
|
52
|
+
* directory at the **package root**, in the package's `files` list, with no
|
|
53
|
+
* `exports` subpath, located by joining `dir` to `dirname(manifestPath)`. The
|
|
54
|
+
* anchor is the platform's, so nothing in the module names a package, a
|
|
55
|
+
* repository root or a build directory in order to find its own pages
|
|
56
|
+
* (`specs/100-module-owned-documentation/contracts/module-documentation-layer.md`
|
|
57
|
+
* R2.1–R2.3).
|
|
58
|
+
*
|
|
59
|
+
* A declared directory that is not on disk is a **refusal**, naming the module —
|
|
60
|
+
* never "this module ships no documentation". That distinction is the whole of
|
|
61
|
+
* the repair `backend/src/manifest-locations.ts` was written for: the `_i18n`
|
|
62
|
+
* boot reconciler logs and skips an absent bundles directory, so a packaged
|
|
63
|
+
* module rendered every palette entry as a raw key with no error anywhere.
|
|
64
|
+
*/
|
|
65
|
+
export const ModuleDocsManifestSchema = z.object({
|
|
66
|
+
/** The directory, relative to the module's own root. */
|
|
67
|
+
dir: z.string().min(1).default('docs'),
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* `docs: false` — this module ships no documentation, deliberately.
|
|
71
|
+
*
|
|
72
|
+
* **Absent and `false` are not the same state**, and the documentation check
|
|
73
|
+
* distinguishes them: absent is a module nobody has decided about, `false` is a
|
|
74
|
+
* decision. The argument is `check:bundle-pairing`'s, one population over — a
|
|
75
|
+
* universal obligation over a population where some members legitimately owe
|
|
76
|
+
* nothing is repaired by empty files whose only effect is to make a check pass.
|
|
77
|
+
* Some modules are infrastructure other modules consume and may honestly
|
|
78
|
+
* document nothing.
|
|
79
|
+
*/
|
|
80
|
+
export const ModuleDocsDeclarationSchema = z.union([ModuleDocsManifestSchema, z.literal(false)]);
|
|
81
|
+
/**
|
|
82
|
+
* A function value the schema accepts by kind.
|
|
83
|
+
*
|
|
84
|
+
* `z.custom` rather than `z.function()`: Zod v4's function schema builds a
|
|
85
|
+
* validating *wrapper*, and this field must pass the author's own closure
|
|
86
|
+
* through by reference — the runner calls it, and a copy would be a second
|
|
87
|
+
* function nothing else in the tree holds.
|
|
88
|
+
*/
|
|
89
|
+
function demoBodySchema(field) {
|
|
90
|
+
return z.custom((value) => typeof value === 'function', {
|
|
91
|
+
message: `demo.${field} must be a function. The body is reached by a relative ` +
|
|
92
|
+
`\`await import()\` from the declaration (contract §1.4), never by a path ` +
|
|
93
|
+
'the platform is expected to guess.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export const ModuleDemoManifestSchema = z.object({
|
|
97
|
+
summary: z.string().min(1).max(200),
|
|
98
|
+
seed: demoBodySchema('seed'),
|
|
99
|
+
reset: demoBodySchema('reset'),
|
|
100
|
+
after: z.array(z.string().regex(moduleIdRe)).readonly().optional(),
|
|
101
|
+
// §6.1. A **name**, so the schema is `z.string()` and deliberately carries no
|
|
102
|
+
// scope or prefix rule: `@endora-commerce/mod-<id>-demo` is this repository's
|
|
103
|
+
// convention and a third-party module's demo package is named by its author.
|
|
104
|
+
// A pattern here would be a derived fact written down (D-100) that answers
|
|
105
|
+
// wrongly for the first package that is not ours.
|
|
106
|
+
package: z.string().min(1).optional(),
|
|
107
|
+
});
|
|
108
|
+
/**
|
|
109
|
+
* `demo: false` — this module has nothing to demonstrate, deliberately.
|
|
110
|
+
*
|
|
111
|
+
* **Absent and `false` are not the same state** (§1.2). It is
|
|
112
|
+
* {@link ModuleDocsDeclarationSchema}'s rule and it exists for the same reason:
|
|
113
|
+
* a universal obligation over a population where some members legitimately owe
|
|
114
|
+
* nothing is repaired by empty files whose only effect is to make a check pass.
|
|
115
|
+
* `pim_connector` and `email` genuinely have nothing to show.
|
|
116
|
+
*/
|
|
117
|
+
export const ModuleDemoDeclarationSchema = z.union([
|
|
118
|
+
ModuleDemoManifestSchema,
|
|
119
|
+
z.literal(false),
|
|
120
|
+
]);
|
|
121
|
+
/**
|
|
122
|
+
* Operator-activation declaration — feature 073, Constitution XVII.
|
|
123
|
+
*
|
|
124
|
+
* The second of the two orthogonal presence axes. Platform availability lives
|
|
125
|
+
* in `module_registrations` and is owned by whoever operates the deployment;
|
|
126
|
+
* this block declares the *business* operator's control, which is an ordinary
|
|
127
|
+
* `Setting` row reconciled from the manifest.
|
|
128
|
+
*
|
|
129
|
+
* It used to sit beside a `license` tier, and the two were kept apart because
|
|
130
|
+
* conflating a build-time entitlement with a runtime toggle would make an
|
|
131
|
+
* operator's switch look like a licensing decision. That tier is gone (D-194
|
|
132
|
+
* removed the edition meta-packages it existed for), so activation is now the
|
|
133
|
+
* only presence declaration a manifest carries.
|
|
134
|
+
*
|
|
135
|
+
* Exactly one of the two forms is valid — enforced in `defineModuleManifest`
|
|
136
|
+
* rather than by the schema, because a Zod union of two non-strict objects
|
|
137
|
+
* accepts a value carrying both.
|
|
138
|
+
*/
|
|
139
|
+
export const ModuleActivationSchema = z.union([
|
|
140
|
+
z.object({
|
|
141
|
+
/**
|
|
142
|
+
* The Setting that holds the operator's choice. Declared rather than
|
|
143
|
+
* derived so a module that already ships an ad-hoc control (`blog.enabled`
|
|
144
|
+
* and friends) can adopt it instead of growing a second switch.
|
|
145
|
+
*/
|
|
146
|
+
settingCode: z.string().regex(settingCodeRe),
|
|
147
|
+
/** Applies when the operator has never chosen. Asserted, never assumed. */
|
|
148
|
+
default: z.boolean(),
|
|
149
|
+
}),
|
|
150
|
+
z.object({
|
|
151
|
+
/** The platform cannot run without this module. */
|
|
152
|
+
nonDeactivatable: z.literal(true),
|
|
153
|
+
/** Operator-facing sentence rendered next to the locked control. */
|
|
154
|
+
reason: z.string().min(1).max(200),
|
|
155
|
+
}),
|
|
156
|
+
]);
|
|
157
|
+
/**
|
|
158
|
+
* A runtime dependency the declaring module deliberately keeps out of
|
|
159
|
+
* `dependencies` — feature 073, Amendment A1.
|
|
160
|
+
*
|
|
161
|
+
* The two are not two spellings of one thing. `dependencies` is read by the
|
|
162
|
+
* **topological install order** (`db/migration-order.ts`, the lifecycle's
|
|
163
|
+
* `ModuleDepGraph`), and a mutual pair declared there closes a cycle that fails
|
|
164
|
+
* the build: `addresses` must install after `organizations` because every
|
|
165
|
+
* stored address is organization-scoped, so `organizations` cannot also declare
|
|
166
|
+
* `addresses`, however real the port edge is. Withholding the declaration used
|
|
167
|
+
* to make the edge invisible to everything else too — the flip-time refusals
|
|
168
|
+
* saw no reason to stop an operator switching the owner off underneath a live
|
|
169
|
+
* resolver.
|
|
170
|
+
*
|
|
171
|
+
* So the edge is declared here instead: **read by the gating and refusal
|
|
172
|
+
* graph, ignored by the install order.** That is the whole trade, stated in the
|
|
173
|
+
* manifest that makes it rather than in a build script's constant, so a
|
|
174
|
+
* refusal and a CI check cannot drift apart on which edges exist.
|
|
175
|
+
*/
|
|
176
|
+
export const ModuleAcknowledgedDependencySchema = z.object({
|
|
177
|
+
/** The module that owns the port. */
|
|
178
|
+
moduleId: z.string().regex(moduleIdRe),
|
|
179
|
+
/** The container registration name this module resolves, e.g. `addressService`. */
|
|
180
|
+
port: z.string().min(1),
|
|
181
|
+
/** Why the edge cannot be declared in `dependencies` — the cycle, spelled out. */
|
|
182
|
+
reason: z.string().min(1).max(800),
|
|
183
|
+
});
|
|
184
|
+
/**
|
|
185
|
+
* An edge that is real to the container but does not bind the operator — D-44.
|
|
186
|
+
*
|
|
187
|
+
* `dependencies` is read as three claims at once: install-and-migration order,
|
|
188
|
+
* "the container resolution is declared", and "an operator may not switch the
|
|
189
|
+
* owner off underneath me". `acknowledgedDependencies` withdraws the first.
|
|
190
|
+
* This withdraws the third, and only the third: a module declares here that it
|
|
191
|
+
* reads a name `moduleId` owns and that it has a defined behaviour when
|
|
192
|
+
* `moduleId` is not there, so the flip-time refusal has nothing to protect.
|
|
193
|
+
*
|
|
194
|
+
* Read by `check-port-dependencies.ts`, which needs the ownership claim and
|
|
195
|
+
* nothing else, and — when the deactivation-consequence dialog ships — by
|
|
196
|
+
* `/platform/modules`, which renders {@link whenAbsent}. Read by **nothing
|
|
197
|
+
* else**: not `ModuleDepGraph`, not `db/migration-order.ts`, and not
|
|
198
|
+
* `ModuleGatingGraph` in either direction. A cross-module foreign key therefore
|
|
199
|
+
* still forces a `dependencies` entry, and `fk-dependency-drift.test.ts` still
|
|
200
|
+
* fails for one declared here instead.
|
|
201
|
+
*
|
|
202
|
+
* The three kinds are the three ways an edge can exist without the bind bit:
|
|
203
|
+
*
|
|
204
|
+
* - **`contributes-to`** — the declaring module pushes an inert descriptor
|
|
205
|
+
* into `moduleId`'s ungated registry at boot. It has no failure mode in
|
|
206
|
+
* either direction: an absent contributor's descriptor is filtered by the
|
|
207
|
+
* host at enumeration, and an absent host's registry is a table nobody
|
|
208
|
+
* walks. `whenAbsent` is forbidden, because nothing degrades.
|
|
209
|
+
* - **`degrades-without`** — the declaring module reads an answer from
|
|
210
|
+
* `moduleId`, checks presence before it does, and keeps working with less.
|
|
211
|
+
* `whenAbsent` is required and states that behaviour, which is what an
|
|
212
|
+
* off-state test for the edge is held to.
|
|
213
|
+
* - **`refuses-without`** — the declaring module reads a **gated port**, has
|
|
214
|
+
* no fallback for it, and lets the 503 `MODULE_DISABLED` refusal reach the
|
|
215
|
+
* caller. The operation stops; the rest of the declaring module keeps
|
|
216
|
+
* working; the owner's activation control keeps working. `whenAbsent` is
|
|
217
|
+
* required and names **what** refuses, because that is the whole payload:
|
|
218
|
+
* the deactivation-consequence ledger classifies the edge `fails-closed`
|
|
219
|
+
* and the operator's confirmation dialog renders this sentence.
|
|
220
|
+
*
|
|
221
|
+
* The third kind was an omission rather than a narrowing, and it is worth
|
|
222
|
+
* saying why, because the gap is invisible from the manifest side. A read with
|
|
223
|
+
* no fallback had only one spelling — `dependencies` (or
|
|
224
|
+
* `acknowledgedDependencies`) — and both carry the bind, so a dependent that
|
|
225
|
+
* cannot itself be switched off turned the *owner's* activation control into a
|
|
226
|
+
* dead switch: the operator flips it, the flip-time refusal names a module
|
|
227
|
+
* that will never go away, and nothing happens. That is a worse answer than
|
|
228
|
+
* either alternative, since a control that lies is not a control. So the
|
|
229
|
+
* missing spelling is "refuse, and do not bind", which is what this kind is;
|
|
230
|
+
* the outcome it produces (`fails-closed`) has been in the ledger's vocabulary
|
|
231
|
+
* since feature 074 and was reachable only for edges that also bound.
|
|
232
|
+
*
|
|
233
|
+
* **The half of the claim about the owner's control is already unspellable**,
|
|
234
|
+
* and it is worth knowing where: rule 2 of `assertNonBindingRules` refuses any
|
|
235
|
+
* non-binding edge whose target the same manifest also names in
|
|
236
|
+
* `dependencies` or `acknowledgedDependencies` — one edge, one claim, in one
|
|
237
|
+
* place. So a `refuses-without` entry cannot sit beside the bind it denies;
|
|
238
|
+
* a module that wants both is telling the operator two things at once and is
|
|
239
|
+
* refused before the ledger ever sees it. `check-port-dependencies.ts` re-
|
|
240
|
+
* derives the same fact from the manifests as a second net, for a manifest
|
|
241
|
+
* built without this helper.
|
|
242
|
+
*
|
|
243
|
+
* The other two halves are the check's alone, because both are properties of
|
|
244
|
+
* the *tree* rather than of the manifest: the name is registered with
|
|
245
|
+
* `di.providePort` (an ungated registration has no refusal to propagate), and
|
|
246
|
+
* the resolution happens at call time (a gated port resolved at boot stops the
|
|
247
|
+
* next start rather than one request — the ledger's
|
|
248
|
+
* `gated-port-before-first-request`, which is assigned before any declaration
|
|
249
|
+
* is consulted and which no entry can therefore rescue).
|
|
250
|
+
*
|
|
251
|
+
* The fourth quadrant — order without bind — stays deliberately unspellable
|
|
252
|
+
* (Constitution IV). An edge that needs both goes back to `dependencies`, and
|
|
253
|
+
* the bind comes back with it.
|
|
254
|
+
*/
|
|
255
|
+
export const ModuleNonBindingDependencySchema = z.object({
|
|
256
|
+
/** The module that owns the registration. */
|
|
257
|
+
moduleId: z.string().regex(moduleIdRe),
|
|
258
|
+
/** The container registration name, e.g. `promptActionToolRegistry`. */
|
|
259
|
+
name: z.string().min(1),
|
|
260
|
+
kind: z.enum(['contributes-to', 'degrades-without', 'refuses-without']),
|
|
261
|
+
/**
|
|
262
|
+
* `degrades-without` and `refuses-without` only: what stops working, and for
|
|
263
|
+
* the second, what refuses. Rendered beside the control.
|
|
264
|
+
*/
|
|
265
|
+
whenAbsent: z.string().min(1).max(200).optional(),
|
|
266
|
+
reason: z.string().min(1).max(800),
|
|
267
|
+
});
|
|
268
|
+
/**
|
|
269
|
+
* One module a deployment knowingly does not ship — D-101's declared escape.
|
|
270
|
+
*
|
|
271
|
+
* A deployment may compose fewer modules than its manifests declare; what it may
|
|
272
|
+
* not do is arrive there silently, so the omission is declared in a committed,
|
|
273
|
+
* reviewed file (`backend/src/apps/<deployment>/divergence.ts`) and the boot
|
|
274
|
+
* refuses an omission that is not in it — or an entry for a module the
|
|
275
|
+
* deployment does ship, which is the same ledger read the other way.
|
|
276
|
+
*
|
|
277
|
+
* This is `ReducedDeploymentDeclaration` under its own name (D-205), and it is
|
|
278
|
+
* unchanged in substance: a module id, and a reason long enough to be an
|
|
279
|
+
* argument. What changed is where it sits — inside
|
|
280
|
+
* {@link DeploymentDivergenceDeclarationSchema}'s `omittedModules`, beside the
|
|
281
|
+
* other two things a deployment declares about itself.
|
|
282
|
+
*/
|
|
283
|
+
export const OmittedModuleSchema = z.object({
|
|
284
|
+
/** The module this deployment does not ship. */
|
|
285
|
+
moduleId: z.string().regex(moduleIdRe),
|
|
286
|
+
/**
|
|
287
|
+
* Why — in prose, and long enough to be an argument. "We do not need it" is
|
|
288
|
+
* not a reason; what the deployment does instead of the capability is.
|
|
289
|
+
*/
|
|
290
|
+
reason: z.string().min(20).max(800),
|
|
291
|
+
});
|
|
292
|
+
/**
|
|
293
|
+
* Everything a deployment declares about how it means to differ from core.
|
|
294
|
+
*
|
|
295
|
+
* `backend/src/apps/<deployment>/divergence.ts`, exporting `divergence`. The
|
|
296
|
+
* file was `reduced-deployment.ts` until it grew past omissions (D-205):
|
|
297
|
+
* *reduced* encodes a direction that is wrong for an addition, wrong for a
|
|
298
|
+
* substitution and wrong for an ordering, while `divergence` is already the
|
|
299
|
+
* word the generator's own header uses for the derived artefact beside it.
|
|
300
|
+
*
|
|
301
|
+
* The shape lives here rather than in `_lifecycle` because the file carrying it
|
|
302
|
+
* belongs to a **deployment**, and a deployment naming a module's internals is
|
|
303
|
+
* the coupling that outlives the module.
|
|
304
|
+
*
|
|
305
|
+
* **It holds judgement, ordering and prose — never population.** The single test
|
|
306
|
+
* for a field is whether the platform can derive it: the deployment's module
|
|
307
|
+
* list is the overlay walk's answer and the divergences themselves are the
|
|
308
|
+
* report's, so neither belongs here
|
|
309
|
+
* (`specs/107-override-report-and-ladder/contracts/deployment-declaration.md` §5).
|
|
310
|
+
*
|
|
311
|
+
* Every field defaults to empty, so a declaration that leaves one out means
|
|
312
|
+
* "none of these" rather than "unparseable" — the reading an absent file already
|
|
313
|
+
* gets. A deployment that diverges by nothing still ships the file with all
|
|
314
|
+
* three written out, because the mechanism is easier to find than to remember.
|
|
315
|
+
*/
|
|
316
|
+
export const DeploymentDivergenceDeclarationSchema = z.object({
|
|
317
|
+
/** The modules this deployment does not ship. D-101, unchanged in substance. */
|
|
318
|
+
omittedModules: z.array(OmittedModuleSchema).default([]),
|
|
319
|
+
/**
|
|
320
|
+
* Wrapping order, per registration name, for a name more than one of this
|
|
321
|
+
* deployment's overlay modules decorates — innermost first.
|
|
322
|
+
*
|
|
323
|
+
* **Checked, never applied.** The composer emits modules in its own order and
|
|
324
|
+
* drains decorations once; this declares that the resulting order was the
|
|
325
|
+
* intended one, and a composition that disagrees refuses. Making the
|
|
326
|
+
* declaration authoritative would put a hand-written array in front of the
|
|
327
|
+
* composer's topological emission, which is two orderings of one thing waiting
|
|
328
|
+
* to disagree.
|
|
329
|
+
*
|
|
330
|
+
* Only a deployment's own overlay modules can appear here: a core module and
|
|
331
|
+
* an installed package may not decorate a name they do not own (D-156.4), so
|
|
332
|
+
* every ambiguity this can resolve is between two of them.
|
|
333
|
+
*
|
|
334
|
+
* Nothing reads it yet — the supply is P4 of
|
|
335
|
+
* `specs/107-override-report-and-ladder/`.
|
|
336
|
+
*/
|
|
337
|
+
decorationOrder: z
|
|
338
|
+
.record(z.string().min(1), z.array(z.string().regex(moduleIdRe)).min(1))
|
|
339
|
+
.default({}),
|
|
340
|
+
/**
|
|
341
|
+
* One sentence per divergence the platform derives, keyed by the derived
|
|
342
|
+
* entry's own key — `<kind>:<module>:<subject>`, never a path and never a
|
|
343
|
+
* line.
|
|
344
|
+
*
|
|
345
|
+
* A flat map rather than a reason field on a per-kind array, and the
|
|
346
|
+
* difference is structural rather than stylistic: a map can only ever
|
|
347
|
+
* *answer*. So the declaration cannot add a divergence the derivation did not
|
|
348
|
+
* find, nor hide one it did — the population is the report's and the judgement
|
|
349
|
+
* is this.
|
|
350
|
+
*
|
|
351
|
+
* The key's grammar is checked where the population it keys into exists;
|
|
352
|
+
* nothing reads this yet — the report is P2 of
|
|
353
|
+
* `specs/107-override-report-and-ladder/`.
|
|
354
|
+
*/
|
|
355
|
+
reasons: z.record(z.string().min(1), z.string().min(20).max(800)).default({}),
|
|
356
|
+
})
|
|
357
|
+
// Three fields are the whole vocabulary, so a fourth is a typo — and a
|
|
358
|
+
// mistyped field name under a lenient object is silently stripped, which
|
|
359
|
+
// reads as "this deployment declares nothing" for a file whose author wrote
|
|
360
|
+
// a declaration. Refusing it names the key.
|
|
361
|
+
.strict();
|
|
362
|
+
/**
|
|
363
|
+
* Refusal-token grammar for {@link ModuleErrorCodeDeclarationSchema}.
|
|
364
|
+
*
|
|
365
|
+
* One code, several reasons — `specs/082-error-code-ownership/contracts/error-code-ownership.md`
|
|
366
|
+
* §1.4. The envelope reads `details.code` and looks up `errors.<CODE>.<token>`,
|
|
367
|
+
* or `errors.<CODE>` when the raise carries no token.
|
|
368
|
+
*
|
|
369
|
+
* **It is a choice between two keys and not a fall-back**, which this note said
|
|
370
|
+
* it was until D-190 (`specs/080-f4-real-scope/rulings.md`) measured it:
|
|
371
|
+
* `localizeErrorEnvelope` composes one key, asks for it once and never re-asks.
|
|
372
|
+
* So a code every raise of which carries a token has no reader for its
|
|
373
|
+
* `errors.<CODE>` sentence — the operator never sees it, and deleting it is
|
|
374
|
+
* still wrong, because `check:error-translations` asks its P1 question at that
|
|
375
|
+
* key and at no other.
|
|
376
|
+
*/
|
|
377
|
+
export const errorCodeTokenRe = /^[a-z][a-z0-9_]*$/;
|
|
378
|
+
/**
|
|
379
|
+
* One error code a module claims as its own
|
|
380
|
+
* (`specs/090-module-owned-error-codes/contracts/error-code-declaration.md` §1.1).
|
|
381
|
+
*
|
|
382
|
+
* **No `message` field, and that is a decision.** The English sentence a caller
|
|
383
|
+
* sees when nothing is translated is the one the raising code wrote: it already
|
|
384
|
+
* exists, it is written where the condition is known, and it can interpolate.
|
|
385
|
+
* A manifest message would be a third English sentence for one condition, and
|
|
386
|
+
* the two would drift exactly as a permission's `label` and its
|
|
387
|
+
* `adminRoles.permission.<code>` bundle key already do. The translated
|
|
388
|
+
* sentences live in the declaring module's own `i18n/<language>.json` under
|
|
389
|
+
* `errors.<CODE>`, which is where the envelope already looks.
|
|
390
|
+
*
|
|
391
|
+
* **`tokens` is declared rather than inferred** because a static reader that
|
|
392
|
+
* does not know the token set cannot tell `errors.CART_COUPON_REJECTED.expired`
|
|
393
|
+
* from a key whose tail is not a code at all — which is a finding. Fourteen keys
|
|
394
|
+
* in `invoices` and `carts` have this shape today.
|
|
395
|
+
*/
|
|
396
|
+
export const ModuleErrorCodeDeclarationSchema = z.object({
|
|
397
|
+
code: z.string().regex(errorCodeRe),
|
|
398
|
+
tokens: z.array(z.string().regex(errorCodeTokenRe)).optional(),
|
|
399
|
+
});
|
|
400
|
+
/**
|
|
401
|
+
* One capability this module **owns** and declares mutually exclusive
|
|
402
|
+
* (`specs/132-connector-family-discovery/contracts/module-capabilities.md` R3.1).
|
|
403
|
+
*
|
|
404
|
+
* The owner declares exclusivity, never the member, and three things follow that
|
|
405
|
+
* are otherwise loose ends. The refusal code stays on the semantic owner, which
|
|
406
|
+
* is what D-95.2 requires — a member raises it and must **not** declare it. A
|
|
407
|
+
* member cannot make a capability exclusive by accident, and cannot un-make it.
|
|
408
|
+
* And if the owner module is not installed in a deployment, the capability is
|
|
409
|
+
* simply not exclusive there, which is the honest answer rather than a refusal:
|
|
410
|
+
* without the shared layer there is no lock row and nothing to enforce with
|
|
411
|
+
* (R3.5).
|
|
412
|
+
*/
|
|
413
|
+
export const ExclusiveCapabilitySchema = z.object({
|
|
414
|
+
/** The capability this module owns and declares mutually exclusive. */
|
|
415
|
+
key: z.string().regex(capabilityKeyRe),
|
|
416
|
+
/** The code raised when a second member is activated. Owned by this module. */
|
|
417
|
+
errorCode: z.string().regex(errorCodeRe),
|
|
418
|
+
});
|
|
419
|
+
export const ModuleManifestSchema = z.object({
|
|
420
|
+
id: z.string().regex(moduleIdRe),
|
|
421
|
+
name: z.string().min(1).max(120),
|
|
422
|
+
description: z.string().max(2000).optional(),
|
|
423
|
+
version: z.string().regex(moduleVersionRe),
|
|
424
|
+
dependencies: z.array(z.string().regex(moduleIdRe)).default([]),
|
|
425
|
+
/**
|
|
426
|
+
* Real port edges withheld from `dependencies` for install-ordering reasons
|
|
427
|
+
* (feature 073, Amendment A1). Consumed by `check-port-dependencies.ts` and
|
|
428
|
+
* by the lifecycle's flip-time dependency refusals; never by the install
|
|
429
|
+
* order or the migration order.
|
|
430
|
+
*/
|
|
431
|
+
acknowledgedDependencies: z.array(ModuleAcknowledgedDependencySchema).optional(),
|
|
432
|
+
/**
|
|
433
|
+
* Real container edges that deliberately do **not** bind the operator (D-44).
|
|
434
|
+
* Consumed by `check-port-dependencies.ts` for the ownership claim; read by
|
|
435
|
+
* no graph and by no ordering. See {@link ModuleNonBindingDependencySchema}.
|
|
436
|
+
*/
|
|
437
|
+
nonBindingDependencies: z.array(ModuleNonBindingDependencySchema).optional(),
|
|
438
|
+
/**
|
|
439
|
+
* Operator-activation control (feature 073). Optional only while the
|
|
440
|
+
* conversion sweep is in flight: `check-module-gating` requires it as soon
|
|
441
|
+
* as a module's seams are converted, so a converted module without it fails
|
|
442
|
+
* CI rather than resolving to an implicit "on".
|
|
443
|
+
*/
|
|
444
|
+
activation: ModuleActivationSchema.optional(),
|
|
445
|
+
/**
|
|
446
|
+
* Per-module settings declaration consumed by the existing feature 004
|
|
447
|
+
* `ManifestReconciler`. When present, its `moduleCode` MUST equal the
|
|
448
|
+
* outer `id` — the loader enforces this at boot.
|
|
449
|
+
*/
|
|
450
|
+
settings: ModuleSettingsManifestSchema.optional(),
|
|
451
|
+
/**
|
|
452
|
+
* Per-module Admin UI translation declaration (feature 019).
|
|
453
|
+
* When present, the lifecycle install hook ingests bundle JSON files
|
|
454
|
+
* from `<bundlesDir>` into the platform's `translation_bundles` store.
|
|
455
|
+
*/
|
|
456
|
+
i18n: ModuleI18nManifestSchema.optional(),
|
|
457
|
+
/**
|
|
458
|
+
* Per-module documentation declaration (feature 100 / roadmap F12).
|
|
459
|
+
*
|
|
460
|
+
* `{ dir }` — the module ships its pages at that directory under its own
|
|
461
|
+
* root; `false` — it ships none, deliberately; **absent** — nobody has
|
|
462
|
+
* decided, which is where every module stands in Phase 1 while the pages are
|
|
463
|
+
* still in the site's own tree. See {@link ModuleDocsDeclarationSchema} for
|
|
464
|
+
* why the last two are not one state.
|
|
465
|
+
*/
|
|
466
|
+
docs: ModuleDocsDeclarationSchema.optional(),
|
|
467
|
+
/**
|
|
468
|
+
* Per-module demo data declaration (feature 113, D-209).
|
|
469
|
+
*
|
|
470
|
+
* `{ summary, seed, reset, after? }` — the module ships demo rows for its own
|
|
471
|
+
* tables; `false` — it has nothing to demonstrate, deliberately; **absent** —
|
|
472
|
+
* nobody has decided. See {@link ModuleDemoDeclarationSchema} for why the last
|
|
473
|
+
* two are not one state, and {@link ModuleDemoManifest} for what a body may
|
|
474
|
+
* do.
|
|
475
|
+
*
|
|
476
|
+
* Declaring it creates **no** lifecycle edge: it puts no module in
|
|
477
|
+
* `dependencies`, changes no migration order and does not stand in the way of
|
|
478
|
+
* an operator switching another module off (§2.3, FR-005).
|
|
479
|
+
*/
|
|
480
|
+
demo: ModuleDemoDeclarationSchema.optional(),
|
|
481
|
+
/**
|
|
482
|
+
* Per-module Admin Command Palette action declarations (feature 020).
|
|
483
|
+
* Each entry becomes a row in `module_actions` at install time and is
|
|
484
|
+
* surfaced in the admin's command palette under the Actions group.
|
|
485
|
+
* Within-module id uniqueness is enforced by the schema.
|
|
486
|
+
*/
|
|
487
|
+
actions: ModuleActionsManifestSchema.optional(),
|
|
488
|
+
/**
|
|
489
|
+
* Per-module admin permission codes merged into the assignable catalogue
|
|
490
|
+
* when the module is enabled (feature 026).
|
|
491
|
+
*/
|
|
492
|
+
permissions: z.array(modulePermissionDeclarationSchema).optional(),
|
|
493
|
+
/**
|
|
494
|
+
* Per-module transactional email declarations (feature 047). Each entry is
|
|
495
|
+
* reconciled into `transactional_emails` at boot; default subject/content are
|
|
496
|
+
* supplied separately at runtime via the EmailDefaultsRegistry.
|
|
497
|
+
*/
|
|
498
|
+
transactionalEmails: z.array(transactionalEmailManifestEntrySchema).optional(),
|
|
499
|
+
/**
|
|
500
|
+
* The capability families this module declares itself a **member** of
|
|
501
|
+
* (feature 132, `contracts/module-capabilities.md` R1).
|
|
502
|
+
*
|
|
503
|
+
* A key is a kebab-case string, not an enum: `CAPABILITY_KEYS` spells the
|
|
504
|
+
* three this repository mints, and a capability owned by a package this
|
|
505
|
+
* repository does not contain is spelled by its owner and needs no entry
|
|
506
|
+
* anywhere here. That openness is the point — it is what lets a connector
|
|
507
|
+
* installed from npm and a per-deployment overlay module join a family on the
|
|
508
|
+
* same terms as a core module, which the three arrays below cannot (R2.1).
|
|
509
|
+
*
|
|
510
|
+
* It carries **membership only** (R1.4). The activation setting code is
|
|
511
|
+
* `activation.settingCode`, which every member already declares and which the
|
|
512
|
+
* registry cache already indexes by module; the three arrays this field
|
|
513
|
+
* replaces each carried a second field byte-identical to it in every entry
|
|
514
|
+
* that could be checked, which is D-100 written into a schema.
|
|
515
|
+
*
|
|
516
|
+
* Declaring it creates **no** lifecycle edge (R1.5): no `dependencies` entry,
|
|
517
|
+
* no migration ordering, no install ordering, and no obstacle to an operator
|
|
518
|
+
* switching the capability's owner off. The same sentence `demo` carries, and
|
|
519
|
+
* enforced the same way — no graph reads this field.
|
|
520
|
+
*
|
|
521
|
+
* Absent means "this module declares no capability", which is true of most
|
|
522
|
+
* modules and is not a finding.
|
|
523
|
+
*/
|
|
524
|
+
capabilities: z.array(z.string().regex(capabilityKeyRe)).optional(),
|
|
525
|
+
/**
|
|
526
|
+
* The capabilities this module **owns** and declares mutually exclusive
|
|
527
|
+
* (feature 132, R3.1). See {@link ExclusiveCapabilitySchema}.
|
|
528
|
+
*/
|
|
529
|
+
exclusiveCapabilities: z.array(ExclusiveCapabilitySchema).optional(),
|
|
530
|
+
// Feature 132 — `pimConnector`, `invoiceLedger` and `erpConnector` are **gone**.
|
|
531
|
+
//
|
|
532
|
+
// Three `z.literal(true).optional()` flags, one per family, each declared by one or
|
|
533
|
+
// two modules and each read by **nothing**: every exclusion answered from a
|
|
534
|
+
// hand-written array in this package instead. The doc comment on `invoiceLedger`
|
|
535
|
+
// asked for this removal by name, and put the question it could not answer alone —
|
|
536
|
+
// *"is the flag the source, or the table?"* The answer is neither: the manifest is
|
|
537
|
+
// the source, in one field for all families (`capabilities` above), and both the
|
|
538
|
+
// flags and the tables go.
|
|
539
|
+
//
|
|
540
|
+
// This is a **breaking** manifest-schema change for any consumer that declared one,
|
|
541
|
+
// which is three modules in this tree and potentially a package outside it; the
|
|
542
|
+
// replacement is one line and is additive.
|
|
543
|
+
/**
|
|
544
|
+
* The operator-visible error codes this module owns (feature 090, D-182).
|
|
545
|
+
*
|
|
546
|
+
* The declaration is what routes the code's sentence to this module's bundle:
|
|
547
|
+
* `errors.<CODE>` in `<module>/i18n/<language>.json`. Which module owns a code
|
|
548
|
+
* is `specs/082-error-code-ownership/contracts/error-code-ownership.md` §1 —
|
|
549
|
+
* the domain noun decides, never the thrower, so `orders` raising `CART_EMPTY`
|
|
550
|
+
* leaves the code owned by `carts`.
|
|
551
|
+
*
|
|
552
|
+
* Absent means "this module owns no operator-visible error code", which is
|
|
553
|
+
* true of most modules and is not a finding.
|
|
554
|
+
*/
|
|
555
|
+
errorCodes: z.array(ModuleErrorCodeDeclarationSchema).optional(),
|
|
556
|
+
/**
|
|
557
|
+
* The Page Builder blocks this module owns (feature 096, FR-001/FR-006).
|
|
558
|
+
*
|
|
559
|
+
* A block's `name` is `<this module's id>.<LocalName>` and is **persisted**:
|
|
560
|
+
* it is written into the `type` position of a Puck node in a `jsonb` column
|
|
561
|
+
* and is the only link between a stored node and the module that can render
|
|
562
|
+
* it. Which module owns a block is the domain noun its fields and data belong
|
|
563
|
+
* to — the rule `specs/082-error-code-ownership/contracts/error-code-ownership.md`
|
|
564
|
+
* §1 already applies to error codes — never the package the renderer file
|
|
565
|
+
* currently sits in.
|
|
566
|
+
*
|
|
567
|
+
* Absent means "this module owns no Page Builder block", which is true of
|
|
568
|
+
* most modules and is not a finding.
|
|
569
|
+
*/
|
|
570
|
+
blocks: z.array(BlockDefinitionSchema).optional(),
|
|
571
|
+
/**
|
|
572
|
+
* The palette sections this module declares (feature 096, FR-009).
|
|
573
|
+
*
|
|
574
|
+
* Declared rather than hard-coded so that contributing a block into a section
|
|
575
|
+
* costs no edit to a shared `categories` map in a package the contributor
|
|
576
|
+
* does not own. Two modules declaring the same key for the same context is
|
|
577
|
+
* expected and merges; a category exists per context, so `layout` for `cms`
|
|
578
|
+
* and `layout` for `email` are two entries.
|
|
579
|
+
*/
|
|
580
|
+
blockCategories: z.array(BlockCategorySchema).optional(),
|
|
581
|
+
/**
|
|
582
|
+
* The environment inputs this module owns (`specs/117-instance-bring-up/`
|
|
583
|
+
* FR-002; `contracts/environment-inputs.md` §R2.2).
|
|
584
|
+
*
|
|
585
|
+
* **This is the only way a module's requirements can reach a client.** A
|
|
586
|
+
* module package ships `dist`, `i18n` and `docs`; `.env.example` is a file in
|
|
587
|
+
* *this* repository. So a client who scaffolds an instance, installs thirty
|
|
588
|
+
* modules and copies the example gets a file that does not mention the
|
|
589
|
+
* variables those modules read — and meets each one as a boot that failed for
|
|
590
|
+
* a reason nothing named. Declared here, the same tree walk that picks up
|
|
591
|
+
* `permissions`, `actions` and `errorCodes` carries them into the generated
|
|
592
|
+
* manifest index, so a core module, a per-deployment overlay module and an
|
|
593
|
+
* installed package all declare on identical terms.
|
|
594
|
+
*
|
|
595
|
+
* **A module declares only what it *owns*.** Most of what a module reads is
|
|
596
|
+
* not its own: `NODE_ENV`, `BACKEND_ROLE`, `STOREFRONT_BASE_URL`,
|
|
597
|
+
* `REVALIDATE_SECRET` and `SETTINGS_SECRET_ENCRYPTION_KEY` are the platform's,
|
|
598
|
+
* declared once in `packages/platform/src/env/index.ts`, and a module's read
|
|
599
|
+
* of one is satisfied by that declaration. Declaring them again would be one
|
|
600
|
+
* fact with thirty homes and thirty `describes` (D-100), so
|
|
601
|
+
* {@link defineModuleManifest} refuses an entry whose `owner` is not this
|
|
602
|
+
* module — and `check:env-inputs` refuses one whose name the platform already
|
|
603
|
+
* owns.
|
|
604
|
+
*
|
|
605
|
+
* Absent means "this module reads no environment variable of its own", which
|
|
606
|
+
* is true of most modules and is not a finding.
|
|
607
|
+
*/
|
|
608
|
+
env: z.array(EnvironmentInputSchema).optional(),
|
|
609
|
+
});
|
|
610
|
+
/**
|
|
611
|
+
* The three cross-field activation rules (feature 073,
|
|
612
|
+
* `contracts/module-activation-manifest.md`). They live here rather than in
|
|
613
|
+
* the schema because a Zod union of two non-strict objects accepts a value
|
|
614
|
+
* carrying both forms, and because the resulting message has to name the
|
|
615
|
+
* module the author is looking at.
|
|
616
|
+
*/
|
|
617
|
+
function assertActivationRules(id, activation) {
|
|
618
|
+
const block = activation;
|
|
619
|
+
const declaresControl = typeof block['settingCode'] === 'string' && typeof block['default'] === 'boolean';
|
|
620
|
+
const declaresNonDeactivatable = block['nonDeactivatable'] === true &&
|
|
621
|
+
typeof block['reason'] === 'string' &&
|
|
622
|
+
block['reason'].length > 0;
|
|
623
|
+
// 1. Exactly one form.
|
|
624
|
+
if (declaresControl === declaresNonDeactivatable) {
|
|
625
|
+
throw new Error(`[contracts/modules] manifest "${id}" must declare exactly one activation ` +
|
|
626
|
+
`form: either { settingCode, default } or { nonDeactivatable: true, reason }.`);
|
|
627
|
+
}
|
|
628
|
+
// 2. An `_`-prefixed id is platform-internal by convention (`moduleIdRe`);
|
|
629
|
+
// this makes the convention enforceable.
|
|
630
|
+
if (id.startsWith('_') && !declaresNonDeactivatable) {
|
|
631
|
+
throw new Error(`[contracts/modules] manifest "${id}" is platform-internal (leading "_") ` +
|
|
632
|
+
`and MUST declare activation as { nonDeactivatable: true, reason }.`);
|
|
633
|
+
}
|
|
634
|
+
// 3. The control belongs to the declaring module. Adopting an existing
|
|
635
|
+
// ad-hoc control (FR-014) is allowed precisely because every such code
|
|
636
|
+
// — `blog.enabled`, `prompt_actions.enabled`, `ksef.integration.enabled` —
|
|
637
|
+
// already sits under its own module's namespace.
|
|
638
|
+
if (declaresControl) {
|
|
639
|
+
const code = block['settingCode'];
|
|
640
|
+
if (code !== id && !code.startsWith(`${id}.`)) {
|
|
641
|
+
throw new Error(`[contracts/modules] manifest "${id}" declares activation setting ` +
|
|
642
|
+
`"${code}", which is outside the module's own namespace ` +
|
|
643
|
+
`("${id}" or "${id}.*").`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The four cross-field capability rules (feature 132,
|
|
649
|
+
* `contracts/module-capabilities.md` R4).
|
|
650
|
+
*
|
|
651
|
+
* They sit beside the activation rules for the reason `assertActivationRules`
|
|
652
|
+
* states about itself: they are cross-field, a non-strict object schema accepts
|
|
653
|
+
* a value carrying both arms, and the message has to name the module the author
|
|
654
|
+
* is looking at — and the key, since a manifest may declare several.
|
|
655
|
+
*
|
|
656
|
+
* **Two rules are deliberately not here**, and both for the same reason:
|
|
657
|
+
* this function sees **one** manifest and cannot see a family. "Exactly one
|
|
658
|
+
* installed module may own a key" (R3.4) and "a member of an *exclusive* key may
|
|
659
|
+
* not declare `activation.default: true`" (R3.6) are refused at derivation, in
|
|
660
|
+
* `capabilityRegistryFrom`, where both facts are in hand.
|
|
661
|
+
*/
|
|
662
|
+
function assertCapabilityRules(m) {
|
|
663
|
+
const memberships = m.capabilities ?? [];
|
|
664
|
+
const owned = m.exclusiveCapabilities ?? [];
|
|
665
|
+
// R4.1 — a member with no activation control cannot participate in an
|
|
666
|
+
// exclusion that is resolved on the activation axis, so the declaration would
|
|
667
|
+
// be a claim nothing could ever check. Asked of members only: an owner is not
|
|
668
|
+
// resolved on that axis, its members are.
|
|
669
|
+
if (memberships.length > 0 && m.activation === undefined) {
|
|
670
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares capability membership ` +
|
|
671
|
+
`(${memberships.join(', ')}) but no \`activation\` block. A member with no ` +
|
|
672
|
+
`activation control cannot participate in an exclusion that is resolved on ` +
|
|
673
|
+
`the activation axis, so the membership could never be enforced or released.`);
|
|
674
|
+
}
|
|
675
|
+
// R4.2 — a duplicate says nothing the single entry does not, and a family read
|
|
676
|
+
// that counts entries rather than modules would count this member twice.
|
|
677
|
+
const seenMembership = new Set();
|
|
678
|
+
for (const key of memberships) {
|
|
679
|
+
if (seenMembership.has(key)) {
|
|
680
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares capability "${key}" twice ` +
|
|
681
|
+
`in \`capabilities\`. Membership is a set; drop the duplicate.`);
|
|
682
|
+
}
|
|
683
|
+
seenMembership.add(key);
|
|
684
|
+
}
|
|
685
|
+
// R4.3 — two entries for one key are two refusal codes for one condition, and
|
|
686
|
+
// nothing decides which of them an operator meets.
|
|
687
|
+
const seenOwned = new Set();
|
|
688
|
+
for (const entry of owned) {
|
|
689
|
+
if (seenOwned.has(entry.key)) {
|
|
690
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares capability "${entry.key}" ` +
|
|
691
|
+
`twice in \`exclusiveCapabilities\`. A key has one owner and one refusal ` +
|
|
692
|
+
`code; two entries leave nothing to decide which an operator meets.`);
|
|
693
|
+
}
|
|
694
|
+
seenOwned.add(entry.key);
|
|
695
|
+
}
|
|
696
|
+
// R4.4 / R3.3 — an owner that is also a member would exclude itself from its
|
|
697
|
+
// own family. Two *different* keys in the two arrays are fine and expected:
|
|
698
|
+
// keys never exclude each other (`capability-exclusivity.md` R2.5).
|
|
699
|
+
for (const key of seenOwned) {
|
|
700
|
+
if (seenMembership.has(key)) {
|
|
701
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares capability "${key}" as both ` +
|
|
702
|
+
`a membership and an exclusive capability it owns. An owner that is also a ` +
|
|
703
|
+
`member would exclude itself from its own family.`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The three `nonBindingDependencies` rules (D-44 §5).
|
|
709
|
+
*
|
|
710
|
+
* They sit beside the activation rules for the same reason: two of the three
|
|
711
|
+
* are cross-field — one reads `dependencies` and `acknowledgedDependencies`,
|
|
712
|
+
* one reads `kind` against `whenAbsent` — and the message has to name the
|
|
713
|
+
* module the author is looking at.
|
|
714
|
+
*/
|
|
715
|
+
function assertNonBindingRules(m) {
|
|
716
|
+
const acknowledged = new Set((m.acknowledgedDependencies ?? []).map((edge) => edge.moduleId));
|
|
717
|
+
for (const edge of m.nonBindingDependencies ?? []) {
|
|
718
|
+
// 1. No self-edges. The array's element regex applies per element and
|
|
719
|
+
// cannot see the outer id, exactly as with `dependencies`.
|
|
720
|
+
if (edge.moduleId === m.id) {
|
|
721
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares a non-binding dependency on ` +
|
|
722
|
+
`itself (forbidden).`);
|
|
723
|
+
}
|
|
724
|
+
// 2. One edge, one claim, in one place — the mirror of the
|
|
725
|
+
// `acknowledgedDependencies` rule above. A target declared in either of
|
|
726
|
+
// the other two arrays already carries the bind, so a withdrawal beside
|
|
727
|
+
// it is a second record of the same edge that nothing keeps in step.
|
|
728
|
+
if (m.dependencies.includes(edge.moduleId)) {
|
|
729
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares a non-binding dependency on ` +
|
|
730
|
+
`"${edge.moduleId}", which it already declares in \`dependencies\` — that ` +
|
|
731
|
+
`declaration already binds the operator, so drop one of the two.`);
|
|
732
|
+
}
|
|
733
|
+
if (acknowledged.has(edge.moduleId)) {
|
|
734
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares a non-binding dependency on ` +
|
|
735
|
+
`"${edge.moduleId}", which it already acknowledges — an acknowledged edge is ` +
|
|
736
|
+
`read by the refusal graph, so the two claims contradict each other.`);
|
|
737
|
+
}
|
|
738
|
+
// 3. `whenAbsent` is the `degrades-without` kind's entire content: the
|
|
739
|
+
// behaviour the module promises and the sentence the platform screen
|
|
740
|
+
// renders. A `contributes-to` edge has no degradation to describe.
|
|
741
|
+
if (edge.kind === 'degrades-without' && edge.whenAbsent === undefined) {
|
|
742
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares "${edge.moduleId}:${edge.name}" ` +
|
|
743
|
+
`as \`degrades-without\` with no \`whenAbsent\` — the kind is a promise about ` +
|
|
744
|
+
`behaviour and the sentence is what a reviewer and an off-state test hold it to.`);
|
|
745
|
+
}
|
|
746
|
+
// 3a. And it is the whole of `refuses-without`, for a sharper reason: the
|
|
747
|
+
// outcome that kind produces is the one an undeclared gated port
|
|
748
|
+
// produces anyway, so the sentence is the only thing the declaration
|
|
749
|
+
// adds. Without it the entry classifies identically to no entry at
|
|
750
|
+
// all, and the operator's dialog falls back to a translated default
|
|
751
|
+
// that names no capability.
|
|
752
|
+
if (edge.kind === 'refuses-without' && edge.whenAbsent === undefined) {
|
|
753
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares "${edge.moduleId}:${edge.name}" ` +
|
|
754
|
+
`as \`refuses-without\` with no \`whenAbsent\` — the ledger classifies such an ` +
|
|
755
|
+
`edge exactly as it classifies an undeclared one, so the sentence is the whole ` +
|
|
756
|
+
`of what the declaration buys. Name what refuses, in the operator's words.`);
|
|
757
|
+
}
|
|
758
|
+
if (edge.kind === 'contributes-to' && edge.whenAbsent !== undefined) {
|
|
759
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares "${edge.moduleId}:${edge.name}" ` +
|
|
760
|
+
`as \`contributes-to\` with a \`whenAbsent\` — a push into an ungated registry ` +
|
|
761
|
+
`degrades nothing, so either drop the sentence or the edge is a pull and the ` +
|
|
762
|
+
`kind is \`degrades-without\`.`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* The `errorCodes` refusals (feature 090,
|
|
768
|
+
* `contracts/error-code-declaration.md` §2, first layer).
|
|
769
|
+
*
|
|
770
|
+
* They live here rather than in the schema for the reason the activation rules
|
|
771
|
+
* do: two of them are cross-element — a duplicate is a relationship between two
|
|
772
|
+
* entries, which an element schema cannot see — and every message has to name
|
|
773
|
+
* the module the author is looking at. All four fire on import, on the author's
|
|
774
|
+
* machine, with no instance and no database.
|
|
775
|
+
*
|
|
776
|
+
* What this layer deliberately does **not** refuse is a code **another** module
|
|
777
|
+
* declares. It sees one manifest and cannot see a second, so a partial refusal
|
|
778
|
+
* here called "the collision rule" would be a green that means "not looking".
|
|
779
|
+
* The collision rule is composition's (§3), and an in-repository collision is
|
|
780
|
+
* refused before that, in CI.
|
|
781
|
+
*
|
|
782
|
+
* Nor does it refuse a code that is a member of `ERROR_CODES`. After feature
|
|
783
|
+
* 090's migration every core module's declarations are members of it, so such a
|
|
784
|
+
* rule would refuse the platform's own manifests; there is no origin field to
|
|
785
|
+
* condition it on, and adding one would be a self-certified exemption issued by
|
|
786
|
+
* the measured party.
|
|
787
|
+
*/
|
|
788
|
+
function assertErrorCodeRules(m) {
|
|
789
|
+
const seenCodes = new Set();
|
|
790
|
+
for (const declaration of m.errorCodes ?? []) {
|
|
791
|
+
if (!errorCodeRe.test(declaration.code)) {
|
|
792
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares error code ` +
|
|
793
|
+
`"${declaration.code}", which is not SCREAMING_SNAKE_CASE ` +
|
|
794
|
+
`(${String(errorCodeRe)}) — the code travels verbatim on the wire and ` +
|
|
795
|
+
'is the tail of the `errors.<CODE>` key its sentence is written under.');
|
|
796
|
+
}
|
|
797
|
+
if (seenCodes.has(declaration.code)) {
|
|
798
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares error code ` +
|
|
799
|
+
`"${declaration.code}" twice — one code has one owner and one sentence, ` +
|
|
800
|
+
'so the second entry can only disagree with the first.');
|
|
801
|
+
}
|
|
802
|
+
seenCodes.add(declaration.code);
|
|
803
|
+
const seenTokens = new Set();
|
|
804
|
+
for (const token of declaration.tokens ?? []) {
|
|
805
|
+
if (!errorCodeTokenRe.test(token)) {
|
|
806
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares refusal token "${token}" ` +
|
|
807
|
+
`under "${declaration.code}", which does not match ${String(errorCodeTokenRe)} — ` +
|
|
808
|
+
'the token is the tail of `errors.<CODE>.<token>` and a key that does not ' +
|
|
809
|
+
'parse is a key nothing reads.');
|
|
810
|
+
}
|
|
811
|
+
if (seenTokens.has(token)) {
|
|
812
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares refusal token "${token}" ` +
|
|
813
|
+
`twice under "${declaration.code}" — one token is one sentence.`);
|
|
814
|
+
}
|
|
815
|
+
seenTokens.add(token);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* The four block-declaration rules (feature 096,
|
|
821
|
+
* `specs/096-page-builder-block-ownership/contracts/block-definition.md` §1).
|
|
822
|
+
*
|
|
823
|
+
* They live here rather than in the schema for the reason the activation rules
|
|
824
|
+
* do: each is cross-field — one reads a block's `name` against the outer `id`,
|
|
825
|
+
* one reads its `category` against the manifest's own `blockCategories`, one
|
|
826
|
+
* reads those declarations against each other — and every message has to name
|
|
827
|
+
* the module the author is looking at. They fire on
|
|
828
|
+
* import, on the author's machine, with no instance and no database, which
|
|
829
|
+
* matters more here than anywhere else in this file: a block name is written
|
|
830
|
+
* into `jsonb` and never rewritten, so a wrong one caught in CI has already
|
|
831
|
+
* been typed into a manifest, and one caught after a release is permanent.
|
|
832
|
+
*
|
|
833
|
+
* `check:block-names` re-derives rules 1–3 for a manifest built without this
|
|
834
|
+
* helper — the same belt-and-braces `check-port-dependencies.ts` applies to
|
|
835
|
+
* `nonBindingDependencies`.
|
|
836
|
+
*
|
|
837
|
+
* **What this layer cannot decide is anything about a second manifest**, and
|
|
838
|
+
* the limit is the one `assertErrorCodeRules` states for itself. Two modules
|
|
839
|
+
* declaring one block name is composition's question and the check's; two
|
|
840
|
+
* modules declaring one `(key, context)` category is neither, because it is
|
|
841
|
+
* **normal and merges** — `contracts/block-definition.md` §1.1 is the ruling,
|
|
842
|
+
* the total order the merge resolves by and the two CI signals that hold
|
|
843
|
+
* in-tree modules to agreeing. Nothing here restates it.
|
|
844
|
+
*
|
|
845
|
+
* Rule 2 is therefore enforced **within the declaring manifest**, and per
|
|
846
|
+
* **context**: a block's category must be declared beside it, for every one of
|
|
847
|
+
* the block's `contexts`. That is what FR-009 asks for — a contributor declares
|
|
848
|
+
* the section in its own manifest instead of editing a shared map — and the
|
|
849
|
+
* per-context reading is T107's correction to Phase 1, which shipped "at least
|
|
850
|
+
* one". Under the weaker reading a block declared for `cms` and `email` whose
|
|
851
|
+
* section exists only in `cms` is uninsertable in the e-mail palette with no
|
|
852
|
+
* error anywhere, which is FR-009's silent-loss shape one level down.
|
|
853
|
+
*/
|
|
854
|
+
function assertBlockRules(m) {
|
|
855
|
+
// 4. One author, one section, one record. Judged first, and before any block
|
|
856
|
+
// is read: a block is judged *against* `blockCategories`, so measuring it
|
|
857
|
+
// against a set that contradicts itself reports the wrong defect. Unlike a
|
|
858
|
+
// cross-module duplicate — which is normal and merges (§1.1) — this one
|
|
859
|
+
// has a single author and is decidable where it is written.
|
|
860
|
+
const declaredSections = new Set();
|
|
861
|
+
for (const category of m.blockCategories ?? []) {
|
|
862
|
+
for (const context of category.contexts) {
|
|
863
|
+
const pair = `${category.key}\u0000${context}`;
|
|
864
|
+
if (declaredSections.has(pair)) {
|
|
865
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares the palette section ` +
|
|
866
|
+
`"${category.key}" twice for context "${context}" — a section is one record, ` +
|
|
867
|
+
'resolved as one record, so a manifest that states it twice has stated a ' +
|
|
868
|
+
'title, a weight and a visibility for nobody to reconcile.');
|
|
869
|
+
}
|
|
870
|
+
declaredSections.add(pair);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
for (const block of m.blocks ?? []) {
|
|
874
|
+
// 0. The grammar, before anything reads a segment of it. A name with no
|
|
875
|
+
// separator has no owner segment to compare against `id`, so a message
|
|
876
|
+
// about ownership would be a message about the wrong thing. The schema
|
|
877
|
+
// refuses it too (`BlockDefinitionSchema`), and parses last; this is the
|
|
878
|
+
// copy that names the module, exactly as `assertErrorCodeRules` re-tests
|
|
879
|
+
// `errorCodeRe`.
|
|
880
|
+
if (!blockNameRe.test(block.name)) {
|
|
881
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}", which is ` +
|
|
882
|
+
`not a namespaced block name (${String(blockNameRe)}) — the name is persisted ` +
|
|
883
|
+
'into `jsonb` and its owner segment is the only link between a stored node and ' +
|
|
884
|
+
'the module that can render it.');
|
|
885
|
+
}
|
|
886
|
+
// 1. One block, one owner, stated once. The owner is the name's first
|
|
887
|
+
// segment and there is no `ownerModule` field to disagree with it.
|
|
888
|
+
const owner = block.name.slice(0, block.name.indexOf('.'));
|
|
889
|
+
if (owner !== m.id) {
|
|
890
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}", whose ` +
|
|
891
|
+
`owner segment "${owner}" is not this module's id — a block has exactly one ` +
|
|
892
|
+
'owner and the name is where that owner is stated, so declaring it here would ' +
|
|
893
|
+
`make "${owner}" unable to own its own block.`);
|
|
894
|
+
}
|
|
895
|
+
// 2. A block offered on no surface. Refused before the category, which
|
|
896
|
+
// cannot be judged without the contexts to judge it against.
|
|
897
|
+
if (block.contexts.length === 0) {
|
|
898
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}" with an ` +
|
|
899
|
+
'empty `contexts` — a block offered on no surface appears in no palette, which ' +
|
|
900
|
+
'is a declaration with no reader.');
|
|
901
|
+
}
|
|
902
|
+
// 3. The category is a declared key, not free text, and it is declared for
|
|
903
|
+
// **every** one of the block's contexts (T107). The block's own order is
|
|
904
|
+
// what the message names, so an author fixing two missing contexts is
|
|
905
|
+
// sent to the first of them rather than to whichever the set iterated.
|
|
906
|
+
const missingContext = block.contexts.find((context) => !(m.blockCategories ?? []).some((category) => category.key === block.category && category.contexts.includes(context)));
|
|
907
|
+
if (missingContext !== undefined) {
|
|
908
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}" in ` +
|
|
909
|
+
`category "${block.category}", which this manifest does not declare in ` +
|
|
910
|
+
`\`blockCategories\` for context "${missingContext}" — a block's section is ` +
|
|
911
|
+
'declared beside it for every context the block is offered in, or the block ' +
|
|
912
|
+
'is uninsertable in that palette with no error anywhere.');
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* The two `demo.after` refusals (feature 113,
|
|
918
|
+
* `contracts/module-demo-data-layer.md` §4.3–§4.4).
|
|
919
|
+
*
|
|
920
|
+
* They sit beside the rules above for the reason those give: both are
|
|
921
|
+
* cross-field — one reads an entry against the outer `id`, one reads the
|
|
922
|
+
* entries against each other — and the message has to name the module the
|
|
923
|
+
* author is looking at. The element regex applies per element and can see
|
|
924
|
+
* neither.
|
|
925
|
+
*
|
|
926
|
+
* What this layer deliberately does **not** refuse is an `after` naming a
|
|
927
|
+
* module that is not installed. §4.4 rules that such an entry orders nothing
|
|
928
|
+
* and is not a finding, and this layer sees one manifest, so a rule keyed on
|
|
929
|
+
* "is that module here" would be a green that means "not looking" in a client's
|
|
930
|
+
* instance and a false red in a partial one.
|
|
931
|
+
*/
|
|
932
|
+
function assertDemoRules(m) {
|
|
933
|
+
if (m.demo === undefined || m.demo === false)
|
|
934
|
+
return;
|
|
935
|
+
const seen = new Set();
|
|
936
|
+
for (const after of m.demo.after ?? []) {
|
|
937
|
+
if (after === m.id) {
|
|
938
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares \`demo.after\` on itself ` +
|
|
939
|
+
`(forbidden) — the list orders this module's demo against *other* modules', ` +
|
|
940
|
+
'and a self-entry orders nothing.');
|
|
941
|
+
}
|
|
942
|
+
if (seen.has(after)) {
|
|
943
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares \`demo.after\` "${after}" ` +
|
|
944
|
+
'twice — one ordering preference is stated once, so the second entry can ' +
|
|
945
|
+
'only agree with the first.');
|
|
946
|
+
}
|
|
947
|
+
seen.add(after);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* A module declares only the environment inputs it **owns**
|
|
952
|
+
* (`specs/117-instance-bring-up/` FR-002, `contracts/environment-inputs.md`
|
|
953
|
+
* §R2.2).
|
|
954
|
+
*
|
|
955
|
+
* The rule this refuses is the one an author gets wrong by being helpful. Of
|
|
956
|
+
* the 28 variables the module tree reads, 7 are the platform's — `NODE_ENV`,
|
|
957
|
+
* `BACKEND_ROLE`, `STOREFRONT_BASE_URL`, `PUBLIC_API_BASE_URL`,
|
|
958
|
+
* `BACKEND_PUBLIC_URL`, `REVALIDATE_SECRET` and
|
|
959
|
+
* `SETTINGS_SECRET_ENCRYPTION_KEY` — read by thirty modules between them, and an
|
|
960
|
+
* author declaring what their module reads rather than what it owns writes the
|
|
961
|
+
* same fact into thirty manifests with thirty `describes`. Whichever a reader
|
|
962
|
+
* reaches first wins, and the other twenty-nine drift.
|
|
963
|
+
*
|
|
964
|
+
* It is refused **here**, in one manifest, at import time, because that is
|
|
965
|
+
* everything this layer can decide on its own: an `owner` naming another module
|
|
966
|
+
* or the platform is wrong whatever the rest of the estate holds. What it
|
|
967
|
+
* cannot decide — whether the name is one the *platform* already declares — is
|
|
968
|
+
* `check:env-inputs`' `module-declares-a-platform-input`, which needs the
|
|
969
|
+
* platform's declaration to answer.
|
|
970
|
+
*/
|
|
971
|
+
function assertEnvironmentInputRules(m) {
|
|
972
|
+
for (const input of m.env ?? []) {
|
|
973
|
+
const owner = input.owner;
|
|
974
|
+
if (owner.kind === 'module' && owner.moduleId === m.id)
|
|
975
|
+
continue;
|
|
976
|
+
const declared = owner.kind === 'module'
|
|
977
|
+
? `the module "${owner.moduleId}"`
|
|
978
|
+
: owner.kind === 'application'
|
|
979
|
+
? `the ${owner.application} application`
|
|
980
|
+
: 'the platform';
|
|
981
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" declares the environment input ` +
|
|
982
|
+
`"${input.name}" as owned by ${declared} — a module declares only what it owns. ` +
|
|
983
|
+
`A read of somebody else's input is satisfied by *their* declaration; declaring ` +
|
|
984
|
+
`it here would be one fact with two homes and two descriptions.`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Identity-with-validation helper for module authors. Modules export a
|
|
989
|
+
* single `manifest` constant via this helper so TypeScript inference is
|
|
990
|
+
* preserved and the loader can ingest the validated payload directly.
|
|
991
|
+
*/
|
|
992
|
+
export function defineModuleManifest(m) {
|
|
993
|
+
// Reject self-dependencies up front — Zod's array regex doesn't catch
|
|
994
|
+
// this because the id field's regex applies independently per element.
|
|
995
|
+
if (m.dependencies.includes(m.id)) {
|
|
996
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" depends on itself (forbidden).`);
|
|
997
|
+
}
|
|
998
|
+
// Settings manifest's moduleCode must equal the outer id.
|
|
999
|
+
if (m.settings && m.settings.moduleCode !== m.id) {
|
|
1000
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" carries a settings ` +
|
|
1001
|
+
`manifest with moduleCode "${m.settings.moduleCode}" (must match).`);
|
|
1002
|
+
}
|
|
1003
|
+
if (m.activation !== undefined) {
|
|
1004
|
+
assertActivationRules(m.id, m.activation);
|
|
1005
|
+
}
|
|
1006
|
+
for (const edge of m.acknowledgedDependencies ?? []) {
|
|
1007
|
+
if (edge.moduleId === m.id) {
|
|
1008
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" acknowledges a dependency on ` +
|
|
1009
|
+
`itself (forbidden).`);
|
|
1010
|
+
}
|
|
1011
|
+
// An acknowledged edge is a declaration that the ordinary one is
|
|
1012
|
+
// impossible. Where both are present the ordinary one already carries the
|
|
1013
|
+
// install order *and* the refusal, and the acknowledgement is a second
|
|
1014
|
+
// record of the same edge that nothing keeps in step.
|
|
1015
|
+
if (m.dependencies.includes(edge.moduleId)) {
|
|
1016
|
+
throw new Error(`[contracts/modules] manifest "${m.id}" acknowledges "${edge.moduleId}", ` +
|
|
1017
|
+
`which it already declares in \`dependencies\` — the acknowledgement is ` +
|
|
1018
|
+
`for edges that cannot be declared, so drop one of the two.`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
assertNonBindingRules(m);
|
|
1022
|
+
assertCapabilityRules(m);
|
|
1023
|
+
assertErrorCodeRules(m);
|
|
1024
|
+
assertBlockRules(m);
|
|
1025
|
+
assertDemoRules(m);
|
|
1026
|
+
assertEnvironmentInputRules(m);
|
|
1027
|
+
return ModuleManifestSchema.parse(m);
|
|
1028
|
+
}
|
|
1029
|
+
// ---------------------------------------------------------------------------
|
|
1030
|
+
// Module CLI commands (feature 080, T042b / D-160.9)
|
|
1031
|
+
// ---------------------------------------------------------------------------
|
|
1032
|
+
/**
|
|
1033
|
+
* The shape a command's name has to take: lowercase, hyphen-separated.
|
|
1034
|
+
*
|
|
1035
|
+
* A command is addressed as `<module id> <command name>` on the host's argv, so
|
|
1036
|
+
* the name shares the module id's alphabet minus the underscore — an operator
|
|
1037
|
+
* types `carts abandonment-sweep`, and `check:naming`'s route-segment rule is
|
|
1038
|
+
* the same shape for the same reason. The host validates against this rather
|
|
1039
|
+
* than accepting whatever a package declared: a name with a space in it is
|
|
1040
|
+
* unaddressable, and a name that differs from the one printed by `--list` is
|
|
1041
|
+
* worse than one that is refused.
|
|
1042
|
+
*/
|
|
1043
|
+
export const MODULE_CLI_COMMAND_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
// Recent-activity eligibility (feature 080, T042j / D-163.1)
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
/**
|
|
1048
|
+
* The shape an audit action token has: `<object>.<verb>`, both snake_case.
|
|
1049
|
+
*
|
|
1050
|
+
* `product.create`, `stock_level.bulk_import`, `prompt_action.execute`. It is
|
|
1051
|
+
* the value stored in `audit_log_entries.action`, and it is matched here rather
|
|
1052
|
+
* than accepted as any string because the declaration is the *only* thing that
|
|
1053
|
+
* puts a token into the dashboard query's `$in` — a typo used to be caught by a
|
|
1054
|
+
* reviewer reading a hand-written array, and there is no array to read now.
|
|
1055
|
+
*
|
|
1056
|
+
* naming:allow-snake-case — the token is persisted verbatim in
|
|
1057
|
+
* `audit_log_entries.action` and is written by `Command.action`, so this is the
|
|
1058
|
+
* existing wire value rather than a new API field.
|
|
1059
|
+
*/
|
|
1060
|
+
export const auditActionRe = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
1061
|
+
/**
|
|
1062
|
+
* One audit action a module offers to the admin home dashboard's
|
|
1063
|
+
* recent-activity card.
|
|
1064
|
+
*
|
|
1065
|
+
* `labelKey` is **relative to the declaring module's i18n namespace**, exactly
|
|
1066
|
+
* as a command-palette action's `labelKey` is: the module ships
|
|
1067
|
+
* `activity.verb.product.create` in its own `i18n/en.json` and `pl.json`, the
|
|
1068
|
+
* card resolves it as `t('<moduleId>', '<labelKey>')`. That is what lets a
|
|
1069
|
+
* third-party package render a verb in the operator's language without the host
|
|
1070
|
+
* shipping a string for it.
|
|
1071
|
+
*
|
|
1072
|
+
* `icon` comes from {@link KnownIconNameSchema}, so the admin maps it through
|
|
1073
|
+
* the one `icon-map.ts` it already has and a package cannot name a component
|
|
1074
|
+
* the SPA does not bundle.
|
|
1075
|
+
*/
|
|
1076
|
+
export const RecentActivityEntrySchema = z.object({
|
|
1077
|
+
/** The `audit_log_entries.action` token, e.g. `product.create`. */
|
|
1078
|
+
action: z.string().regex(auditActionRe),
|
|
1079
|
+
icon: KnownIconNameSchema,
|
|
1080
|
+
/** Module-namespace-relative i18n key for the verb, e.g. `activity.verb.product.create`. */
|
|
1081
|
+
labelKey: z.string().min(1).max(255),
|
|
1082
|
+
});
|
|
1083
|
+
/**
|
|
1084
|
+
* A module's declaration that its activity is **eligible** for the dashboard's
|
|
1085
|
+
* recent-activity card — D-163.1, the first of the ruling's two axes.
|
|
1086
|
+
*
|
|
1087
|
+
* It is a declaration and not a decision. Whether a declared module's rows
|
|
1088
|
+
* actually appear is the operator's, held in
|
|
1089
|
+
* {@link recentActivityVisibilitySettingCode}'s Setting and defaulting to
|
|
1090
|
+
* visible — Constitution XVII's two-axis shape applied to a narrower object.
|
|
1091
|
+
* Neither axis overwrites the other: a module author cannot put entries on
|
|
1092
|
+
* somebody's home screen by fiat, and an operator cannot be surprised by a card
|
|
1093
|
+
* they did not configure.
|
|
1094
|
+
*
|
|
1095
|
+
* It replaces four hand-maintained tables that had already drifted apart inside
|
|
1096
|
+
* core (D-163): the server allow-list that filtered the dashboard query, the
|
|
1097
|
+
* action → module prefix map, the route's `module` enum and the admin's
|
|
1098
|
+
* `ACTIVITY_RENDERING`. Every one of them is derived from this now, so a
|
|
1099
|
+
* package's row reaches the card and a fifth hand-written entry has nowhere to
|
|
1100
|
+
* be written.
|
|
1101
|
+
*
|
|
1102
|
+
* Declared as an export of `manifest.ts` beside `installHook`,
|
|
1103
|
+
* `lifecycleParticipant` and `cliCommands`, walked by the same generator, so
|
|
1104
|
+
* core, overlay and an installed package declare one on identical terms.
|
|
1105
|
+
*/
|
|
1106
|
+
export const ModuleRecentActivitySchema = z.object({
|
|
1107
|
+
entries: z.array(RecentActivityEntrySchema).min(1),
|
|
1108
|
+
});
|
|
1109
|
+
/**
|
|
1110
|
+
* Identity-with-validation helper for module authors, the twin of
|
|
1111
|
+
* {@link defineModuleManifest}.
|
|
1112
|
+
*/
|
|
1113
|
+
export function defineModuleRecentActivity(declaration) {
|
|
1114
|
+
return ModuleRecentActivitySchema.parse(declaration);
|
|
1115
|
+
}
|
|
1116
|
+
/** The suffix every recent-activity visibility Setting code ends in. */
|
|
1117
|
+
export const RECENT_ACTIVITY_VISIBILITY_SETTING_SUFFIX = 'recent_activity_visible';
|
|
1118
|
+
/**
|
|
1119
|
+
* Raised when a module's id cannot carry a Setting code — see
|
|
1120
|
+
* {@link recentActivityVisibilitySettingCode}.
|
|
1121
|
+
*/
|
|
1122
|
+
export class RecentActivitySettingCodeInvalid extends Error {
|
|
1123
|
+
name = 'RecentActivitySettingCodeInvalid';
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* The Setting that holds the operator's choice for one declaring module.
|
|
1127
|
+
*
|
|
1128
|
+
* **Derived, never declared.** `activation.settingCode` is declared because a
|
|
1129
|
+
* module that already shipped an ad-hoc control had to be able to adopt it;
|
|
1130
|
+
* there is no such history here, and a declared code would be a fifth place a
|
|
1131
|
+
* module could disagree with the platform about its own name. D-163.1 also
|
|
1132
|
+
* fixes the default — visible — so there is nothing else for a declaration to
|
|
1133
|
+
* carry.
|
|
1134
|
+
*/
|
|
1135
|
+
export function recentActivityVisibilitySettingCode(moduleId) {
|
|
1136
|
+
const code = `${moduleId}.${RECENT_ACTIVITY_VISIBILITY_SETTING_SUFFIX}`;
|
|
1137
|
+
if (!settingCodeRe.test(code)) {
|
|
1138
|
+
throw new RecentActivitySettingCodeInvalid(`[contracts/modules] module "${moduleId}" declares recent-activity eligibility, but ` +
|
|
1139
|
+
`"${code}" is not a valid setting code. A platform-internal module id (leading ` +
|
|
1140
|
+
`underscore) cannot own one; the card is for a domain module's activity.`);
|
|
1141
|
+
}
|
|
1142
|
+
return code;
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* The settings manifest the platform reconciles for a module, which is the
|
|
1146
|
+
* module's own declaration plus the one Setting its recent-activity eligibility
|
|
1147
|
+
* implies.
|
|
1148
|
+
*
|
|
1149
|
+
* Two callers and one derivation, deliberately (D-100): the boot reconcile
|
|
1150
|
+
* walks every shipped module's settings, and the lifecycle orchestrator's
|
|
1151
|
+
* `install` reconciles exactly the arriving module's. A package has only the
|
|
1152
|
+
* second — since D-157.6(b) `install` is its sole author — so a second copy of
|
|
1153
|
+
* this merge would mean a packaged module's control existing on one path and
|
|
1154
|
+
* not the other.
|
|
1155
|
+
*
|
|
1156
|
+
* Returns `undefined` when the module declares neither, so a caller can keep
|
|
1157
|
+
* treating "no settings" as an absent value.
|
|
1158
|
+
*/
|
|
1159
|
+
export function settingsManifestWithRecentActivity(manifest, recentActivity) {
|
|
1160
|
+
if (!recentActivity)
|
|
1161
|
+
return manifest.settings;
|
|
1162
|
+
const entry = {
|
|
1163
|
+
code: recentActivityVisibilitySettingCode(manifest.id),
|
|
1164
|
+
name: `${manifest.name}: show activity on the dashboard`,
|
|
1165
|
+
description: `Whether ${manifest.name}'s entries appear on the admin home dashboard's Recent ` +
|
|
1166
|
+
'Activity card. Switching it off hides them from that card only — the audit trail ' +
|
|
1167
|
+
'itself is unchanged and the entries stay on the audit-log screen.',
|
|
1168
|
+
valueType: 'boolean',
|
|
1169
|
+
defaultValue: true,
|
|
1170
|
+
// Managed on /platform/modules beside the module's activation control, the
|
|
1171
|
+
// surface an operator already uses for exactly this kind of choice. A
|
|
1172
|
+
// second control on the generic Settings screen would be two doors onto one
|
|
1173
|
+
// decision.
|
|
1174
|
+
hidden: true,
|
|
1175
|
+
...(manifest.settings?.groups[0]?.code
|
|
1176
|
+
? { groupCode: manifest.settings.groups[0].code }
|
|
1177
|
+
: {}),
|
|
1178
|
+
};
|
|
1179
|
+
if (!manifest.settings) {
|
|
1180
|
+
return {
|
|
1181
|
+
moduleCode: manifest.id,
|
|
1182
|
+
groups: [{ code: manifest.id, name: manifest.name }],
|
|
1183
|
+
settings: [{ ...entry, groupCode: manifest.id }],
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
if (manifest.settings.settings.some((s) => s.code === entry.code)) {
|
|
1187
|
+
return manifest.settings;
|
|
1188
|
+
}
|
|
1189
|
+
return {
|
|
1190
|
+
...manifest.settings,
|
|
1191
|
+
settings: [...manifest.settings.settings, entry],
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
// Registry record
|
|
1196
|
+
// ---------------------------------------------------------------------------
|
|
1197
|
+
export const RegistryStateSchema = z.enum([
|
|
1198
|
+
'installing',
|
|
1199
|
+
'installed',
|
|
1200
|
+
'disabled',
|
|
1201
|
+
'uninstalled',
|
|
1202
|
+
]);
|
|
1203
|
+
/** Persisted shape of a row in `module_registrations` (admin HTTP DTO). */
|
|
1204
|
+
export const ModuleRegistryRecordSchema = z.object({
|
|
1205
|
+
moduleId: z.string().regex(moduleIdRe),
|
|
1206
|
+
state: RegistryStateSchema,
|
|
1207
|
+
version: z.string(),
|
|
1208
|
+
installedAt: z.iso.datetime(),
|
|
1209
|
+
lastStateChangeAt: z.iso.datetime(),
|
|
1210
|
+
lastInstallFailedAt: z.iso.datetime().nullable(),
|
|
1211
|
+
lastInstallError: z.string().nullable(),
|
|
1212
|
+
});
|
|
1213
|
+
// ---------------------------------------------------------------------------
|
|
1214
|
+
// Admin HTTP — `GET /api/v1/admin/modules` response shape
|
|
1215
|
+
// ---------------------------------------------------------------------------
|
|
1216
|
+
export const ModuleListItemFlagSchema = z.enum([
|
|
1217
|
+
'orphan',
|
|
1218
|
+
'pending-upgrade',
|
|
1219
|
+
'dep-missing',
|
|
1220
|
+
'dep-disabled',
|
|
1221
|
+
]);
|
|
1222
|
+
export const ModuleListItemStateSchema = z.enum([
|
|
1223
|
+
'installing',
|
|
1224
|
+
'installed',
|
|
1225
|
+
'disabled',
|
|
1226
|
+
'uninstalled',
|
|
1227
|
+
'not-installed',
|
|
1228
|
+
]);
|
|
1229
|
+
export const ModuleListItemSchema = z.object({
|
|
1230
|
+
id: z.string(),
|
|
1231
|
+
name: z.string(),
|
|
1232
|
+
description: z.string().nullable(),
|
|
1233
|
+
version: z.object({
|
|
1234
|
+
registered: z.string().nullable(),
|
|
1235
|
+
onDisk: z.string().nullable(),
|
|
1236
|
+
}),
|
|
1237
|
+
state: ModuleListItemStateSchema,
|
|
1238
|
+
dependencies: z.array(z.string()),
|
|
1239
|
+
flags: z.array(ModuleListItemFlagSchema),
|
|
1240
|
+
installedAt: z.iso.datetime().nullable(),
|
|
1241
|
+
lastStateChangeAt: z.iso.datetime().nullable(),
|
|
1242
|
+
});
|
|
1243
|
+
export const ModuleListResponseSchema = z.object({
|
|
1244
|
+
modules: z.array(ModuleListItemSchema),
|
|
1245
|
+
});
|
|
1246
|
+
export const ModuleListQuerySchema = z.object({
|
|
1247
|
+
state: z
|
|
1248
|
+
.enum(['installing', 'installed', 'disabled', 'uninstalled'])
|
|
1249
|
+
.optional(),
|
|
1250
|
+
flag: z.enum(['orphan', 'pending-upgrade']).optional(),
|
|
1251
|
+
});
|
|
1252
|
+
// ---------------------------------------------------------------------------
|
|
1253
|
+
// Feature 073 — module presence projections
|
|
1254
|
+
// ---------------------------------------------------------------------------
|
|
1255
|
+
/**
|
|
1256
|
+
* One module's presence as the server computed it. `present` is the
|
|
1257
|
+
* conjunction of the two axes, precomputed server-side: neither frontend
|
|
1258
|
+
* recombines them, which is what makes "off means absent" one decision rather
|
|
1259
|
+
* than two implementations that can disagree.
|
|
1260
|
+
*
|
|
1261
|
+
* The axes stay separately visible because Constitution XVII requires the
|
|
1262
|
+
* Admin UI to render them differently — *installed but switched off* shows an
|
|
1263
|
+
* actionable control, *not available at platform level* shows absent or
|
|
1264
|
+
* blocked-with-a-reason.
|
|
1265
|
+
*/
|
|
1266
|
+
export const ModulePresenceSchema = z.object({
|
|
1267
|
+
id: z.string().regex(moduleIdRe),
|
|
1268
|
+
/** platformAvailable && operatorActivated. */
|
|
1269
|
+
present: z.boolean(),
|
|
1270
|
+
platformState: RegistryStateSchema.or(z.literal('not-installed')),
|
|
1271
|
+
/** The operator axis alone. */
|
|
1272
|
+
activated: z.boolean(),
|
|
1273
|
+
deactivatable: z.boolean(),
|
|
1274
|
+
/** The module's own declared reason, rendered next to the locked control. */
|
|
1275
|
+
nonDeactivatableReason: z.string().nullable(),
|
|
1276
|
+
});
|
|
1277
|
+
/** `GET /api/v1/admin/module-presence` — every admin, no permission code. */
|
|
1278
|
+
export const AdminModulePresenceResponseSchema = z.object({
|
|
1279
|
+
modules: z.array(ModulePresenceSchema),
|
|
1280
|
+
/**
|
|
1281
|
+
* The serving process is TTL-refreshing from PostgreSQL because its pub/sub
|
|
1282
|
+
* link is unhealthy, so this projection may lag a flip made elsewhere by up
|
|
1283
|
+
* to `FALLBACK_TTL_MS`. Reported rather than hidden: the platform screen has
|
|
1284
|
+
* to be able to say "this is stale" instead of quietly showing an operator a
|
|
1285
|
+
* state that is no longer true.
|
|
1286
|
+
*/
|
|
1287
|
+
degraded: z.boolean(),
|
|
1288
|
+
});
|
|
1289
|
+
/** The storefront needs no axis detail — only whether to render at all. */
|
|
1290
|
+
export const StorefrontModulePresenceSchema = z.object({
|
|
1291
|
+
id: z.string().regex(moduleIdRe),
|
|
1292
|
+
present: z.boolean(),
|
|
1293
|
+
});
|
|
1294
|
+
/** `GET /api/v1/storefront/module-presence` — public, tag `modules:presence`. */
|
|
1295
|
+
export const StorefrontModulePresenceResponseSchema = z.object({
|
|
1296
|
+
modules: z.array(StorefrontModulePresenceSchema),
|
|
1297
|
+
});
|
|
1298
|
+
/**
|
|
1299
|
+
* `POST /api/v1/admin/modules/:id/activation` — the operator axis, and the
|
|
1300
|
+
* only door to it. The ordinary settings write path refuses an activation
|
|
1301
|
+
* code, so this endpoint's audited Command is where every flip is recorded.
|
|
1302
|
+
*/
|
|
1303
|
+
export const ModuleActivationRequestSchema = z.object({
|
|
1304
|
+
active: z.boolean(),
|
|
1305
|
+
});
|
|
1306
|
+
/** The module's presence *after* the flip, so no client recomputes it. */
|
|
1307
|
+
export const ModuleActivationResponseSchema = z.object({
|
|
1308
|
+
module: ModulePresenceSchema,
|
|
1309
|
+
});
|
|
1310
|
+
/**
|
|
1311
|
+
* `GET /api/v1/admin/modules/:id/deactivation-impact` — the live half of the
|
|
1312
|
+
* confirmation an operator is shown before switching a module off.
|
|
1313
|
+
*
|
|
1314
|
+
* Feature 074's consequence rows are **static**: one sentence per present
|
|
1315
|
+
* dependent, taken from that dependent's `whenAbsent` declaration, so the
|
|
1316
|
+
* dialog can be rendered from the ledger with no database read. This response
|
|
1317
|
+
* carries the facts that only a live read can answer, and today there is
|
|
1318
|
+
* exactly one — how many people hold a second factor (the owner's ruling on
|
|
1319
|
+
* D-96.5).
|
|
1320
|
+
*
|
|
1321
|
+
* Three properties of the shape, each deliberate:
|
|
1322
|
+
*
|
|
1323
|
+
* - **Named after the fact, not after the module.** `mfa` owns the table and
|
|
1324
|
+
* answers the question through a port; the wire shape says what the number
|
|
1325
|
+
* means. When a second module needs a live datum this becomes a list — one
|
|
1326
|
+
* entry per fact — which is a change to make when there are two, not now
|
|
1327
|
+
* (Constitution IV).
|
|
1328
|
+
* - **Nullable, always.** `null` means "not available", not "zero": the module
|
|
1329
|
+
* is already off, or the read failed. A count that cannot be fetched must
|
|
1330
|
+
* never stop an operator switching a module off, so the caller renders the
|
|
1331
|
+
* rest of the dialog and says the number is unavailable.
|
|
1332
|
+
* - **Read while the module is still on.** The dialog precedes the flip, so
|
|
1333
|
+
* the gate on the owning port is open when the question is asked. That is
|
|
1334
|
+
* what makes a live count implementable at all — see `MfaEnrolmentCountPort`.
|
|
1335
|
+
*/
|
|
1336
|
+
export const ModuleDeactivationImpactSchema = z.object({
|
|
1337
|
+
moduleId: z.string().regex(moduleIdRe),
|
|
1338
|
+
/** Subjects with an active second factor; `null` when unavailable. */
|
|
1339
|
+
activeSecondFactorUsers: z
|
|
1340
|
+
.object({
|
|
1341
|
+
admins: z.number().int().nonnegative(),
|
|
1342
|
+
customers: z.number().int().nonnegative(),
|
|
1343
|
+
})
|
|
1344
|
+
.nullable(),
|
|
1345
|
+
});
|
|
1346
|
+
// ---- Feature 060 — API interceptor diagnostics (read-only admin) ----------
|
|
1347
|
+
/**
|
|
1348
|
+
* One row of the interceptor execution plan served by
|
|
1349
|
+
* `GET /api/v1/admin/api-interceptors`. Items are sorted in execution order:
|
|
1350
|
+
* target, then phase (pre before post), then order + (module, id) tie-break.
|
|
1351
|
+
*/
|
|
1352
|
+
export const apiInterceptorEntrySchema = z.object({
|
|
1353
|
+
/** Endpoint identity, e.g. `POST /api/v1/orders`. */
|
|
1354
|
+
target: z.string(),
|
|
1355
|
+
phase: z.enum(['pre', 'post']),
|
|
1356
|
+
order: z.number().int(),
|
|
1357
|
+
/** Owning module id — execution is lifecycle-gated on this module. */
|
|
1358
|
+
module: z.string(),
|
|
1359
|
+
/** Interceptor id, unique within the module. */
|
|
1360
|
+
id: z.string(),
|
|
1361
|
+
/** Live enabled state of the owning module at request time. */
|
|
1362
|
+
moduleEnabled: z.boolean(),
|
|
1363
|
+
});
|
|
1364
|
+
export const apiInterceptorListSchema = z.object({
|
|
1365
|
+
items: z.array(apiInterceptorEntrySchema),
|
|
1366
|
+
});
|
|
1367
|
+
// ---------------------------------------------------------------------------
|
|
1368
|
+
// --- no port over the module manifests -------------------------------------
|
|
1369
|
+
//
|
|
1370
|
+
// **Which modules a deployment ships is a composition-root input, not a
|
|
1371
|
+
// module's port** (D-98.5). `ModuleManifestReadPort` stood here unprovided and
|
|
1372
|
+
// is deleted: the root builds the resolved registry and passes it *into* the
|
|
1373
|
+
// lifecycle orchestrator, so a `_lifecycle`-owned port over that value would
|
|
1374
|
+
// make the orchestrator's own input come out of the orchestrator. `_lifecycle`
|
|
1375
|
+
// owns what it adds — the dependency graph, the install hooks, the registry
|
|
1376
|
+
// rows, the operator surface — not the list. The gate could not close either:
|
|
1377
|
+
// `_lifecycle` is non-deactivatable, and a `providePort`'s one distinguishing
|
|
1378
|
+
// property over a plain registration is the 503 at the seam.
|
|
1379
|
+
//
|
|
1380
|
+
// The four modules that read manifests — `_i18n`, `admin_actions`,
|
|
1381
|
+
// `admin_roles`, `settings` — take `resolvedModuleRegistry` as a root-supplied
|
|
1382
|
+
// value and each declares the narrow view it needs. That is the rule this
|
|
1383
|
+
// settles: **aggregate reads build catalogues; targeted reads are refused.** A
|
|
1384
|
+
// `get(moduleId)` would let any module read any other module's permissions,
|
|
1385
|
+
// settings, palette actions and `activation` declaration and branch on them —
|
|
1386
|
+
// a question with no declared edge, no gate and no ledger row. Presence
|
|
1387
|
+
// questions go through `effectiveState`; capability questions go through a
|
|
1388
|
+
// port the neighbour publishes.
|
|
1389
|
+
// ---------------------------------------------------------------------------
|
|
1390
|
+
//# sourceMappingURL=modules.js.map
|