@cosmicdrift/kumiko-samples 0.404.0 → 0.404.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +25 -25
  3. package/packages/bundled-features/src/account-security/README.md +7 -5
  4. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
  5. package/packages/bundled-features/src/documents/changes.json +8 -0
  6. package/packages/bundled-features/src/host-routing/README.md +10 -10
  7. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
  8. package/packages/bundled-features/src/legal-pages/README.md +21 -13
  9. package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
  10. package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
  11. package/packages/bundled-features/src/template-resolver/README.md +29 -29
  12. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  13. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
  14. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
  15. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
  16. package/packages/bundled-features/src/user-data-rights/README.md +33 -33
  17. package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
  18. package/samples/apps/admin-console/README.md +5 -5
  19. package/samples/apps/admin-console/package.json +3 -3
  20. package/samples/apps/cap-billing-demo/README.md +15 -15
  21. package/samples/apps/config-demo/README.md +7 -7
  22. package/samples/apps/config-demo/package.json +2 -2
  23. package/samples/apps/marketing-demo/README.md +2 -2
  24. package/samples/apps/marketing-demo/package.json +2 -2
  25. package/samples/apps/showcase/package.json +3 -3
  26. package/samples/apps/styleguide/package.json +2 -2
  27. package/samples/apps/ui-walkthrough/README.md +8 -9
  28. package/samples/apps/ui-walkthrough/package.json +2 -2
  29. package/samples/apps/use-all-bundled/README.md +2 -2
  30. package/samples/apps/use-all-bundled/package.json +2 -2
  31. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
  32. package/samples/apps/user-data-rights-demo/README.md +12 -12
  33. package/samples/apps/workspaces/package.json +2 -2
  34. package/samples/recipes/apex-landing/README.md +3 -3
  35. package/samples/recipes/apex-landing/package.json +1 -1
  36. package/samples/recipes/apex-surface-auth/README.md +13 -9
  37. package/samples/recipes/auth-mfa/README.md +2 -4
  38. package/samples/recipes/auth-mfa/package.json +2 -2
  39. package/samples/recipes/boot-check/README.md +2 -2
  40. package/samples/recipes/compliance-profiles-demo/README.md +5 -5
  41. package/samples/recipes/compose-stacks/README.md +8 -8
  42. package/samples/recipes/cross-feature-events/README.md +1 -0
  43. package/samples/recipes/crypto-shredding-hr/README.md +3 -3
  44. package/samples/recipes/custom-fields-basic/README.md +1 -1
  45. package/samples/recipes/documents/README.md +25 -0
  46. package/samples/recipes/embedded-entity-form/package.json +2 -2
  47. package/samples/recipes/encrypted-tenant-config/README.md +8 -8
  48. package/samples/recipes/env-composition/README.md +10 -10
  49. package/samples/recipes/error-contract/README.md +9 -9
  50. package/samples/recipes/event-sourcing/README.md +9 -9
  51. package/samples/recipes/ledger-invoicing/README.md +6 -6
  52. package/samples/recipes/legal-pages/README.md +16 -16
  53. package/samples/recipes/managed-config/README.md +15 -11
  54. package/samples/recipes/managed-pages/README.md +7 -7
  55. package/samples/recipes/pdf-audit-export/package.json +1 -1
  56. package/samples/recipes/pipeline-basics/README.md +13 -13
  57. package/samples/recipes/public-share-token/README.md +4 -4
  58. package/samples/recipes/record-detail-layout/package.json +3 -3
  59. package/samples/recipes/security-baseline/README.md +4 -1
  60. package/samples/recipes/session-revocation/README.md +1 -1
  61. package/samples/recipes/tenant-settings/README.md +4 -4
  62. package/samples/recipes/tier-admin/README.md +2 -2
  63. package/samples/recipes/user-data-rights/README.md +21 -19
  64. package/samples/recipes/wizard-form/package.json +3 -3
  65. package/samples/recipes/writeform-section/package.json +3 -3
@@ -68,6 +68,7 @@ The integration test pins three guarantees:
68
68
  ```bash
69
69
  bun kumiko test integration samples/cross-feature-events
70
70
  ```
71
+
71
72
  4. **Catch-up job** — `catch-up-job.integration.test.ts` places orders
72
73
  before the first dispatcher pass and checks that the `catchUp` job
73
74
  invoices each of them exactly once.
@@ -7,7 +7,7 @@ unreadable forever, and reads render the `[[erased]]` sentinel.
7
7
 
8
8
  The recipe ships a mini HR feature: an `employee` entity whose name and email
9
9
  are the employee's own PII, and an `hr-comment` entity whose body is
10
- encrypted under the key of the employee the comment is *about* — so a
10
+ encrypted under the key of the employee the comment is _about_ — so a
11
11
  manager's comment dies with the employee's key, not the manager's.
12
12
 
13
13
  ## What it shows
@@ -41,7 +41,7 @@ manager's comment dies with the employee's key, not the manager's.
41
41
  than an alphabetically paginated one — not a plaintext sort column beside
42
42
  the encrypted one, which would survive the key erase and quietly undo the
43
43
  shredding.
44
- - **Forget = `kms.eraseKey(subject)`** — afterwards detail *and* list render
44
+ - **Forget = `kms.eraseKey(subject)`** — afterwards detail _and_ list render
45
45
  `[[erased]]` for every protected field while the stored ciphertext bytes
46
46
  stay untouched.
47
47
 
@@ -62,7 +62,7 @@ deletion grace period.
62
62
 
63
63
  1. Create an employee → the executor creates a subject key on first insert
64
64
  and stores `displayName`/`email` as ciphertext.
65
- 2. A comment about the employee is encrypted under the *employee's* key via
65
+ 2. A comment about the employee is encrypted under the _employee's_ key via
66
66
  `userOwned: { ownerField: "employeeId" }`.
67
67
  3. `kms.eraseKey({ kind: "user", userId })` — idempotent, tombstone stays
68
68
  for the audit trail.
@@ -3,7 +3,7 @@
3
3
  Let a tenant add their own fields to your entity at runtime — without
4
4
  writing a migration, without rebuilding your handler, without a code
5
5
  change. The recipe wires a `property` entity into the `custom-fields`
6
- bundle and drives a full *define → set → read* roundtrip.
6
+ bundle and drives a full _define → set → read_ roundtrip.
7
7
 
8
8
  The result: the tenant defines `internalNumber: text`, sets it on a
9
9
  property, and reads it back flattened onto the row — looking exactly
@@ -0,0 +1,25 @@
1
+ # Documents
2
+
3
+ A document register on top of existing records: captured files with a category, linked to records, visible only to whoever may read those records.
4
+
5
+ ## What it shows
6
+
7
+ - `createDocumentsFeature` (`@cosmicdrift/kumiko-bundled-features/documents`) with `categories`, `linkTargets` and `access` roles.
8
+ - A `capture` rule turns a file field into a document source without the entity knowing about the documents feature: every saved `contract.file` is filed as a document of category `contract`, titled from the row, and linked to the contract's property.
9
+ - Visibility follows the linked records: a document is visible to users who may read the linked records and the contract that owns the file. The `documents:query:for-record` query returns the documents of one record; a property reader sees none for the property because they may not read the contract that owns the file.
10
+
11
+ ## Source
12
+
13
+ Feature entry point: `src/feature.ts`.
14
+
15
+ ```ts file=<rootDir>/_samples/recipes-documents/src/feature.ts
16
+ ```
17
+
18
+ ## Tests
19
+
20
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
21
+
22
+ ```bash
23
+ cd samples/recipes/documents
24
+ bun test
25
+ ```
@@ -5,10 +5,10 @@
5
5
  "dependencies": {
6
6
  "@cosmicdrift/kumiko-framework": "workspace:*",
7
7
  "@cosmicdrift/kumiko-renderer": "workspace:*",
8
- "react": "^19.2.6"
8
+ "react": "^19.3.1"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cosmicdrift/kumiko-testing": "workspace:*",
12
- "@types/react": "^19.2.14"
12
+ "@types/react": "^19.3.0"
13
13
  }
14
14
  }
@@ -10,10 +10,10 @@ the platform operator being able to read the plaintext from the DB.
10
10
  import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
11
11
 
12
12
  createTenantConfig("text", {
13
- encrypted: true, // ← ciphertext in the DB
14
- write: access.admin, // ← the tenant admin writes their own
15
- read: access.admin, // ← nobody else sees it
16
- mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav
13
+ encrypted: true, // ← ciphertext in the DB
14
+ write: access.admin, // ← the tenant admin writes their own
15
+ read: access.admin, // ← nobody else sees it
16
+ mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav
17
17
  });
18
18
  ```
19
19
 
@@ -39,10 +39,10 @@ i18n key of the field label, `mask.order` its position.
39
39
 
40
40
  ## Vs. `r.secret` / `samples/recipes/secrets-demo`
41
41
 
42
- | Pattern | Scope | Use case |
43
- |---|---|---|
44
- | **`r.secret`** (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers). |
45
- | **`encrypted: true` config key** (this recipe) | Per-tenant | Customer-owned secrets. Customer sets, rotates, and never sees another customer's. Same envelope encryption + KEK keyring under the hood. |
42
+ | Pattern | Scope | Use case |
43
+ | -------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | **`r.secret`** (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers). |
45
+ | **`encrypted: true` config key** (this recipe) | Per-tenant | Customer-owned secrets. Customer sets, rotates, and never sees another customer's. Same envelope encryption + KEK keyring under the hood. |
46
46
 
47
47
  Combinable: the platform uses `r.secret` for its internal secrets,
48
48
  and in parallel customers have their tenant-owned `encrypted: true`
@@ -21,16 +21,16 @@ instead of keeping a hand-written copy of every feature's variables.
21
21
 
22
22
  ## Blocks
23
23
 
24
- | Block | Where it lives | Registered by |
25
- | --- | --- | --- |
26
- | `kmsEnvSchema` | `crypto-shredding` | `createCryptoShreddingFeature()` |
27
- | `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
28
- | `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
29
- | `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
30
- | `paddleEnvSchema({ prices })` | `subscription-paddle` | app (`extend`); the feature registers the bridge keys |
31
- | `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
32
- | `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
33
- | `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
24
+ | Block | Where it lives | Registered by |
25
+ | ---------------------------------------------- | ------------------------- | ----------------------------------------------------- |
26
+ | `kmsEnvSchema` | `crypto-shredding` | `createCryptoShreddingFeature()` |
27
+ | `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
28
+ | `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
29
+ | `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
30
+ | `paddleEnvSchema({ prices })` | `subscription-paddle` | app (`extend`); the feature registers the bridge keys |
31
+ | `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
32
+ | `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
33
+ | `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
34
34
 
35
35
  ## When to reach for it
36
36
 
@@ -14,15 +14,15 @@ Every Kumiko error class in a real handler context. A single feature `orders-lit
14
14
 
15
15
  ## The classes — when do I use which?
16
16
 
17
- | Class | HTTP | Use in handler |
18
- |---|---|---|
19
- | `ValidationError` | 400 | Automatic from Zod. Never throw manually, only for validation-hook errors. |
20
- | `AccessDeniedError` | 403 | "You're not allowed" — ownership, role check, field lock. |
21
- | `NotFoundError` | 404 | Entity doesn't exist. Automatic via `failNotFound(entity, id)`. |
22
- | `ConflictError` | 409 | State collision without a version (e.g. "paid orders can't be cancelled"). |
23
- | `VersionConflictError` | 409 | Optimistic lock — comes out of CrudExecutor automatically. You never throw it. |
24
- | `UnprocessableError` | 422 | Business rule violated. The reason string describes what. |
25
- | `InternalError` | 500 | You **don't throw it yourself**. The framework wraps unexpected throws automatically. |
17
+ | Class | HTTP | Use in handler |
18
+ | ---------------------- | ---- | ------------------------------------------------------------------------------------- |
19
+ | `ValidationError` | 400 | Automatic from Zod. Never throw manually, only for validation-hook errors. |
20
+ | `AccessDeniedError` | 403 | "You're not allowed" — ownership, role check, field lock. |
21
+ | `NotFoundError` | 404 | Entity doesn't exist. Automatic via `failNotFound(entity, id)`. |
22
+ | `ConflictError` | 409 | State collision without a version (e.g. "paid orders can't be cancelled"). |
23
+ | `VersionConflictError` | 409 | Optimistic lock — comes out of CrudExecutor automatically. You never throw it. |
24
+ | `UnprocessableError` | 422 | Business rule violated. The reason string describes what. |
25
+ | `InternalError` | 500 | You **don't throw it yourself**. The framework wraps unexpected throws automatically. |
26
26
 
27
27
  ## Convenience helpers
28
28
 
@@ -6,15 +6,15 @@ blocks Kumiko provides.
6
6
 
7
7
  ## What the sample shows
8
8
 
9
- | API | Purpose |
10
- |---|---|
11
- | `r.defineEvent(name, schema, { version, migrations })` | Event schema with version number + upcaster chain |
12
- | `ctx.events.append({ aggregateId, aggregateType, type, payload })` | Domain event on aggregate stream |
13
- | `r.projection(...)` | Single-stream read model, inline in the write TX |
14
- | `r.multiStreamProjection(...)` | Cross-aggregate read model, async via dispatcher |
15
- | `ctx.events.load(id, { asOf })` | Live aggregation with point-in-time |
16
- | `ctx.events.archive(id, { aggregateType })` | Archive a stream (Marten `ArchiveStream`) |
17
- | `ctx.dispatch.queryProjection(name)` | Tenant-scoped read-model query |
9
+ | API | Purpose |
10
+ | ------------------------------------------------------------------ | ------------------------------------------------- |
11
+ | `r.defineEvent(name, schema, { version, migrations })` | Event schema with version number + upcaster chain |
12
+ | `ctx.events.append({ aggregateId, aggregateType, type, payload })` | Domain event on aggregate stream |
13
+ | `r.projection(...)` | Single-stream read model, inline in the write TX |
14
+ | `r.multiStreamProjection(...)` | Cross-aggregate read model, async via dispatcher |
15
+ | `ctx.events.load(id, { asOf })` | Live aggregation with point-in-time |
16
+ | `ctx.events.archive(id, { aggregateType })` | Archive a stream (Marten `ArchiveStream`) |
17
+ | `ctx.dispatch.queryProjection(name)` | Tenant-scoped read-model query |
18
18
 
19
19
  ## Domain
20
20
 
@@ -38,12 +38,12 @@ invoicingFlow …`):
38
38
  Both recipes use the same primitive — accounts + balanced transactions — but
39
39
  record different things:
40
40
 
41
- | | [ledger-banking](../ledger-banking/) | ledger-invoicing |
42
- |---|---|---|
43
- | Accounts | all `asset` (bank accounts) | `asset` + `income` + `liability` |
44
- | A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
45
- | Revenue recognised | n/a | at invoice time (accrual) |
46
- | Reports surfaced | balances | balances + P&L |
41
+ | | [ledger-banking](../ledger-banking/) | ledger-invoicing |
42
+ | ------------------ | ------------------------------------ | ----------------------------------------- |
43
+ | Accounts | all `asset` (bank accounts) | `asset` + `income` + `liability` |
44
+ | A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
45
+ | Revenue recognised | n/a | at invoice time (accrual) |
46
+ | Reports surfaced | balances | balances + P&L |
47
47
 
48
48
  ## Run
49
49
 
@@ -162,12 +162,12 @@ page in `createLegalPagesLayout({ siteName })`.
162
162
 
163
163
  After the seed these URLs are immediately live:
164
164
 
165
- | URL | Content |
166
- |---|---|
167
- | `https://your-app.example/legal/impressum` | Impressum (DE) |
168
- | `https://your-app.example/legal/datenschutz` | Datenschutzerklärung (DE) |
169
- | `https://your-app.example/legal/imprint` | Imprint (EN, if seeded) |
170
- | `https://your-app.example/legal/privacy` | Privacy Policy (EN, if seeded) |
165
+ | URL | Content |
166
+ | -------------------------------------------- | ------------------------------ |
167
+ | `https://your-app.example/legal/impressum` | Impressum (DE) |
168
+ | `https://your-app.example/legal/datenschutz` | Datenschutzerklärung (DE) |
169
+ | `https://your-app.example/legal/imprint` | Imprint (EN, if seeded) |
170
+ | `https://your-app.example/legal/privacy` | Privacy Policy (EN, if seeded) |
171
171
 
172
172
  → Footer links are set per app (legal-pages does not ship a footer
173
173
  component — deliberately, every app has its own layout).
@@ -195,22 +195,22 @@ ACL: `["TenantAdmin", "SystemAdmin"]`. Cache header `public, max-age=300`
195
195
  When the required blocks (`imprint/de` + `privacy/de`) are missing
196
196
  from SYSTEM_TENANT:
197
197
 
198
- | Mode | Behavior |
199
- |---|---|
198
+ | Mode | Behavior |
199
+ | --------------------- | ----------------------------------------------------------- |
200
200
  | `NODE_ENV=production` | App boot throws an error with a slug list — container exits |
201
- | otherwise (dev/test) | `console.warn` with a slug list, app starts anyway |
201
+ | otherwise (dev/test) | `console.warn` with a slug list, app starts anyway |
202
202
 
203
203
  → Safety net: no production deploy without populated legal pages.
204
204
 
205
205
  ## Troubleshooting
206
206
 
207
- | Symptom | Cause | Fix |
208
- |---|---|---|
209
- | Route returns `503 legal page unavailable` | `anonymousAccess` not configured in `runProdApp` | Set `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` |
210
- | Boot check throws `ctx.templateResolver missing` | `extraContext.templateResolver` not wired | Set `extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) })` |
211
- | Route returns `404 not configured` | Required block doesn't exist or has `content=null` | `seedTextBlock` with a content string |
212
- | Multi-tenant app: tenant subdomain shows an empty page | (Bug regression?) Routes should ALWAYS show SYSTEM_TENANT texts | The `legal-pages.integration.test.ts` test "SYSTEM_TENANT routing" covers this — should be green |
213
- | `<script>` tags in a Markdown body land 1:1 in the HTML | Deliberately accepted right now — see [legal-pages/README.md XSS section](../../../packages/bundled-features/src/legal-pages/README.md#xss--currently-not-secured-by-design) | DOMPurify is a Phase 2 once a multi-author setup arrives |
207
+ | Symptom | Cause | Fix |
208
+ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
209
+ | Route returns `503 legal page unavailable` | `anonymousAccess` not configured in `runProdApp` | Set `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` |
210
+ | Boot check throws `ctx.templateResolver missing` | `extraContext.templateResolver` not wired | Set `extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) })` |
211
+ | Route returns `404 not configured` | Required block doesn't exist or has `content=null` | `seedTextBlock` with a content string |
212
+ | Multi-tenant app: tenant subdomain shows an empty page | (Bug regression?) Routes should ALWAYS show SYSTEM_TENANT texts | The `legal-pages.integration.test.ts` test "SYSTEM_TENANT routing" covers this — should be green |
213
+ | `<script>` tags in a Markdown body land 1:1 in the HTML | Deliberately accepted right now — see [legal-pages/README.md XSS section](../../../packages/bundled-features/src/legal-pages/README.md#xss--currently-not-secured-by-design) | DOMPurify is a Phase 2 once a multi-author setup arrives |
214
214
 
215
215
  ## Cross-refs
216
216
 
@@ -6,15 +6,19 @@ backings and two scopes, without a hand-written screen, nav, or env-wiring map.
6
6
 
7
7
  Two keys, deliberately different:
8
8
 
9
- | Key | Scope | Backing | Story |
10
- |---|---|---|---|
9
+ | Key | Scope | Backing | Story |
10
+ | ----------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
11
11
  | `payment-api-key` | `system` | `secrets` | Platform-owned secret (one Stripe key bills all tenants). Stored envelope-encrypted in the secrets store, masked in every query, revealed only for the owning feature's `ctx.config` read. |
12
- | `smtp-host` | `tenant` | config | Platform default that a tenant admin overrides. Cascade resolves `tenant-row → system-row → default`; one tenant's override never leaks to another. |
12
+ | `smtp-host` | `tenant` | config | Platform default that a tenant admin overrides. Cascade resolves `tenant-row → system-row → default`; one tenant's override never leaks to another. |
13
13
 
14
14
  ## Pattern
15
15
 
16
16
  ```ts illustration
17
- import { access, createSystemConfig, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
17
+ import {
18
+ access,
19
+ createSystemConfig,
20
+ createTenantConfig,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
18
22
 
19
23
  r.config({
20
24
  keys: {
@@ -48,7 +52,7 @@ r.config({
48
52
  valid for `scope: "system"` — secrets are flat per `(tenant, key)` with no
49
53
  cascade, so a tenant override would be incoherent and is rejected at boot.
50
54
  - **`env: "SMTP_HOST"`** — at boot (`runProdApp`) the env var seeds the key's
51
- default as an app-override, below any `system-row`. The env var *is* the
55
+ default as an app-override, below any `system-row`. The env var _is_ the
52
56
  default; no manual `AppConfigOverrides` map, no re-typing in the admin UI.
53
57
  - **`mask`** — the key surfaces automatically in the self-populating settings
54
58
  hub: `buildConfigFeatureSchema` derives a `configEdit` screen + a child nav
@@ -86,14 +90,14 @@ the config backing only needs the `ctx.services.secrets` context shown above.
86
90
 
87
91
  ## Vs. the neighbouring recipes
88
92
 
89
- | Pattern | Storage | Cascade | Use case |
90
- |---|---|---|---|
91
- | **`encrypted: true` config key** (`encrypted-tenant-config`) | `config_values`, AES ciphertext | system → tenant | Per-tenant customer secret, no KEK rotation/audit. |
92
- | **`backing: "secrets"` config key** (this recipe) | secrets envelope, system tenant | none (system-only) | Platform-owned secret needing rotation + audit-on-read, declared as a config key. |
93
- | **`r.secret`** (`secrets-demo`) | secrets envelope | none | Same storage, but addressed directly — no config key, no settings-hub entry. |
93
+ | Pattern | Storage | Cascade | Use case |
94
+ | ------------------------------------------------------------ | ------------------------------- | ------------------ | --------------------------------------------------------------------------------- |
95
+ | **`encrypted: true` config key** (`encrypted-tenant-config`) | `config_values`, AES ciphertext | system → tenant | Per-tenant customer secret, no KEK rotation/audit. |
96
+ | **`backing: "secrets"` config key** (this recipe) | secrets envelope, system tenant | none (system-only) | Platform-owned secret needing rotation + audit-on-read, declared as a config key. |
97
+ | **`r.secret`** (`secrets-demo`) | secrets envelope | none | Same storage, but addressed directly — no config key, no settings-hub entry. |
94
98
 
95
99
  `backing: "secrets"` is the bridge: you get the secrets store's guarantees
96
- *and* the config feature's declarative surface (masking, settings hub, the same
100
+ _and_ the config feature's declarative surface (masking, settings hub, the same
97
101
  `config:write:set` write path).
98
102
 
99
103
  ## Flow
@@ -139,14 +139,14 @@ exact same helpers.
139
139
 
140
140
  ## Security posture
141
141
 
142
- | Threat | Mitigation |
143
- |---|---|
144
- | `<script>`/raw HTML in a page body | `renderSafeMarkdown` escapes raw HTML (Markdown-only) |
145
- | Cross-tenant content on a shared CDN | `Vary: Host` + per-tenant `resolveApexTenant` |
146
- | Draft leak to anonymous visitors | published-only; drafts → 404 |
142
+ | Threat | Mitigation |
143
+ | --------------------------------------------------------- | ------------------------------------------------------------------- |
144
+ | `<script>`/raw HTML in a page body | `renderSafeMarkdown` escapes raw HTML (Markdown-only) |
145
+ | Cross-tenant content on a shared CDN | `Vary: Host` + per-tenant `resolveApexTenant` |
146
+ | Draft leak to anonymous visitors | published-only; drafts → 404 |
147
147
  | `@import`/`url()`/`expression()`/`</style>` in custom CSS | allowlist-by-construction sanitizer (rebuilt from validated tokens) |
148
- | Scope-escape / overlay onto host chrome | scope-prefix + `position`/`isolation`/`overflow` containment |
149
- | Untrusted tenant abusing CSS | tier-gate (`managed-pages-css`) + `allowCustomCss` opt-in |
148
+ | Scope-escape / overlay onto host chrome | scope-prefix + `position`/`isolation`/`overflow` containment |
149
+ | Untrusted tenant abusing CSS | tier-gate (`managed-pages-css`) + `allowCustomCss` opt-in |
150
150
 
151
151
  Custom-CSS sanitization for untrusted tenants is **best-effort defense-in-depth**:
152
152
  a tenant can still restyle its **own** page area within the clip. Keep it
@@ -8,6 +8,6 @@
8
8
  },
9
9
  "devDependencies": {
10
10
  "@cosmicdrift/kumiko-testing": "workspace:*",
11
- "unpdf": "^1.8.1"
11
+ "unpdf": "^1.9.1"
12
12
  }
13
13
  }
@@ -7,18 +7,18 @@ behaves end-to-end against the worktree's framework source.
7
7
 
8
8
  ## What's inside
9
9
 
10
- | Step | Where it shows up |
11
- |---|---|
12
- | `r.step.aggregate.create` | `product:create` |
13
- | `r.step.aggregate.update` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
14
- | `r.step.aggregate.appendEvent` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
15
- | `r.step.read.findOne` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
16
- | `r.step.read.findMany` | `report:archive-low-stock-products` |
17
- | `r.step.compute` | `product:adjust-stock`, `product:bulk-adjust` |
18
- | `r.step.branch` | `product:rename`, `product:adjust-stock` |
19
- | `r.step.forEach` | `product:bulk-adjust`, `report:archive-low-stock-products` |
20
- | `r.step.return` | every handler |
21
- | `r.step.unsafeProjectionUpsert` | `product:adjust-stock` |
10
+ | Step | Where it shows up |
11
+ | ------------------------------- | ------------------------------------------------------------------------------ |
12
+ | `r.step.aggregate.create` | `product:create` |
13
+ | `r.step.aggregate.update` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
14
+ | `r.step.aggregate.appendEvent` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
15
+ | `r.step.read.findOne` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
16
+ | `r.step.read.findMany` | `report:archive-low-stock-products` |
17
+ | `r.step.compute` | `product:adjust-stock`, `product:bulk-adjust` |
18
+ | `r.step.branch` | `product:rename`, `product:adjust-stock` |
19
+ | `r.step.forEach` | `product:bulk-adjust`, `report:archive-low-stock-products` |
20
+ | `r.step.return` | every handler |
21
+ | `r.step.unsafeProjectionUpsert` | `product:adjust-stock` |
22
22
  | `r.step.unsafeProjectionDelete` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
23
23
 
24
24
  ## Domain
@@ -84,7 +84,7 @@ engine APIs land in a published release.
84
84
  in the pure event-sourcing shape — it appends a domain event
85
85
  (`product-stock-adjusted`) and lets the inline
86
86
  `product-stock-counter` projection update `currentStock`. The
87
- alternative (calling `aggregate.update` *and* `appendEvent` in
87
+ alternative (calling `aggregate.update` _and_ `appendEvent` in
88
88
  one handler) inflates the stream-version mid-handler and trips
89
89
  optimistic-locking on the next call. The other handlers
90
90
  (`rename`, `bulk-adjust`) use `aggregate.update` directly
@@ -20,11 +20,11 @@ the URL carries a share token).
20
20
 
21
21
  ## Handlers
22
22
 
23
- | QN | Access |
24
- |---|---|
23
+ | QN | Access |
24
+ | -------------------------------------- | --------------------------- |
25
25
  | `public-share:write:share-link:create` | authenticated (`openToAll`) |
26
- | `public-share:write:share-link:revoke` | owner |
27
- | `public-share:query:share-by-token` | anonymous + authenticated |
26
+ | `public-share:write:share-link:revoke` | owner |
27
+ | `public-share:query:share-by-token` | anonymous + authenticated |
28
28
 
29
29
  ## Run tests
30
30
 
@@ -9,14 +9,14 @@
9
9
  "dependencies": {
10
10
  "@cosmicdrift/kumiko-framework": "workspace:*",
11
11
  "@cosmicdrift/kumiko-renderer": "workspace:*",
12
- "react": "^19.2.6"
12
+ "react": "^19.3.1"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@cosmicdrift/kumiko-server-runtime": "workspace:*",
16
16
  "@cosmicdrift/kumiko-testing": "workspace:*",
17
- "@playwright/test": "^1.62.1",
17
+ "@playwright/test": "^1.64.1",
18
18
  "@tailwindcss/cli": "^4.3.3",
19
- "@types/react": "^19.2.14",
19
+ "@types/react": "^19.3.0",
20
20
  "tailwindcss": "^4.3.3"
21
21
  }
22
22
  }
@@ -45,7 +45,10 @@ mounts `sessions` as part of its own require-chain. Combine both directly;
45
45
  `dedupeFeatures()` collapses the two identical `sessions` instances at boot:
46
46
 
47
47
  ```ts illustration
48
- import { composeGdprStack, composeSecurityStack } from "@cosmicdrift/kumiko-dev-server/compose-stacks";
48
+ import {
49
+ composeGdprStack,
50
+ composeSecurityStack,
51
+ } from "@cosmicdrift/kumiko-dev-server/compose-stacks";
49
52
 
50
53
  export const APP_FEATURES = [
51
54
  // ...config, user, tenant, auth-foundation...
@@ -25,7 +25,7 @@ const server = buildServer({
25
25
  }),
26
26
  // ... other features
27
27
  ]),
28
- context: { db, /* ... */ },
28
+ context: { db /* ... */ },
29
29
  jwtSecret: process.env.JWT_SECRET,
30
30
  auth: {
31
31
  membershipQuery: "tenant:query:memberships",
@@ -56,12 +56,12 @@ role vocabulary overrides those instead of forking the feature.
56
56
 
57
57
  ## Why the schema needs a special case
58
58
 
59
- `buildInsertSchema` always makes `currency` required *inside* a money field's
59
+ `buildInsertSchema` always makes `currency` required _inside_ a money field's
60
60
  value object, even when the field itself is optional — a caller literally
61
61
  cannot submit `{ amount: 1000 }` against the plain `defineEntityCreateHandler`.
62
62
  `defineCreateWithTenantDefaults` swaps in its own schema (`.extend()` over the
63
63
  same `buildInsertSchema` output, with just the declared `currencyFields`
64
- relaxed) so the caller *can* omit `currency`, then fills it from
64
+ relaxed) so the caller _can_ omit `currency`, then fills it from
65
65
  `ctx.config(TenantSettingsConfig.currency)` before delegating to the same
66
66
  `executor.create()` the generic factory uses.
67
67
 
@@ -74,13 +74,13 @@ changes them from **Settings → Tenant → Default Currency / Default Locale**.
74
74
 
75
75
  ## Flow
76
76
 
77
- 1. No tenant override yet → a new invoice gets the *feature's own* default
77
+ 1. No tenant override yet → a new invoice gets the _feature's own_ default
78
78
  (`"EUR"`/`"en"`, set via `createTenantSettingsFeature()`'s own defaults).
79
79
  2. Tenant sets `currency` to `"CHF"` and `locale` to `"de"` via the
80
80
  Settings-Hub (or `config:write:set` directly).
81
81
  3. A new invoice that doesn't specify `amount.currency`/`language` picks up
82
82
  `"CHF"`/`"de"` — no entity code changed, no second migration.
83
- 4. A caller that *does* specify `amount.currency` still wins — the tenant
83
+ 4. A caller that _does_ specify `amount.currency` still wins — the tenant
84
84
  setting only fills gaps, it never overrides an explicit value.
85
85
 
86
86
  ## Tests
@@ -9,7 +9,7 @@ toggleable features unlock in the same request.
9
9
  ## What it shows
10
10
 
11
11
  - **`tier-engine:write:set-tenant-tier`** — a SystemAdmin assigns a tier
12
- to *any* tenant, cross-tenant. Stamps `source: "manual"` so a future
12
+ to _any_ tenant, cross-tenant. Stamps `source: "manual"` so a future
13
13
  Stripe → tier sync won't overwrite the grant.
14
14
  - **`tier-engine:query:get-tenant-tier`** — SystemAdmin reads back which
15
15
  tier a tenant is on plus its source (`"manual"` vs `"billing"`).
@@ -50,7 +50,7 @@ notes-export → r.toggleable() domain feature unlocked by pro tier
50
50
  path bypasses the `postSave` entity-hook the resolver normally uses to
51
51
  invalidate the cache after a tier-assignment change. Without an explicit
52
52
  cache update the grant would persist (next request would see it) but
53
- *this* request still sees the old tier — surprising for an operator who
53
+ _this_ request still sees the old tier — surprising for an operator who
54
54
  just clicked "set tier to pro".
55
55
 
56
56
  The feature wires `onAssigned` into `createSetTenantTierWrite` for
@@ -17,13 +17,13 @@ defineFeature("notes", (r) => {
17
17
  r.useExtension(EXT_USER_DATA, "note", {
18
18
  export: async (ctx) => ({
19
19
  entity: "note",
20
- rows: await ctx.db.select().from(notesTable)
21
- .where(and(eq(notesTable.tenantId, ctx.tenantId),
22
- eq(notesTable.authorId, ctx.userId))),
20
+ rows: await ctx.db
21
+ .select()
22
+ .from(notesTable)
23
+ .where(and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId))),
23
24
  }),
24
25
  delete: async (ctx, strategy) => {
25
- const where = and(eq(notesTable.tenantId, ctx.tenantId),
26
- eq(notesTable.authorId, ctx.userId));
26
+ const where = and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId));
27
27
  if (strategy === "anonymize") {
28
28
  await ctx.db.update(notesTable).set({ authorId: null }).where(where);
29
29
  } else {
@@ -36,21 +36,21 @@ defineFeature("notes", (r) => {
36
36
 
37
37
  ## What runs automatically
38
38
 
39
- | Article | What happens | Trigger |
40
- |---|---|---|
39
+ | Article | What happens | Trigger |
40
+ | ------------ | ---------------------------------------------- | -------------------------------------- |
41
41
  | Art. 15 + 20 | Notes snippet in export ZIP + magic-link email | `runUserExport` (via `request-export`) |
42
- | Art. 17 | Notes deleted or anonymized after grace | `runForgetCleanup` cron |
43
- | Art. 18 | Restriction blocks login while active | Auth middleware |
44
- | Operator | Brute-force detection on magic-link endpoint | Edge rate-limit + audit log |
42
+ | Art. 17 | Notes deleted or anonymized after grace | `runForgetCleanup` cron |
43
+ | Art. 18 | Restriction blocks login while active | Auth middleware |
44
+ | Operator | Brute-force detection on magic-link endpoint | Edge rate-limit + audit log |
45
45
 
46
46
  ## Strategy-aware delete
47
47
 
48
48
  `retention.policyFor("note")` resolves per entity:
49
49
 
50
- | Profile | Strategy | Effect |
51
- |---|---|---|
52
- | `eu-dsgvo` (default) | `delete` | Notes hard-deleted |
53
- | `de-hr-dsgvo-hgb` | `anonymize` for HR entities | `authorId=null`, row kept for multi-user refs |
50
+ | Profile | Strategy | Effect |
51
+ | -------------------- | --------------------------- | --------------------------------------------- |
52
+ | `eu-dsgvo` (default) | `delete` | Notes hard-deleted |
53
+ | `de-hr-dsgvo-hgb` | `anonymize` for HR entities | `authorId=null`, row kept for multi-user refs |
54
54
 
55
55
  The integration test pins both paths.
56
56
 
@@ -91,7 +91,9 @@ const result = await runRetentionCleanup({
91
91
  registry: stack.registry,
92
92
  tenantId,
93
93
  preloadedTenantPreset: null,
94
- now: getTemporal().Now.instant().add({ hours: 31 * 24 }), // keepFor 30d + 1 day
94
+ now: getTemporal()
95
+ .Now.instant()
96
+ .add({ hours: 31 * 24 }), // keepFor 30d + 1 day
95
97
  files: createFileContext(() => Promise.resolve(provider)), // the stack's storage provider
96
98
  });
97
99
  // result.hardDeleted === 1, bytes and file_refs row gone
@@ -99,10 +101,10 @@ const result = await runRetentionCleanup({
99
101
 
100
102
  ## Full app vs this recipe
101
103
 
102
- | | This recipe | [user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) |
103
- |---|---|---|
104
- | Scope | One hook pattern | Runnable app + todos + files |
105
- | Boot | Integration test only | `bun dev` on port 4291 |
104
+ | | This recipe | [user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) |
105
+ | ----- | --------------------- | ---------------------------------------------------------------- |
106
+ | Scope | One hook pattern | Runnable app + todos + files |
107
+ | Boot | Integration test only | `bun dev` on port 4291 |
106
108
 
107
109
  ## Dependencies
108
110
 
@@ -10,14 +10,14 @@
10
10
  "@cosmicdrift/kumiko-bundled-features": "workspace:*",
11
11
  "@cosmicdrift/kumiko-framework": "workspace:*",
12
12
  "@cosmicdrift/kumiko-renderer": "workspace:*",
13
- "react": "^19.2.6"
13
+ "react": "^19.3.1"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@cosmicdrift/kumiko-server-runtime": "workspace:*",
17
17
  "@cosmicdrift/kumiko-testing": "workspace:*",
18
- "@playwright/test": "^1.62.1",
18
+ "@playwright/test": "^1.64.1",
19
19
  "@tailwindcss/cli": "^4.3.3",
20
- "@types/react": "^19.2.14",
20
+ "@types/react": "^19.3.0",
21
21
  "tailwindcss": "^4.3.3"
22
22
  }
23
23
  }