@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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/feature-toggles. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# feature-toggles
|
|
4
|
+
|
|
5
|
+
Runtime feature-toggle flip — two toggleable features, one registers a cross-feature hook on the other; disabling either has the documented effect without reboot.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Feature Toggles
|
|
10
|
+
|
|
11
|
+
Runtime global feature toggles without reboot.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Dispatcher gate → `feature_disabled` when a feature is off
|
|
16
|
+
- Cross-feature hooks skipped when a dependency toggle is off
|
|
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/feature-toggles
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
37
|
+
import {
|
|
38
|
+
createFeatureTogglesFeature,
|
|
39
|
+
GlobalFeatureToggleRuntime,
|
|
40
|
+
globalFeatureStateTable,
|
|
41
|
+
} from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
|
|
42
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
43
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import {
|
|
45
|
+
createTestUser,
|
|
46
|
+
setupTestStack,
|
|
47
|
+
type TestStack,
|
|
48
|
+
unsafeCreateEntityTable,
|
|
49
|
+
unsafePushTables,
|
|
50
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
51
|
+
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
52
|
+
import {
|
|
53
|
+
createProductAuditFeature,
|
|
54
|
+
createProductFeature,
|
|
55
|
+
productAuditEntity,
|
|
56
|
+
productAuditTable,
|
|
57
|
+
productEntity,
|
|
58
|
+
productTable,
|
|
59
|
+
} from "../feature";
|
|
60
|
+
|
|
61
|
+
let stack: TestStack;
|
|
62
|
+
let runtime: GlobalFeatureToggleRuntime;
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
let effective: () => ReadonlySet<string> = () => new Set();
|
|
66
|
+
const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("runtime");
|
|
67
|
+
|
|
68
|
+
stack = await setupTestStack({
|
|
69
|
+
features: [
|
|
70
|
+
createProductFeature(),
|
|
71
|
+
createProductAuditFeature(),
|
|
72
|
+
createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
|
|
73
|
+
],
|
|
74
|
+
effectiveFeatures: () => effective(),
|
|
75
|
+
systemHooks: [],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await unsafePushTables(stack.db, { globalFeatureStateTable });
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, productEntity);
|
|
80
|
+
await unsafeCreateEntityTable(stack.db, productAuditEntity, "product-audit");
|
|
81
|
+
|
|
82
|
+
runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
|
|
83
|
+
await runtime.initialize();
|
|
84
|
+
effective = runtime.effectiveFeatures;
|
|
85
|
+
runtimeHolder.set(runtime);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
await stack?.cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${productAuditTable.tableName}"`);
|
|
94
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
|
|
95
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${globalFeatureStateTable.tableName}"`);
|
|
96
|
+
await runtime.refresh();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const admin = createTestUser({
|
|
100
|
+
id: "11111111-1111-1111-1111-111111111111",
|
|
101
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
102
|
+
roles: ["SystemAdmin"],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
async function createProduct(name: string) {
|
|
106
|
+
return stack.http.write("product:write:product:create", { name }, admin);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("feature-toggles showcase", () => {
|
|
110
|
+
test("runtime flip: product on → create works + audit-hook fires; off → 403; on → works again", async () => {
|
|
111
|
+
// ON
|
|
112
|
+
const ok = await createProduct("alpha");
|
|
113
|
+
expect((await ok.json()).isSuccess).toBe(true);
|
|
114
|
+
const auditRowsAfterOn = await selectMany(stack.db, productAuditTable);
|
|
115
|
+
expect(auditRowsAfterOn.length).toBe(1);
|
|
116
|
+
|
|
117
|
+
// OFF
|
|
118
|
+
runtime.apply("product", false);
|
|
119
|
+
const denied = await createProduct("beta");
|
|
120
|
+
const deniedBody = (await denied.json()) as {
|
|
121
|
+
isSuccess: boolean;
|
|
122
|
+
error?: { code: string };
|
|
123
|
+
};
|
|
124
|
+
expect(deniedBody.error?.code).toBe("feature_disabled");
|
|
125
|
+
|
|
126
|
+
// ON again
|
|
127
|
+
runtime.apply("product", true);
|
|
128
|
+
const again = await createProduct("gamma");
|
|
129
|
+
expect((await again.json()).isSuccess).toBe(true);
|
|
130
|
+
const auditRowsAfterReEnable = await selectMany(stack.db, productAuditTable);
|
|
131
|
+
expect(auditRowsAfterReEnable.length).toBe(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("cross-feature hook: disabling product-audit skips its hook but product keeps working", async () => {
|
|
135
|
+
runtime.apply("product-audit", false);
|
|
136
|
+
|
|
137
|
+
const ok = await createProduct("delta");
|
|
138
|
+
expect((await ok.json()).isSuccess).toBe(true);
|
|
139
|
+
// Product row written — handler unaffected.
|
|
140
|
+
const productRows = await selectMany(stack.db, productTable);
|
|
141
|
+
expect(productRows.length).toBe(1);
|
|
142
|
+
// Audit hook owned by product-audit — silently skipped.
|
|
143
|
+
const auditRows = await selectMany(stack.db, productAuditTable);
|
|
144
|
+
expect(auditRows.length).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### src/feature.ts
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// Feature-Toggles Showcase — two toggleable features that prove the two
|
|
153
|
+
// operator-visible gate points in one test:
|
|
154
|
+
//
|
|
155
|
+
// 1. Dispatcher-gate on the owner feature's handler (403 feature_disabled)
|
|
156
|
+
// 2. Hook-filter on a cross-feature r.hook({ allOf }) (skipped when hook-owner off)
|
|
157
|
+
//
|
|
158
|
+
// The feature-toggles bundled-feature itself is loaded in the integration
|
|
159
|
+
// test so the canonical wiring (runtime accessor + effectiveFeatures
|
|
160
|
+
// callback + set-handler) is exercised as documentation, not just in the
|
|
161
|
+
// framework's own tests.
|
|
162
|
+
|
|
163
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
164
|
+
import {
|
|
165
|
+
createBooleanField,
|
|
166
|
+
createEntity,
|
|
167
|
+
createSystemUser,
|
|
168
|
+
createTextField,
|
|
169
|
+
defineFeature,
|
|
170
|
+
type FeatureDefinition,
|
|
171
|
+
SYSTEM_TENANT_ID,
|
|
172
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
173
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
174
|
+
import { z } from "zod";
|
|
175
|
+
|
|
176
|
+
// product — toggleable, default on. Owns the `product` entity and a
|
|
177
|
+
// create-handler. Wire-path: event-store executor → projection table →
|
|
178
|
+
// lifecycle pipeline (that's where the cross-feature hook below gets
|
|
179
|
+
// invoked from).
|
|
180
|
+
export const productEntity = createEntity({
|
|
181
|
+
table: "read_products",
|
|
182
|
+
fields: {
|
|
183
|
+
name: createTextField({
|
|
184
|
+
personal: false,
|
|
185
|
+
reason: "is_business_data",
|
|
186
|
+
required: true,
|
|
187
|
+
maxLength: 100,
|
|
188
|
+
}),
|
|
189
|
+
active: createBooleanField({ default: true }),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
export const productTable = buildEntityTable("product", productEntity);
|
|
193
|
+
const productCrud = createEventStoreExecutor(productTable, productEntity, {
|
|
194
|
+
entityName: "product",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
export function createProductFeature(): FeatureDefinition {
|
|
198
|
+
return defineFeature("product", (r) => {
|
|
199
|
+
r.systemScope();
|
|
200
|
+
r.toggleable({ default: true });
|
|
201
|
+
r.entity("product", productEntity);
|
|
202
|
+
r.writeHandler(
|
|
203
|
+
"product:create",
|
|
204
|
+
z.object({ name: z.string().min(1).max(100) }),
|
|
205
|
+
async (event, ctx) => {
|
|
206
|
+
if (!ctx.systemDb) {
|
|
207
|
+
throw new InternalError({
|
|
208
|
+
message: "product:create requires ctx.systemDb — is r.systemScope() still set?",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
212
|
+
"product catalog is system-wide, not tenant-scoped",
|
|
213
|
+
);
|
|
214
|
+
return productCrud.create(event.payload, event.user, db);
|
|
215
|
+
},
|
|
216
|
+
{ access: { roles: ["SystemAdmin"] } },
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// product-audit — toggleable, default on. Registers a cross-feature
|
|
222
|
+
// r.hook({ allOf: "product" }) on product's postSave. When this feature is globally off,
|
|
223
|
+
// the hook is silently skipped; product's own write-handler keeps working.
|
|
224
|
+
// When product itself is off, the handler is gated before any write
|
|
225
|
+
// happens — the hook never has anything to react to.
|
|
226
|
+
export const productAuditEntity = createEntity({
|
|
227
|
+
table: "read_product_audits",
|
|
228
|
+
fields: {
|
|
229
|
+
productName: createTextField({
|
|
230
|
+
personal: false,
|
|
231
|
+
reason: "is_business_data",
|
|
232
|
+
required: true,
|
|
233
|
+
maxLength: 100,
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
export const productAuditTable = buildEntityTable("product-audit", productAuditEntity);
|
|
238
|
+
const productAuditCrud = createEventStoreExecutor(productAuditTable, productAuditEntity, {
|
|
239
|
+
entityName: "product-audit",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
export function createProductAuditFeature(): FeatureDefinition {
|
|
243
|
+
return defineFeature("product-audit", (r) => {
|
|
244
|
+
r.systemScope();
|
|
245
|
+
r.toggleable({ default: true });
|
|
246
|
+
r.entity("product-audit", productAuditEntity);
|
|
247
|
+
|
|
248
|
+
r.hook("postSave", { allOf: "product" }, async (result, ctx) => {
|
|
249
|
+
if (result.kind !== "save" || !result.isNew) return;
|
|
250
|
+
if (!ctx.systemDb) return;
|
|
251
|
+
const name = (result.changes as Record<string, unknown>)["name"] as string | undefined;
|
|
252
|
+
if (!name) return;
|
|
253
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
254
|
+
"audit sink is system-wide, mirrors the system-wide product catalog",
|
|
255
|
+
);
|
|
256
|
+
// Cross-feature audit sink is itself an r.entity projection — write it via
|
|
257
|
+
// the executor (system actor, no human caller) so the row is event-backed
|
|
258
|
+
// and survives a rebuild, not a direct method-form insert.
|
|
259
|
+
await productAuditCrud.create({ productName: name }, createSystemUser(SYSTEM_TENANT_ID), db);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
```
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/field-access. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# field-access
|
|
4
|
+
|
|
5
|
+
Field-level access control. When entity-level roles aren't granular enough.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Field-level access
|
|
10
|
+
|
|
11
|
+
Restrict individual fields by role — separate read and write rules per
|
|
12
|
+
field, no `if` branches in the handler. This recipe ships an `employee`
|
|
13
|
+
entity where `salary` is visible to Admin and Accounting (only Admin
|
|
14
|
+
writes) and `internalNotes` is Admin-only on both sides.
|
|
15
|
+
|
|
16
|
+
The framework strips fields the caller cannot read on the way out and
|
|
17
|
+
rejects writes that touch fields the caller cannot write. Your handler
|
|
18
|
+
never sees a forbidden field on input, never produces one on output.
|
|
19
|
+
|
|
20
|
+
## What it shows
|
|
21
|
+
|
|
22
|
+
- **`access: { read, write }` on a field factory** — two independent
|
|
23
|
+
rules per field, both expressed as role lists.
|
|
24
|
+
- **Read-side filtering** — querying as `Employee` returns the row
|
|
25
|
+
without the `salary` and `internalNotes` keys; querying as
|
|
26
|
+
`Accounting` returns `salary` but not `internalNotes`.
|
|
27
|
+
- **Write-side enforcement** — a write payload that includes
|
|
28
|
+
`internalNotes` from a non-Admin role fails with
|
|
29
|
+
`field_access_denied` and the offending field path.
|
|
30
|
+
- **No handler-level branching** — the same `r.queryHandler` body
|
|
31
|
+
serves all three roles; the framework filters output per call.
|
|
32
|
+
|
|
33
|
+
## Feature composition
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
hr → employee entity with field-level access on salary + internalNotes
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Single feature, no bundled dependencies — field access is declared on
|
|
40
|
+
the field factories inside `createEntity`.
|
|
41
|
+
|
|
42
|
+
## Flow
|
|
43
|
+
|
|
44
|
+
1. Admin creates an employee with all fields populated.
|
|
45
|
+
2. Accounting reads detail → sees `salary`, not `internalNotes`.
|
|
46
|
+
3. Employee reads detail → sees `name`/`email` only.
|
|
47
|
+
4. Accounting tries to write `salary` → `field_access_denied` (read-only).
|
|
48
|
+
5. Employee updates `name` → succeeds (unrestricted field).
|
|
49
|
+
|
|
50
|
+
## When to reach for it
|
|
51
|
+
|
|
52
|
+
Sensitive columns that a feature still needs to expose — phone numbers,
|
|
53
|
+
salary figures, internal compliance notes. The whole row stays usable
|
|
54
|
+
for everyone; specific fields disappear for callers without the
|
|
55
|
+
right role.
|
|
56
|
+
|
|
57
|
+
## Tests
|
|
58
|
+
|
|
59
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
bun kumiko test integration samples/field-access
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or from the recipe directory:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Covers all three roles reading and writing, including the expected
|
|
72
|
+
`field_access_denied` response shape.
|
|
73
|
+
|
|
74
|
+
## Related samples
|
|
75
|
+
|
|
76
|
+
- [basic-entity](/en/samples/recipes-basic-entity/) — start here if you
|
|
77
|
+
need standard CRUD before adding field rules.
|
|
78
|
+
- [custom-handlers](/en/samples/recipes-custom-handlers/) — explicit
|
|
79
|
+
handlers when generated CRUD is not enough.
|
|
80
|
+
|
|
81
|
+
## Source files
|
|
82
|
+
|
|
83
|
+
### src/__tests__/feature.integration.test.ts
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// Field Access Sample — Integration Test
|
|
87
|
+
// Proves: field-level read/write restrictions per role
|
|
88
|
+
|
|
89
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
90
|
+
import type { EntityId, SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
91
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
92
|
+
import {
|
|
93
|
+
createTestUser,
|
|
94
|
+
setupTestStack,
|
|
95
|
+
type TestStack,
|
|
96
|
+
TestUsers,
|
|
97
|
+
unsafeCreateEntityTable,
|
|
98
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
99
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
100
|
+
import { employeeEntity, employeeFeature } from "../feature";
|
|
101
|
+
|
|
102
|
+
let stack: TestStack;
|
|
103
|
+
|
|
104
|
+
const adminUser = TestUsers.admin;
|
|
105
|
+
const accountingUser = createTestUser({ id: 2, roles: ["Accounting"] });
|
|
106
|
+
const employeeUser = createTestUser({ id: 3, roles: ["Employee"] });
|
|
107
|
+
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
stack = await setupTestStack({ features: [employeeFeature] });
|
|
110
|
+
await unsafeCreateEntityTable(stack.db, employeeEntity);
|
|
111
|
+
await createEventsTable(stack.db);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterAll(async () => {
|
|
115
|
+
await stack.cleanup();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
async function seedEmployee(): Promise<EntityId> {
|
|
119
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
120
|
+
"hr:write:employee:create",
|
|
121
|
+
{
|
|
122
|
+
name: "Test Employee",
|
|
123
|
+
email: "test@company.de",
|
|
124
|
+
salary: 75000,
|
|
125
|
+
internalNotes: "Top performer",
|
|
126
|
+
},
|
|
127
|
+
adminUser,
|
|
128
|
+
);
|
|
129
|
+
return data.id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("read access: field filtering per role", () => {
|
|
133
|
+
test("Admin sees all fields", async () => {
|
|
134
|
+
const id = await seedEmployee();
|
|
135
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
136
|
+
"hr:query:employee:detail",
|
|
137
|
+
{ id },
|
|
138
|
+
adminUser,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(detail["name"]).toBe("Test Employee");
|
|
142
|
+
expect(detail["salary"]).toBe(75000);
|
|
143
|
+
expect(detail["internalNotes"]).toBe("Top performer");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("Accounting sees salary but not internalNotes", async () => {
|
|
147
|
+
const id = await seedEmployee();
|
|
148
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
149
|
+
"hr:query:employee:detail",
|
|
150
|
+
{ id },
|
|
151
|
+
accountingUser,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(detail["name"]).toBe("Test Employee");
|
|
155
|
+
expect(detail["salary"]).toBe(75000);
|
|
156
|
+
expect(detail["internalNotes"]).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("Employee sees neither salary nor internalNotes", async () => {
|
|
160
|
+
const id = await seedEmployee();
|
|
161
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
162
|
+
"hr:query:employee:detail",
|
|
163
|
+
{ id },
|
|
164
|
+
employeeUser,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(detail["name"]).toBe("Test Employee");
|
|
168
|
+
expect(detail["salary"]).toBeUndefined();
|
|
169
|
+
expect(detail["internalNotes"]).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("write access: field-level write restrictions", () => {
|
|
174
|
+
test("Admin can write salary", async () => {
|
|
175
|
+
const id = await seedEmployee();
|
|
176
|
+
const updated = await stack.http.writeOk<SaveContext>(
|
|
177
|
+
"hr:write:employee:update",
|
|
178
|
+
{
|
|
179
|
+
id,
|
|
180
|
+
changes: { salary: 80000 },
|
|
181
|
+
version: 1,
|
|
182
|
+
},
|
|
183
|
+
adminUser,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(updated.data["salary"]).toBe(80000);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("Accounting cannot write salary (read-only)", async () => {
|
|
190
|
+
const id = await seedEmployee();
|
|
191
|
+
const error = await stack.http.writeErr(
|
|
192
|
+
"hr:write:employee:update",
|
|
193
|
+
{
|
|
194
|
+
id,
|
|
195
|
+
changes: { salary: 99999 },
|
|
196
|
+
version: 1,
|
|
197
|
+
},
|
|
198
|
+
accountingUser,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expectErrorIncludes(error, "field_access_denied");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("Employee cannot write salary", async () => {
|
|
205
|
+
const id = await seedEmployee();
|
|
206
|
+
const error = await stack.http.writeErr(
|
|
207
|
+
"hr:write:employee:update",
|
|
208
|
+
{
|
|
209
|
+
id,
|
|
210
|
+
changes: { salary: 1000000 },
|
|
211
|
+
version: 1,
|
|
212
|
+
},
|
|
213
|
+
employeeUser,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expectErrorIncludes(error, "field_access_denied");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("Employee can write unrestricted fields", async () => {
|
|
220
|
+
const id = await seedEmployee();
|
|
221
|
+
const updated = await stack.http.writeOk<SaveContext>(
|
|
222
|
+
"hr:write:employee:update",
|
|
223
|
+
{
|
|
224
|
+
id,
|
|
225
|
+
changes: { name: "Updated Name" },
|
|
226
|
+
version: 1,
|
|
227
|
+
},
|
|
228
|
+
employeeUser,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(updated.data["name"]).toBe("Updated Name");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### src/feature.ts
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import {
|
|
240
|
+
createEntity,
|
|
241
|
+
createNumberField,
|
|
242
|
+
createTextField,
|
|
243
|
+
defineFeature,
|
|
244
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
245
|
+
|
|
246
|
+
export const employeeEntity = createEntity({
|
|
247
|
+
table: "read_sample_employees",
|
|
248
|
+
fields: {
|
|
249
|
+
name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
250
|
+
email: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
251
|
+
salary: createNumberField({
|
|
252
|
+
access: { read: ["Admin", "Accounting"], write: ["Admin"] },
|
|
253
|
+
}),
|
|
254
|
+
internalNotes: createTextField({
|
|
255
|
+
personal: { of: "id" },
|
|
256
|
+
find: "none",
|
|
257
|
+
access: { read: ["Admin"], write: ["Admin"] },
|
|
258
|
+
}),
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const allRoles = { access: { roles: ["Admin", "Accounting", "Employee"] } } as const;
|
|
263
|
+
|
|
264
|
+
export const employeeFeature = defineFeature("hr", (r) => {
|
|
265
|
+
r.crud("employee", employeeEntity, {
|
|
266
|
+
write: allRoles,
|
|
267
|
+
read: allRoles,
|
|
268
|
+
verbs: { delete: false, list: false, restore: false },
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
```
|