@cosmicdrift/kumiko-framework 0.362.0 → 0.364.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
package/AGENTS.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Kumiko for coding agents
|
|
2
|
+
|
|
3
|
+
This file ships inside `@cosmicdrift/kumiko-framework`. It always matches the
|
|
4
|
+
installed version. Read it before you add or change a feature in a Kumiko app.
|
|
5
|
+
|
|
6
|
+
Next to it, under `agents/`, are generated files. They are rebuilt from the
|
|
7
|
+
framework source on every release, so they never describe an API that does
|
|
8
|
+
not exist in your version:
|
|
9
|
+
|
|
10
|
+
| File | What it holds |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `agents/features.md` | Every bundled feature: name, purpose, config keys, dependencies |
|
|
13
|
+
| `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer-web` with its short description |
|
|
14
|
+
| `agents/examples.md` | Index of the worked examples, one per concept |
|
|
15
|
+
| `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
|
|
16
|
+
|
|
17
|
+
The paths are relative to this file. From an app root they live under
|
|
18
|
+
`node_modules/@cosmicdrift/kumiko-framework/`. Type signatures are in
|
|
19
|
+
`dist/*.d.ts` of each package; this file does not repeat them.
|
|
20
|
+
|
|
21
|
+
Order of work for a new feature:
|
|
22
|
+
|
|
23
|
+
1. Search `agents/features.md` for a bundled feature that already does it.
|
|
24
|
+
2. Find the closest example in `agents/examples.md` and read it fully.
|
|
25
|
+
3. Build the feature with the declarative building blocks below.
|
|
26
|
+
4. Write an integration test against the real stack.
|
|
27
|
+
5. Run boot validation and the tests. Boot errors name the fix; apply it.
|
|
28
|
+
|
|
29
|
+
## Mental model
|
|
30
|
+
|
|
31
|
+
A Kumiko app is a list of features. Everything a feature does is registered
|
|
32
|
+
on the `r` registrar inside `defineFeature(name, (r) => { ... })`. There is no
|
|
33
|
+
router, controller or ORM to wire by hand. The framework reads the
|
|
34
|
+
registrations, validates them at boot, and builds HTTP endpoints, database
|
|
35
|
+
tables, events, screens and navigation from them.
|
|
36
|
+
|
|
37
|
+
**Feature.** A named unit (`defineFeature("tasks", ...)`). It owns entities,
|
|
38
|
+
handlers, screens, navs, translations, config and events. Features talk to
|
|
39
|
+
each other through events, declared APIs and extensions, never through
|
|
40
|
+
imports of each other's internals. An app mounts features in
|
|
41
|
+
`src/run-config.ts` (`APP_FEATURES`).
|
|
42
|
+
|
|
43
|
+
**Entity.** A typed record built with `createEntity({ fields })` and field
|
|
44
|
+
factories such as `createTextField`, `createNumberField`,
|
|
45
|
+
`createBooleanField`. An entity is event sourced: every write appends an
|
|
46
|
+
event, and the table you query is a projection of those events. You never
|
|
47
|
+
write the table yourself.
|
|
48
|
+
|
|
49
|
+
**Handler.** The only way in. A write handler changes state, a query handler
|
|
50
|
+
reads it. Both have a qualified name (QN), a Zod payload schema and an access
|
|
51
|
+
rule. `r.crud(name, entity, options)` registers the entity plus create,
|
|
52
|
+
update, delete, restore, list and detail handlers in one call. Turn single
|
|
53
|
+
verbs off with `verbs: { delete: false }` and register them yourself when
|
|
54
|
+
they need different access or logic. Custom logic goes into
|
|
55
|
+
`r.writeHandler(qn, schema, fn, { access })` and
|
|
56
|
+
`r.queryHandler(qn, schema, fn, { access })`; inside, use the entity
|
|
57
|
+
executor from `createEntityExecutor(name, entity)`.
|
|
58
|
+
|
|
59
|
+
**Screen.** A declarative page: `r.screen({ id, type, ... })`. Types include
|
|
60
|
+
`entityList`, `entityEdit`, `projectionList`, `projectionDetail`,
|
|
61
|
+
`actionForm`, `dashboard` and `custom`. The renderer builds the UI from the
|
|
62
|
+
declaration. Columns, sections, row actions, tabs, wizard steps and
|
|
63
|
+
conditional fields are data, not JSX.
|
|
64
|
+
|
|
65
|
+
**Nav.** A sidebar entry: `r.nav({ id, label, screen, parent, order })`.
|
|
66
|
+
Parents may belong to another feature; refer to them by QN.
|
|
67
|
+
|
|
68
|
+
**Events.** Declare a domain event with `r.defineEvent(name, zodSchema)`.
|
|
69
|
+
Emit it inside a write handler with `emitEvent(ctx, def, { ... })` or
|
|
70
|
+
`ctx.appendEvent(...)`; it commits in the same transaction as the write.
|
|
71
|
+
React to it with `r.projection(...)` (state on the same stream) or
|
|
72
|
+
`r.multiStreamProjection({ name, apply })` (async consumer across streams,
|
|
73
|
+
at least once, ordered).
|
|
74
|
+
|
|
75
|
+
**Tenant scoping.** Every request runs for one tenant. `ctx.db`, executors
|
|
76
|
+
and queries are already filtered to that tenant. You do not pass a
|
|
77
|
+
`tenantId` around and you do not add tenant filters yourself. A feature
|
|
78
|
+
that holds global data calls `r.systemScope()`.
|
|
79
|
+
|
|
80
|
+
**Access.** Default deny. Every handler states who may call it:
|
|
81
|
+
`{ access: { roles: ["Admin", "User"] } }`, or
|
|
82
|
+
`{ access: { openToAll: { reason: "<why every signed-in user may>" } } }`.
|
|
83
|
+
Screens and nav entries can narrow visibility further with their own
|
|
84
|
+
`access`. Anonymous endpoints use the `anonymous` role. Field level and row
|
|
85
|
+
level rules exist as well (see the table below).
|
|
86
|
+
|
|
87
|
+
**Personal data.** Every text field says whether it holds personal data;
|
|
88
|
+
the field types reject a text field without that stance.
|
|
89
|
+
`personal: false, reason: "is_business_data"` (a snake_case reason) for
|
|
90
|
+
business data. For personal data name the subject, `personal: "self"`,
|
|
91
|
+
`personal: { of: "<ownerField>" }` or `personal: "tenant"`, plus
|
|
92
|
+
`find: "exact" | "fuzzy" | "none" | "secret"`. A plain foreign key to a user
|
|
93
|
+
is `personal: "ref"`. The framework encrypts personal fields per subject and
|
|
94
|
+
can erase them on request.
|
|
95
|
+
|
|
96
|
+
**Client.** The web side of a feature is a `ClientFeatureDefinition` in the
|
|
97
|
+
feature's `web/index.ts` (name, translations, custom screen components). It
|
|
98
|
+
is mounted in `src/client.tsx`. Declarative screens need no client code.
|
|
99
|
+
|
|
100
|
+
## Qualified names
|
|
101
|
+
|
|
102
|
+
Names are built from the feature name, a kind and an id. Use the QN wherever
|
|
103
|
+
another feature or a test refers to something.
|
|
104
|
+
|
|
105
|
+
| Thing | Pattern | Example from the scaffolded demo feature |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| Write handler | `<feature>:write:<entity>:<verb>` | `tasks:write:task:create` |
|
|
108
|
+
| Query handler | `<feature>:query:<entity>:<verb>` | `tasks:query:task:list` |
|
|
109
|
+
| Screen | `<feature>:screen:<id>` | `tasks:screen:task-list` |
|
|
110
|
+
| Nav entry | `<feature>:nav:<id>` | `tasks:nav:tasks` |
|
|
111
|
+
| Translation key | `<feature>:<path>` | `tasks:nav.tasks` |
|
|
112
|
+
| Entity field label | `<feature>:entity:<entity>:field:<field>` | `tasks:entity:task:field:title` |
|
|
113
|
+
|
|
114
|
+
Inside one feature, a screen id without prefix (`screen: "task-edit"`) refers
|
|
115
|
+
to a screen of the same feature.
|
|
116
|
+
|
|
117
|
+
## Where things live in an app
|
|
118
|
+
|
|
119
|
+
The scaffold (`bun create kumiko-app <name>`) produces this layout. Keep it.
|
|
120
|
+
|
|
121
|
+
| Path | Content |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `src/run-config.ts` | `APP_FEATURES`, the mounted feature list |
|
|
124
|
+
| `src/features/<name>/feature.ts` | `defineFeature(...)` with entities, handlers, screens, navs |
|
|
125
|
+
| `src/features/<name>/index.ts` | Public exports of the feature |
|
|
126
|
+
| `src/features/<name>/web/index.ts` | Client feature definition |
|
|
127
|
+
| `src/client.tsx` | Web entry, mounts the client features |
|
|
128
|
+
| `src/__tests__/*.test.ts` | Unit tests, no services |
|
|
129
|
+
| `src/__tests__/*.integration.test.ts` | Integration tests against Postgres and Redis |
|
|
130
|
+
| `e2e/*.spec.ts` | Playwright tests against a booted app |
|
|
131
|
+
| `kumiko/schema.ts`, `kumiko/migrations/` | Database schema and generated migrations |
|
|
132
|
+
|
|
133
|
+
The scaffolded `src/features/tasks/feature.ts` and
|
|
134
|
+
`src/__tests__/tasks.integration.test.ts` are the smallest complete pair of
|
|
135
|
+
feature and test. Start from them.
|
|
136
|
+
|
|
137
|
+
## Commands in an app
|
|
138
|
+
|
|
139
|
+
| Goal | Command |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| Run the app with demo data | `bun dev` |
|
|
142
|
+
| Boot validation without serving | `bun run boot` |
|
|
143
|
+
| Types | `bun run typecheck` |
|
|
144
|
+
| Lint | `bun run lint` |
|
|
145
|
+
| Unit tests | `bun run test` |
|
|
146
|
+
| Integration tests (needs `docker compose up -d`) | `bun run test:integration` |
|
|
147
|
+
| Browser tests | `bun run e2e` |
|
|
148
|
+
| New migration after an entity change | `bun run schema:generate` |
|
|
149
|
+
| Apply migrations | `bun run schema:apply` |
|
|
150
|
+
|
|
151
|
+
## Rules: never this, do that
|
|
152
|
+
|
|
153
|
+
These rules are enforced by boot validation, guards or review. Breaking them
|
|
154
|
+
produces code that compiles and then fails in production.
|
|
155
|
+
|
|
156
|
+
### Data and events
|
|
157
|
+
|
|
158
|
+
- Never `INSERT`, `UPDATE` or `DELETE` on an entity table or the events
|
|
159
|
+
table, and never call a raw database client for it. Write through a
|
|
160
|
+
handler: `r.crud`, the `defineEntity*Handler` helpers, the executor from
|
|
161
|
+
`createEntityExecutor`, or `emitEvent` / `ctx.appendEvent`.
|
|
162
|
+
- Never store a value that can be computed. Derive it in a query or a
|
|
163
|
+
projection.
|
|
164
|
+
- Never hand-write SQL for reads that an executor `list` or `detail` covers.
|
|
165
|
+
Use `r.queryHandler` with the executor, or a projection with
|
|
166
|
+
`ctx.queryProjection`.
|
|
167
|
+
- Never change an event schema in place once it has been emitted. Version it
|
|
168
|
+
(see `agents/examples/event-sourcing.md`).
|
|
169
|
+
- Never read or write another tenant's data. `ctx.db` is tenant scoped on
|
|
170
|
+
purpose. Cross-tenant work belongs in system features with explicit
|
|
171
|
+
system access.
|
|
172
|
+
|
|
173
|
+
### Handlers and access
|
|
174
|
+
|
|
175
|
+
- Never register a handler without `access`. Use roles, or `openToAll` with
|
|
176
|
+
a concrete `reason`.
|
|
177
|
+
- Never throw plain `Error` for an expected domain failure. Return a typed
|
|
178
|
+
failure from `@cosmicdrift/kumiko-framework/errors` (for example
|
|
179
|
+
`failNotFound(entity, id)`); it reaches the client as a structured error
|
|
180
|
+
with a reason and, where documented, a `docsUrl`.
|
|
181
|
+
- Never parse request input by hand. The Zod schema of the handler is the
|
|
182
|
+
boundary; inside the handler the payload is typed.
|
|
183
|
+
- Read-modify-write updates pass the `version` they read, so concurrent
|
|
184
|
+
writes fail with `version_conflict` instead of overwriting each other.
|
|
185
|
+
|
|
186
|
+
### Types
|
|
187
|
+
|
|
188
|
+
- Never use `any`. Validate external data once at the boundary (Zod), then
|
|
189
|
+
work with the inferred type.
|
|
190
|
+
- Never use `as` to force a type except at a real system boundary. Narrow
|
|
191
|
+
unions with a discriminant and `unknown` with a type guard.
|
|
192
|
+
- Never write a magic string where a union type or a QN exists.
|
|
193
|
+
|
|
194
|
+
### UI
|
|
195
|
+
|
|
196
|
+
- Never write your own JSX for a list, form, detail page or dashboard.
|
|
197
|
+
Declare a screen. Use `type: "custom"` only when no screen type fits.
|
|
198
|
+
- Inside a custom screen, never build your own card, table, badge, button or
|
|
199
|
+
chart. Use the widgets from `@cosmicdrift/kumiko-renderer-web`
|
|
200
|
+
(`agents/widgets.md`) and the primitives from `usePrimitives()`.
|
|
201
|
+
- Never fetch data with `useEffect` plus `fetch`. Use `useQuery`
|
|
202
|
+
(`live: true` for server-sent updates) and `useMutation`.
|
|
203
|
+
- Never use inline styles, hex colors or Tailwind palette classes. Use the
|
|
204
|
+
theme tokens (`bg-primary`, `text-status-ok`, ...).
|
|
205
|
+
- Never put user-facing text in code. Use translation keys and `t("...")`;
|
|
206
|
+
register the texts with `r.translations` and the client translations.
|
|
207
|
+
|
|
208
|
+
### Tests
|
|
209
|
+
|
|
210
|
+
- Integration tests run against the real stack over real HTTP. In an app:
|
|
211
|
+
`setupAppTestStack([feature])` plus `seedTenant(stack)` from
|
|
212
|
+
`@cosmicdrift/kumiko-testing`, then `tenant.api.writeOk` and
|
|
213
|
+
`tenant.api.queryOk`. Never assemble a dispatcher yourself
|
|
214
|
+
(`createTestDispatcher` or similar); it skips the production wiring.
|
|
215
|
+
- Every test seeds its own tenant. No shared tenant, no global fixtures.
|
|
216
|
+
- Assert behavior through handlers and their results, not internal tables.
|
|
217
|
+
- A red or flaky test gets diagnosed. Never fix it with a longer timeout, a
|
|
218
|
+
retry, `serial` or fewer workers.
|
|
219
|
+
- Tests that call real external providers end in `.real.test.ts` and run
|
|
220
|
+
only via `bun run test:real`, never in CI.
|
|
221
|
+
|
|
222
|
+
### Boot errors
|
|
223
|
+
|
|
224
|
+
- Boot validation stops the app when a declaration is inconsistent: an
|
|
225
|
+
unknown field in a column, a nav pointing at a missing screen, an
|
|
226
|
+
encrypted field marked searchable. The message names the
|
|
227
|
+
feature, the place and the fix. Apply the fix the message names instead
|
|
228
|
+
of working around the check.
|
|
229
|
+
|
|
230
|
+
## I need X, use Y
|
|
231
|
+
|
|
232
|
+
Before building anything here, search `agents/features.md`: auth, tenants,
|
|
233
|
+
mail, files, jobs, billing, audit, notifications and GDPR flows already exist
|
|
234
|
+
as bundled features.
|
|
235
|
+
|
|
236
|
+
| I need | Use | Read |
|
|
237
|
+
| --- | --- | --- |
|
|
238
|
+
| A CRUD entity with list and edit | `createEntity` + `r.crud` + `entityList` / `entityEdit` screens | `agents/examples/basic-entity.md` |
|
|
239
|
+
| Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
|
|
240
|
+
| Relations between entities | `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
|
|
241
|
+
| Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
|
|
242
|
+
| List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
|
|
243
|
+
| A record page with header, metrics, tabs | `projectionDetail` with `header`, `metrics`, `layout.mode: "tabs"` | `agents/examples/record-detail-layout.md` |
|
|
244
|
+
| A multi-step form | `layout.mode: "wizard"` | `agents/examples/wizard-form.md` |
|
|
245
|
+
| A form inside a drawer | `RenderEdit` in a `Drawer` | `agents/examples/embedded-entity-form.md` |
|
|
246
|
+
| Role-based access | `access: { roles }`, `openToAll` | `agents/examples/access-control.md` |
|
|
247
|
+
| Field-level access | Field access rules | `agents/examples/field-access.md` |
|
|
248
|
+
| Row ownership | Ownership rules | `agents/examples/ownership.md` |
|
|
249
|
+
| Tenant separation | Built in; see what it guarantees | `agents/examples/tenant-isolation.md` |
|
|
250
|
+
| Public or anonymous endpoints | `anonymous` role | `agents/examples/anonymous-access.md` |
|
|
251
|
+
| Allowed state changes | Entity transitions | `agents/examples/state-machine.md` |
|
|
252
|
+
| React to a write in another feature | `r.defineEvent` + `emitEvent` + `r.multiStreamProjection` | `agents/examples/cross-feature-events.md` |
|
|
253
|
+
| Projections, event versions, history | `r.projection`, `ctx.loadAggregate`, `ctx.queryProjection` | `agents/examples/event-sourcing.md` |
|
|
254
|
+
| Before or after save logic | Lifecycle hooks | `agents/examples/lifecycle-hooks.md` |
|
|
255
|
+
| Typed errors with reasons | `@cosmicdrift/kumiko-framework/errors` | `agents/examples/error-contract.md` |
|
|
256
|
+
| Translations | `r.translations` + client translations | `agents/examples/i18n.md` |
|
|
257
|
+
| Config per tenant or system | Managed config | `agents/examples/managed-config.md` |
|
|
258
|
+
| Tenant defaults (currency, locale) | Tenant settings | `agents/examples/tenant-settings.md` |
|
|
259
|
+
| Secrets such as API keys | Secrets with envelope encryption | `agents/examples/secrets-demo.md` |
|
|
260
|
+
| Personal data and erasure | `personal` / `find` annotations, crypto shredding | `agents/examples/crypto-shredding-hr.md` |
|
|
261
|
+
| GDPR export and forget for my data | `user-data-rights` extension | `agents/examples/user-data-rights.md` |
|
|
262
|
+
| Full-text search | Search feature | `agents/examples/search.md` |
|
|
263
|
+
| Live updates in the browser | Server-sent events, `useQuery({ live: true })` | `agents/examples/realtime-sse.md` |
|
|
264
|
+
| Feature toggles | Toggleable features | `agents/examples/feature-toggles.md` |
|
|
265
|
+
| Rate limits | Rate limiting | `agents/examples/rate-limiting.md` |
|
|
266
|
+
| Idempotent writes | Request id dedupe | `agents/examples/idempotency.md` |
|
|
267
|
+
| File variants and images | File fields, derivatives | `agents/examples/files-post-processing.md` |
|
|
268
|
+
| Money fields | Currency fields | `agents/examples/currencies-per-tenant.md` |
|
|
269
|
+
| Dates, times, time zones | Time-aware field types, `ctx.tz` | `agents/examples/timezones.md` |
|
|
270
|
+
| Tags or notes on any entity | Tags, notes history | `agents/examples/tags-basic.md`, `agents/examples/notes-history-basic.md` |
|
|
271
|
+
| Tenant-defined extra fields | Custom fields | `agents/examples/custom-fields-basic.md` |
|
|
272
|
+
| Lookup tables and seeds | Upsert seeds | `agents/examples/reference-data.md` |
|
|
273
|
+
| Multi-step background flows | Workflow engine | `agents/examples/workflow-engine.md` |
|
|
274
|
+
| A mount invariant for my feature | `r.bootCheck` | `agents/examples/boot-check.md` |
|
|
275
|
+
| A widget for a custom screen | Search by concern | `agents/widgets.md` |
|
|
276
|
+
| A bundled feature and its config | Search by name or purpose | `agents/features.md` |
|
|
277
|
+
|
|
278
|
+
## Reading an example
|
|
279
|
+
|
|
280
|
+
Each `agents/examples/<name>.md` starts with the sample's README, then every
|
|
281
|
+
source file with its path. `src/feature.ts` holds the feature;
|
|
282
|
+
`src/__tests__/*.integration.test.ts` shows how it is tested. Samples inside
|
|
283
|
+
the framework repo set up their stack with `setupTestStack` from
|
|
284
|
+
`@cosmicdrift/kumiko-framework/stack` and create tables in the test. In an
|
|
285
|
+
app, `setupAppTestStack` and `seedTenant` do that for you; keep the
|
|
286
|
+
assertions, take the setup from your scaffolded integration test.
|
|
287
|
+
|
|
288
|
+
Some samples import from `@app/define`. That alias points at the generated
|
|
289
|
+
`.kumiko/define.ts`, which re-exports the engine with event types narrowed to
|
|
290
|
+
the app's own `r.defineEvent` declarations. `bun dev` and `bun run build`
|
|
291
|
+
regenerate it; never edit it. Without it, import the same names from
|
|
292
|
+
`@cosmicdrift/kumiko-framework/engine`.
|
|
293
|
+
|
|
294
|
+
## Package entry points
|
|
295
|
+
|
|
296
|
+
Import only from these subpaths of `@cosmicdrift/kumiko-framework`. Deep
|
|
297
|
+
imports into `src/` or `dist/` break on the next release.
|
|
298
|
+
|
|
299
|
+
| Subpath | Use it for |
|
|
300
|
+
| --- | --- |
|
|
301
|
+
| `/engine` | `defineFeature`, `createEntity`, field factories, handler helpers, executors, events |
|
|
302
|
+
| `/errors` | Typed failures and error helpers |
|
|
303
|
+
| `/stack` | Test stack, `TestUsers`, `createTestUser` |
|
|
304
|
+
| `/testing` | Assertion helpers such as `expectErrorIncludes` |
|
|
305
|
+
| `/ui-types` | Screen definition types (`EntityListScreenDefinition`, ...) |
|
|
306
|
+
| `/db` | `buildEntityTable` and database helpers for tests and migrations |
|
|
307
|
+
| `/event-store` | Event store helpers |
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/access-control. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# access-control
|
|
4
|
+
|
|
5
|
+
Default-deny access rules, openToAll vs. role-based handlers, and auto-indices for foreign keys.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Access Control
|
|
10
|
+
|
|
11
|
+
Default-deny access rules and FK indices via relations.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Handler `access` roles as the default-deny gate
|
|
16
|
+
- Relation-declared FK indices for join-friendly schemas
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd samples/recipes/access-control
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
37
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
39
|
+
import {
|
|
40
|
+
setupTestStack,
|
|
41
|
+
type TestStack,
|
|
42
|
+
TestUsers,
|
|
43
|
+
unsafeCreateEntityTable,
|
|
44
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
45
|
+
import { z } from "zod";
|
|
46
|
+
import {
|
|
47
|
+
accessControlFeature,
|
|
48
|
+
projectEntity,
|
|
49
|
+
projectTable,
|
|
50
|
+
taskEntity,
|
|
51
|
+
taskTable,
|
|
52
|
+
} from "../feature";
|
|
53
|
+
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
const admin = TestUsers.admin; // { id: 1, tenantId: 1, roles: ["Admin"] }
|
|
56
|
+
const user = TestUsers.user; // { id: 2, tenantId: 1, roles: ["User"] }
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
stack = await setupTestStack({ features: [accessControlFeature] });
|
|
60
|
+
await unsafeCreateEntityTable(stack.db, projectEntity);
|
|
61
|
+
await unsafeCreateEntityTable(stack.db, taskEntity);
|
|
62
|
+
await createEventsTable(stack.db);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await stack?.cleanup();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("default-deny: refuses handlers without access", () => {
|
|
70
|
+
test("throws at registration when a handler lacks an access rule", () => {
|
|
71
|
+
// `access` is required since fw#2855 — the registrar throws before validateBoot ever runs.
|
|
72
|
+
expect(() =>
|
|
73
|
+
defineFeature("broken-feature", (r) => {
|
|
74
|
+
// @ts-expect-error — No { access: ... }: registration must reject this.
|
|
75
|
+
r.writeHandler("forgotAccess", z.object({ name: z.string() }), async () => ({
|
|
76
|
+
isSuccess: true as const,
|
|
77
|
+
data: {},
|
|
78
|
+
}));
|
|
79
|
+
}),
|
|
80
|
+
).toThrow(/requires schema \+ handler \+ options\.access/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("role-based vs openToAll", () => {
|
|
85
|
+
test("Admin-only handler rejects a User caller with access_denied", async () => {
|
|
86
|
+
const raw = await stack.http.write(
|
|
87
|
+
"access-control:write:project:create",
|
|
88
|
+
{ name: "Rejected", ownerId: "11111111-0000-4000-8000-000000000001" },
|
|
89
|
+
user,
|
|
90
|
+
);
|
|
91
|
+
const body = (await raw.json()) as { isSuccess: boolean; error?: { code: string } };
|
|
92
|
+
expect(body.isSuccess).toBe(false);
|
|
93
|
+
expect(body.error?.code).toBe("access_denied");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("Admin-only handler accepts an Admin caller", async () => {
|
|
97
|
+
await stack.http.writeOk(
|
|
98
|
+
"access-control:write:project:create",
|
|
99
|
+
{ name: "Alpha", ownerId: "11111111-0000-4000-8000-000000000001" },
|
|
100
|
+
admin,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("openToAll handler accepts any authenticated user", async () => {
|
|
105
|
+
const proj = await stack.http.writeOk<{ data: { id: string } }>(
|
|
106
|
+
"access-control:write:project:create",
|
|
107
|
+
{ name: "Shared", ownerId: "11111111-0000-4000-8000-000000000001" },
|
|
108
|
+
admin,
|
|
109
|
+
);
|
|
110
|
+
await stack.http.writeOk(
|
|
111
|
+
"access-control:write:task:create",
|
|
112
|
+
{ title: "User's task", projectId: proj.data.id },
|
|
113
|
+
user,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("auto-indices from relations", () => {
|
|
119
|
+
test("task table has an index on project_id because of the belongsTo relation", () => {
|
|
120
|
+
const names = taskTable.indexes.map((i) => i.name);
|
|
121
|
+
|
|
122
|
+
expect(names).toContain("read_ac_tasks_tenant_id_idx");
|
|
123
|
+
expect(names).toContain("read_ac_tasks_project_id_idx");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("project table has only the tenant_id index (no belongsTo relations)", () => {
|
|
127
|
+
expect(projectTable.indexes.map((i) => i.name)).toEqual(["read_ac_projects_tenant_id_idx"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("optimistic locking: version required in update schema", () => {
|
|
132
|
+
test("update without version fails schema validation before reaching the crud executor", async () => {
|
|
133
|
+
const proj = await stack.http.writeOk<{ data: { id: string } }>(
|
|
134
|
+
"access-control:write:project:create",
|
|
135
|
+
{ name: "VersionedProject", ownerId: "11111111-0000-4000-8000-000000000001" },
|
|
136
|
+
admin,
|
|
137
|
+
);
|
|
138
|
+
const created = await stack.http.writeOk<{ data: { id: string } }>(
|
|
139
|
+
"access-control:write:task:create",
|
|
140
|
+
{ title: "Needs version", projectId: proj.data.id },
|
|
141
|
+
user,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const raw = await stack.http.write(
|
|
145
|
+
"access-control:write:task:update",
|
|
146
|
+
// Missing `version` — schema rejects before any DB call.
|
|
147
|
+
{ id: created.data.id, changes: { title: "Updated" } },
|
|
148
|
+
user,
|
|
149
|
+
);
|
|
150
|
+
const body = (await raw.json()) as { isSuccess: boolean; error?: { code: string } };
|
|
151
|
+
expect(body.isSuccess).toBe(false);
|
|
152
|
+
expect(body.error?.code).toBe("validation_error");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("update with correct version succeeds", async () => {
|
|
156
|
+
const proj = await stack.http.writeOk<{ data: { id: string } }>(
|
|
157
|
+
"access-control:write:project:create",
|
|
158
|
+
{ name: "VersionedOk", ownerId: "11111111-0000-4000-8000-000000000001" },
|
|
159
|
+
admin,
|
|
160
|
+
);
|
|
161
|
+
const created = await stack.http.writeOk<{ data: { id: string } }>(
|
|
162
|
+
"access-control:write:task:create",
|
|
163
|
+
{ title: "Task v1", projectId: proj.data.id },
|
|
164
|
+
user,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await stack.http.writeOk(
|
|
168
|
+
"access-control:write:task:update",
|
|
169
|
+
{ id: created.data.id, version: 1, changes: { title: "Task v2" } },
|
|
170
|
+
user,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Ensures this sample is wired through the full stack — if any of its
|
|
176
|
+
// handlers regress to missing access or broken schemas, boot or this list
|
|
177
|
+
// query will fail loudly.
|
|
178
|
+
describe("end-to-end", () => {
|
|
179
|
+
test("list returns seeded tasks across users", async () => {
|
|
180
|
+
const proj = await stack.http.writeOk<{ data: { id: string } }>(
|
|
181
|
+
"access-control:write:project:create",
|
|
182
|
+
{ name: "ListTarget", ownerId: "11111111-0000-4000-8000-000000000001" },
|
|
183
|
+
admin,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await stack.http.writeOk(
|
|
187
|
+
"access-control:write:task:create",
|
|
188
|
+
{ title: "t1", projectId: proj.data.id },
|
|
189
|
+
user,
|
|
190
|
+
);
|
|
191
|
+
await stack.http.writeOk(
|
|
192
|
+
"access-control:write:task:create",
|
|
193
|
+
{ title: "t2", projectId: proj.data.id },
|
|
194
|
+
admin,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const list = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
198
|
+
"access-control:query:task:list",
|
|
199
|
+
{},
|
|
200
|
+
user,
|
|
201
|
+
);
|
|
202
|
+
expect(list.rows.length).toBeGreaterThanOrEqual(2);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### src/feature.ts
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// Access Control + FK-Indices Sample
|
|
211
|
+
//
|
|
212
|
+
// Demonstrates the framework's security + performance defaults:
|
|
213
|
+
//
|
|
214
|
+
// 1. Default-deny: every handler must declare an access rule. Boot fails
|
|
215
|
+
// loudly when one is missing. Two shapes:
|
|
216
|
+
// { roles: ["Admin", ...] } — role allowlist
|
|
217
|
+
// { openToAll: { reason: "..." } } — any authenticated user, with a required reason
|
|
218
|
+
//
|
|
219
|
+
// 2. Foreign-key relations declared via r.relation() become indexed columns
|
|
220
|
+
// automatically — no manual CREATE INDEX. Inspect the Drizzle table
|
|
221
|
+
// config in tests to verify.
|
|
222
|
+
|
|
223
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
224
|
+
import {
|
|
225
|
+
createEntity,
|
|
226
|
+
createTextField,
|
|
227
|
+
defineEntityCreateHandler,
|
|
228
|
+
defineEntityListHandler,
|
|
229
|
+
defineFeature,
|
|
230
|
+
defineRoles,
|
|
231
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
232
|
+
import { z } from "zod";
|
|
233
|
+
|
|
234
|
+
// Typed role registry — `defineRoles` returns an object keyed by role name
|
|
235
|
+
// so access rules can reference `AccessControlRoles.Admin` instead of the
|
|
236
|
+
// magic string "Admin". Typos become compile errors, renames refactor.
|
|
237
|
+
export const AccessControlRoles = defineRoles(["Admin"] as const);
|
|
238
|
+
|
|
239
|
+
export const projectEntity = createEntity({
|
|
240
|
+
table: "read_ac_projects",
|
|
241
|
+
fields: {
|
|
242
|
+
name: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
243
|
+
ownerId: createTextField({ personal: "ref", required: true }),
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
export const taskEntity = createEntity({
|
|
248
|
+
table: "read_ac_tasks",
|
|
249
|
+
fields: {
|
|
250
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
251
|
+
assigneeId: createTextField({ personal: "ref" }),
|
|
252
|
+
projectId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// The "owner" relation points at a hypothetical external `user` entity — in
|
|
257
|
+
// a real app this would be @cosmicdrift/kumiko-bundled-features/user. For the sample we
|
|
258
|
+
// keep it internal: projectRelations only holds FK metadata (no cross-entity
|
|
259
|
+
// resolution) so the sample stays self-contained.
|
|
260
|
+
export const projectRelations = {} as const;
|
|
261
|
+
|
|
262
|
+
export const taskRelations = {
|
|
263
|
+
project: { type: "belongsTo", target: "project", foreignKey: "projectId" },
|
|
264
|
+
} as const;
|
|
265
|
+
|
|
266
|
+
// Relations flow into buildEntityTable — every belongsTo FK gets its own
|
|
267
|
+
// index on this table.
|
|
268
|
+
export const projectTable = buildEntityTable("project", projectEntity, {
|
|
269
|
+
relations: projectRelations,
|
|
270
|
+
});
|
|
271
|
+
export const taskTable = buildEntityTable("task", taskEntity, {
|
|
272
|
+
relations: taskRelations,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
export const accessControlFeature = defineFeature("access-control", (r) => {
|
|
276
|
+
r.entity("project", projectEntity);
|
|
277
|
+
r.entity("task", taskEntity);
|
|
278
|
+
r.relation("task", "project", taskRelations.project);
|
|
279
|
+
|
|
280
|
+
// Admin-only: standard create — no business logic, helper handles schema +
|
|
281
|
+
// executor + handler-body.
|
|
282
|
+
r.writeHandler(
|
|
283
|
+
defineEntityCreateHandler("project", projectEntity, {
|
|
284
|
+
access: { roles: [AccessControlRoles.Admin] },
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// Custom create: assigneeId defaults to the caller when omitted, so we
|
|
289
|
+
// hand-write this handler instead of using the helper. The framework helpers
|
|
290
|
+
// are deliberately scope-limited — opt out the moment you need defaults.
|
|
291
|
+
const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
|
|
292
|
+
r.writeHandler(
|
|
293
|
+
"task:create",
|
|
294
|
+
z.object({
|
|
295
|
+
title: z.string().min(1),
|
|
296
|
+
projectId: z.uuid(),
|
|
297
|
+
assigneeId: z.uuid().optional(),
|
|
298
|
+
}),
|
|
299
|
+
async (event, ctx) =>
|
|
300
|
+
taskExecutor.create(
|
|
301
|
+
{
|
|
302
|
+
...event.payload,
|
|
303
|
+
assigneeId: event.payload.assigneeId ?? event.user.id,
|
|
304
|
+
},
|
|
305
|
+
event.user,
|
|
306
|
+
ctx.db,
|
|
307
|
+
),
|
|
308
|
+
{
|
|
309
|
+
access: {
|
|
310
|
+
openToAll: {
|
|
311
|
+
reason:
|
|
312
|
+
"any signed-in tenant user may create a task; assigneeId defaults to the caller when omitted",
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Update requires version — optimistic locking is the default and this
|
|
319
|
+
// narrowed schema (only title + assigneeId mutable) is stricter than the
|
|
320
|
+
// helper's generic update schema, so we keep it hand-written.
|
|
321
|
+
r.writeHandler(
|
|
322
|
+
"task:update",
|
|
323
|
+
z.object({
|
|
324
|
+
id: z.uuid(),
|
|
325
|
+
version: z.number(),
|
|
326
|
+
changes: z.object({
|
|
327
|
+
title: z.string().min(1).optional(),
|
|
328
|
+
assigneeId: z.uuid().optional(),
|
|
329
|
+
}),
|
|
330
|
+
}),
|
|
331
|
+
async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
|
|
332
|
+
{
|
|
333
|
+
access: {
|
|
334
|
+
openToAll: {
|
|
335
|
+
reason:
|
|
336
|
+
"any signed-in tenant user may update a task; optimistic locking (version) " +
|
|
337
|
+
"guards against concurrent overwrites, not caller identity",
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Self-service list — no custom logic.
|
|
344
|
+
r.queryHandler(
|
|
345
|
+
defineEntityListHandler("task", taskEntity, {
|
|
346
|
+
access: {
|
|
347
|
+
openToAll: {
|
|
348
|
+
reason:
|
|
349
|
+
"any signed-in tenant user may list tasks; there is no per-user filter in this recipe",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
```
|