@cosmicdrift/kumiko-framework 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.
package/AGENTS.md CHANGED
@@ -7,17 +7,17 @@ Next to it, under `agents/`, are generated files. They are rebuilt from the
7
7
  framework source on every release, so they never describe an API that does
8
8
  not exist in your version:
9
9
 
10
- | File | What it holds |
11
- | --- | --- |
12
- | `agents/features-index.md` | One entry per bundled feature: purpose, factory import, required features, config keys without a default, secrets |
13
- | `agents/features.md` | Every bundled feature in detail: purpose, config keys, secrets, dependencies, APIs |
14
- | `agents/api.md` | Index of the API reference: one file per entry point an app imports |
15
- | `agents/api/<entry>.md` | Signatures of one entry point; options and screen types as property lists |
16
- | `agents/api/types.md` | Types the entry points use but do not export, such as nested option shapes |
17
- | `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer/web` with its short description |
18
- | `agents/boot-rules.md` | Every boot rule by id: summary, fix and source file; a boot violation links its entry |
19
- | `agents/examples.md` | Index of the worked examples, one per concept |
20
- | `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
10
+ | File | What it holds |
11
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------------- |
12
+ | `agents/features-index.md` | One entry per bundled feature: purpose, factory import, required features, config keys without a default, secrets |
13
+ | `agents/features.md` | Every bundled feature in detail: purpose, config keys, secrets, dependencies, APIs |
14
+ | `agents/api.md` | Index of the API reference: one file per entry point an app imports |
15
+ | `agents/api/<entry>.md` | Signatures of one entry point; options and screen types as property lists |
16
+ | `agents/api/types.md` | Types the entry points use but do not export, such as nested option shapes |
17
+ | `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer/web` with its short description |
18
+ | `agents/boot-rules.md` | Every boot rule by id: summary, fix and source file; a boot violation links its entry |
19
+ | `agents/examples.md` | Index of the worked examples, one per concept |
20
+ | `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
21
21
 
22
22
  The paths are relative to this file. From an app root they live under
23
23
  `node_modules/@cosmicdrift/kumiko-framework/`. For signatures and option
@@ -131,14 +131,14 @@ is mounted in `src/client.tsx`. Declarative screens need no client code.
131
131
  Names are built from the feature name, a kind and an id. Use the QN wherever
132
132
  another feature or a test refers to something.
133
133
 
134
- | Thing | Pattern | Example from the scaffolded demo feature |
135
- | --- | --- | --- |
136
- | Write handler | `<feature>:write:<entity>:<verb>` | `tasks:write:task:create` |
137
- | Query handler | `<feature>:query:<entity>:<verb>` | `tasks:query:task:list` |
138
- | Screen | `<feature>:screen:<id>` | `tasks:screen:task-list` |
139
- | Nav entry | `<feature>:nav:<id>` | `tasks:nav:tasks` |
140
- | Translation key | `<feature>:<path>` | `tasks:nav.tasks` |
141
- | Entity field label | `<feature>:entity:<entity>:field:<field>` | `tasks:entity:task:field:title` |
134
+ | Thing | Pattern | Example from the scaffolded demo feature |
135
+ | ------------------ | ----------------------------------------- | ---------------------------------------- |
136
+ | Write handler | `<feature>:write:<entity>:<verb>` | `tasks:write:task:create` |
137
+ | Query handler | `<feature>:query:<entity>:<verb>` | `tasks:query:task:list` |
138
+ | Screen | `<feature>:screen:<id>` | `tasks:screen:task-list` |
139
+ | Nav entry | `<feature>:nav:<id>` | `tasks:nav:tasks` |
140
+ | Translation key | `<feature>:<path>` | `tasks:nav.tasks` |
141
+ | Entity field label | `<feature>:entity:<entity>:field:<field>` | `tasks:entity:task:field:title` |
142
142
 
143
143
  Inside one feature, a screen id without prefix (`screen: "task-edit"`) refers
144
144
  to a screen of the same feature.
@@ -147,20 +147,20 @@ to a screen of the same feature.
147
147
 
148
148
  The scaffold (`bun create kumiko-app <name>`) produces this layout. Keep it.
149
149
 
150
- | Path | Content |
151
- | --- | --- |
152
- | `kumiko.config.ts` | `defineAppConfig({ features, prodFeatures?, client, ports })`: the one list the tooling reads |
153
- | `src/run-config.ts` | `APP_FEATURES`, the mounted feature list |
154
- | `src/features/<name>/feature.ts` | `defineFeature(...)` with entities, handlers, screens, navs |
155
- | `src/features/<name>/index.ts` | Public exports of the feature |
156
- | `src/features/<name>/web/index.ts` | Client feature definition |
157
- | `src/client.tsx` | Web entry, mounts the client features |
158
- | `src/__tests__/*.test.ts` | Unit tests, no services |
159
- | `src/__tests__/*.integration.test.ts` | Integration tests against Postgres and Redis |
160
- | `e2e/*.spec.ts` | Playwright tests against a booted app |
161
- | `kumiko/schema.ts`, `kumiko/migrations/` | Generated schema shim over `kumiko.config.ts`, and the migrations |
162
- | `bin/server.ts`, `bin/main.ts`, `bin/env-schema.ts` | Dev server, production boot, env contract |
163
- | `e2e/_helpers/`, `src/__tests__/_setup.ts` | Shared e2e helpers and the shared integration stack setup |
150
+ | Path | Content |
151
+ | --------------------------------------------------- | --------------------------------------------------------------------------------------------- |
152
+ | `kumiko.config.ts` | `defineAppConfig({ features, prodFeatures?, client, ports })`: the one list the tooling reads |
153
+ | `src/run-config.ts` | `APP_FEATURES`, the mounted feature list |
154
+ | `src/features/<name>/feature.ts` | `defineFeature(...)` with entities, handlers, screens, navs |
155
+ | `src/features/<name>/index.ts` | Public exports of the feature |
156
+ | `src/features/<name>/web/index.ts` | Client feature definition |
157
+ | `src/client.tsx` | Web entry, mounts the client features |
158
+ | `src/__tests__/*.test.ts` | Unit tests, no services |
159
+ | `src/__tests__/*.integration.test.ts` | Integration tests against Postgres and Redis |
160
+ | `e2e/*.spec.ts` | Playwright tests against a booted app |
161
+ | `kumiko/schema.ts`, `kumiko/migrations/` | Generated schema shim over `kumiko.config.ts`, and the migrations |
162
+ | `bin/server.ts`, `bin/main.ts`, `bin/env-schema.ts` | Dev server, production boot, env contract |
163
+ | `e2e/_helpers/`, `src/__tests__/_setup.ts` | Shared e2e helpers and the shared integration stack setup |
164
164
 
165
165
  Files whose first line reads `kumiko-managed` (deploy/, `.github/workflows/`,
166
166
  `.husky/pre-push`, the bunfigs, `kumiko/schema.ts`) belong to the framework.
@@ -176,20 +176,20 @@ feature and test. Start from them.
176
176
 
177
177
  ## Commands in an app
178
178
 
179
- | Goal | Command |
180
- | --- | --- |
181
- | Run the app with demo data | `bun dev` |
182
- | Boot validation without serving | `bun run boot` |
183
- | Boot, guards, managed files and script standard | `bun run check` |
184
- | Restore framework-owned files | `bunx kumiko sync-managed --write` |
185
- | Types | `bun run typecheck` |
186
- | Lint | `bun run lint` |
187
- | Unit tests | `bun run test` |
188
- | Integration tests (needs `docker compose up -d`) | `bun run test:integration` |
189
- | Browser tests | `bun run e2e` |
190
- | New migration after an entity change or a newly mounted bundled feature | `bun run schema:generate <name>` |
191
- | Check migrations against the features, no database needed | `bun kumiko-schema validate` |
192
- | Apply migrations | `bun run schema:apply` |
179
+ | Goal | Command |
180
+ | ----------------------------------------------------------------------- | ---------------------------------- |
181
+ | Run the app with demo data | `bun dev` |
182
+ | Boot validation without serving | `bun run boot` |
183
+ | Boot, guards, managed files and script standard | `bun run check` |
184
+ | Restore framework-owned files | `bunx kumiko sync-managed --write` |
185
+ | Types | `bun run typecheck` |
186
+ | Lint | `bun run lint` |
187
+ | Unit tests | `bun run test` |
188
+ | Integration tests (needs `docker compose up -d`) | `bun run test:integration` |
189
+ | Browser tests | `bun run e2e` |
190
+ | New migration after an entity change or a newly mounted bundled feature | `bun run schema:generate <name>` |
191
+ | Check migrations against the features, no database needed | `bun kumiko-schema validate` |
192
+ | Apply migrations | `bun run schema:apply` |
193
193
 
194
194
  `<name>` is required and becomes part of the file name
195
195
  (`schema:generate add-invoice`). Mounting a bundled feature in
@@ -303,50 +303,50 @@ Before building anything here, search `agents/features-index.md`: auth, tenants,
303
303
  mail, files, jobs, billing, audit, notifications and GDPR flows already exist
304
304
  as bundled features.
305
305
 
306
- | I need | Use | Read |
307
- | --- | --- | --- |
308
- | A CRUD entity with list and edit | `createEntity` + `r.crud` + `entityList` / `entityEdit` screens | `agents/examples/basic-entity.md` |
309
- | Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
310
- | Filtered lists and counts | Executor `list` with `filter` and `totalCount` | `agents/examples/custom-handlers.md` |
311
- | Key figures on a dashboard | `dashboard` screen with `stat` / `stat-group` panels and a query handler | `agents/examples/dashboard-stats.md` |
312
- | Relations between entities | `createReferenceField({ entity })` for the foreign key (user ids: `entity: "user:user"`), `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
313
- | Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
314
- | List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
315
- | A record page with header, metrics, tabs | `projectionDetail` with `header`, `metrics`, `layout.mode: "tabs"` | `agents/examples/record-detail-layout.md` |
316
- | A multi-step form | `layout.mode: "wizard"` | `agents/examples/wizard-form.md` |
317
- | A form inside a drawer | `RenderEdit` in a `Drawer` | `agents/examples/embedded-entity-form.md` |
318
- | Role-based access | `access: { roles }`, `openToAll` | `agents/examples/access-control.md` |
319
- | Field-level access | Field access rules | `agents/examples/field-access.md` |
320
- | Row ownership | Ownership rules | `agents/examples/ownership.md` |
321
- | Tenant separation | Built in; see what it guarantees | `agents/examples/tenant-isolation.md` |
322
- | Public or anonymous endpoints | `anonymous` role | `agents/examples/anonymous-access.md` |
323
- | Allowed state changes | Entity transitions | `agents/examples/state-machine.md` |
324
- | React to a write in another feature | `r.defineEvent` + `emitEvent` + `r.multiStreamProjection` | `agents/examples/cross-feature-events.md` |
325
- | Projections, event versions, history | `r.projection`, `ctx.events.load`, `ctx.dispatch.queryProjection` | `agents/examples/event-sourcing.md` |
326
- | Before or after save logic | Lifecycle hooks | `agents/examples/lifecycle-hooks.md` |
327
- | Typed errors with reasons | `@cosmicdrift/kumiko-framework/errors` | `agents/examples/error-contract.md` |
328
- | Translations | `r.translations` + client translations | `agents/examples/i18n.md` |
329
- | Config per tenant or system | Managed config | `agents/examples/managed-config.md` |
330
- | Tenant defaults (currency, locale) | Tenant settings | `agents/examples/tenant-settings.md` |
331
- | Secrets such as API keys | Secrets with envelope encryption | `agents/examples/secrets-demo.md` |
332
- | Personal data and erasure | `personal` / `find` annotations, crypto shredding | `agents/examples/crypto-shredding-hr.md` |
333
- | GDPR export and forget for my data | `user-data-rights` extension | `agents/examples/user-data-rights.md` |
334
- | Full-text search | Search feature | `agents/examples/search.md` |
335
- | Live updates in the browser | Server-sent events, `useQuery({ live: true })` | `agents/examples/realtime-sse.md` |
336
- | Feature toggles | Toggleable features | `agents/examples/feature-toggles.md` |
337
- | Rate limits | Rate limiting | `agents/examples/rate-limiting.md` |
338
- | Idempotent writes | Request id dedupe | `agents/examples/idempotency.md` |
339
- | File variants and images | File fields, derivatives | `agents/examples/files-post-processing.md` |
340
- | Money fields | Currency fields | `agents/examples/currencies-per-tenant.md` |
341
- | Dates, times, time zones | Time-aware field types, `ctx.tz` | `agents/examples/timezones.md` |
342
- | Tags or notes on any entity | Tags, notes history | `agents/examples/tags-basic.md`, `agents/examples/notes-history-basic.md` |
343
- | Tenant-defined extra fields | Custom fields | `agents/examples/custom-fields-basic.md` |
344
- | Lookup tables and seeds | Upsert seeds | `agents/examples/reference-data.md` |
345
- | Multi-step background flows | Workflow engine | `agents/examples/workflow-engine.md` |
346
- | A mount invariant for my feature | `r.bootCheck` | `agents/examples/boot-check.md` |
347
- | A widget for a custom screen | Search by concern | `agents/widgets.md` |
348
- | A bundled feature and its config | Search by name or purpose | `agents/features-index.md`, then `agents/features.md` |
349
- | The options of a function, screen or field | Search by name | `agents/api.md` |
306
+ | I need | Use | Read |
307
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
308
+ | A CRUD entity with list and edit | `createEntity` + `r.crud` + `entityList` / `entityEdit` screens | `agents/examples/basic-entity.md` |
309
+ | Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
310
+ | Filtered lists and counts | Executor `list` with `filter` and `totalCount` | `agents/examples/custom-handlers.md` |
311
+ | Key figures on a dashboard | `dashboard` screen with `stat` / `stat-group` panels and a query handler | `agents/examples/dashboard-stats.md` |
312
+ | Relations between entities | `createReferenceField({ entity })` for the foreign key (user ids: `entity: "user:user"`), `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
313
+ | Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
314
+ | List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
315
+ | A record page with header, metrics, tabs | `projectionDetail` with `header`, `metrics`, `layout.mode: "tabs"` | `agents/examples/record-detail-layout.md` |
316
+ | A multi-step form | `layout.mode: "wizard"` | `agents/examples/wizard-form.md` |
317
+ | A form inside a drawer | `RenderEdit` in a `Drawer` | `agents/examples/embedded-entity-form.md` |
318
+ | Role-based access | `access: { roles }`, `openToAll` | `agents/examples/access-control.md` |
319
+ | Field-level access | Field access rules | `agents/examples/field-access.md` |
320
+ | Row ownership | Ownership rules | `agents/examples/ownership.md` |
321
+ | Tenant separation | Built in; see what it guarantees | `agents/examples/tenant-isolation.md` |
322
+ | Public or anonymous endpoints | `anonymous` role | `agents/examples/anonymous-access.md` |
323
+ | Allowed state changes | Entity transitions | `agents/examples/state-machine.md` |
324
+ | React to a write in another feature | `r.defineEvent` + `emitEvent` + `r.multiStreamProjection` | `agents/examples/cross-feature-events.md` |
325
+ | Projections, event versions, history | `r.projection`, `ctx.events.load`, `ctx.dispatch.queryProjection` | `agents/examples/event-sourcing.md` |
326
+ | Before or after save logic | Lifecycle hooks | `agents/examples/lifecycle-hooks.md` |
327
+ | Typed errors with reasons | `@cosmicdrift/kumiko-framework/errors` | `agents/examples/error-contract.md` |
328
+ | Translations | `r.translations` + client translations | `agents/examples/i18n.md` |
329
+ | Config per tenant or system | Managed config | `agents/examples/managed-config.md` |
330
+ | Tenant defaults (currency, locale) | Tenant settings | `agents/examples/tenant-settings.md` |
331
+ | Secrets such as API keys | Secrets with envelope encryption | `agents/examples/secrets-demo.md` |
332
+ | Personal data and erasure | `personal` / `find` annotations, crypto shredding | `agents/examples/crypto-shredding-hr.md` |
333
+ | GDPR export and forget for my data | `user-data-rights` extension | `agents/examples/user-data-rights.md` |
334
+ | Full-text search | Search feature | `agents/examples/search.md` |
335
+ | Live updates in the browser | Server-sent events, `useQuery({ live: true })` | `agents/examples/realtime-sse.md` |
336
+ | Feature toggles | Toggleable features | `agents/examples/feature-toggles.md` |
337
+ | Rate limits | Rate limiting | `agents/examples/rate-limiting.md` |
338
+ | Idempotent writes | Request id dedupe | `agents/examples/idempotency.md` |
339
+ | File variants and images | File fields, derivatives | `agents/examples/files-post-processing.md` |
340
+ | Money fields | Currency fields | `agents/examples/currencies-per-tenant.md` |
341
+ | Dates, times, time zones | Time-aware field types, `ctx.tz` | `agents/examples/timezones.md` |
342
+ | Tags or notes on any entity | Tags, notes history | `agents/examples/tags-basic.md`, `agents/examples/notes-history-basic.md` |
343
+ | Tenant-defined extra fields | Custom fields | `agents/examples/custom-fields-basic.md` |
344
+ | Lookup tables and seeds | Upsert seeds | `agents/examples/reference-data.md` |
345
+ | Multi-step background flows | Workflow engine | `agents/examples/workflow-engine.md` |
346
+ | A mount invariant for my feature | `r.bootCheck` | `agents/examples/boot-check.md` |
347
+ | A widget for a custom screen | Search by concern | `agents/widgets.md` |
348
+ | A bundled feature and its config | Search by name or purpose | `agents/features-index.md`, then `agents/features.md` |
349
+ | The options of a function, screen or field | Search by name | `agents/api.md` |
350
350
 
351
351
  ## Reading an example
352
352
 
@@ -367,15 +367,15 @@ Import only from these entry points. Deep imports into `src/` or `dist/`
367
367
  break on the next release. Each one has its signatures in the file named in
368
368
  `agents/api.md`.
369
369
 
370
- | Entry point | Use it for |
371
- | --- | --- |
372
- | `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field factories, handler helpers, executors, events |
373
- | `@cosmicdrift/kumiko-framework/errors` | Typed failures and error helpers |
374
- | `@cosmicdrift/kumiko-framework/ui-types` | Screen definition types (`EntityListScreenDefinition`, ...) |
375
- | `@cosmicdrift/kumiko-framework/db` | `buildEntityTable` and database helpers for tests and migrations |
376
- | `@cosmicdrift/kumiko-framework/event-store` | Event store helpers |
377
- | `@cosmicdrift/kumiko-framework/seeding` | `TestUsers`, `createTestUser`, seed helpers |
378
- | `@cosmicdrift/kumiko-renderer/web` | Widgets, hooks and the web app shell |
379
- | `@cosmicdrift/kumiko-dev-server/testing` | `setupAppTestStack`, `seedTenant` for app integration tests |
380
- | `@cosmicdrift/kumiko-testing/stack` | `setupTestStack`, the stack the samples use |
381
- | `@cosmicdrift/kumiko-testing/helpers` | Assertion helpers such as `expectErrorIncludes` |
370
+ | Entry point | Use it for |
371
+ | ------------------------------------------- | ------------------------------------------------------------------------------------ |
372
+ | `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field factories, handler helpers, executors, events |
373
+ | `@cosmicdrift/kumiko-framework/errors` | Typed failures and error helpers |
374
+ | `@cosmicdrift/kumiko-framework/ui-types` | Screen definition types (`EntityListScreenDefinition`, ...) |
375
+ | `@cosmicdrift/kumiko-framework/db` | `buildEntityTable` and database helpers for tests and migrations |
376
+ | `@cosmicdrift/kumiko-framework/event-store` | Event store helpers |
377
+ | `@cosmicdrift/kumiko-framework/seeding` | `TestUsers`, `createTestUser`, seed helpers |
378
+ | `@cosmicdrift/kumiko-renderer/web` | Widgets, hooks and the web app shell |
379
+ | `@cosmicdrift/kumiko-dev-server/testing` | `setupAppTestStack`, `seedTenant` for app integration tests |
380
+ | `@cosmicdrift/kumiko-testing/stack` | `setupTestStack`, the stack the samples use |
381
+ | `@cosmicdrift/kumiko-testing/helpers` | Assertion helpers such as `expectErrorIncludes` |
package/README.md CHANGED
@@ -47,7 +47,9 @@ export const taskFeature = defineFeature("tasks", (r) => {
47
47
  write: { access: { roles: ["User"] } },
48
48
  read: {
49
49
  access: {
50
- openToAll: { reason: "any signed-in user may list and view tasks; only writes are role-gated" },
50
+ openToAll: {
51
+ reason: "any signed-in user may list and view tasks; only writes are role-gated",
52
+ },
51
53
  },
52
54
  },
53
55
  });
@@ -80,22 +82,22 @@ export const taskFeature = defineFeature("tasks", (r) => {
80
82
 
81
83
  ## Package exports
82
84
 
83
- | Entry | What's in it |
84
- |---|---|
85
- | `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field helpers, access rules, registry |
86
- | `@cosmicdrift/kumiko-framework/db` | `deriveEntityTableMeta`, `createEventStoreExecutor`, migrations, tenant-db |
87
- | `@cosmicdrift/kumiko-framework/event-store` | `events` table, `append`, `loadAggregate`, `loadAggregateAsOf` |
88
- | `@cosmicdrift/kumiko-framework/pipeline` | Dispatcher, event-dispatcher (AsyncDaemon), projection-rebuild, SSE + search consumers |
89
- | `@cosmicdrift/kumiko-framework/api` | `buildServer`, auth middleware, SSE route, error contract |
90
- | `@cosmicdrift/kumiko-framework/auth` | JWT helper, password hashing, session users |
91
- | `@cosmicdrift/kumiko-framework/search` | `SearchAdapter` interface, in-memory adapter, Meili wrapper |
92
- | `@cosmicdrift/kumiko-framework/jobs` | BullMQ-backed job runner, cron scheduling |
93
- | `@cosmicdrift/kumiko-framework/files` | Signed-URL upload/download, tenant-scoped storage |
94
- | `@cosmicdrift/kumiko-framework/i18n` | i18next setup, per-feature translation registration |
95
- | `@cosmicdrift/kumiko-framework/ui` | React hooks (Zustand stores, SSE subscription, optimistic mutations) |
96
- | `@cosmicdrift/kumiko-testing/stack` | `setupTestStack`, `createTestDb`, request helpers |
97
- | `@cosmicdrift/kumiko-framework/utils` | Safe JSON, qualified-name helpers |
98
- | `@cosmicdrift/kumiko-framework/errors` | Error classes, `writeFailure`, reason contracts |
85
+ | Entry | What's in it |
86
+ | ------------------------------------------- | -------------------------------------------------------------------------------------- |
87
+ | `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field helpers, access rules, registry |
88
+ | `@cosmicdrift/kumiko-framework/db` | `deriveEntityTableMeta`, `createEventStoreExecutor`, migrations, tenant-db |
89
+ | `@cosmicdrift/kumiko-framework/event-store` | `events` table, `append`, `loadAggregate`, `loadAggregateAsOf` |
90
+ | `@cosmicdrift/kumiko-framework/pipeline` | Dispatcher, event-dispatcher (AsyncDaemon), projection-rebuild, SSE + search consumers |
91
+ | `@cosmicdrift/kumiko-framework/api` | `buildServer`, auth middleware, SSE route, error contract |
92
+ | `@cosmicdrift/kumiko-framework/auth` | JWT helper, password hashing, session users |
93
+ | `@cosmicdrift/kumiko-framework/search` | `SearchAdapter` interface, in-memory adapter, Meili wrapper |
94
+ | `@cosmicdrift/kumiko-framework/jobs` | BullMQ-backed job runner, cron scheduling |
95
+ | `@cosmicdrift/kumiko-framework/files` | Signed-URL upload/download, tenant-scoped storage |
96
+ | `@cosmicdrift/kumiko-framework/i18n` | i18next setup, per-feature translation registration |
97
+ | `@cosmicdrift/kumiko-framework/ui` | React hooks (Zustand stores, SSE subscription, optimistic mutations) |
98
+ | `@cosmicdrift/kumiko-testing/stack` | `setupTestStack`, `createTestDb`, request helpers |
99
+ | `@cosmicdrift/kumiko-framework/utils` | Safe JSON, qualified-name helpers |
100
+ | `@cosmicdrift/kumiko-framework/errors` | Error classes, `writeFailure`, reason contracts |
99
101
 
100
102
  ## Core concepts
101
103
 
@@ -18,9 +18,9 @@ spec also paints a product-board mock (`hero-app.png`), uses it in the hero
18
18
 
19
19
  `buildLandingPage`'s `head.schemaJson` combines `organizationSchema` +
20
20
  `webPageSchema` (from the `seo` feature) into one `@graph` block — Organization
21
- + WebPage nodes in a single `<script type="application/ld+json">`, which
22
- `renderApexPage` already knows how to emit. This is the seam an app extends
23
- with `faqPageSchema` for an FAQ section, or its own custom schema.org type.
21
+ - WebPage nodes in a single `<script type="application/ld+json">`, which
22
+ `renderApexPage` already knows how to emit. This is the seam an app extends
23
+ with `faqPageSchema` for an FAQ section, or its own custom schema.org type.
24
24
 
25
25
  ## Site discovery: sitemap.xml / llms.txt
26
26
 
@@ -36,11 +36,15 @@ A typical app has two mounts sharing locale and primitives:
36
36
 
37
37
  ```tsx illustration
38
38
  import {
39
- ForgotPasswordScreen, SignupScreen, createLoginRoute,
40
- AuthShellProvider, emailPasswordClient,
39
+ ForgotPasswordScreen,
40
+ SignupScreen,
41
+ createLoginRoute,
42
+ AuthShellProvider,
43
+ emailPasswordClient,
41
44
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
42
45
  import {
43
- RequestAccountDeletionScreen, ConfirmAccountDeletionScreen,
46
+ RequestAccountDeletionScreen,
47
+ ConfirmAccountDeletionScreen,
44
48
  } from "@cosmicdrift/kumiko-bundled-features/user-data-rights/web";
45
49
  import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
46
50
 
@@ -51,7 +55,7 @@ import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
51
55
  const LoginRoute = createLoginRoute({ loginScreenProps: { signupHref: "/signup" } });
52
56
 
53
57
  createPublicSurface({
54
- clientFeatures: [emailPasswordClient()], // SessionProvider + i18n
58
+ clientFeatures: [emailPasswordClient()], // SessionProvider + i18n
55
59
  shell: ({ children }) => (
56
60
  <MarketingChrome>
57
61
  <AuthShellProvider shell={(card) => <div className="py-12 flex justify-center">{card}</div>}>
@@ -60,10 +64,10 @@ createPublicSurface({
60
64
  </MarketingChrome>
61
65
  ),
62
66
  routes: [
63
- { path: "/login", component: <LoginRoute /> },
64
- { path: "/signup", component: <SignupScreen loginHref="/login" /> },
67
+ { path: "/login", component: <LoginRoute /> },
68
+ { path: "/signup", component: <SignupScreen loginHref="/login" /> },
65
69
  { path: "/forgot-password", component: <ForgotPasswordScreen loginHref="/login" /> },
66
- { path: "/delete-account", component: <RequestAccountDeletionScreen /> },
70
+ { path: "/delete-account", component: <RequestAccountDeletionScreen /> },
67
71
  { path: "/delete-account/confirm", component: <ConfirmAccountDeletionScreen /> },
68
72
  ],
69
73
  fallback: <LoginRoute />,
@@ -98,8 +102,8 @@ runProdApp({
98
102
 
99
103
  GDPR Art. 17 applies when the user **cannot log in** (lockout). The flow is
100
104
  email-verified (magic link), not login-gated. The HMAC token carries `userId`
101
- + expiry (no DB table); purpose `"deletion-request"` blocks replay on other
102
- token endpoints. Second confirm is idempotent (`user_not_in_active_state`).
105
+ - expiry (no DB table); purpose `"deletion-request"` blocks replay on other
106
+ token endpoints. Second confirm is idempotent (`user_not_in_active_state`).
103
107
 
104
108
  ## Tests
105
109
 
@@ -30,9 +30,7 @@ const mfaFeature = createAuthMfaFeature({
30
30
  issuer: "Your App",
31
31
  });
32
32
  const sessionCallbacks = createSessionCallbacks({ db });
33
- bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
34
- sessionCallbacks.sessionRevokeAllOthers,
35
- );
33
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
36
34
 
37
35
  const server = buildServer({
38
36
  registry: buildRegistry([
@@ -45,7 +43,7 @@ const server = buildServer({
45
43
  mfaFeature,
46
44
  // ... other features
47
45
  ]),
48
- context: { db, /* ... */ },
46
+ context: { db /* ... */ },
49
47
  jwtSecret: process.env.JWT_SECRET,
50
48
  auth: {
51
49
  membershipQuery: "tenant:query:memberships",
@@ -20,7 +20,7 @@ never mounted, and nothing caught it — until now.
20
20
  ctx exposing every mounted feature. Throw to fail the boot with a clear,
21
21
  feature-prefixed message.
22
22
  - **Conditional invariants `r.requires` can't express** — the check only
23
- fails when *this* feature's own shape demands it (has a PII field), not
23
+ fails when _this_ feature's own shape demands it (has a PII field), not
24
24
  unconditionally. A plain `r.requires({ features: ["user-data-hook"] })` would fail even
25
25
  for a prompt-store variant with no PII fields at all.
26
26
  - **`validateBoot`** — the framework's boot-time validator, run directly
@@ -46,7 +46,7 @@ user-data-hook → companion feature the check requires when PII is present
46
46
 
47
47
  ## When to reach for it
48
48
 
49
- You're writing a feature whose validity depends on *another* feature being
49
+ You're writing a feature whose validity depends on _another_ feature being
50
50
  mounted, but only under a condition your feature alone can evaluate (e.g.
51
51
  "only if I have PII fields", "only if I expose more than N screens"). If the
52
52
  requirement is unconditional, `r.requires`/`r.optionalRequires` is simpler
@@ -16,11 +16,11 @@ branches in your domain handlers.
16
16
  How a multi-tenant platform uses `compliance-profiles` to deliver different
17
17
  regulatory behaviour per tenant:
18
18
 
19
- | Tenant | Profile | Supervisory authority | Languages | Tenant-destroy grace |
20
- |---|---|---|---|---|
21
- | **A** (DACH) | `eu-dsgvo` | BlnBDI Berlin | de / en | 30 days |
22
- | **B** (Switzerland) | `swiss-dsg` | EDÖB Bern | de / fr / it / en | 30 days |
23
- | **C** (DACH-HR) | `de-hr-dsgvo-hgb` | State DPA | de | 60 days (HR override) |
19
+ | Tenant | Profile | Supervisory authority | Languages | Tenant-destroy grace |
20
+ | ------------------- | ----------------- | --------------------- | ----------------- | --------------------- |
21
+ | **A** (DACH) | `eu-dsgvo` | BlnBDI Berlin | de / en | 30 days |
22
+ | **B** (Switzerland) | `swiss-dsg` | EDÖB Bern | de / fr / it / en | 30 days |
23
+ | **C** (DACH-HR) | `de-hr-dsgvo-hgb` | State DPA | de | 60 days (HR override) |
24
24
 
25
25
  Plus tenant B with an override (`gracePeriod: { days: 90 }`) extending
26
26
  user-rights grace without losing other profile fields — deep-merge on the
@@ -36,14 +36,14 @@ the option.
36
36
 
37
37
  ## Boot requirements
38
38
 
39
- | Stack | Boot option | Also satisfied by |
40
- | --- | --- | --- |
41
- | `composeMailStack` | `mail` | a mounted `channel-email` |
42
- | `composeFileStack` without providers | `fileStorage` | a mounted file provider |
43
- | `composeGdprStack` | `kmsMode` | `kms` or `allowPlaintextPii` |
44
- | `composeGdprStack({ selfService })`, `composeUserDataRightsStack` | `mail` | a mounted `channel-email` |
45
- | `composeSecurityStack` | `kmsMode`, `rateLimit` not `false` | `kms` or `allowPlaintextPii` |
46
- | `composeSaasStack({ billing: "stripe" })` | `billing` (warning only) | a mounted subscription provider |
39
+ | Stack | Boot option | Also satisfied by |
40
+ | ----------------------------------------------------------------- | ---------------------------------- | ------------------------------- |
41
+ | `composeMailStack` | `mail` | a mounted `channel-email` |
42
+ | `composeFileStack` without providers | `fileStorage` | a mounted file provider |
43
+ | `composeGdprStack` | `kmsMode` | `kms` or `allowPlaintextPii` |
44
+ | `composeGdprStack({ selfService })`, `composeUserDataRightsStack` | `mail` | a mounted `channel-email` |
45
+ | `composeSecurityStack` | `kmsMode`, `rateLimit` not `false` | `kms` or `allowPlaintextPii` |
46
+ | `composeSaasStack({ billing: "stripe" })` | `billing` (warning only) | a mounted subscription provider |
47
47
 
48
48
  A missing option fails the boot with:
49
49
 
@@ -76,6 +76,7 @@ The integration test pins three guarantees:
76
76
  ```bash
77
77
  bun kumiko test integration samples/cross-feature-events
78
78
  ```
79
+
79
80
  4. **Catch-up job** — `catch-up-job.integration.test.ts` places orders
80
81
  before the first dispatcher pass and checks that the `catchUp` job
81
82
  invoices each of them exactly once.
@@ -15,7 +15,7 @@ unreadable forever, and reads render the `[[erased]]` sentinel.
15
15
 
16
16
  The recipe ships a mini HR feature: an `employee` entity whose name and email
17
17
  are the employee's own PII, and an `hr-comment` entity whose body is
18
- encrypted under the key of the employee the comment is *about* — so a
18
+ encrypted under the key of the employee the comment is _about_ — so a
19
19
  manager's comment dies with the employee's key, not the manager's.
20
20
 
21
21
  ## What it shows
@@ -49,7 +49,7 @@ manager's comment dies with the employee's key, not the manager's.
49
49
  than an alphabetically paginated one — not a plaintext sort column beside
50
50
  the encrypted one, which would survive the key erase and quietly undo the
51
51
  shredding.
52
- - **Forget = `kms.eraseKey(subject)`** — afterwards detail *and* list render
52
+ - **Forget = `kms.eraseKey(subject)`** — afterwards detail _and_ list render
53
53
  `[[erased]]` for every protected field while the stored ciphertext bytes
54
54
  stay untouched.
55
55
 
@@ -70,7 +70,7 @@ deletion grace period.
70
70
 
71
71
  1. Create an employee → the executor creates a subject key on first insert
72
72
  and stores `displayName`/`email` as ciphertext.
73
- 2. A comment about the employee is encrypted under the *employee's* key via
73
+ 2. A comment about the employee is encrypted under the _employee's_ key via
74
74
  `userOwned: { ownerField: "employeeId" }`.
75
75
  3. `kms.eraseKey({ kind: "user", userId })` — idempotent, tombstone stays
76
76
  for the audit trail.
@@ -11,7 +11,7 @@ Tenant-defined extra fields on an entity without writing migrations.
11
11
  Let a tenant add their own fields to your entity at runtime — without
12
12
  writing a migration, without rebuilding your handler, without a code
13
13
  change. The recipe wires a `property` entity into the `custom-fields`
14
- bundle and drives a full *define → set → read* roundtrip.
14
+ bundle and drives a full _define → set → read_ roundtrip.
15
15
 
16
16
  The result: the tenant defines `internalNumber: text`, sets it on a
17
17
  property, and reads it back flattened onto the row — looking exactly
@@ -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
 
@@ -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
 
@@ -68,7 +68,9 @@ export async function pruneEvents(db, options) {
68
68
  if (candidates.length === 0) {
69
69
  return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
70
70
  }
71
- const maxCandidateId = candidates.reduce((acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
71
+ const maxCandidateId = candidates.reduce(
72
+ // biome-ignore lint/complexity/useMathMinMax: ids are bigint, Math.max throws a TypeError on BigInt
73
+ (acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
72
74
  // Step 2 — safety guard: check every ACTIVE consumer has moved past
73
75
  // the candidate set. Disabled consumers are intentionally excluded —
74
76
  // ops disables them precisely to park them while pruning happens.
@@ -1,5 +1,5 @@
1
1
  export const MEILISEARCH_PEER = {
2
2
  name: "meilisearch",
3
- range: "^0.58.0",
3
+ range: "^0.62.0",
4
4
  requiredBy: "@cosmicdrift/kumiko-framework/search/meilisearch",
5
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.404.0",
3
+ "version": "0.404.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,21 +203,21 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.404.0",
207
- "bullmq": "^5.76.7",
206
+ "@cosmicdrift/kumiko-types": "0.404.1",
207
+ "bullmq": "^5.81.5",
208
208
  "hono": "^4.13.14",
209
- "i18next": "^26.1.0",
210
- "ioredis": "^5.10.1",
211
- "jose": "^6.2.13",
212
- "pino": "^10.3.1",
209
+ "i18next": "^26.5.11",
210
+ "ioredis": "^5.11.1",
211
+ "jose": "^6.2.14",
212
+ "pino": "^10.4.0",
213
213
  "temporal-polyfill": "^0.3.2",
214
- "uuid": "^14.0.0",
214
+ "uuid": "^14.0.3",
215
215
  "write-excel-file": "^4.1.1",
216
216
  "zod": "^4.6.7"
217
217
  },
218
218
  "peerDependencies": {
219
219
  "bun-types": "^1.4.4",
220
- "meilisearch": "^0.58.0"
220
+ "meilisearch": "^0.62.0"
221
221
  },
222
222
  "peerDependenciesMeta": {
223
223
  "meilisearch": {
@@ -225,11 +225,11 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.404.0",
228
+ "@cosmicdrift/kumiko-testing": "0.404.1",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
- "meilisearch": "^0.58.0",
232
- "pino-pretty": "^13.1.3"
231
+ "meilisearch": "^0.62.0",
232
+ "pino-pretty": "^13.2.0"
233
233
  },
234
234
  "publishConfig": {
235
235
  "registry": "https://registry.npmjs.org",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",