@cosmicdrift/kumiko-bundled-features 0.1.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 (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,19 @@
1
+ // Feature name
2
+ export const TEXT_CONTENT_FEATURE = "text-content" as const;
3
+
4
+ // Qualified write handler names (QN format: scope:type:name)
5
+ export const TextContentHandlers = {
6
+ set: "text-content:write:set",
7
+ } as const;
8
+
9
+ // Qualified query handler names (QN format: scope:type:name)
10
+ export const TextContentQueries = {
11
+ bySlug: "text-content:query:by-slug",
12
+ } as const;
13
+
14
+ // Error codes
15
+ export const TextContentErrors = {
16
+ notFound: "text_block_not_found",
17
+ invalidSlug: "invalid_slug",
18
+ invalidLang: "invalid_lang",
19
+ } as const;
@@ -0,0 +1,29 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { bySlugQuery } from "./handlers/by-slug.query";
3
+ import { setWrite } from "./handlers/set.write";
4
+ import { textBlockEntity } from "./table";
5
+
6
+ // text-content — generischer Container für statische Texte (Impressum,
7
+ // Datenschutz, FAQ, About, ToS, Marketing-Snippets). Pro
8
+ // (tenantId, slug, lang) genau ein Block. Inhalt ist Markdown — die
9
+ // Konvertierung zu HTML übernehmen Consumer-Features wie legal-pages
10
+ // (das opt-in obendrauf-Feature für Compliance-Pages).
11
+ //
12
+ // Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
13
+ // das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
14
+ // query — Routes/Render kommen pro Use-Case (legal-pages, etc.).
15
+ export function createTextContentFeature(): FeatureDefinition {
16
+ return defineFeature("text-content", (r) => {
17
+ r.entity("text-block", textBlockEntity);
18
+
19
+ const handlers = {
20
+ set: r.writeHandler(setWrite),
21
+ };
22
+
23
+ const queries = {
24
+ bySlug: r.queryHandler(bySlugQuery),
25
+ };
26
+
27
+ return { handlers, queries };
28
+ });
29
+ }
@@ -0,0 +1,55 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { type TextBlockRow, textBlocksTable } from "../table";
7
+
8
+ // Public-Read by (tenantId, slug, lang). Anonymous: muss `anonymous`
9
+ // in roles enthalten — openToAll alleine ist auth-only (Regression-
10
+ // Guard). Tenant-Scope kommt default aus query.user.tenantId (anonymous-
11
+ // context setzt SYSTEM_TENANT_ID oder Host-resolved-tenant je nach App-
12
+ // Setup). Optional `tenantIdOverride` (SystemAdmin-only) erlaubt cross-
13
+ // tenant Read — symmetrisch zum set-handler. Use-case: Edit-UI lädt den
14
+ // SYSTEM_TENANT-Block für SystemAdmin der nicht direkt drauf member ist.
15
+ export const bySlugQuery = defineQueryHandler({
16
+ name: "by-slug",
17
+ schema: z.object({
18
+ slug: z.string().min(1).max(64),
19
+ lang: z.string().min(2).max(8),
20
+ /** Optional cross-tenant read — nur für SystemAdmin. Siehe
21
+ * set.write.ts für die symmetrische write-side. */
22
+ tenantIdOverride: z.string().min(1).optional(),
23
+ }),
24
+ // Public-Read: muss explizit `anonymous` enthalten damit no-JWT-
25
+ // Visitors auf Marketing-/Legal-Pages den Text sehen. openToAll
26
+ // alleine ist auth-only (Regression-Guard) — siehe
27
+ // docs/plans/datenschutz/legal-artifacts.md.
28
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
29
+ handler: async (query, ctx) => {
30
+ const override = query.payload.tenantIdOverride;
31
+ if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
32
+ throw new AccessDeniedError({
33
+ i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
34
+ details: { reason: "tenant_override_requires_system_admin" },
35
+ });
36
+ }
37
+ const tenantId = override ?? query.user.tenantId;
38
+ const row = await fetchOne<TextBlockRow>(
39
+ ctx.db,
40
+ textBlocksTable,
41
+ eq(textBlocksTable["tenantId"], tenantId),
42
+ eq(textBlocksTable["slug"], query.payload.slug),
43
+ eq(textBlocksTable["lang"], query.payload.lang),
44
+ );
45
+
46
+ if (!row) return null;
47
+ return {
48
+ slug: row.slug,
49
+ lang: row.lang,
50
+ title: row.title,
51
+ body: row.body,
52
+ updatedAt: row.updatedAt,
53
+ };
54
+ },
55
+ });
@@ -0,0 +1,118 @@
1
+ import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
7
+
8
+ const slugSchema = z
9
+ .string()
10
+ .min(1)
11
+ .max(64)
12
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
13
+
14
+ const langSchema = z
15
+ .string()
16
+ .min(2)
17
+ .max(8)
18
+ .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "lang must be ISO 639-1 (e.g. de, en, en-us)");
19
+
20
+ const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
21
+ entityName: "text-block",
22
+ });
23
+
24
+ // Upsert handler — eine Operation pro (tenantId, slug, lang). Bei
25
+ // existierender Row → update, sonst → create. Tenant-Scope kommt
26
+ // default aus event.user. Tenant-Admins setzen Texte für ihren
27
+ // eigenen Tenant; Plattform-Sysadmins können via optional
28
+ // `tenantIdOverride` für einen anderen Tenant schreiben (typisch:
29
+ // SYSTEM_TENANT_ID für legal-pages-content den die ganze Plattform
30
+ // teilt). Override ist SystemAdmin-only — TenantAdmin's Override-
31
+ // Versuch → 403.
32
+ export const setWrite = defineWriteHandler({
33
+ name: "set",
34
+ schema: z.object({
35
+ slug: slugSchema,
36
+ lang: langSchema,
37
+ title: z.string().min(1).max(200),
38
+ body: z.string().max(100_000).nullable(),
39
+ /** Optional cross-tenant write — nur für SystemAdmin. Typischer
40
+ * use-case: legal-pages-Edit-UI lässt SystemAdmin auf
41
+ * SYSTEM_TENANT_ID schreiben (sonst landet der text auf seinem
42
+ * eigenen platform-tenant und legal-pages-routes lesen ihn
43
+ * nicht). TenantAdmin's Versuch → ForbiddenError. */
44
+ tenantIdOverride: z.string().min(1).optional(),
45
+ }),
46
+ // SystemAdmin ist eine GLOBALE Rolle (users.roles), TenantAdmin pro
47
+ // tenant-membership. SystemAdmin braucht beide Pfade explizit weil
48
+ // er nicht implicit TenantAdmin auf jedem Tenant ist (siehe
49
+ // project_global_roles_sysadmin memory). Ohne SystemAdmin könnte
50
+ // niemand SYSTEM_TENANT-Texte setzen — nur via Test-Helper.
51
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
52
+ handler: async (event, ctx) => {
53
+ const db = ctx.db;
54
+ const override = event.payload.tenantIdOverride;
55
+ if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
56
+ return writeFailure(
57
+ new AccessDeniedError({
58
+ i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
59
+ details: { reason: "tenant_override_requires_system_admin" },
60
+ }),
61
+ );
62
+ }
63
+ const tenantId = override ?? event.user.tenantId;
64
+ // Bei tenantIdOverride muss auch der user-context auf den ziel-tenant
65
+ // umgestellt werden, sonst läuft der event-store-Lookup
66
+ // (getStreamVersion) gegen user.tenantId statt tenantId — und findet
67
+ // den stream nicht → version_conflict obwohl die projection-row da ist.
68
+ // Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
69
+ // SYSTEM_TENANT) als by verwendet.
70
+ const executorUser =
71
+ override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
72
+
73
+ const existing = await fetchOne<TextBlockRow>(
74
+ db,
75
+ textBlocksTable,
76
+ eq(textBlocksTable["tenantId"], tenantId),
77
+ eq(textBlocksTable["slug"], event.payload.slug),
78
+ eq(textBlocksTable["lang"], event.payload.lang),
79
+ );
80
+
81
+ if (existing) {
82
+ const result = await executor.update(
83
+ {
84
+ id: existing.id,
85
+ version: existing.version,
86
+ changes: {
87
+ title: event.payload.title,
88
+ body: event.payload.body,
89
+ },
90
+ },
91
+ executorUser,
92
+ db,
93
+ );
94
+ if (!result.isSuccess) return result;
95
+ return {
96
+ isSuccess: true as const,
97
+ data: { slug: event.payload.slug, lang: event.payload.lang, isNew: false },
98
+ };
99
+ }
100
+
101
+ const result = await executor.create(
102
+ {
103
+ slug: event.payload.slug,
104
+ lang: event.payload.lang,
105
+ title: event.payload.title,
106
+ body: event.payload.body,
107
+ tenantId,
108
+ },
109
+ executorUser,
110
+ db,
111
+ );
112
+ if (!result.isSuccess) return result;
113
+ return {
114
+ isSuccess: true as const,
115
+ data: { slug: event.payload.slug, lang: event.payload.lang, isNew: true },
116
+ };
117
+ },
118
+ });
@@ -0,0 +1,14 @@
1
+ export {
2
+ createTextContentApi,
3
+ requireTextContent,
4
+ type TextBlock,
5
+ type TextContentApi,
6
+ } from "./api";
7
+ export {
8
+ TEXT_CONTENT_FEATURE,
9
+ TextContentErrors,
10
+ TextContentHandlers,
11
+ TextContentQueries,
12
+ } from "./constants";
13
+ export { createTextContentFeature } from "./feature";
14
+ export { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
@@ -0,0 +1,91 @@
1
+ // Test-Helper für text-content. Legt einen TextBlock direkt über den
2
+ // Event-Store-Executor an — gleicher Pfad wie der echte set-Handler,
3
+ // aber ohne Access-Check. Idempotent: zweiter Call mit gleichem
4
+ // (tenantId, slug, lang) updated den existing Block.
5
+
6
+ import {
7
+ createEventStoreExecutor,
8
+ createTenantDb,
9
+ type DbConnection,
10
+ fetchOne,
11
+ } from "@cosmicdrift/kumiko-framework/db";
12
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { eq } from "drizzle-orm";
15
+ import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
16
+
17
+ const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
18
+ entityName: "text-block",
19
+ });
20
+
21
+ export type SeedTextBlockOptions = {
22
+ readonly tenantId: TenantId;
23
+ readonly slug: string;
24
+ readonly lang: string;
25
+ readonly title: string;
26
+ readonly body?: string | null;
27
+ readonly by?: SessionUser;
28
+ };
29
+
30
+ export async function seedTextBlock(
31
+ db: DbConnection,
32
+ opts: SeedTextBlockOptions,
33
+ ): Promise<{ id: string | number }> {
34
+ // Default-user muss user.tenantId === opts.tenantId haben, sonst
35
+ // landet der event-store-stream im user.tenantId-bucket aber die
36
+ // projection-row im opts.tenantId-bucket. Spätere echte writes via
37
+ // set-handler (mit korrektem tenant-context) finden den stream
38
+ // nicht → version_conflict. TestUsers.systemAdmin ist hardcoded
39
+ // testTenantId(1), nicht opts.tenantId — explizit überschreiben.
40
+ const by = opts.by ?? { ...TestUsers.systemAdmin, tenantId: opts.tenantId };
41
+ // executor.create erwartet TenantDb — wrapping nötig damit die runtime-
42
+ // checks (tenant-scope-validation) greifen.
43
+ const tdb = createTenantDb(db, opts.tenantId, "system");
44
+
45
+ const existing = await fetchOne<TextBlockRow>(
46
+ db,
47
+ textBlocksTable,
48
+ eq(textBlocksTable["tenantId"], opts.tenantId),
49
+ eq(textBlocksTable["slug"], opts.slug),
50
+ eq(textBlocksTable["lang"], opts.lang),
51
+ );
52
+
53
+ if (existing) {
54
+ const result = await executor.update(
55
+ {
56
+ id: existing.id,
57
+ version: existing.version,
58
+ changes: { title: opts.title, body: opts.body ?? null },
59
+ },
60
+ by,
61
+ tdb,
62
+ );
63
+ if (!result.isSuccess) {
64
+ throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
65
+ }
66
+ return { id: existing.id };
67
+ }
68
+
69
+ const result = await executor.create(
70
+ {
71
+ slug: opts.slug,
72
+ lang: opts.lang,
73
+ title: opts.title,
74
+ body: opts.body ?? null,
75
+ tenantId: opts.tenantId,
76
+ },
77
+ by,
78
+ tdb,
79
+ );
80
+ if (!result.isSuccess) {
81
+ throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
82
+ }
83
+ // @cast-boundary db-row executor.create result.data ist Drizzle-row
84
+ // (Record<string, unknown>), projected nach INSERT/RETURNING auf
85
+ // TextBlockRow. Runtime-narrowing in der nächsten Zeile.
86
+ const data = result.data as Partial<TextBlockRow>;
87
+ if (data.id === undefined) {
88
+ throw new Error("seedTextBlock: executor.create did not return an id");
89
+ }
90
+ return { id: data.id };
91
+ }
@@ -0,0 +1,45 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
3
+
4
+ // TextBlock — generischer Container für statische Texte (legal pages,
5
+ // FAQ, About, ToS, Marketing-Snippets). Pro (tenantId, slug, lang) genau
6
+ // eine Row. SYSTEM_TENANT_ID für app-weite Texte (Impressum etc.), sonst
7
+ // Tenant-eigene Texte.
8
+ //
9
+ // Inhaltsformat ist Markdown (App-Renderer entscheidet Markdown→HTML).
10
+ // Body bleibt nullable damit ein leerer Block existieren kann (z.B.
11
+ // während Tenant-Onboarding bevor der Admin den finalen Text schreibt).
12
+ export const textBlockEntity = createEntity({
13
+ table: "read_text_blocks",
14
+ fields: {
15
+ slug: createTextField({ required: true }),
16
+ lang: createTextField({ required: true }),
17
+ title: createTextField({ required: true }),
18
+ body: createTextField({}),
19
+ },
20
+ indexes: [
21
+ { unique: true, columns: ["tenantId", "slug", "lang"], name: "read_text_blocks_unique" },
22
+ ],
23
+ });
24
+
25
+ export const textBlocksTable = buildDrizzleTable("text-block", textBlockEntity);
26
+
27
+ // Concrete Row-Type — single-source dafür dass die unknown-Werte die
28
+ // Drizzle aus `Record<string, unknown>` liefert genau einmal benannt
29
+ // werden (statt 6× `row["x"] as Y` Casts in Handlern + Seeding).
30
+ // Kommt aus `entity.fields` + Standard-Spalten (id, version, tenantId,
31
+ // createdAt, updatedAt, createdBy, updatedBy) die buildBaseColumns
32
+ // erzwingt.
33
+ export type TextBlockRow = {
34
+ readonly id: string | number;
35
+ readonly version: number;
36
+ readonly tenantId: string;
37
+ readonly slug: string;
38
+ readonly lang: string;
39
+ readonly title: string;
40
+ readonly body: string | null;
41
+ readonly createdAt: Date;
42
+ readonly updatedAt: Date;
43
+ readonly createdBy: string;
44
+ readonly updatedBy: string;
45
+ };
@@ -0,0 +1,182 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { describe, expect, test } from "vitest";
3
+ import { type AddOnMap, composeApp, type TierMap } from "../compose-app";
4
+
5
+ // --- App-spezifischer Cap-Shape (typed, kein Record<string, unknown>) ---
6
+
7
+ type AppCaps = {
8
+ readonly apps: number;
9
+ readonly mailsPerMonth: number;
10
+ };
11
+
12
+ // --- Test fixtures ---
13
+
14
+ const baseFeature = defineFeature("auth", () => {});
15
+ const tenantF = defineFeature("tenant", () => {});
16
+ const designerF = defineFeature("designer", () => {});
17
+ const aiPatchF = defineFeature("ai-patch", () => {});
18
+ const aiConvF = defineFeature("ai-conversation", () => {});
19
+ const byokEncF = defineFeature("byok-encryption", () => {});
20
+ const dedicatedF = defineFeature("dedicated-stack", () => {});
21
+
22
+ const featureRegistry: Record<string, FeatureDefinition> = {
23
+ designer: designerF,
24
+ "ai-patch": aiPatchF,
25
+ "ai-conversation": aiConvF,
26
+ "byok-encryption": byokEncF,
27
+ "dedicated-stack": dedicatedF,
28
+ };
29
+
30
+ const tierMap: TierMap<AppCaps> = {
31
+ free: { features: [], caps: { apps: 1, mailsPerMonth: 1000 } },
32
+ pro: { features: ["designer", "ai-patch"], caps: { apps: 5, mailsPerMonth: 10_000 } },
33
+ business: {
34
+ features: ["designer", "ai-patch", "ai-conversation"],
35
+ caps: { apps: 20, mailsPerMonth: 50_000 },
36
+ },
37
+ };
38
+
39
+ const addOnMap: AddOnMap<AppCaps> = {
40
+ "byok-encryption": { features: ["byok-encryption"] },
41
+ "dedicated-stack": {
42
+ features: ["dedicated-stack"],
43
+ capOverrides: { mailsPerMonth: 100_000 },
44
+ },
45
+ };
46
+
47
+ // --- Tests ---
48
+
49
+ describe("composeApp", () => {
50
+ test("Free tier mounts only base features", () => {
51
+ const result = composeApp<AppCaps>({
52
+ base: [baseFeature, tenantF],
53
+ featureRegistry,
54
+ tierMap,
55
+ addOnMap,
56
+ tier: "free",
57
+ addOns: [],
58
+ });
59
+
60
+ expect(result.features.map((f) => f.name)).toEqual(["auth", "tenant"]);
61
+ // Typed caps — `result.caps.apps` is `number`, not `unknown`.
62
+ expect(result.caps.apps).toBe(1);
63
+ expect(result.caps.mailsPerMonth).toBe(1000);
64
+ });
65
+
66
+ test("Pro tier adds Designer + ai-patch", () => {
67
+ const result = composeApp<AppCaps>({
68
+ base: [baseFeature, tenantF],
69
+ featureRegistry,
70
+ tierMap,
71
+ addOnMap,
72
+ tier: "pro",
73
+ addOns: [],
74
+ });
75
+
76
+ expect(result.features.map((f) => f.name)).toEqual(["auth", "tenant", "designer", "ai-patch"]);
77
+ expect(result.caps).toEqual({ apps: 5, mailsPerMonth: 10_000 });
78
+ });
79
+
80
+ test("Add-On adds its features on top of tier", () => {
81
+ const result = composeApp<AppCaps>({
82
+ base: [baseFeature],
83
+ featureRegistry,
84
+ tierMap,
85
+ addOnMap,
86
+ tier: "pro",
87
+ addOns: ["byok-encryption"],
88
+ });
89
+
90
+ expect(result.features.map((f) => f.name)).toEqual([
91
+ "auth",
92
+ "designer",
93
+ "ai-patch",
94
+ "byok-encryption",
95
+ ]);
96
+ });
97
+
98
+ test("Add-On capOverrides win over tier caps", () => {
99
+ const result = composeApp<AppCaps>({
100
+ base: [baseFeature],
101
+ featureRegistry,
102
+ tierMap,
103
+ addOnMap,
104
+ tier: "pro",
105
+ addOns: ["dedicated-stack"],
106
+ });
107
+
108
+ expect(result.caps).toEqual({
109
+ apps: 5, // from pro
110
+ mailsPerMonth: 100_000, // overridden by dedicated-stack
111
+ });
112
+ });
113
+
114
+ test("dedupe — feature listed in tier and add-on mounts only once", () => {
115
+ // Set up an add-on that re-lists ai-patch (which Pro already has).
116
+ const overlapAddOnMap: AddOnMap<AppCaps> = {
117
+ ...addOnMap,
118
+ "ai-power-pack": { features: ["ai-patch", "ai-conversation"] },
119
+ };
120
+
121
+ const result = composeApp<AppCaps>({
122
+ base: [],
123
+ featureRegistry,
124
+ tierMap,
125
+ addOnMap: overlapAddOnMap,
126
+ tier: "pro",
127
+ addOns: ["ai-power-pack"],
128
+ });
129
+
130
+ // ai-patch only mounts once, ai-conversation mounts as add-on extension.
131
+ const names = result.features.map((f) => f.name);
132
+ expect(names).toEqual(["designer", "ai-patch", "ai-conversation"]);
133
+ expect(names.filter((n) => n === "ai-patch")).toHaveLength(1);
134
+ });
135
+
136
+ test("unknown tier throws with helpful message", () => {
137
+ expect(() =>
138
+ composeApp<AppCaps>({
139
+ base: [],
140
+ featureRegistry,
141
+ tierMap,
142
+ addOnMap,
143
+ tier: "platinum",
144
+ addOns: [],
145
+ }),
146
+ ).toThrow(/unknown tier "platinum"/);
147
+ });
148
+
149
+ test("unknown add-on throws with helpful message", () => {
150
+ expect(() =>
151
+ composeApp<AppCaps>({
152
+ base: [],
153
+ featureRegistry,
154
+ tierMap,
155
+ addOnMap,
156
+ tier: "free",
157
+ addOns: ["unicorn-mode"],
158
+ }),
159
+ ).toThrow(/unknown add-on "unicorn-mode"/);
160
+ });
161
+
162
+ test("tier referencing unknown feature throws", () => {
163
+ const brokenTierMap: TierMap<AppCaps> = {
164
+ ...tierMap,
165
+ "broken-tier": {
166
+ features: ["does-not-exist"],
167
+ caps: { apps: 0, mailsPerMonth: 0 },
168
+ },
169
+ };
170
+
171
+ expect(() =>
172
+ composeApp<AppCaps>({
173
+ base: [],
174
+ featureRegistry,
175
+ tierMap: brokenTierMap,
176
+ addOnMap,
177
+ tier: "broken-tier",
178
+ addOns: [],
179
+ }),
180
+ ).toThrow(/unknown feature "does-not-exist"/);
181
+ });
182
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { tierAssignmentAggregateId } from "../aggregate-id";
3
+ import { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "../constants";
4
+ import { tierEngineFeature } from "../feature";
5
+
6
+ // Drift-Pin-Tests — diese Werte sind Cross-File-Contracts, ein Wechsel
7
+ // muss bewusst geschehen und die anderen Stellen mitziehen. Wenn diese
8
+ // Tests rot werden: stop, denk nach, sync alle Stellen.
9
+
10
+ describe("tier-engine drift pins", () => {
11
+ test("TIER_ENGINE_FEATURE matches the registered feature-name", () => {
12
+ expect(tierEngineFeature.name).toBe(TIER_ENGINE_FEATURE);
13
+ expect(tierEngineFeature.name).toBe("tier-engine");
14
+ });
15
+
16
+ test("Handler-QNs follow the scope:type:name convention with feature-prefix", () => {
17
+ expect(TierEngineHandlers.create).toBe("tier-engine:write:tier-assignment:create");
18
+ expect(TierEngineHandlers.update).toBe("tier-engine:write:tier-assignment:update");
19
+ expect(TierEngineQueries.list).toBe("tier-engine:query:tier-assignment:list");
20
+ expect(TierEngineQueries.getActiveTier).toBe("tier-engine:query:get-active-tier");
21
+
22
+ // Every QN must start with the feature-name as scope.
23
+ for (const qn of [...Object.values(TierEngineHandlers), ...Object.values(TierEngineQueries)]) {
24
+ expect(qn.startsWith(`${TIER_ENGINE_FEATURE}:`)).toBe(true);
25
+ }
26
+ });
27
+
28
+ test("tier-assignment aggregate-id namespace is stable across boots", () => {
29
+ // The namespace UUID is in stone — changing it re-keys every existing
30
+ // aggregate-stream and breaks event-replay + projection-rebuild +
31
+ // audit-trail. If this test fails: revert the namespace, do not adjust
32
+ // the test.
33
+ const id1 = tierAssignmentAggregateId("00000000-0000-4000-8000-000000000001");
34
+ const id2 = tierAssignmentAggregateId("00000000-0000-4000-8000-000000000001");
35
+ const id3 = tierAssignmentAggregateId("00000000-0000-4000-8000-000000000002");
36
+
37
+ expect(id1).toBe(id2); // same input → same output (deterministic)
38
+ expect(id1).not.toBe(id3); // different input → different output
39
+ // Pin the actual value — drift-detector for the namespace constant.
40
+ expect(id1).toBe("4d7b6b9b-5257-56f7-b668-5d0b92dbd4dc");
41
+ });
42
+ });