@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,254 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/chat-channels. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# chat-channels
|
|
4
|
+
|
|
5
|
+
Chat channels. Delivery notifications to Slack, minus the browser tab.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Sample: Chat Channels
|
|
10
|
+
|
|
11
|
+
Shows how a feature posts to a tenant's Slack channel without ever handling the webhook URL.
|
|
12
|
+
|
|
13
|
+
## What you learn here
|
|
14
|
+
|
|
15
|
+
**Route-only targets:** Chat targets belong to the tenant, not to a user. A handler addresses one with `ctx.notify(type, { route: { slack: "ops" } })`; `"ops"` is a connection name. The chat channels declare no `resolve`, so a normal `to: userId` notification skips them without a log row.
|
|
16
|
+
|
|
17
|
+
**Secrets for the URL:** The tenant stores the webhook URL as a secret under `channel-slack:webhooks.<connection name>` (namespace declared by the channel, written through `secrets:write:set`). The `delivery.send` job reads it with an audit entry (`channel-slack:send`), posts the message and records only the connection name and an outcome code (`http_500`, `timeout`, `redirect_blocked`, `host_not_allowed`, `missing_credentials`, `invalid_address`).
|
|
18
|
+
|
|
19
|
+
**App-level hardening:** Each channel only talks to its provider's hosts, over https, without following redirects. `allowedHosts`, `requireHttps` and `timeoutMs` are options of `createChannelSlackFeature(opts)`. Tenants cannot change them; the test uses them to point at a local stub.
|
|
20
|
+
|
|
21
|
+
**Production boot:** `runProdApp`, `runDevApp` and `runWorkerApp` wire `ctx.notify` and the tenant secrets for you, so no app wiring is needed. Queued channels run through the `delivery.render`/`delivery.send` jobs on the worker lane (all-in-one by default); a "send test message" handler can pass `immediate: true` to `ctx.notify` to deliver inline and read `sent`/`failed` from the returned `deliveries`.
|
|
22
|
+
|
|
23
|
+
The same shape works for `channel-discord`, `channel-teams` (connection name + webhook secret) and `channel-telegram` (one `botToken` secret, the address is the chat id).
|
|
24
|
+
|
|
25
|
+
## Run
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bun test --config=bunfig.integration.toml --timeout=15000 samples/recipes/chat-channels
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Proves the chat-channel path end to end: handler -> ctx.notify(route) ->
|
|
37
|
+
// delivery.send job (real BullMQ worker) -> tenant secret -> HTTP POST to a local
|
|
38
|
+
// stub standing in for Slack. Nothing real is contacted; the stub URL is only
|
|
39
|
+
// reachable because the app mounts the channel with an allowlist override.
|
|
40
|
+
|
|
41
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
42
|
+
import { randomBytes } from "node:crypto";
|
|
43
|
+
import { createChannelSlackFeature } from "@cosmicdrift/kumiko-bundled-features/channel-slack";
|
|
44
|
+
import {
|
|
45
|
+
configValuesTable,
|
|
46
|
+
createConfigFeature,
|
|
47
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
48
|
+
import {
|
|
49
|
+
createDeliveryFeature,
|
|
50
|
+
createDeliveryTestContext,
|
|
51
|
+
deliveryAttemptsTable,
|
|
52
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
53
|
+
import {
|
|
54
|
+
createSecretsContext,
|
|
55
|
+
createSecretsFeature,
|
|
56
|
+
tenantSecretsTable,
|
|
57
|
+
} from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
58
|
+
import {
|
|
59
|
+
createTenantFeature,
|
|
60
|
+
TenantQueries,
|
|
61
|
+
tenantEntity,
|
|
62
|
+
tenantMembershipsTable,
|
|
63
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
64
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
65
|
+
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
66
|
+
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
67
|
+
import {
|
|
68
|
+
createTestUser,
|
|
69
|
+
setupTestStack,
|
|
70
|
+
type TestStack,
|
|
71
|
+
unsafeCreateEntityTable,
|
|
72
|
+
unsafePushTables,
|
|
73
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
74
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
75
|
+
import { OPS_ANNOUNCEMENT_TYPE, opsFeature } from "../feature";
|
|
76
|
+
|
|
77
|
+
const admin = createTestUser({ roles: ["Admin", "TenantAdmin"] });
|
|
78
|
+
const viewer = createTestUser({ roles: ["Viewer"] });
|
|
79
|
+
|
|
80
|
+
let stack: TestStack;
|
|
81
|
+
let jobRunner: JobRunner;
|
|
82
|
+
let stub: ReturnType<typeof Bun.serve>;
|
|
83
|
+
const received: unknown[] = [];
|
|
84
|
+
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
const redisUrl = process.env["REDIS_URL"];
|
|
87
|
+
if (!redisUrl) throw new Error("REDIS_URL required for the async delivery recipe test");
|
|
88
|
+
|
|
89
|
+
stub = Bun.serve({
|
|
90
|
+
hostname: "127.0.0.1",
|
|
91
|
+
port: 0,
|
|
92
|
+
async fetch(req) {
|
|
93
|
+
received.push(await req.json());
|
|
94
|
+
return new Response("ok");
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const masterKeyProvider = createEnvMasterKeyProvider({
|
|
99
|
+
env: {
|
|
100
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
101
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
stack = await setupTestStack({
|
|
106
|
+
features: [
|
|
107
|
+
createConfigFeature(),
|
|
108
|
+
createTenantFeature(),
|
|
109
|
+
createSecretsFeature(),
|
|
110
|
+
createDeliveryFeature(),
|
|
111
|
+
createChannelSlackFeature({ allowedHosts: ["127.0.0.1"], requireHttps: false }),
|
|
112
|
+
opsFeature,
|
|
113
|
+
],
|
|
114
|
+
masterKeyProvider,
|
|
115
|
+
extraContext: (deps) => {
|
|
116
|
+
const secrets = createSecretsContext({
|
|
117
|
+
db: deps.db,
|
|
118
|
+
masterKeyProvider,
|
|
119
|
+
registry: deps.registry,
|
|
120
|
+
});
|
|
121
|
+
jobRunner = createJobRunner({
|
|
122
|
+
registry: deps.registry,
|
|
123
|
+
context: { db: deps.db, secrets },
|
|
124
|
+
redisUrl,
|
|
125
|
+
consumerLane: "worker",
|
|
126
|
+
queueNamePrefix: `kumiko-chat-recipe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
secrets,
|
|
130
|
+
...createDeliveryTestContext(deps, {
|
|
131
|
+
tenantUserIdsQuery: TenantQueries.resolveUserIds,
|
|
132
|
+
jobRunner,
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
await unsafePushTables(stack.db, {
|
|
138
|
+
configValuesTable,
|
|
139
|
+
tenantMembershipsTable,
|
|
140
|
+
tenant_secrets: tenantSecretsTable,
|
|
141
|
+
});
|
|
142
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity, "tenant");
|
|
143
|
+
await jobRunner.start();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterAll(async () => {
|
|
147
|
+
await jobRunner.stop();
|
|
148
|
+
stub.stop(true);
|
|
149
|
+
await stack.cleanup();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("chat-channels recipe", () => {
|
|
153
|
+
test("announce -> Slack webhook receives the escaped text; log shows the connection name", async () => {
|
|
154
|
+
// The tenant admin stores the webhook URL under the connection name.
|
|
155
|
+
await stack.http.writeOk(
|
|
156
|
+
"secrets:write:set",
|
|
157
|
+
{
|
|
158
|
+
key: "channel-slack:webhooks.ops",
|
|
159
|
+
value: `http://127.0.0.1:${stub.port}/services/T000/B000/XXXX`,
|
|
160
|
+
},
|
|
161
|
+
admin,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await stack.http.writeOk(
|
|
165
|
+
"ops:write:announce",
|
|
166
|
+
{ connection: "ops", title: "Deploy done", body: "release <v2> & friends" },
|
|
167
|
+
admin,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await waitFor(
|
|
171
|
+
async () => {
|
|
172
|
+
const rows = await selectMany(db(), deliveryAttemptsTable, {
|
|
173
|
+
notificationType: OPS_ANNOUNCEMENT_TYPE,
|
|
174
|
+
channel: "slack",
|
|
175
|
+
});
|
|
176
|
+
expect(rows.some((r) => r["status"] === "sent")).toBe(true);
|
|
177
|
+
},
|
|
178
|
+
{ delays: Array(40).fill(250) },
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(received).toEqual([{ text: "Deploy done\nrelease <v2> & friends" }]);
|
|
182
|
+
const rows = await selectMany(db(), deliveryAttemptsTable, {
|
|
183
|
+
notificationType: OPS_ANNOUNCEMENT_TYPE,
|
|
184
|
+
});
|
|
185
|
+
expect(rows.map((r) => r["recipientAddress"])).toEqual(["ops"]);
|
|
186
|
+
expect(JSON.stringify(rows)).not.toContain("XXXX");
|
|
187
|
+
}, 20000);
|
|
188
|
+
|
|
189
|
+
test("announce rejects a caller without the Admin role", async () => {
|
|
190
|
+
const attemptsBefore = await selectMany(db(), deliveryAttemptsTable, {
|
|
191
|
+
notificationType: OPS_ANNOUNCEMENT_TYPE,
|
|
192
|
+
});
|
|
193
|
+
const error = await stack.http.writeErr(
|
|
194
|
+
"ops:write:announce",
|
|
195
|
+
{ connection: "ops", title: "Not allowed" },
|
|
196
|
+
viewer,
|
|
197
|
+
);
|
|
198
|
+
expect(error.code).toBe("access_denied");
|
|
199
|
+
const attemptsAfter = await selectMany(db(), deliveryAttemptsTable, {
|
|
200
|
+
notificationType: OPS_ANNOUNCEMENT_TYPE,
|
|
201
|
+
});
|
|
202
|
+
expect(attemptsAfter).toHaveLength(attemptsBefore.length);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
function db() {
|
|
207
|
+
return stack.db;
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### src/feature.ts
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// Chat Channels Sample
|
|
215
|
+
//
|
|
216
|
+
// A handler tells the tenant's ops chat about something. Chat targets belong to
|
|
217
|
+
// the tenant, not to a user, so the feature addresses one by connection name via
|
|
218
|
+
// `route` — it never sees the webhook URL. The tenant stores that URL as a secret
|
|
219
|
+
// under `channel-slack:webhooks.<connection name>`.
|
|
220
|
+
|
|
221
|
+
import {
|
|
222
|
+
defineFeature,
|
|
223
|
+
defineWriteHandler,
|
|
224
|
+
type NotifyFn,
|
|
225
|
+
qn,
|
|
226
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
227
|
+
import * as z from "zod";
|
|
228
|
+
|
|
229
|
+
export const OPS_ANNOUNCEMENT_TYPE = qn("ops", "notify", "announcement");
|
|
230
|
+
|
|
231
|
+
export const opsFeature = defineFeature("ops", (r) => {
|
|
232
|
+
r.requires("delivery");
|
|
233
|
+
|
|
234
|
+
r.writeHandler(
|
|
235
|
+
defineWriteHandler({
|
|
236
|
+
name: "announce",
|
|
237
|
+
schema: z.object({
|
|
238
|
+
connection: z.string(),
|
|
239
|
+
title: z.string().min(1),
|
|
240
|
+
body: z.string().optional(),
|
|
241
|
+
}),
|
|
242
|
+
access: { roles: ["Admin"] },
|
|
243
|
+
handler: async (event, ctx) => {
|
|
244
|
+
const notify = ctx.notify as NotifyFn;
|
|
245
|
+
await notify(OPS_ANNOUNCEMENT_TYPE, {
|
|
246
|
+
route: { slack: event.payload.connection },
|
|
247
|
+
data: { title: event.payload.title, body: event.payload.body },
|
|
248
|
+
});
|
|
249
|
+
return { isSuccess: true, data: { announced: true } };
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
```
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/compliance-profiles-demo. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# compliance-profiles-demo
|
|
4
|
+
|
|
5
|
+
Two-tenant demo: EU + Swiss compliance profiles deliver different authority contacts, languages, and grace periods through the same App-code.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# compliance-profiles-demo
|
|
10
|
+
|
|
11
|
+
Same app code, three tenants, three regulatory profiles — no `if (region)`
|
|
12
|
+
branches in your domain handlers.
|
|
13
|
+
|
|
14
|
+
## What it shows
|
|
15
|
+
|
|
16
|
+
How a multi-tenant platform uses `compliance-profiles` to deliver different
|
|
17
|
+
regulatory behaviour per tenant:
|
|
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) |
|
|
24
|
+
|
|
25
|
+
Plus tenant B with an override (`gracePeriod: { days: 90 }`) extending
|
|
26
|
+
user-rights grace without losing other profile fields — deep-merge on the
|
|
27
|
+
base profile; atomic paths replace, the rest inherits from `swiss-dsg` (which
|
|
28
|
+
itself extends `eu-dsgvo`).
|
|
29
|
+
|
|
30
|
+
## Feature composition
|
|
31
|
+
|
|
32
|
+
The sample imports one feature:
|
|
33
|
+
|
|
34
|
+
```ts illustration
|
|
35
|
+
import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
|
|
36
|
+
|
|
37
|
+
export const features = [createComplianceProfilesFeature()];
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The feature exposes `compliance.forTenant` — other features (`user-data-rights`,
|
|
41
|
+
`data-retention`, tenant lifecycle) resolve the effective profile via this API
|
|
42
|
+
instead of hard-coding region rules.
|
|
43
|
+
|
|
44
|
+
## Flow
|
|
45
|
+
|
|
46
|
+
1. Operator assigns a profile to each tenant (`eu-dsgvo`, `swiss-dsg`, …).
|
|
47
|
+
2. Tenant B adds a partial override (longer grace) — merged atomically.
|
|
48
|
+
3. `compliance.forTenant(tenantId)` returns resolved fields (languages,
|
|
49
|
+
supervisory authority, grace periods, retention presets).
|
|
50
|
+
4. Downstream features read the resolver — forget grace, retention policy, and
|
|
51
|
+
audit obligations follow the profile without app-level branching.
|
|
52
|
+
|
|
53
|
+
## Tests
|
|
54
|
+
|
|
55
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# From kumiko-framework repo root:
|
|
59
|
+
bun test samples/recipes/compliance-profiles-demo/src/__tests__/feature.integration.test.ts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Five full-stack tests via `setupTestStack` + real HTTP — profile assignment,
|
|
63
|
+
override merge, and cross-tenant isolation.
|
|
64
|
+
|
|
65
|
+
## Local dev
|
|
66
|
+
|
|
67
|
+
This recipe has no standalone server bootstrap — tests are self-contained. To
|
|
68
|
+
try interactively, mount `complianceProfilesDemoFeatures` in your own
|
|
69
|
+
`runDevApp` config.
|
|
70
|
+
|
|
71
|
+
## Related samples
|
|
72
|
+
|
|
73
|
+
- [user-data-rights](/en/samples/recipes-user-data-rights/) — forget grace from
|
|
74
|
+
`compliance.forTenant` drives deletion timing.
|
|
75
|
+
- [apps-user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) — full
|
|
76
|
+
GDPR app with `eu-dsgvo` profile wired.
|
|
77
|
+
|
|
78
|
+
## Source files
|
|
79
|
+
|
|
80
|
+
### src/__tests__/feature.integration.test.ts
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// Two-tenant compliance-profiles demo (S1.6).
|
|
84
|
+
//
|
|
85
|
+
// Beweist die Cross-Tenant-Isolation des compliance-profiles-Features:
|
|
86
|
+
// gleiche App, zwei Tenants, zwei Profile, zwei effektive Verhalten.
|
|
87
|
+
//
|
|
88
|
+
// Tenant A (DACH) → eu-dsgvo:
|
|
89
|
+
// - Aufsicht: BlnBDI Berlin
|
|
90
|
+
// - Sprachen: de, en
|
|
91
|
+
// - Grace-Period: 30d
|
|
92
|
+
//
|
|
93
|
+
// Tenant B (Schweiz) → swiss-dsg (extends eu-dsgvo):
|
|
94
|
+
// - Aufsicht: EDÖB Bern
|
|
95
|
+
// - Sprachen: de, fr, it, en
|
|
96
|
+
// - Grace-Period: 30d (geerbt)
|
|
97
|
+
//
|
|
98
|
+
// Tenant C (DACH-HR) → de-hr-dsgvo-hgb (extends eu-dsgvo, HR-Override):
|
|
99
|
+
// - Aufsicht: Landes-Datenschutzbehörde
|
|
100
|
+
// - Sprachen: de
|
|
101
|
+
// - Tenant-Destroy-Grace: 60d (HR-Override)
|
|
102
|
+
// - Audit-Retention: 10y (HGB-Override)
|
|
103
|
+
// - Plus worksCouncilApprovalRequired
|
|
104
|
+
|
|
105
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
106
|
+
import { tenantComplianceProfileEntity } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
|
|
107
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
108
|
+
import {
|
|
109
|
+
createTestUser,
|
|
110
|
+
setupTestStack,
|
|
111
|
+
type TestStack,
|
|
112
|
+
testTenantId,
|
|
113
|
+
unsafeCreateEntityTable,
|
|
114
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
115
|
+
import { complianceProfilesDemoFeatures } from "../feature";
|
|
116
|
+
|
|
117
|
+
const SET_PROFILE = "compliance-profiles:write:set-profile";
|
|
118
|
+
const FOR_TENANT = "compliance-profiles:query:for-tenant";
|
|
119
|
+
|
|
120
|
+
let stack: TestStack;
|
|
121
|
+
|
|
122
|
+
const tenantADachAdmin = createTestUser({
|
|
123
|
+
id: 1,
|
|
124
|
+
tenantId: testTenantId(1),
|
|
125
|
+
roles: ["TenantAdmin"],
|
|
126
|
+
});
|
|
127
|
+
const tenantBSwissAdmin = createTestUser({
|
|
128
|
+
id: 2,
|
|
129
|
+
tenantId: testTenantId(2),
|
|
130
|
+
roles: ["TenantAdmin"],
|
|
131
|
+
});
|
|
132
|
+
const tenantCHrAdmin = createTestUser({
|
|
133
|
+
id: 3,
|
|
134
|
+
tenantId: testTenantId(3),
|
|
135
|
+
roles: ["TenantAdmin"],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
stack = await setupTestStack({ features: complianceProfilesDemoFeatures });
|
|
140
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
141
|
+
await createEventsTable(stack.db);
|
|
142
|
+
|
|
143
|
+
// Onboarding-Setup: jeder Tenant-Admin waehlt sein Profile.
|
|
144
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantADachAdmin);
|
|
145
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "swiss-dsg" }, tenantBSwissAdmin);
|
|
146
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "de-hr-dsgvo-hgb" }, tenantCHrAdmin);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterAll(async () => {
|
|
150
|
+
await stack.cleanup();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("compliance-profiles-demo :: cross-tenant isolation", () => {
|
|
154
|
+
test("Tenant A (DACH) → eu-dsgvo: BlnBDI + DE/EN-Sprachen", async () => {
|
|
155
|
+
const result = await stack.http.queryOk<{
|
|
156
|
+
profile: {
|
|
157
|
+
key: string;
|
|
158
|
+
breach: { authorityContact: string };
|
|
159
|
+
notifications: { languages: string[] };
|
|
160
|
+
userRights: { gracePeriod: { days: number } };
|
|
161
|
+
tenantDestroyGracePeriod: { days: number };
|
|
162
|
+
};
|
|
163
|
+
}>(FOR_TENANT, {}, tenantADachAdmin);
|
|
164
|
+
|
|
165
|
+
expect(result.profile.key).toBe("eu-dsgvo");
|
|
166
|
+
expect(result.profile.breach.authorityContact).toBe("BlnBDI Berlin");
|
|
167
|
+
expect(result.profile.notifications.languages).toEqual(["de", "en"]);
|
|
168
|
+
expect(result.profile.userRights.gracePeriod).toEqual({ days: 30 });
|
|
169
|
+
expect(result.profile.tenantDestroyGracePeriod).toEqual({ days: 30 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("Tenant B (Schweiz) → swiss-dsg: EDÖB + DE/FR/IT/EN-Sprachen", async () => {
|
|
173
|
+
const result = await stack.http.queryOk<{
|
|
174
|
+
profile: {
|
|
175
|
+
key: string;
|
|
176
|
+
breach: { authorityContact: string };
|
|
177
|
+
notifications: { languages: string[] };
|
|
178
|
+
userRights: { gracePeriod: { days: number } };
|
|
179
|
+
};
|
|
180
|
+
}>(FOR_TENANT, {}, tenantBSwissAdmin);
|
|
181
|
+
|
|
182
|
+
expect(result.profile.key).toBe("swiss-dsg");
|
|
183
|
+
expect(result.profile.breach.authorityContact).toBe("EDÖB Bern");
|
|
184
|
+
expect(result.profile.notifications.languages).toEqual(["de", "fr", "it", "en"]);
|
|
185
|
+
// Geerbt aus eu-dsgvo via extends:
|
|
186
|
+
expect(result.profile.userRights.gracePeriod).toEqual({ days: 30 });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("Tenant C (DACH-HR) → de-hr-dsgvo-hgb: HR-Override greift", async () => {
|
|
190
|
+
const result = await stack.http.queryOk<{
|
|
191
|
+
profile: {
|
|
192
|
+
key: string;
|
|
193
|
+
breach: { authorityContact: string; worksCouncilNotificationRequired?: boolean };
|
|
194
|
+
notifications: { languages: string[] };
|
|
195
|
+
tenantDestroyGracePeriod: { days: number };
|
|
196
|
+
auditLog: { retention: { years?: number } };
|
|
197
|
+
subProcessor: { worksCouncilApprovalRequired?: boolean };
|
|
198
|
+
};
|
|
199
|
+
}>(FOR_TENANT, {}, tenantCHrAdmin);
|
|
200
|
+
|
|
201
|
+
expect(result.profile.key).toBe("de-hr-dsgvo-hgb");
|
|
202
|
+
expect(result.profile.breach.authorityContact).toBe("Landes-Datenschutzbehörde");
|
|
203
|
+
expect(result.profile.breach.worksCouncilNotificationRequired).toBe(true);
|
|
204
|
+
expect(result.profile.notifications.languages).toEqual(["de"]);
|
|
205
|
+
expect(result.profile.tenantDestroyGracePeriod).toEqual({ days: 60 });
|
|
206
|
+
expect(result.profile.auditLog.retention).toEqual({ years: 10 });
|
|
207
|
+
expect(result.profile.subProcessor.worksCouncilApprovalRequired).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("Profile-Wechsel auf demselben Tenant zeigt sofort neues Verhalten", async () => {
|
|
211
|
+
// Eigener Tenant D damit der Test reihenfolge-unabhaengig laeuft
|
|
212
|
+
// (Tenant A bleibt auf eu-dsgvo fuer die anderen Tests).
|
|
213
|
+
const tenantDAdmin = createTestUser({
|
|
214
|
+
id: 4,
|
|
215
|
+
tenantId: testTenantId(4),
|
|
216
|
+
roles: ["TenantAdmin"],
|
|
217
|
+
});
|
|
218
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantDAdmin);
|
|
219
|
+
|
|
220
|
+
// Wechsel von eu-dsgvo auf swiss-dsg
|
|
221
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "swiss-dsg" }, tenantDAdmin);
|
|
222
|
+
const after = await stack.http.queryOk<{
|
|
223
|
+
profile: { key: string; breach: { authorityContact: string } };
|
|
224
|
+
}>(FOR_TENANT, {}, tenantDAdmin);
|
|
225
|
+
expect(after.profile.key).toBe("swiss-dsg");
|
|
226
|
+
expect(after.profile.breach.authorityContact).toBe("EDÖB Bern");
|
|
227
|
+
|
|
228
|
+
// Zurueck auf eu-dsgvo
|
|
229
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantDAdmin);
|
|
230
|
+
const back = await stack.http.queryOk<{
|
|
231
|
+
profile: { key: string; breach: { authorityContact: string } };
|
|
232
|
+
}>(FOR_TENANT, {}, tenantDAdmin);
|
|
233
|
+
expect(back.profile.key).toBe("eu-dsgvo");
|
|
234
|
+
expect(back.profile.breach.authorityContact).toBe("BlnBDI Berlin");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("Tenant-Override merged auf base-profile, andere Felder bleiben", async () => {
|
|
238
|
+
// Eigener Tenant E damit der Override-State Tenant B nicht
|
|
239
|
+
// ueberschreibt (reihenfolge-unabhaengig).
|
|
240
|
+
const tenantEAdmin = createTestUser({
|
|
241
|
+
id: 5,
|
|
242
|
+
tenantId: testTenantId(5),
|
|
243
|
+
roles: ["TenantAdmin"],
|
|
244
|
+
});
|
|
245
|
+
await stack.http.writeOk(
|
|
246
|
+
SET_PROFILE,
|
|
247
|
+
{
|
|
248
|
+
profileKey: "swiss-dsg",
|
|
249
|
+
override: JSON.stringify({
|
|
250
|
+
userRights: { gracePeriod: { days: 90 } },
|
|
251
|
+
}),
|
|
252
|
+
},
|
|
253
|
+
tenantEAdmin,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const result = await stack.http.queryOk<{
|
|
257
|
+
profile: {
|
|
258
|
+
userRights: {
|
|
259
|
+
gracePeriod: { days: number };
|
|
260
|
+
portabilityFormat: string[];
|
|
261
|
+
restrictionAllowed: boolean;
|
|
262
|
+
};
|
|
263
|
+
notifications: { languages: string[] };
|
|
264
|
+
};
|
|
265
|
+
}>(FOR_TENANT, {}, tenantEAdmin);
|
|
266
|
+
|
|
267
|
+
expect(result.profile.userRights.gracePeriod).toEqual({ days: 90 });
|
|
268
|
+
// Andere userRights aus swiss-dsg bleiben
|
|
269
|
+
expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
|
|
270
|
+
expect(result.profile.userRights.restrictionAllowed).toBe(true);
|
|
271
|
+
// Sprachen aus swiss-dsg unverändert
|
|
272
|
+
expect(result.profile.notifications.languages).toEqual(["de", "fr", "it", "en"]);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### src/feature.ts
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
// Compliance-Profiles Demo
|
|
281
|
+
//
|
|
282
|
+
// Same App-Code, two different tenants, two different DSGVO-Profile —
|
|
283
|
+
// das ist die Sales-Story von Compliance-Profiles. Tenant-Admin waehlt
|
|
284
|
+
// einmal beim Onboarding, danach laufen alle User-Rights-Grace-Periods,
|
|
285
|
+
// Notification-Sprachen, Breach-Disclosure-Pflichten und Audit-Log-
|
|
286
|
+
// Retentions automatisch profil-spezifisch.
|
|
287
|
+
//
|
|
288
|
+
// Demo-Szenario:
|
|
289
|
+
// Tenant A (DACH-Customer) → eu-dsgvo
|
|
290
|
+
// - 30d Grace-Period fuer Forget
|
|
291
|
+
// - DE/EN-Notifications
|
|
292
|
+
// - Aufsicht: BlnBDI Berlin
|
|
293
|
+
//
|
|
294
|
+
// Tenant B (Swiss-Customer) → swiss-dsg
|
|
295
|
+
// - 30d Grace-Period (geerbt aus eu-dsgvo via extends)
|
|
296
|
+
// - DE/FR/IT/EN-Notifications
|
|
297
|
+
// - Aufsicht: EDÖB Bern
|
|
298
|
+
//
|
|
299
|
+
// Beide Tenants laufen auf derselben App-Instance. Das compliance-
|
|
300
|
+
// profiles-Feature haelt den Profile-State pro Tenant; jedes consumer-
|
|
301
|
+
// Feature ruft `compliance.forTenant` (Sprint 2+) und sieht das
|
|
302
|
+
// effektive Profile fuer den aktuellen Tenant.
|
|
303
|
+
|
|
304
|
+
import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
|
|
305
|
+
|
|
306
|
+
export const complianceProfilesDemoFeatures = [createComplianceProfilesFeature()];
|
|
307
|
+
```
|