@cosmicdrift/kumiko-framework 0.393.0 → 0.395.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/AGENTS.md +1 -1
  2. package/agents/examples/cross-feature-events.md +87 -0
  3. package/agents/examples/dashboard-stats.md +141 -2
  4. package/agents/examples/env-composition.md +7 -7
  5. package/agents/examples/inbound-mail-triage.md +12 -0
  6. package/agents/examples/pdf-audit-export.md +103 -50
  7. package/agents/features.md +17 -15
  8. package/agents/widgets.md +1 -1
  9. package/dist/api/entity-read-access.d.ts +1 -0
  10. package/dist/api/entity-read-access.js +20 -5
  11. package/dist/api/server-event-dispatcher.js +23 -2
  12. package/dist/compliance/profiles.js +4 -4
  13. package/dist/crypto/index.d.ts +1 -0
  14. package/dist/crypto/index.js +1 -0
  15. package/dist/crypto/kms-outage.d.ts +8 -0
  16. package/dist/crypto/kms-outage.js +33 -0
  17. package/dist/crypto/pii-field-encryption.js +4 -3
  18. package/dist/engine/boot-validator/entity-handler.d.ts +0 -1
  19. package/dist/engine/boot-validator/entity-handler.js +2 -16
  20. package/dist/engine/boot-validator/query-output-columns.js +7 -0
  21. package/dist/engine/boot-validator/query-refs.js +9 -0
  22. package/dist/engine/boot-validator/screens-actions.js +19 -0
  23. package/dist/engine/boot-validator/screens-dashboard.d.ts +3 -2
  24. package/dist/engine/boot-validator/screens-dashboard.js +13 -6
  25. package/dist/engine/boot-validator/screens-kind-entity-list.js +2 -0
  26. package/dist/engine/boot-validator/screens-kind-projection-list.js +2 -0
  27. package/dist/engine/boot-validator/screens-lists.js +3 -0
  28. package/dist/engine/boot-validator/selector-owner-panels.js +1 -1
  29. package/dist/engine/boot-validator/transaction-phases.d.ts +3 -0
  30. package/dist/engine/boot-validator/transaction-phases.js +28 -0
  31. package/dist/engine/define-handler.d.ts +3 -2
  32. package/dist/engine/define-handler.js +3 -0
  33. package/dist/engine/factories.d.ts +2 -0
  34. package/dist/engine/feature-config-events-jobs.js +3 -0
  35. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  36. package/dist/engine/feature-entity-handlers.js +1 -17
  37. package/dist/engine/handler-definition-slots.d.ts +17 -0
  38. package/dist/engine/handler-definition-slots.js +20 -0
  39. package/dist/engine/qualified-name.d.ts +2 -1
  40. package/dist/engine/qualified-name.js +2 -14
  41. package/dist/engine/registry-facade.js +6 -0
  42. package/dist/engine/registry-read-models.d.ts +2 -0
  43. package/dist/engine/registry-read-models.js +24 -0
  44. package/dist/engine/registry-state.d.ts +4 -0
  45. package/dist/engine/registry-state.js +1 -0
  46. package/dist/engine/registry-validate.js +12 -1
  47. package/dist/engine/registry.js +2 -0
  48. package/dist/engine/strip-disabled-features-from-app-schema.js +9 -6
  49. package/dist/events-cli.d.ts +19 -0
  50. package/dist/events-cli.js +121 -0
  51. package/dist/files/bind-pending-file-refs.js +2 -1
  52. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  53. package/dist/files/can-caller-read-file-ref.d.ts +10 -0
  54. package/dist/files/can-caller-read-file-ref.js +12 -0
  55. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  56. package/dist/files/create-file-ref-from-bytes.js +7 -2
  57. package/dist/files/create-file-ref.d.ts +4 -12
  58. package/dist/files/create-file-ref.js +2 -45
  59. package/dist/files/default-file-guard.d.ts +4 -0
  60. package/dist/files/default-file-guard.js +41 -0
  61. package/dist/files/delete-record-file-refs.d.ts +8 -0
  62. package/dist/files/delete-record-file-refs.js +20 -0
  63. package/dist/files/file-routes.d.ts +1 -0
  64. package/dist/files/file-routes.js +19 -38
  65. package/dist/files/index.d.ts +6 -2
  66. package/dist/files/index.js +4 -1
  67. package/dist/files/record-only-file-entity-types.d.ts +2 -0
  68. package/dist/files/record-only-file-entity-types.js +22 -0
  69. package/dist/files/record-read-check.js +11 -0
  70. package/dist/files/types.d.ts +2 -1
  71. package/dist/files/types.js +1 -1
  72. package/dist/files/upload-field-resolution.d.ts +24 -0
  73. package/dist/files/upload-field-resolution.js +51 -0
  74. package/dist/jobs/job-runner-event-triggers.d.ts +12 -0
  75. package/dist/jobs/job-runner-event-triggers.js +64 -0
  76. package/dist/jobs/job-runner-helpers.d.ts +5 -0
  77. package/dist/jobs/job-runner-helpers.js +10 -0
  78. package/dist/jobs/job-runner-types.d.ts +1 -0
  79. package/dist/jobs/job-runner.js +3 -51
  80. package/dist/pipeline/aggregate-type-removal.d.ts +37 -0
  81. package/dist/pipeline/aggregate-type-removal.js +146 -0
  82. package/dist/pipeline/catch-up-job-consumer.d.ts +15 -0
  83. package/dist/pipeline/catch-up-job-consumer.js +38 -0
  84. package/dist/pipeline/dispatch-write.js +4 -9
  85. package/dist/pipeline/job-trigger-consumer.d.ts +5 -0
  86. package/dist/pipeline/job-trigger-consumer.js +48 -0
  87. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -2
  88. package/dist/pipeline/pre-transaction-reservations.js +16 -0
  89. package/dist/pipeline/system-hooks.d.ts +0 -3
  90. package/dist/pipeline/system-hooks.js +0 -48
  91. package/dist/ui-types/index.d.ts +1 -1
  92. package/package.json +11 -3
  93. package/src/changes.json +48 -0
@@ -82,7 +82,7 @@ Runtime on/off switch for the auth-email-password self-signup flow. Handler-less
82
82
 
83
83
  ## billing-foundation
84
84
 
85
- Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-dodo` unless you are writing a new payment provider.
85
+ Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-paddle` unless you are writing a new payment provider.
86
86
 
87
87
  Category: billing
88
88
 
@@ -389,7 +389,7 @@ Requires: `tenant-lifecycle`
389
389
 
390
390
  ## inbound-provider-imap
391
391
 
392
- Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a mailbox through the "Connect mailbox" form on the mail accounts list (`connect-mailbox` checks the IMAP login and the SMTP login of the outgoing server first, then creates the account and its credential secret in one transaction; `reconnect-mailbox` does the same check for an existing mailbox with a sign-in error and overwrites its credential secret); programmatic setups store the per-account connection JSON (host/port/secure/user/password, plus smtpHost/smtpPort for sending) in the account credential secret slot before the first sync. Implements the provider `send`: `sendMailFromAccount` sends over the SMTP server of the mailbox itself (implicit TLS, same login) with the mailbox address as From and stores a copy in its sent folder.
392
+ Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a shared mailbox through the "Connect mailbox" form on the mail accounts list and members a personal one through the same form on "My mail accounts" (`connect-mailbox` and `connect-my-mailbox` check the IMAP login and the SMTP login of the outgoing server first, then creates the account and its credential secret in one transaction; `reconnect-mailbox` does the same check for an existing mailbox with a sign-in error and overwrites its credential secret); programmatic setups store the per-account connection JSON (host/port/secure/user/password, plus smtpHost/smtpPort for sending) in the account credential secret slot before the first sync. Implements the provider `send`: `sendMailFromAccount` sends over the SMTP server of the mailbox itself (implicit TLS, same login) with the mailbox address as From and stores a copy in its sent folder.
393
393
 
394
394
  Category: notifications
395
395
 
@@ -473,7 +473,7 @@ Secrets:
473
473
 
474
474
  ## managed-pages
475
475
 
476
- Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.
476
+ Tenant-editable, server-rendered public pages with per-tenant branding. Stores one `page` (Markdown or editor-document HTML body, per `bodyFormat`) per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core and document bodies through the template-resolver document sanitizer, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.
477
477
 
478
478
  Category: content
479
479
 
@@ -622,9 +622,17 @@ Category: infrastructure
622
622
 
623
623
  Requires: `secrets`
624
624
 
625
- ## subscription-dodo
625
+ ## subscription-mollie
626
+
627
+ Mollie payment provider plugin for `billing-foundation`, covering the DACH/EU mid-market use case. Mount via `createSubscriptionMollieFeature({ apiKey, webhookUrl, priceToTier, priceToConfig })` — the factory validates that `priceToTier` and `priceToConfig` keys are identical at boot time. Implements `verifyAndParseWebhook` (lazy Mollie-API fetch + heuristic event-type mapping) and `createCheckoutSession` (customer + first-payment with `sequenceType="first"`); `createPortalSession` and `cancelSubscription` are not available because Mollie has no customer portal and requires a `customerId` that the plugin contract does not carry.
628
+
629
+ Category: billing
630
+
631
+ Requires: `billing-foundation`
632
+
633
+ ## subscription-paddle
626
634
 
627
- Dodo Payments provider plugin for `billing-foundation`. Dodo acts as merchant of record: it sells to the customer and issues invoice and VAT, so the consumer-protection texts for this provider come from `consumerProtection.providerTexts`. Reads its API key and webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant), plus a `billingLive` master switch and an `environment` key (test | live). `environment` is its own key because Dodo serves test and live mode from different API hosts and a live key must never hit the test host or the other way round; `billingLive` only decides whether checkouts are offered at all. All four are runtime config, so keys rotate and prod goes live without a redeploy, and the `mask` on each derives the sysadmin settings screen and nav. Mount via `createSubscriptionDodoFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env to secrets bridge fallbacks (`DODO_PAYMENTS_API_KEY`, `DODO_PAYMENTS_WEBHOOK_KEY`). The plugin always mounts; `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true. Webhooks arrive at `/api/subscription/webhook/dodo` and are verified per the Standard Webhooks spec (HMAC-SHA256 over id, timestamp and the raw body, 5 minute tolerance); events for products outside `priceToTier` or without tenant metadata are dropped. Implements hosted checkout, customer portal, cancel and resume, `retrievePrices` (10-minute TTL cache), `retrieveSubscription` for the sync job, and an in-app plan switch: `previewPlanSwitch` shows the proration before the customer confirms, `applyPlanSwitch` changes the plan without a hosted page and waits for the `plan_changed` webhook to move the tier. A plan switch in Dodo requires all plan products to belong to one Product Collection.
635
+ Paddle Billing provider plugin for `billing-foundation`. Paddle acts as merchant of record: it sells to the customer and issues invoice and VAT, so the consumer-protection texts for this provider come from `consumerProtection.providerTexts`. Reads its API key, webhook secret and tenant binding key from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant), the public Paddle.js `client-token`, a `billingLive` master switch and an `environment` key (sandbox | live). `environment` is its own key because Paddle serves sandbox and live from different API hosts and a live key must never hit the sandbox host or the other way round; `billingLive` only decides whether checkouts are offered at all. All of them are runtime config, so keys rotate and prod goes live without a redeploy, and the `mask` on each derives the sysadmin settings screen and nav. Mount via `createSubscriptionPaddleFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret`/`tenantBindingKey`/`clientToken` options are env to config bridge fallbacks (`PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`, `PADDLE_TENANT_BINDING_KEY`, `PADDLE_CLIENT_TOKEN`). The plugin always mounts; `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, and billing counts as enabled only with API key and binding key present. Webhooks arrive at `/webhooks/subscription/paddle` when mounted through the server-runtime `billing.paddle` boot option, or at `/api/subscription/webhook/paddle` when `createSubscriptionWebhookRoute` is mounted by hand and are verified with the `Paddle-Signature` header (HMAC-SHA256 over `ts:rawBody` keyed with the raw secret, any `h1` may match, 5 minute tolerance). Paddle.js can open checkouts with arbitrary `customData` from the browser, so `custom_data.tenantId` is only trusted together with `tenantBinding`, an HMAC of the tenant id under the binding key; events without a valid binding, for prices outside `priceToTier` or without a tenant are dropped. Rotating the binding key makes the webhooks of existing subscriptions fail that check until they are re-bound. Paddle has no hosted checkout page, so the feature serves the Default Payment Link itself at `/billing/paddle-checkout` (the Default Payment Link in the Paddle dashboard must point to `<APP_BASE_URL>/billing/paddle-checkout`; it is the only source, because checkouts are created without a `checkout.url`). Create subscription prices in the Paddle catalog with quantity minimum and maximum 1; for one-off packs set the quantity limits on purpose, because the payment event carries `quantity` and the app has to multiply its credits by it: an anonymous page with its own CSP that loads Paddle.js from cdn.paddle.com, the one external script this framework allows, and opens the overlay for the transaction. Implements checkout, customer portal, cancel and resume, `retrievePrices` (batched, 10-minute TTL cache), `retrieveSubscription` for the sync job, and an in-app plan switch: `previewPlanSwitch` shows the proration before the customer confirms, `applyPlanSwitch` changes the plan without a hosted page and waits for the `subscription.updated` webhook to move the tier.
628
636
 
629
637
  Category: billing
630
638
 
@@ -636,17 +644,11 @@ Config keys:
636
644
  | --- | --- | --- | --- |
637
645
  | `api-key` | text | system | |
638
646
  | `billing-live` | boolean | system | `false` |
639
- | `environment` | select | system | `test` |
647
+ | `client-token` | text | system | |
648
+ | `environment` | select | system | `sandbox` |
649
+ | `tenant-binding-key` | text | system | |
640
650
  | `webhook-secret` | text | system | |
641
651
 
642
- ## subscription-mollie
643
-
644
- Mollie payment provider plugin for `billing-foundation`, covering the DACH/EU mid-market use case. Mount via `createSubscriptionMollieFeature({ apiKey, webhookUrl, priceToTier, priceToConfig })` — the factory validates that `priceToTier` and `priceToConfig` keys are identical at boot time. Implements `verifyAndParseWebhook` (lazy Mollie-API fetch + heuristic event-type mapping) and `createCheckoutSession` (customer + first-payment with `sequenceType="first"`); `createPortalSession` and `cancelSubscription` are not available because Mollie has no customer portal and requires a `customerId` that the plugin contract does not carry.
645
-
646
- Category: billing
647
-
648
- Requires: `billing-foundation`
649
-
650
652
  ## subscription-stripe
651
653
 
652
654
  Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog), `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), `retrieveSubscription` (live provider-side snapshot for the `sync-subscriptions` backfill job, mapped identically to the webhook path), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).
@@ -671,7 +673,7 @@ Category: data
671
673
 
672
674
  ## template-resolver
673
675
 
674
- Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. The same list decides delivery: a tenant override is served (mail, PDF, layouts, text blocks) only while its template is listed in tenantEditable; otherwise the system version is served. A locked override stays stored, a SystemAdmin sees it in the overview marked as not delivered, and listing the template makes it effective again. Tenant rows of a collection mounted with `access` are always served. A collection mounted without `access` is SystemAdmin only. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
676
+ Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. The same list decides delivery: a tenant override is served (mail, PDF, layouts, text blocks) only while its template is listed in tenantEditable; otherwise the system version is served. A locked override stays stored, a SystemAdmin sees it in the overview marked as not delivered, and listing the template makes it effective again. Tenant rows of a collection mounted with `access` are always served. A collection mounted without `access` is SystemAdmin only. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area that stores text, `document` a WYSIWYG document editor that stores sanitized HTML (bold, italic, headings, lists, links, variable chips). Both offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
675
677
 
676
678
  Category: notifications
677
679
 
package/agents/widgets.md CHANGED
@@ -77,7 +77,6 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
77
77
  | `ResizablePanelGroup` | |
78
78
  | `ResultPanel` | Result section for a calculator: SectionCard with an empty state (Banner) or a metric list (DetailList) plus optional extras (table, notes). |
79
79
  | `ResultTable` | Static, prop-driven result table for calculated rows (tranches, scenarios). |
80
- | `RichContentEditor` | |
81
80
  | `ScreenWidthProvider` | |
82
81
  | `SectionCard` | Section with a title and optional action slot (range switcher, filter) — replaces repeated `<section className={CARD}>…<h2>` blocks in custom screens. |
83
82
  | `SelectField` | Select field = Field + Input(kind:"select"). |
@@ -115,6 +114,7 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
115
114
  | --- | --- |
116
115
  | `useBrowserNavApi` | React hook that builds a NavApi from browser history. |
117
116
  | `useBrowserTokensApi` | Builds a TokensApi for the browser. |
117
+ | `useDispatchTarget` | Recommended production dispatch. |
118
118
  | `useDispatcher` | |
119
119
  | `useDispatcherStatus` | |
120
120
  | `useDraft` | Form state for calculator screens: a draft object plus `patch` for partial updates, and `field(name)` that provides `{ id, name, value, onChange }` props ready for field widgets (NumberField/MoneyField/PercentField). |
@@ -1,5 +1,6 @@
1
1
  import type { AccessRule } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import type { OwnershipMap } from "@cosmicdrift/kumiko-types/ownership";
3
3
  import type { Registry } from "../engine/types/index.ts";
4
+ export declare function assertNoAggregateTypeKeyCollisions(registry: Registry): void;
4
5
  export declare function collectEntityReadAccess(registry: Registry): ReadonlyMap<string, readonly AccessRule[]>;
5
6
  export declare function collectEntityRowReadMaps(registry: Registry): ReadonlyMap<string, OwnershipMap>;
@@ -1,12 +1,26 @@
1
+ import { toKebab } from "@cosmicdrift/kumiko-types/kebab-case";
2
+ // Both collectors key by the entity name and its kebab-case form, so two registered names that
3
+ // fold to the same key (billing.period / billingPeriod) would merge their rules or overwrite each
4
+ // other's row maps: fail at boot instead of leaking or hiding signals.
5
+ export function assertNoAggregateTypeKeyCollisions(registry) {
6
+ const nameByKey = new Map();
7
+ for (const entityName of registry.getAllEntities().keys()) {
8
+ for (const key of new Set([entityName, toKebab(entityName)])) {
9
+ const other = nameByKey.get(key);
10
+ if (other !== undefined && other !== entityName) {
11
+ throw new Error(`Entities "${other}" and "${entityName}" map to the same live-signal aggregate type "${key}"; rename one of them.`);
12
+ }
13
+ nameByKey.set(key, entityName);
14
+ }
15
+ }
16
+ }
1
17
  // Access rules of the query handlers that let a role read an entity (the
2
18
  // handler's own entity or one it declares via liveEntities). A live signal
3
19
  // carries the id of every row of the entity, so a signed-in connection gets it
4
20
  // only when one of these rules grants its roles. An aggregate type without a
5
21
  // reading query has no entry and is never put on the tenant channel.
6
- function toKebabCase(name) {
7
- return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
8
- }
9
22
  export function collectEntityReadAccess(registry) {
23
+ assertNoAggregateTypeKeyCollisions(registry);
10
24
  const rulesByEntity = new Map();
11
25
  for (const [handlerName, handler] of registry.getAllQueryHandlers()) {
12
26
  const entities = new Set(handler.liveEntities ?? []);
@@ -16,7 +30,7 @@ export function collectEntityReadAccess(registry) {
16
30
  for (const entityName of entities) {
17
31
  // Executors may write under the kebab-case form of the camelCase r.entity() name
18
32
  // (document-extract for documentExtract); the signal carries that aggregate type.
19
- for (const aggregateType of new Set([entityName, toKebabCase(entityName)])) {
33
+ for (const aggregateType of new Set([entityName, toKebab(entityName)])) {
20
34
  const rules = rulesByEntity.get(aggregateType) ?? [];
21
35
  rules.push(handler.access);
22
36
  rulesByEntity.set(aggregateType, rules);
@@ -28,12 +42,13 @@ export function collectEntityReadAccess(registry) {
28
42
  // Entities whose own access.read narrows rows per role (e.g. Driver sees only assigned rows).
29
43
  // A signal for such an entity must not name ids to a role that does not read every row.
30
44
  export function collectEntityRowReadMaps(registry) {
45
+ assertNoAggregateTypeKeyCollisions(registry);
31
46
  const mapsByEntity = new Map();
32
47
  for (const [entityName, entity] of registry.getAllEntities()) {
33
48
  const readMap = entity.access?.read;
34
49
  if (readMap === undefined || Object.keys(readMap).length === 0)
35
50
  continue;
36
- for (const aggregateType of new Set([entityName, toKebabCase(entityName)])) {
51
+ for (const aggregateType of new Set([entityName, toKebab(entityName)])) {
37
52
  mapsByEntity.set(aggregateType, readMap);
38
53
  }
39
54
  }
@@ -2,9 +2,11 @@ import { withExecutorManagedWrites } from "../db/tenant-db.js";
2
2
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
3
3
  import { runsInLane } from "../engine/run-in.js";
4
4
  import { createTenantFileContext } from "../files/file-handle.js";
5
+ import { catchUpEventTypesOf, createCatchUpJobEventConsumer, } from "../pipeline/catch-up-job-consumer.js";
5
6
  import { createEventDispatcher, } from "../pipeline/event-dispatcher.js";
7
+ import { createJobTriggerEventConsumer } from "../pipeline/job-trigger-consumer.js";
6
8
  import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context.js";
7
- import { createAccessInvalidationEventConsumer, createJobTriggerEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, } from "../pipeline/system-hooks.js";
9
+ import { createAccessInvalidationEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, } from "../pipeline/system-hooks.js";
8
10
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
9
11
  export function createServerEventDispatcher(deps) {
10
12
  const { options, context, sseBroker, observability, fileProviderResolver, instanceId } = deps;
@@ -27,7 +29,8 @@ export function createServerEventDispatcher(deps) {
27
29
  context,
28
30
  fileProviderResolver,
29
31
  });
30
- const consumers = [...systemConsumers, ...mspConsumers];
32
+ const catchUpJobConsumers = buildCatchUpJobConsumers(options, baseDb);
33
+ const consumers = [...systemConsumers, ...mspConsumers, ...catchUpJobConsumers];
31
34
  const { disabled: dispatcherDisabled, systemConsumers: _systemConsumersOpt, ...dispatcherTunables } = options.eventDispatcher ?? {};
32
35
  let eventDispatcher;
33
36
  if (consumers.length > 0 && baseDb && !dispatcherDisabled) {
@@ -82,6 +85,24 @@ function buildSystemEventConsumers(options, sseBroker, searchAdapter) {
82
85
  }
83
86
  return systemConsumers;
84
87
  }
88
+ function buildCatchUpJobConsumers(options, baseDb) {
89
+ const jobRunner = options.dispatcherOptions?.jobRunner;
90
+ if (!jobRunner || !baseDb)
91
+ return [];
92
+ const upcast = { db: baseDb, upcasters: options.registry.getEventUpcasters() };
93
+ if (!(options.eventDispatcher?.systemConsumers?.jobTrigger ?? true))
94
+ return [];
95
+ const processLane = options.processLane ?? "worker";
96
+ const consumers = [];
97
+ for (const [jobName, jobDef] of options.registry.getAllJobs()) {
98
+ if (!("on" in jobDef.trigger) || !jobDef.trigger.catchUp)
99
+ continue;
100
+ if (!runsInLane(jobDef.runIn, processLane))
101
+ continue;
102
+ consumers.push(createCatchUpJobEventConsumer(jobRunner, jobName, options.registry.getJobFeature(jobName), catchUpEventTypesOf(jobDef.trigger), upcast));
103
+ }
104
+ return consumers;
105
+ }
85
106
  function buildMultiStreamProjectionConsumers(options, deps) {
86
107
  const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
87
108
  const mspJobs = jobRunnerForTriggers?.dispatch
@@ -35,7 +35,7 @@ const RAW_PROFILES = {
35
35
  "eu-dsgvo": {
36
36
  key: "eu-dsgvo",
37
37
  region: "EU",
38
- label: "EU — DSGVO Standard",
38
+ label: "EU — GDPR standard",
39
39
  userRights: {
40
40
  gracePeriod: { days: 30 },
41
41
  restrictionAllowed: true,
@@ -69,7 +69,7 @@ const RAW_PROFILES = {
69
69
  "swiss-dsg": {
70
70
  key: "swiss-dsg",
71
71
  region: "CH",
72
- label: "Schweiz — Bundesgesetz über den Datenschutz (rev. 2023)",
72
+ label: "Switzerland — Federal Act on Data Protection (rev. 2023)",
73
73
  extends: "eu-dsgvo",
74
74
  notifications: {
75
75
  languages: ["de", "fr", "it", "en"],
@@ -84,7 +84,7 @@ const RAW_PROFILES = {
84
84
  "de-hr-dsgvo-hgb": {
85
85
  key: "de-hr-dsgvo-hgb",
86
86
  region: "DE",
87
- label: "Deutschland HR — DSGVO + HGB + Personalakten",
87
+ label: "Germany HR — GDPR + HGB + personnel files",
88
88
  extends: "eu-dsgvo",
89
89
  userRights: {
90
90
  gracePeriod: { days: 30 },
@@ -121,7 +121,7 @@ const RAW_PROFILES = {
121
121
  "minimal-no-region": {
122
122
  key: "minimal-no-region",
123
123
  region: "—",
124
- label: "Minimal — kein Compliance-Profile (NICHT für Production)",
124
+ label: "Minimal — no compliance profile (NOT for production)",
125
125
  userRights: {
126
126
  gracePeriod: { days: 30 },
127
127
  restrictionAllowed: false,
@@ -4,6 +4,7 @@ export { InMemoryKmsAdapter } from "./in-memory-kms-adapter.ts";
4
4
  export { isSelfPiiField } from "./is-self-pii-field.ts";
5
5
  export { resolvePlatformKeks } from "./kek-source.ts";
6
6
  export { KeyAlreadyExistsError, KeyErasedError, type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForUser, } from "./kms-adapter.ts";
7
+ export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.ts";
7
8
  export { type KmsWiring, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.ts";
8
9
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.ts";
9
10
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.ts";
@@ -4,6 +4,7 @@ export { InMemoryKmsAdapter } from "./in-memory-kms-adapter.js";
4
4
  export { isSelfPiiField } from "./is-self-pii-field.js";
5
5
  export { resolvePlatformKeks } from "./kek-source.js";
6
6
  export { KeyAlreadyExistsError, KeyErasedError, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForUser, } from "./kms-adapter.js";
7
+ export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.js";
7
8
  export { requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
8
9
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
10
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
@@ -0,0 +1,8 @@
1
+ import { KumikoError } from "../errors/kumiko-error.ts";
2
+ export declare const ENCRYPTION_UNAVAILABLE_CODE = "encryption_unavailable";
3
+ export declare class EncryptionUnavailableError extends KumikoError {
4
+ readonly code = "encryption_unavailable";
5
+ readonly httpStatus = 503;
6
+ constructor(cause?: Error);
7
+ }
8
+ export declare function classifyKmsOutage<T>(call: () => Promise<T>): Promise<T>;
@@ -0,0 +1,33 @@
1
+ import { KumikoError } from "../errors/kumiko-error.js";
2
+ import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError } from "./kms-adapter.js";
3
+ export const ENCRYPTION_UNAVAILABLE_CODE = "encryption_unavailable";
4
+ // The key store failed (database, Key Manager, network), so nothing could be encrypted or decrypted.
5
+ // A caller retries later: the outage says nothing about the record it was writing.
6
+ export class EncryptionUnavailableError extends KumikoError {
7
+ code = ENCRYPTION_UNAVAILABLE_CODE;
8
+ httpStatus = 503;
9
+ constructor(cause) {
10
+ super({
11
+ message: "the PII key store is unavailable",
12
+ i18nKey: "errors.internal",
13
+ ...(cause && { cause }),
14
+ });
15
+ }
16
+ }
17
+ function isKeyStateError(error) {
18
+ return (error instanceof KeyErasedError ||
19
+ error instanceof KeyNotFoundError ||
20
+ error instanceof KeyAlreadyExistsError);
21
+ }
22
+ // Key-state errors are answers of a working key store and keep their own type for the callers that
23
+ // branch on them; everything else thrown by the adapter is the store itself failing.
24
+ export async function classifyKmsOutage(call) {
25
+ try {
26
+ return await call();
27
+ }
28
+ catch (error) {
29
+ if (isKeyStateError(error) || error instanceof KumikoError)
30
+ throw error;
31
+ throw new EncryptionUnavailableError(error instanceof Error ? error : undefined);
32
+ }
33
+ }
@@ -17,6 +17,7 @@
17
17
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
18
18
  import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
19
19
  import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, PII_ERASED_SENTINEL, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
20
+ import { classifyKmsOutage } from "./kms-outage.js";
20
21
  import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
21
22
  import { resolveSubjectForField } from "./subject-resolver.js";
22
23
  // Spec value (crypto-shredding.md) — renderers show it verbatim.
@@ -219,12 +220,12 @@ let injectedKms;
219
220
  function gateIrreversibleKmsErase(adapter) {
220
221
  return {
221
222
  capabilities: adapter.capabilities,
222
- createKey: (subject, ctx) => adapter.createKey(subject, ctx),
223
- getKey: (subject, ctx) => adapter.getKey(subject, ctx),
223
+ createKey: (subject, ctx) => classifyKmsOutage(() => adapter.createKey(subject, ctx)),
224
+ getKey: (subject, ctx) => classifyKmsOutage(() => adapter.getKey(subject, ctx)),
224
225
  health: () => adapter.health(),
225
226
  eraseKey: async (subject, ctx) => {
226
227
  assertIrreversibleOperationAllowed("kms.eraseKey");
227
- await adapter.eraseKey(subject, ctx);
228
+ await classifyKmsOutage(() => adapter.eraseKey(subject, ctx));
228
229
  },
229
230
  };
230
231
  }
@@ -8,7 +8,6 @@ export declare const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string>;
8
8
  export declare function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void;
9
9
  export declare function validateHandlerAccess(feature: FeatureDefinition): void;
10
10
  export declare function validateAdditionalRateLimits(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
11
- export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
12
11
  export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
13
12
  export declare function validateRateLimitCost(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
14
13
  export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
@@ -5,6 +5,7 @@ import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
5
5
  import { access as accessPresets } from "../config-helpers.js";
6
6
  import { parseRefTarget } from "../parse-ref-target.js";
7
7
  import { isSelectOptionTone } from "./select-option-tone.js";
8
+ import { validateBeforeTransactionWhenNested, validatePerCommandTransaction, } from "./transaction-phases.js";
8
9
  export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
9
10
  // Field names that typically contain PII. Without a `pii: true` /
10
11
  // `userOwned` / `tenantOwned` / `allowPlaintext` marker → boot warning.
@@ -141,6 +142,7 @@ export function validateHandlerAccess(feature) {
141
142
  validateAdditionalRateLimits(feature.name, name, handler);
142
143
  validateRateLimitCost(feature.name, name, handler);
143
144
  validatePerCommandTransaction(feature.name, name, handler);
145
+ validateBeforeTransactionWhenNested(feature.name, name, handler);
144
146
  }
145
147
  }
146
148
  const IP_BUCKETED_RATE_LIMIT_PER = new Set(["ip", "ip+handler"]);
@@ -196,22 +198,6 @@ function validatePayloadBucket(where, shape, entry) {
196
198
  throw new Error(`${where} for "${field}" with windowSeconds ${entry.windowSeconds} — must be a positive integer.`);
197
199
  }
198
200
  }
199
- function perCommandConflict(handler) {
200
- if (handler.transaction !== "perCommand")
201
- return undefined;
202
- if (handler.beforeTransaction)
203
- return "beforeTransaction";
204
- if (handler.reserveBeforeTransaction)
205
- return "reserveBeforeTransaction";
206
- return undefined;
207
- }
208
- export function validatePerCommandTransaction(featureName, handlerName, handler) {
209
- const conflicting = perCommandConflict(handler);
210
- if (conflicting !== undefined) {
211
- throw new Error(`write handler "${featureName}:write:${handlerName}" declares transaction: "perCommand" together with ${conflicting} — ` +
212
- "a perCommand handler holds no transaction, so move the phase into the commands it issues via ctx.dispatch.write.");
213
- }
214
- }
215
201
  const IP_SCOPED_RATE_LIMIT_PER = ["ip", "ip+handler"];
216
202
  export function validateTenantlessAnonymous(featureName, handlerName, handler) {
217
203
  // skip: handler does not opt into tenantless anonymous calls
@@ -198,12 +198,19 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
198
198
  // contract with no author-declared field names to check.
199
199
  }
200
200
  }
201
+ function checkToolbarVisibleWhenFields(queryHandlers, featureName, screenId, screen) {
202
+ for (const action of screen.toolbarActions ?? []) {
203
+ checkVisibleWhenField(queryHandlers, action.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) toolbarAction "${action.id}"`);
204
+ }
205
+ }
201
206
  function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen) {
202
207
  checkVisibleWhenField(queryHandlers, screen.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (${screen.type})`);
203
208
  if (screen.type === "projectionList") {
209
+ checkToolbarVisibleWhenFields(queryHandlers, featureName, screenId, screen);
204
210
  checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen);
205
211
  }
206
212
  else if (screen.type === "entityList") {
213
+ checkToolbarVisibleWhenFields(queryHandlers, featureName, screenId, screen);
207
214
  if (screen.expandableRow !== undefined) {
208
215
  const rowShape = resolveListRowShape(queryHandlers, screen.expandableRow.query, `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow.title}"`);
209
216
  for (const column of screen.expandableRow.columns) {
@@ -110,6 +110,13 @@ function checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, scre
110
110
  checkFormFieldQueryRefs(queryHandlers, featureName, screenId, "secretMint confirm", screen.confirm.fields);
111
111
  }
112
112
  }
113
+ function checkToolbarVisibleWhenQueryRefs(queryHandlers, featureName, screenId, screen) {
114
+ for (const action of screen.toolbarActions ?? []) {
115
+ if (action.visibleWhen === undefined)
116
+ continue;
117
+ checkVisibleWhenQueryRef(queryHandlers, action.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) toolbarAction "${action.id}" visibleWhen`);
118
+ }
119
+ }
113
120
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
114
121
  if (screen.visibleWhen !== undefined) {
115
122
  const { idParam } = screen.visibleWhen;
@@ -121,8 +128,10 @@ function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
121
128
  }
122
129
  if (screen.type === "projectionList") {
123
130
  checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
131
+ checkToolbarVisibleWhenQueryRefs(queryHandlers, featureName, screenId, screen);
124
132
  }
125
133
  else if (screen.type === "entityList") {
134
+ checkToolbarVisibleWhenQueryRefs(queryHandlers, featureName, screenId, screen);
126
135
  if (screen.expandableRow !== undefined) {
127
136
  checkQueryRef(queryHandlers, screen.expandableRow.query, () => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}"`);
128
137
  }
@@ -1,5 +1,6 @@
1
1
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
2
2
  import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
3
+ import { fallbackTargets, SCREEN_FALLBACK_REASONS, } from "@cosmicdrift/kumiko-types/screen";
3
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
4
5
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
5
6
  import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
@@ -465,7 +466,25 @@ export function validateScreenVisibleWhen(feature, screenId, screen, allScreenQn
465
466
  validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
466
467
  }
467
468
  }
469
+ function assertFallbackMapShape(context, fallback) {
470
+ const reasons = Object.keys(fallback);
471
+ if (reasons.length === 0) {
472
+ throw new Error(`${context} fallback map is empty. Set at least one of ${SCREEN_FALLBACK_REASONS.join(", ")} or use a screen id string.`);
473
+ }
474
+ const unknownReason = reasons.find((reason) => !isOneOf(SCREEN_FALLBACK_REASONS, reason));
475
+ if (unknownReason !== undefined) {
476
+ throw new Error(`${context} fallback has unknown reason "${unknownReason}". Use ${SCREEN_FALLBACK_REASONS.join(", ")}.`);
477
+ }
478
+ }
468
479
  function validateScreenFallback(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
480
+ if (typeof fallback !== "string") {
481
+ assertFallbackMapShape(`[Feature ${feature.name}] Screen "${screenId}" (${screenType})`, fallback);
482
+ }
483
+ for (const target of fallbackTargets(fallback)) {
484
+ validateFallbackTarget(feature, screenId, screenType, target, allScreenQns, featureMap);
485
+ }
486
+ }
487
+ function validateFallbackTarget(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
469
488
  const context = `[Feature ${feature.name}] Screen "${screenId}" (${screenType})`;
470
489
  validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
471
490
  const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
@@ -1,5 +1,5 @@
1
1
  import type { FeatureDefinition } from "../types/index.ts";
2
- import type { DashboardCustomPanel, DashboardScreenDefinition, DashboardScreenPanel } from "../types/screen.ts";
2
+ import type { DashboardCustomPanel, DashboardPanelVisibility, DashboardScreenDefinition, DashboardScreenPanel } from "../types/screen.ts";
3
3
  import { resolveRowActionNavigateTarget } from "./screens-actions.ts";
4
4
  export declare function validateDashboardScreen(featureName: string, screenId: string, screen: DashboardScreenDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
5
5
  type DashboardActionNavLookups = {
@@ -10,6 +10,7 @@ type DashboardActionNavLookups = {
10
10
  };
11
11
  export declare function validateDashboardActions(featureName: string, screenId: string, screen: DashboardScreenDefinition, allWriteHandlerQns: ReadonlySet<string>, lookups: DashboardActionNavLookups): void;
12
12
  export declare function validateDashboardRecordBinding(featureName: string, screenId: string, screen: DashboardScreenDefinition): void;
13
- export declare function validateDashboardScreenPanel(featureName: string, screenId: string, panel: DashboardScreenPanel, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
13
+ export declare function validateDashboardScreenPanel(featureName: string, screenId: string, panel: DashboardScreenPanel, featureMap: ReadonlyMap<string, FeatureDefinition>, hostHasRecord: boolean | undefined): void;
14
+ export declare function validateDashboardPanelVisibleWhen(context: string, visibleWhen: DashboardPanelVisibility | undefined): void;
14
15
  export declare function validateDashboardCustomPanel(featureName: string, screenId: string, panel: DashboardCustomPanel): void;
15
16
  export {};
@@ -26,7 +26,7 @@ export function validateDashboardScreen(featureName, screenId, screen, featureMa
26
26
  validateDashboardCustomPanel(featureName, screenId, panel);
27
27
  }
28
28
  else if (panel.kind === "screen") {
29
- validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
29
+ validateDashboardScreenPanel(featureName, screenId, panel, featureMap, screen.record !== undefined);
30
30
  }
31
31
  else if (panel.kind === "text") {
32
32
  validateDashboardTextPanel(featureName, screenId, panel, featureMap);
@@ -166,12 +166,13 @@ function validateDashboardTimeRange(featureName, screenId, timeRange, filterId)
166
166
  }
167
167
  const EMBEDDABLE_SCREEN_TYPES = new Set([
168
168
  "projectionList",
169
+ "projectionDetail",
169
170
  "actionForm",
170
171
  "secretMint",
171
172
  "configEdit",
172
173
  "secretsEdit",
173
174
  ]);
174
- function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap) {
175
+ function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap, hostHasRecord) {
175
176
  const targetQn = resolveScreenTargetQn(featureName, ref);
176
177
  const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
177
178
  const target = featureMap.get(targetFeatureName)?.screens[targetShortId];
@@ -183,6 +184,12 @@ function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap
183
184
  throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
184
185
  `${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
185
186
  }
187
+ // The tile hands the dashboard's bound record id to the detail screen; without a binding it has no id.
188
+ // undefined = host unknown (extension-selector panels), checked nowhere else.
189
+ if (target.type === "projectionDetail" && hostHasRecord === false) {
190
+ throw new Error(`${context} embeds "${targetQn}", a projectionDetail, but the host dashboard has no record binding ` +
191
+ `to take its id from. Add record: { param } to the dashboard or embed another screen type.`);
192
+ }
186
193
  // ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
187
194
  // cancel even when embedded, which would pull the user off the dashboard.
188
195
  if ((target.type === "actionForm" || target.type === "secretMint") &&
@@ -194,9 +201,9 @@ function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap
194
201
  }
195
202
  return target;
196
203
  }
197
- export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
204
+ export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap, hostHasRecord) {
198
205
  const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
199
- validateEmbeddableScreenRef(context, "screen", panel.screen, featureName, featureMap);
206
+ validateEmbeddableScreenRef(context, "screen", panel.screen, featureName, featureMap, hostHasRecord);
200
207
  validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
201
208
  // skip: no fallback declared, nothing more to validate
202
209
  if (panel.fallback === undefined)
@@ -204,7 +211,7 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
204
211
  if (panel.visibleWhen === undefined) {
205
212
  throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
206
213
  }
207
- const target = validateEmbeddableScreenRef(context, "fallback", panel.fallback, featureName, featureMap);
214
+ const target = validateEmbeddableScreenRef(context, "fallback", panel.fallback, featureName, featureMap, hostHasRecord);
208
215
  // A gated fallback could hide in turn; the tile would show nothing where it promised a stand-in.
209
216
  if (target.visibleWhen !== undefined) {
210
217
  throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
@@ -237,7 +244,7 @@ function validateDashboardTextLinkBlock(context, block, featureName, featureMap)
237
244
  `List the roles or omit roles.`);
238
245
  }
239
246
  }
240
- function validateDashboardPanelVisibleWhen(context, visibleWhen) {
247
+ export function validateDashboardPanelVisibleWhen(context, visibleWhen) {
241
248
  if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
242
249
  throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
243
250
  }
@@ -8,6 +8,7 @@ import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
8
8
  import { validateListBulkActions } from "./list-bulk-actions.js";
9
9
  import { validateListTableOptions } from "./list-table-options.js";
10
10
  import { assertWriteHandlerRegistered, rejectRedirectOnListRowAction, resolveRowActionNavigateTarget, validateActionFieldRefs, validateAtMostOneRowClick, validateDrawerTargetAction, validateRowActionNavigateParams, } from "./screens-actions.js";
11
+ import { validateDashboardPanelVisibleWhen } from "./screens-dashboard.js";
11
12
  import { buildUnknownFieldMessage, listActionContext, rejectEmptyColumns, rejectNonArrayInFilter, resolveScreenEntity, validateListColumnRendering, } from "./screens-kind-shared.js";
12
13
  import { validateRelatedListSection, validateRowActionTarget } from "./screens-lists.js";
13
14
  function resolveEntityListFields(ctx, screenId, entityName) {
@@ -156,6 +157,7 @@ function validateEntityListRowAction(ctx, screenId, screen, fields, action) {
156
157
  // unregistered writeHandler QNs fail at boot instead of on the first click.
157
158
  function validateEntityListToolbarAction(ctx, screenId, fields, action) {
158
159
  const featureName = ctx.feature.name;
160
+ validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (entityList) toolbarAction "${action.id}"`, action.visibleWhen);
159
161
  if (action.kind === "navigate") {
160
162
  const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
161
163
  if (!ctx.allScreenQns.has(candidateQn) && !ctx.navTargetShortIds.has(action.screen)) {