@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.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. 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 &lt;v2&gt; &amp; 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
+ ```