@cosmicdrift/kumiko-framework 0.403.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 (59) hide show
  1. package/AGENTS.md +103 -103
  2. package/README.md +19 -17
  3. package/agents/api/framework-engine.md +3 -0
  4. package/agents/api/renderer-web.md +1 -0
  5. package/agents/api/types.md +23 -0
  6. package/agents/examples/apex-landing.md +3 -3
  7. package/agents/examples/apex-surface-auth.md +13 -9
  8. package/agents/examples/auth-mfa.md +2 -4
  9. package/agents/examples/boot-check.md +2 -2
  10. package/agents/examples/compliance-profiles-demo.md +5 -5
  11. package/agents/examples/compose-stacks.md +8 -8
  12. package/agents/examples/cross-feature-events.md +1 -0
  13. package/agents/examples/crypto-shredding-hr.md +3 -3
  14. package/agents/examples/custom-fields-basic.md +1 -1
  15. package/agents/examples/documents.md +28 -0
  16. package/agents/examples/encrypted-tenant-config.md +8 -8
  17. package/agents/examples/env-composition.md +10 -10
  18. package/agents/examples/error-contract.md +9 -9
  19. package/agents/examples/event-sourcing.md +9 -9
  20. package/agents/examples/lane-routing.md +40 -1
  21. package/agents/examples/ledger-invoicing.md +6 -6
  22. package/agents/examples/legal-pages.md +16 -16
  23. package/agents/examples/managed-config.md +15 -11
  24. package/agents/examples/managed-pages.md +7 -7
  25. package/agents/examples/pipeline-basics.md +13 -13
  26. package/agents/examples/public-share-token.md +4 -4
  27. package/agents/examples/security-baseline.md +4 -1
  28. package/agents/examples/session-revocation.md +1 -1
  29. package/agents/examples/tenant-settings.md +4 -4
  30. package/agents/examples/tier-admin.md +2 -2
  31. package/agents/examples/user-data-rights.md +21 -19
  32. package/dist/api/redis-sse-broker.d.ts +1 -0
  33. package/dist/api/redis-sse-broker.js +5 -3
  34. package/dist/engine/feature-config-events-jobs.js +11 -7
  35. package/dist/engine/soft-delete-cleanup.js +2 -2
  36. package/dist/entrypoint/index.d.ts +5 -2
  37. package/dist/entrypoint/index.js +15 -6
  38. package/dist/entrypoint/internals.d.ts +2 -0
  39. package/dist/files/feature.js +1 -1
  40. package/dist/jobs/cron-repeat-options.d.ts +9 -0
  41. package/dist/jobs/cron-repeat-options.js +9 -0
  42. package/dist/jobs/index.d.ts +1 -0
  43. package/dist/jobs/index.js +1 -0
  44. package/dist/jobs/job-runner-context.js +4 -1
  45. package/dist/jobs/job-runner-types.d.ts +5 -0
  46. package/dist/jobs/job-runner.js +14 -5
  47. package/dist/jobs/maintenance-window.d.ts +7 -0
  48. package/dist/jobs/maintenance-window.js +14 -0
  49. package/dist/pipeline/event-retention.js +3 -1
  50. package/dist/redis/cache-sync-bus.d.ts +1 -0
  51. package/dist/redis/cache-sync-bus.js +15 -6
  52. package/dist/redis/client.d.ts +12 -0
  53. package/dist/redis/client.js +30 -5
  54. package/dist/redis/index.d.ts +2 -2
  55. package/dist/redis/index.js +1 -1
  56. package/dist/search/meilisearch-peer.js +1 -1
  57. package/package.json +12 -12
  58. package/presets/biome-app.json +1 -1
  59. package/src/changes.json +12 -0
@@ -4,6 +4,34 @@
4
4
 
5
5
  A document register on top of existing records: captured files, expiry dates and per-record visibility.
6
6
 
7
+ ## README
8
+
9
+ # Documents
10
+
11
+ A document register on top of existing records: captured files with a category, linked to records, visible only to whoever may read those records.
12
+
13
+ ## What it shows
14
+
15
+ - `createDocumentsFeature` (`@cosmicdrift/kumiko-bundled-features/documents`) with `categories`, `linkTargets` and `access` roles.
16
+ - 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.
17
+ - 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.
18
+
19
+ ## Source
20
+
21
+ Feature entry point: `src/feature.ts`.
22
+
23
+ ```ts file=<rootDir>/_samples/recipes-documents/src/feature.ts
24
+ ```
25
+
26
+ ## Tests
27
+
28
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
29
+
30
+ ```bash
31
+ cd samples/recipes/documents
32
+ bun test
33
+ ```
34
+
7
35
  ## Source files
8
36
 
9
37
  ### src/__tests__/feature.integration.test.ts
@@ -18,10 +18,10 @@ the platform operator being able to read the plaintext from the DB.
18
18
  import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
19
19
 
20
20
  createTenantConfig("text", {
21
- encrypted: true, // ← ciphertext in the DB
22
- write: access.admin, // ← the tenant admin writes their own
23
- read: access.admin, // ← nobody else sees it
24
- mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav
21
+ encrypted: true, // ← ciphertext in the DB
22
+ write: access.admin, // ← the tenant admin writes their own
23
+ read: access.admin, // ← nobody else sees it
24
+ mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav
25
25
  });
26
26
  ```
27
27
 
@@ -47,10 +47,10 @@ i18n key of the field label, `mask.order` its position.
47
47
 
48
48
  ## Vs. `r.secret` / `samples/recipes/secrets-demo`
49
49
 
50
- | Pattern | Scope | Use case |
51
- |---|---|---|
52
- | **`r.secret`** (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers). |
53
- | **`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. |
50
+ | Pattern | Scope | Use case |
51
+ | -------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | **`r.secret`** (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers). |
53
+ | **`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. |
54
54
 
55
55
  Combinable: the platform uses `r.secret` for its internal secrets,
56
56
  and in parallel customers have their tenant-owned `encrypted: true`
@@ -29,16 +29,16 @@ instead of keeping a hand-written copy of every feature's variables.
29
29
 
30
30
  ## Blocks
31
31
 
32
- | Block | Where it lives | Registered by |
33
- | --- | --- | --- |
34
- | `kmsEnvSchema` | `crypto-shredding` | `createCryptoShreddingFeature()` |
35
- | `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
36
- | `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
37
- | `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
38
- | `paddleEnvSchema({ prices })` | `subscription-paddle` | app (`extend`); the feature registers the bridge keys |
39
- | `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
40
- | `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
41
- | `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
32
+ | Block | Where it lives | Registered by |
33
+ | ---------------------------------------------- | ------------------------- | ----------------------------------------------------- |
34
+ | `kmsEnvSchema` | `crypto-shredding` | `createCryptoShreddingFeature()` |
35
+ | `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
36
+ | `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
37
+ | `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
38
+ | `paddleEnvSchema({ prices })` | `subscription-paddle` | app (`extend`); the feature registers the bridge keys |
39
+ | `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
40
+ | `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
41
+ | `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
42
42
 
43
43
  ## When to reach for it
44
44
 
@@ -22,15 +22,15 @@ Every Kumiko error class in a real handler context. A single feature `orders-lit
22
22
 
23
23
  ## The classes — when do I use which?
24
24
 
25
- | Class | HTTP | Use in handler |
26
- |---|---|---|
27
- | `ValidationError` | 400 | Automatic from Zod. Never throw manually, only for validation-hook errors. |
28
- | `AccessDeniedError` | 403 | "You're not allowed" — ownership, role check, field lock. |
29
- | `NotFoundError` | 404 | Entity doesn't exist. Automatic via `failNotFound(entity, id)`. |
30
- | `ConflictError` | 409 | State collision without a version (e.g. "paid orders can't be cancelled"). |
31
- | `VersionConflictError` | 409 | Optimistic lock — comes out of CrudExecutor automatically. You never throw it. |
32
- | `UnprocessableError` | 422 | Business rule violated. The reason string describes what. |
33
- | `InternalError` | 500 | You **don't throw it yourself**. The framework wraps unexpected throws automatically. |
25
+ | Class | HTTP | Use in handler |
26
+ | ---------------------- | ---- | ------------------------------------------------------------------------------------- |
27
+ | `ValidationError` | 400 | Automatic from Zod. Never throw manually, only for validation-hook errors. |
28
+ | `AccessDeniedError` | 403 | "You're not allowed" — ownership, role check, field lock. |
29
+ | `NotFoundError` | 404 | Entity doesn't exist. Automatic via `failNotFound(entity, id)`. |
30
+ | `ConflictError` | 409 | State collision without a version (e.g. "paid orders can't be cancelled"). |
31
+ | `VersionConflictError` | 409 | Optimistic lock — comes out of CrudExecutor automatically. You never throw it. |
32
+ | `UnprocessableError` | 422 | Business rule violated. The reason string describes what. |
33
+ | `InternalError` | 500 | You **don't throw it yourself**. The framework wraps unexpected throws automatically. |
34
34
 
35
35
  ## Convenience helpers
36
36
 
@@ -14,15 +14,15 @@ blocks Kumiko provides.
14
14
 
15
15
  ## What the sample shows
16
16
 
17
- | API | Purpose |
18
- |---|---|
19
- | `r.defineEvent(name, schema, { version, migrations })` | Event schema with version number + upcaster chain |
20
- | `ctx.events.append({ aggregateId, aggregateType, type, payload })` | Domain event on aggregate stream |
21
- | `r.projection(...)` | Single-stream read model, inline in the write TX |
22
- | `r.multiStreamProjection(...)` | Cross-aggregate read model, async via dispatcher |
23
- | `ctx.events.load(id, { asOf })` | Live aggregation with point-in-time |
24
- | `ctx.events.archive(id, { aggregateType })` | Archive a stream (Marten `ArchiveStream`) |
25
- | `ctx.dispatch.queryProjection(name)` | Tenant-scoped read-model query |
17
+ | API | Purpose |
18
+ | ------------------------------------------------------------------ | ------------------------------------------------- |
19
+ | `r.defineEvent(name, schema, { version, migrations })` | Event schema with version number + upcaster chain |
20
+ | `ctx.events.append({ aggregateId, aggregateType, type, payload })` | Domain event on aggregate stream |
21
+ | `r.projection(...)` | Single-stream read model, inline in the write TX |
22
+ | `r.multiStreamProjection(...)` | Cross-aggregate read model, async via dispatcher |
23
+ | `ctx.events.load(id, { asOf })` | Live aggregation with point-in-time |
24
+ | `ctx.events.archive(id, { aggregateType })` | Archive a stream (Marten `ArchiveStream`) |
25
+ | `ctx.dispatch.queryProjection(name)` | Tenant-scoped read-model query |
26
26
 
27
27
  ## Domain
28
28
 
@@ -13,6 +13,7 @@ Pin jobs to deploy lanes (`api` | `worker`) with event-triggered fan-out.
13
13
  ## What it shows
14
14
 
15
15
  - `r.job({ runIn: "worker" })` after an HTTP write
16
+ - `trigger: { maintenance: true }` for a daily housekeeping job in the app's maintenance window (`KUMIKO_MAINTENANCE_WINDOW`)
16
17
  - End-to-end verification via `createAllInOneEntrypoint`
17
18
 
18
19
  ## Source
@@ -65,7 +66,12 @@ import {
65
66
  type TestDb,
66
67
  type TestRedis,
67
68
  } from "@cosmicdrift/kumiko-testing/stack";
68
- import { createLaneRoutingFeature, renderedReceipts, sentConfirmations } from "../feature";
69
+ import {
70
+ createLaneRoutingFeature,
71
+ pruneRuns,
72
+ renderedReceipts,
73
+ sentConfirmations,
74
+ } from "../feature";
69
75
 
70
76
  const JWT = "lane-routing-sample-secret-minimum-32-chars!";
71
77
  const adminUser = TestUsers.admin;
@@ -87,6 +93,7 @@ afterAll(async () => {
87
93
  afterEach(() => {
88
94
  renderedReceipts.length = 0;
89
95
  sentConfirmations.length = 0;
96
+ pruneRuns.length = 0;
90
97
  });
91
98
 
92
99
  describe("lane-routing sample", () => {
@@ -132,6 +139,30 @@ describe("lane-routing sample", () => {
132
139
  await entry.stop();
133
140
  }
134
141
  });
142
+
143
+ test("a maintenance job is registered for the daily window and runs on the worker lane", async () => {
144
+ const registry = createRegistry([createLaneRoutingFeature()]);
145
+ expect(registry.getJob("orders:job:prune-old-receipts")?.trigger).toEqual({
146
+ maintenance: true,
147
+ });
148
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
149
+ const entry = createAllInOneEntrypoint({
150
+ rowLevelSecurity: "off",
151
+ registry,
152
+ context: { db: testDb.db, redis: testRedis.redis },
153
+ jwtSecret: JWT,
154
+ redisUrl,
155
+ queueNamePrefix: `lane-routing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
156
+ maintenanceWindow: { hour: 5, minute: 30 },
157
+ });
158
+ await entry.start();
159
+ try {
160
+ await entry.jobRunner.dispatch("orders:job:prune-old-receipts", {});
161
+ await waitFor(() => expect(pruneRuns).toHaveLength(1));
162
+ } finally {
163
+ await entry.stop();
164
+ }
165
+ });
135
166
  });
136
167
  ```
137
168
 
@@ -163,6 +194,7 @@ import { z } from "zod";
163
194
  // opaque to the framework.
164
195
  export const renderedReceipts: Array<{ customerName: string; amount: number }> = [];
165
196
  export const sentConfirmations: Array<{ customerName: string; amount: number }> = [];
197
+ export const pruneRuns: number[] = [];
166
198
 
167
199
  // The entity declaration secures the qualified-name prefix "orders:write:order:create"
168
200
  // for the write handler below and thereby the job trigger on this event.
@@ -240,6 +272,13 @@ export function createLaneRoutingFeature(): FeatureDefinition {
240
272
  });
241
273
  },
242
274
  );
275
+
276
+ // Housekeeping: `maintenance` runs once a day in the app's maintenance window
277
+ // (KUMIKO_MAINTENANCE_WINDOW, UTC, default 03:00) instead of a cron time of its own, so a
278
+ // sleeping app wakes once a day for all its cleanup jobs.
279
+ r.job("prune-old-receipts", { trigger: { maintenance: true }, runIn: "worker" }, async () => {
280
+ pruneRuns.push(Date.now());
281
+ });
243
282
  });
244
283
  }
245
284
  ```
@@ -46,12 +46,12 @@ invoicingFlow …`):
46
46
  Both recipes use the same primitive — accounts + balanced transactions — but
47
47
  record different things:
48
48
 
49
- | | [ledger-banking](../ledger-banking/) | ledger-invoicing |
50
- |---|---|---|
51
- | Accounts | all `asset` (bank accounts) | `asset` + `income` + `liability` |
52
- | A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
53
- | Revenue recognised | n/a | at invoice time (accrual) |
54
- | Reports surfaced | balances | balances + P&L |
49
+ | | [ledger-banking](../ledger-banking/) | ledger-invoicing |
50
+ | ------------------ | ------------------------------------ | ----------------------------------------- |
51
+ | Accounts | all `asset` (bank accounts) | `asset` + `income` + `liability` |
52
+ | A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
53
+ | Revenue recognised | n/a | at invoice time (accrual) |
54
+ | Reports surfaced | balances | balances + P&L |
55
55
 
56
56
  ## Run
57
57
 
@@ -170,12 +170,12 @@ page in `createLegalPagesLayout({ siteName })`.
170
170
 
171
171
  After the seed these URLs are immediately live:
172
172
 
173
- | URL | Content |
174
- |---|---|
175
- | `https://your-app.example/legal/impressum` | Impressum (DE) |
176
- | `https://your-app.example/legal/datenschutz` | Datenschutzerklärung (DE) |
177
- | `https://your-app.example/legal/imprint` | Imprint (EN, if seeded) |
178
- | `https://your-app.example/legal/privacy` | Privacy Policy (EN, if seeded) |
173
+ | URL | Content |
174
+ | -------------------------------------------- | ------------------------------ |
175
+ | `https://your-app.example/legal/impressum` | Impressum (DE) |
176
+ | `https://your-app.example/legal/datenschutz` | Datenschutzerklärung (DE) |
177
+ | `https://your-app.example/legal/imprint` | Imprint (EN, if seeded) |
178
+ | `https://your-app.example/legal/privacy` | Privacy Policy (EN, if seeded) |
179
179
 
180
180
  → Footer links are set per app (legal-pages does not ship a footer
181
181
  component — deliberately, every app has its own layout).
@@ -203,22 +203,22 @@ ACL: `["TenantAdmin", "SystemAdmin"]`. Cache header `public, max-age=300`
203
203
  When the required blocks (`imprint/de` + `privacy/de`) are missing
204
204
  from SYSTEM_TENANT:
205
205
 
206
- | Mode | Behavior |
207
- |---|---|
206
+ | Mode | Behavior |
207
+ | --------------------- | ----------------------------------------------------------- |
208
208
  | `NODE_ENV=production` | App boot throws an error with a slug list — container exits |
209
- | otherwise (dev/test) | `console.warn` with a slug list, app starts anyway |
209
+ | otherwise (dev/test) | `console.warn` with a slug list, app starts anyway |
210
210
 
211
211
  → Safety net: no production deploy without populated legal pages.
212
212
 
213
213
  ## Troubleshooting
214
214
 
215
- | Symptom | Cause | Fix |
216
- |---|---|---|
217
- | Route returns `503 legal page unavailable` | `anonymousAccess` not configured in `runProdApp` | Set `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` |
218
- | Boot check throws `ctx.templateResolver missing` | `extraContext.templateResolver` not wired | Set `extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) })` |
219
- | Route returns `404 not configured` | Required block doesn't exist or has `content=null` | `seedTextBlock` with a content string |
220
- | 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 |
221
- | `<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 |
215
+ | Symptom | Cause | Fix |
216
+ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
217
+ | Route returns `503 legal page unavailable` | `anonymousAccess` not configured in `runProdApp` | Set `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` |
218
+ | Boot check throws `ctx.templateResolver missing` | `extraContext.templateResolver` not wired | Set `extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) })` |
219
+ | Route returns `404 not configured` | Required block doesn't exist or has `content=null` | `seedTextBlock` with a content string |
220
+ | 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 |
221
+ | `<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 |
222
222
 
223
223
  ## Cross-refs
224
224
 
@@ -14,15 +14,19 @@ backings and two scopes, without a hand-written screen, nav, or env-wiring map.
14
14
 
15
15
  Two keys, deliberately different:
16
16
 
17
- | Key | Scope | Backing | Story |
18
- |---|---|---|---|
17
+ | Key | Scope | Backing | Story |
18
+ | ----------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
19
  | `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. |
20
- | `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. |
20
+ | `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. |
21
21
 
22
22
  ## Pattern
23
23
 
24
24
  ```ts illustration
25
- import { access, createSystemConfig, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
25
+ import {
26
+ access,
27
+ createSystemConfig,
28
+ createTenantConfig,
29
+ } from "@cosmicdrift/kumiko-framework/engine";
26
30
 
27
31
  r.config({
28
32
  keys: {
@@ -56,7 +60,7 @@ r.config({
56
60
  valid for `scope: "system"` — secrets are flat per `(tenant, key)` with no
57
61
  cascade, so a tenant override would be incoherent and is rejected at boot.
58
62
  - **`env: "SMTP_HOST"`** — at boot (`runProdApp`) the env var seeds the key's
59
- default as an app-override, below any `system-row`. The env var *is* the
63
+ default as an app-override, below any `system-row`. The env var _is_ the
60
64
  default; no manual `AppConfigOverrides` map, no re-typing in the admin UI.
61
65
  - **`mask`** — the key surfaces automatically in the self-populating settings
62
66
  hub: `buildConfigFeatureSchema` derives a `configEdit` screen + a child nav
@@ -94,14 +98,14 @@ the config backing only needs the `ctx.services.secrets` context shown above.
94
98
 
95
99
  ## Vs. the neighbouring recipes
96
100
 
97
- | Pattern | Storage | Cascade | Use case |
98
- |---|---|---|---|
99
- | **`encrypted: true` config key** (`encrypted-tenant-config`) | `config_values`, AES ciphertext | system → tenant | Per-tenant customer secret, no KEK rotation/audit. |
100
- | **`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. |
101
- | **`r.secret`** (`secrets-demo`) | secrets envelope | none | Same storage, but addressed directly — no config key, no settings-hub entry. |
101
+ | Pattern | Storage | Cascade | Use case |
102
+ | ------------------------------------------------------------ | ------------------------------- | ------------------ | --------------------------------------------------------------------------------- |
103
+ | **`encrypted: true` config key** (`encrypted-tenant-config`) | `config_values`, AES ciphertext | system → tenant | Per-tenant customer secret, no KEK rotation/audit. |
104
+ | **`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. |
105
+ | **`r.secret`** (`secrets-demo`) | secrets envelope | none | Same storage, but addressed directly — no config key, no settings-hub entry. |
102
106
 
103
107
  `backing: "secrets"` is the bridge: you get the secrets store's guarantees
104
- *and* the config feature's declarative surface (masking, settings hub, the same
108
+ _and_ the config feature's declarative surface (masking, settings hub, the same
105
109
  `config:write:set` write path).
106
110
 
107
111
  ## Flow
@@ -147,14 +147,14 @@ exact same helpers.
147
147
 
148
148
  ## Security posture
149
149
 
150
- | Threat | Mitigation |
151
- |---|---|
152
- | `<script>`/raw HTML in a page body | `renderSafeMarkdown` escapes raw HTML (Markdown-only) |
153
- | Cross-tenant content on a shared CDN | `Vary: Host` + per-tenant `resolveApexTenant` |
154
- | Draft leak to anonymous visitors | published-only; drafts → 404 |
150
+ | Threat | Mitigation |
151
+ | --------------------------------------------------------- | ------------------------------------------------------------------- |
152
+ | `<script>`/raw HTML in a page body | `renderSafeMarkdown` escapes raw HTML (Markdown-only) |
153
+ | Cross-tenant content on a shared CDN | `Vary: Host` + per-tenant `resolveApexTenant` |
154
+ | Draft leak to anonymous visitors | published-only; drafts → 404 |
155
155
  | `@import`/`url()`/`expression()`/`</style>` in custom CSS | allowlist-by-construction sanitizer (rebuilt from validated tokens) |
156
- | Scope-escape / overlay onto host chrome | scope-prefix + `position`/`isolation`/`overflow` containment |
157
- | Untrusted tenant abusing CSS | tier-gate (`managed-pages-css`) + `allowCustomCss` opt-in |
156
+ | Scope-escape / overlay onto host chrome | scope-prefix + `position`/`isolation`/`overflow` containment |
157
+ | Untrusted tenant abusing CSS | tier-gate (`managed-pages-css`) + `allowCustomCss` opt-in |
158
158
 
159
159
  Custom-CSS sanitization for untrusted tenants is **best-effort defense-in-depth**:
160
160
  a tenant can still restyle its **own** page area within the clip. Keep it
@@ -15,18 +15,18 @@ behaves end-to-end against the worktree's framework source.
15
15
 
16
16
  ## What's inside
17
17
 
18
- | Step | Where it shows up |
19
- |---|---|
20
- | `r.step.aggregate.create` | `product:create` |
21
- | `r.step.aggregate.update` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
22
- | `r.step.aggregate.appendEvent` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
23
- | `r.step.read.findOne` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
24
- | `r.step.read.findMany` | `report:archive-low-stock-products` |
25
- | `r.step.compute` | `product:adjust-stock`, `product:bulk-adjust` |
26
- | `r.step.branch` | `product:rename`, `product:adjust-stock` |
27
- | `r.step.forEach` | `product:bulk-adjust`, `report:archive-low-stock-products` |
28
- | `r.step.return` | every handler |
29
- | `r.step.unsafeProjectionUpsert` | `product:adjust-stock` |
18
+ | Step | Where it shows up |
19
+ | ------------------------------- | ------------------------------------------------------------------------------ |
20
+ | `r.step.aggregate.create` | `product:create` |
21
+ | `r.step.aggregate.update` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
22
+ | `r.step.aggregate.appendEvent` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
23
+ | `r.step.read.findOne` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
24
+ | `r.step.read.findMany` | `report:archive-low-stock-products` |
25
+ | `r.step.compute` | `product:adjust-stock`, `product:bulk-adjust` |
26
+ | `r.step.branch` | `product:rename`, `product:adjust-stock` |
27
+ | `r.step.forEach` | `product:bulk-adjust`, `report:archive-low-stock-products` |
28
+ | `r.step.return` | every handler |
29
+ | `r.step.unsafeProjectionUpsert` | `product:adjust-stock` |
30
30
  | `r.step.unsafeProjectionDelete` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
31
31
 
32
32
  ## Domain
@@ -92,7 +92,7 @@ engine APIs land in a published release.
92
92
  in the pure event-sourcing shape — it appends a domain event
93
93
  (`product-stock-adjusted`) and lets the inline
94
94
  `product-stock-counter` projection update `currentStock`. The
95
- alternative (calling `aggregate.update` *and* `appendEvent` in
95
+ alternative (calling `aggregate.update` _and_ `appendEvent` in
96
96
  one handler) inflates the stream-version mid-handler and trips
97
97
  optimistic-locking on the next call. The other handlers
98
98
  (`rename`, `bulk-adjust`) use `aggregate.update` directly
@@ -28,11 +28,11 @@ the URL carries a share token).
28
28
 
29
29
  ## Handlers
30
30
 
31
- | QN | Access |
32
- |---|---|
31
+ | QN | Access |
32
+ | -------------------------------------- | --------------------------- |
33
33
  | `public-share:write:share-link:create` | authenticated (`openToAll`) |
34
- | `public-share:write:share-link:revoke` | owner |
35
- | `public-share:query:share-by-token` | anonymous + authenticated |
34
+ | `public-share:write:share-link:revoke` | owner |
35
+ | `public-share:query:share-by-token` | anonymous + authenticated |
36
36
 
37
37
  ## Run tests
38
38
 
@@ -53,7 +53,10 @@ mounts `sessions` as part of its own require-chain. Combine both directly;
53
53
  `dedupeFeatures()` collapses the two identical `sessions` instances at boot:
54
54
 
55
55
  ```ts illustration
56
- import { composeGdprStack, composeSecurityStack } from "@cosmicdrift/kumiko-dev-server/compose-stacks";
56
+ import {
57
+ composeGdprStack,
58
+ composeSecurityStack,
59
+ } from "@cosmicdrift/kumiko-dev-server/compose-stacks";
57
60
 
58
61
  export const APP_FEATURES = [
59
62
  // ...config, user, tenant, auth-foundation...
@@ -33,7 +33,7 @@ const server = buildServer({
33
33
  }),
34
34
  // ... other features
35
35
  ]),
36
- context: { db, /* ... */ },
36
+ context: { db /* ... */ },
37
37
  jwtSecret: process.env.JWT_SECRET,
38
38
  auth: {
39
39
  membershipQuery: "tenant:query:memberships",
@@ -64,12 +64,12 @@ role vocabulary overrides those instead of forking the feature.
64
64
 
65
65
  ## Why the schema needs a special case
66
66
 
67
- `buildInsertSchema` always makes `currency` required *inside* a money field's
67
+ `buildInsertSchema` always makes `currency` required _inside_ a money field's
68
68
  value object, even when the field itself is optional — a caller literally
69
69
  cannot submit `{ amount: 1000 }` against the plain `defineEntityCreateHandler`.
70
70
  `defineCreateWithTenantDefaults` swaps in its own schema (`.extend()` over the
71
71
  same `buildInsertSchema` output, with just the declared `currencyFields`
72
- relaxed) so the caller *can* omit `currency`, then fills it from
72
+ relaxed) so the caller _can_ omit `currency`, then fills it from
73
73
  `ctx.config(TenantSettingsConfig.currency)` before delegating to the same
74
74
  `executor.create()` the generic factory uses.
75
75
 
@@ -82,13 +82,13 @@ changes them from **Settings → Tenant → Default Currency / Default Locale**.
82
82
 
83
83
  ## Flow
84
84
 
85
- 1. No tenant override yet → a new invoice gets the *feature's own* default
85
+ 1. No tenant override yet → a new invoice gets the _feature's own_ default
86
86
  (`"EUR"`/`"en"`, set via `createTenantSettingsFeature()`'s own defaults).
87
87
  2. Tenant sets `currency` to `"CHF"` and `locale` to `"de"` via the
88
88
  Settings-Hub (or `config:write:set` directly).
89
89
  3. A new invoice that doesn't specify `amount.currency`/`language` picks up
90
90
  `"CHF"`/`"de"` — no entity code changed, no second migration.
91
- 4. A caller that *does* specify `amount.currency` still wins — the tenant
91
+ 4. A caller that _does_ specify `amount.currency` still wins — the tenant
92
92
  setting only fills gaps, it never overrides an explicit value.
93
93
 
94
94
  ## Tests
@@ -17,7 +17,7 @@ toggleable features unlock in the same request.
17
17
  ## What it shows
18
18
 
19
19
  - **`tier-engine:write:set-tenant-tier`** — a SystemAdmin assigns a tier
20
- to *any* tenant, cross-tenant. Stamps `source: "manual"` so a future
20
+ to _any_ tenant, cross-tenant. Stamps `source: "manual"` so a future
21
21
  Stripe → tier sync won't overwrite the grant.
22
22
  - **`tier-engine:query:get-tenant-tier`** — SystemAdmin reads back which
23
23
  tier a tenant is on plus its source (`"manual"` vs `"billing"`).
@@ -58,7 +58,7 @@ notes-export → r.toggleable() domain feature unlocked by pro tier
58
58
  path bypasses the `postSave` entity-hook the resolver normally uses to
59
59
  invalidate the cache after a tier-assignment change. Without an explicit
60
60
  cache update the grant would persist (next request would see it) but
61
- *this* request still sees the old tier — surprising for an operator who
61
+ _this_ request still sees the old tier — surprising for an operator who
62
62
  just clicked "set tier to pro".
63
63
 
64
64
  The feature wires `onAssigned` into `createSetTenantTierWrite` for
@@ -25,13 +25,13 @@ defineFeature("notes", (r) => {
25
25
  r.useExtension(EXT_USER_DATA, "note", {
26
26
  export: async (ctx) => ({
27
27
  entity: "note",
28
- rows: await ctx.db.select().from(notesTable)
29
- .where(and(eq(notesTable.tenantId, ctx.tenantId),
30
- eq(notesTable.authorId, ctx.userId))),
28
+ rows: await ctx.db
29
+ .select()
30
+ .from(notesTable)
31
+ .where(and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId))),
31
32
  }),
32
33
  delete: async (ctx, strategy) => {
33
- const where = and(eq(notesTable.tenantId, ctx.tenantId),
34
- eq(notesTable.authorId, ctx.userId));
34
+ const where = and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId));
35
35
  if (strategy === "anonymize") {
36
36
  await ctx.db.update(notesTable).set({ authorId: null }).where(where);
37
37
  } else {
@@ -44,21 +44,21 @@ defineFeature("notes", (r) => {
44
44
 
45
45
  ## What runs automatically
46
46
 
47
- | Article | What happens | Trigger |
48
- |---|---|---|
47
+ | Article | What happens | Trigger |
48
+ | ------------ | ---------------------------------------------- | -------------------------------------- |
49
49
  | Art. 15 + 20 | Notes snippet in export ZIP + magic-link email | `runUserExport` (via `request-export`) |
50
- | Art. 17 | Notes deleted or anonymized after grace | `runForgetCleanup` cron |
51
- | Art. 18 | Restriction blocks login while active | Auth middleware |
52
- | Operator | Brute-force detection on magic-link endpoint | Edge rate-limit + audit log |
50
+ | Art. 17 | Notes deleted or anonymized after grace | `runForgetCleanup` cron |
51
+ | Art. 18 | Restriction blocks login while active | Auth middleware |
52
+ | Operator | Brute-force detection on magic-link endpoint | Edge rate-limit + audit log |
53
53
 
54
54
  ## Strategy-aware delete
55
55
 
56
56
  `retention.policyFor("note")` resolves per entity:
57
57
 
58
- | Profile | Strategy | Effect |
59
- |---|---|---|
60
- | `eu-dsgvo` (default) | `delete` | Notes hard-deleted |
61
- | `de-hr-dsgvo-hgb` | `anonymize` for HR entities | `authorId=null`, row kept for multi-user refs |
58
+ | Profile | Strategy | Effect |
59
+ | -------------------- | --------------------------- | --------------------------------------------- |
60
+ | `eu-dsgvo` (default) | `delete` | Notes hard-deleted |
61
+ | `de-hr-dsgvo-hgb` | `anonymize` for HR entities | `authorId=null`, row kept for multi-user refs |
62
62
 
63
63
  The integration test pins both paths.
64
64
 
@@ -99,7 +99,9 @@ const result = await runRetentionCleanup({
99
99
  registry: stack.registry,
100
100
  tenantId,
101
101
  preloadedTenantPreset: null,
102
- now: getTemporal().Now.instant().add({ hours: 31 * 24 }), // keepFor 30d + 1 day
102
+ now: getTemporal()
103
+ .Now.instant()
104
+ .add({ hours: 31 * 24 }), // keepFor 30d + 1 day
103
105
  files: createFileContext(() => Promise.resolve(provider)), // the stack's storage provider
104
106
  });
105
107
  // result.hardDeleted === 1, bytes and file_refs row gone
@@ -107,10 +109,10 @@ const result = await runRetentionCleanup({
107
109
 
108
110
  ## Full app vs this recipe
109
111
 
110
- | | This recipe | [user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) |
111
- |---|---|---|
112
- | Scope | One hook pattern | Runnable app + todos + files |
113
- | Boot | Integration test only | `bun dev` on port 4291 |
112
+ | | This recipe | [user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) |
113
+ | ----- | --------------------- | ---------------------------------------------------------------- |
114
+ | Scope | One hook pattern | Runnable app + todos + files |
115
+ | Boot | Integration test only | `bun dev` on port 4291 |
114
116
 
115
117
  ## Dependencies
116
118
 
@@ -4,6 +4,7 @@ export type RedisSseBrokerOptions = {
4
4
  readonly redisUrl: string;
5
5
  readonly clientOptions?: RedisClientOptions;
6
6
  readonly channelPrefix?: string;
7
+ readonly channelSubscribePattern?: string;
7
8
  };
8
9
  export type RedisSseBroker = SseBroker & {
9
10
  readonly kind: "redis";