@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,361 @@
1
+ import type { TextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
2
+ import {
3
+ createTextContentApi,
4
+ createTextContentFeature,
5
+ textBlockEntity,
6
+ } from "@cosmicdrift/kumiko-bundled-features/text-content";
7
+ import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
8
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createEntityTable,
13
+ setupTestStack,
14
+ type TestStack,
15
+ } from "@cosmicdrift/kumiko-framework/stack";
16
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
17
+ import { createLegalPagesFeature, runLegalPagesBootCheck } from "../feature";
18
+ import { renderMarkdownToHtml, wrapInLayout } from "../markdown";
19
+
20
+ let stack: TestStack;
21
+ let db: DbConnection;
22
+
23
+ const textFeature = createTextContentFeature();
24
+ const legalFeature = createLegalPagesFeature();
25
+
26
+ beforeAll(async () => {
27
+ // legal-pages braucht zwei wirings:
28
+ // 1. anonymousAccess für die /legal/*-Routes (laufen ohne JWT)
29
+ // 2. extraContext.textContent damit der Boot-Check + interner
30
+ // Cross-Feature-Lookup ohne direct DB-Coupling funktioniert
31
+ stack = await setupTestStack({
32
+ features: [textFeature, legalFeature],
33
+ anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
34
+ extraContext: ({ db }) => ({
35
+ textContent: createTextContentApi(db),
36
+ }),
37
+ });
38
+ db = stack.db;
39
+ await createEntityTable(db, textBlockEntity);
40
+ await createEventsTable(db);
41
+
42
+ // Seed legal blocks für SYSTEM_TENANT in DE
43
+ await seedTextBlock(db, {
44
+ tenantId: SYSTEM_TENANT_ID,
45
+ slug: "imprint",
46
+ lang: "de",
47
+ title: "Impressum",
48
+ body: "## Angaben gemäß § 5 TMG\n\n**Marc Frost**\n\nSlevogtstr. 10, Leipzig",
49
+ });
50
+ await seedTextBlock(db, {
51
+ tenantId: SYSTEM_TENANT_ID,
52
+ slug: "privacy",
53
+ lang: "de",
54
+ title: "Datenschutzerklärung",
55
+ body: "## 1. Überblick\n\nWir verarbeiten **keine Tracking-Cookies**.",
56
+ });
57
+ await seedTextBlock(db, {
58
+ tenantId: SYSTEM_TENANT_ID,
59
+ slug: "imprint",
60
+ lang: "en",
61
+ title: "Imprint",
62
+ body: "## Provider\n\n**Marc Frost**\n\nLeipzig, Germany",
63
+ });
64
+ });
65
+
66
+ afterAll(async () => {
67
+ await stack.cleanup();
68
+ });
69
+
70
+ describe("legal-pages :: GET /legal/impressum", () => {
71
+ test("returns rendered HTML for DE imprint", async () => {
72
+ const res = await stack.app.request("/legal/impressum");
73
+ expect(res.status).toBe(200);
74
+ expect(res.headers.get("content-type")).toContain("text/html");
75
+ const body = await res.text();
76
+ expect(body).toContain("<title>Impressum</title>");
77
+ expect(body).toContain('lang="de"');
78
+ expect(body).toContain("Marc Frost");
79
+ expect(body).toContain("<h2>"); // markdown-rendered ## heading
80
+ });
81
+ });
82
+
83
+ describe("legal-pages :: GET /legal/datenschutz", () => {
84
+ test("returns rendered HTML for DE privacy", async () => {
85
+ const res = await stack.app.request("/legal/datenschutz");
86
+ expect(res.status).toBe(200);
87
+ const body = await res.text();
88
+ expect(body).toContain("<title>Datenschutzerklärung</title>");
89
+ expect(body).toContain("Tracking-Cookies");
90
+ expect(body).toContain("<strong>"); // markdown bold
91
+ });
92
+ });
93
+
94
+ describe("legal-pages :: GET /legal/imprint (EN)", () => {
95
+ test("returns rendered HTML for EN imprint", async () => {
96
+ const res = await stack.app.request("/legal/imprint");
97
+ expect(res.status).toBe(200);
98
+ const body = await res.text();
99
+ expect(body).toContain('lang="en"');
100
+ expect(body).toContain("Leipzig");
101
+ });
102
+ });
103
+
104
+ describe("legal-pages :: GET /legal/privacy (EN, not seeded)", () => {
105
+ test("returns 404 with helpful message when block missing", async () => {
106
+ const res = await stack.app.request("/legal/privacy");
107
+ expect(res.status).toBe(404);
108
+ const body = await res.text();
109
+ expect(body).toContain("Privacy Policy");
110
+ expect(body).toContain("Tenant-Admin");
111
+ });
112
+ });
113
+
114
+ describe("legal-pages :: edge-cases", () => {
115
+ test("Block existiert mit body=null → Route returnt 404 statt leerer HTML", async () => {
116
+ // seedTextBlock erlaubt body=null als legitimer Stub-State.
117
+ // Routes sollen das als "not configured" behandeln, NICHT als
118
+ // valides leeres Page rendern (würde DSGVO-pflichtige Page als
119
+ // existent vortäuschen).
120
+ await seedTextBlock(db, {
121
+ tenantId: SYSTEM_TENANT_ID,
122
+ slug: "imprint",
123
+ lang: "fr",
124
+ title: "Mentions légales",
125
+ body: null,
126
+ });
127
+ // Keine /legal/imprint-fr-Route registriert (LEGAL_ROUTES ist
128
+ // de+en) — wir adden nicht extra. Stattdessen testen wir das
129
+ // Verhalten via direct getBlock-Lookup gegen einen leeren
130
+ // privacy-en Block (existiert noch nicht im stack-setup).
131
+ await seedTextBlock(db, {
132
+ tenantId: SYSTEM_TENANT_ID,
133
+ slug: "privacy",
134
+ lang: "en",
135
+ title: "Privacy Policy",
136
+ body: null,
137
+ });
138
+ const res = await stack.app.request("/legal/privacy");
139
+ expect(res.status).toBe(404);
140
+ const body = await res.text();
141
+ expect(body).toContain("Tenant-Admin");
142
+ });
143
+
144
+ test("Markdown-Body mit <script> wird NICHT escaped (dokumentiertes XSS-Verhalten, siehe README)", async () => {
145
+ // Bewusstes Verhalten: marked.parse rendered HTML 1:1, kein
146
+ // DOMPurify-Layer aktuell. Dokumentiert in legal-pages/README.md
147
+ // ('XSS — bewusst aktuell nicht gesichert'). Test pinnt das
148
+ // Verhalten — wenn es sich ändert (z.B. DOMPurify dazu), schlägt
149
+ // dieser Test fehl und der Wechsel ist dokumentiert.
150
+ await seedTextBlock(db, {
151
+ tenantId: SYSTEM_TENANT_ID,
152
+ slug: "imprint",
153
+ lang: "de",
154
+ title: "Impressum",
155
+ body: "## XSS-Test\n\n<script>window.x=1</script>\n\nDanach.",
156
+ });
157
+ const res = await stack.app.request("/legal/impressum");
158
+ expect(res.status).toBe(200);
159
+ const html = await res.text();
160
+ // Aktuelles Verhalten: script-tag bleibt unescaped im Output
161
+ expect(html).toContain("<script>window.x=1</script>");
162
+ });
163
+ });
164
+
165
+ describe("legal-pages :: cache-control", () => {
166
+ test("sets public cache header for 5min", async () => {
167
+ const res = await stack.app.request("/legal/impressum");
168
+ expect(res.headers.get("cache-control")).toBe("public, max-age=300");
169
+ });
170
+ });
171
+
172
+ describe("markdown render helpers", () => {
173
+ test("renderMarkdownToHtml converts markdown to HTML", () => {
174
+ const html = renderMarkdownToHtml("# Title\n\n**bold**");
175
+ expect(html).toContain("<h1>");
176
+ expect(html).toContain("<strong>bold</strong>");
177
+ });
178
+
179
+ test("wrapInLayout produces valid HTML5 with title + lang", () => {
180
+ const html = wrapInLayout({ title: "Test", bodyHtml: "<p>x</p>", lang: "de" });
181
+ expect(html).toContain("<!doctype html>");
182
+ expect(html).toContain('lang="de"');
183
+ expect(html).toContain("<title>Test</title>");
184
+ expect(html).toContain("<p>x</p>");
185
+ });
186
+
187
+ test("wrapInLayout escapes title to prevent XSS", () => {
188
+ const html = wrapInLayout({
189
+ title: "<script>alert(1)</script>",
190
+ bodyHtml: "x",
191
+ lang: "en",
192
+ });
193
+ expect(html).not.toContain("<script>alert(1)</script>");
194
+ expect(html).toContain("&lt;script&gt;");
195
+ });
196
+ });
197
+
198
+ // Boot-Check direkt (ohne dev-server-Job-Runner-Path) — verifiziert
199
+ // dass die Logik fehlende Blocks im SYSTEM_TENANT erkennt. Der eigentliche
200
+ // runOnBoot-Trigger lebt im JobRunner und wird in jobs-feature integration-
201
+ // tests separately exercised.
202
+ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", () => {
203
+ test("legal-pages serven SYSTEM_TENANT-Texte auch wenn tenantResolver einen anderen Tenant zurückgibt", async () => {
204
+ // Simuliert publicstatus's Setup: host-basierter tenantResolver der
205
+ // tenant-subdomain → tenant-tenantId resolved. Ohne den X-Tenant-Fix
206
+ // würde /legal/impressum für tenant-x.example.com tenant-x's
207
+ // (leeren) imprint-Block abfragen → 404. Mit Fix immer SYSTEM_TENANT.
208
+ const otherTenantId = "22222222-2222-4222-8222-222222222222";
209
+ const hostScopedStack = await setupTestStack({
210
+ features: [createTextContentFeature(), createLegalPagesFeature()],
211
+ anonymousAccess: {
212
+ // Resolver gibt IMMER einen anderen Tenant zurück — wenn legal-
213
+ // pages den respektieren würde, wäre der DB-Lookup leer.
214
+ tenantResolver: () => otherTenantId,
215
+ tenantExists: async (id) => id === otherTenantId || id === SYSTEM_TENANT_ID,
216
+ },
217
+ extraContext: ({ db }) => ({
218
+ textContent: createTextContentApi(db),
219
+ }),
220
+ });
221
+ try {
222
+ await createEntityTable(hostScopedStack.db, textBlockEntity);
223
+ await createEventsTable(hostScopedStack.db);
224
+
225
+ // Block NUR im SYSTEM_TENANT seeden — NICHT im otherTenantId
226
+ await seedTextBlock(hostScopedStack.db, {
227
+ tenantId: SYSTEM_TENANT_ID,
228
+ slug: "imprint",
229
+ lang: "de",
230
+ title: "System-Impressum",
231
+ body: "## Plattform\n\nMarc Frost",
232
+ });
233
+
234
+ const res = await hostScopedStack.app.request("/legal/impressum");
235
+ expect(res.status).toBe(200);
236
+ const body = await res.text();
237
+ expect(body).toContain("System-Impressum");
238
+ expect(body).toContain("Marc Frost");
239
+ } finally {
240
+ await hostScopedStack.cleanup();
241
+ }
242
+ });
243
+ });
244
+
245
+ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
246
+ // Direkter Test der Boot-Check-Logik mit constructed ctx-Objects —
247
+ // keine JobRunner-Coupling, keine Test-Stacks. Das ist die echte
248
+ // Verhalten-Test-Surface; r.job() ist nur thin shell darum.
249
+
250
+ type Block = { slug: string; lang: string; title: string; body: string | null };
251
+
252
+ function fakeTextContent(blocks: readonly Block[]): {
253
+ api: TextContentApi;
254
+ calls: { tenantId: string; slug: string; lang: string }[];
255
+ } {
256
+ const calls: { tenantId: string; slug: string; lang: string }[] = [];
257
+ return {
258
+ calls,
259
+ api: {
260
+ getBlock: async ({ tenantId, slug, lang }) => {
261
+ calls.push({ tenantId, slug, lang });
262
+ const block = blocks.find((b) => b.slug === slug && b.lang === lang);
263
+ if (!block) return null;
264
+ return { ...block, updatedAt: new Date() };
265
+ },
266
+ },
267
+ };
268
+ }
269
+
270
+ test("alle Pflicht-Blocks vorhanden → log.info, kein throw", async () => {
271
+ const { api } = fakeTextContent([
272
+ { slug: "imprint", lang: "de", title: "I", body: "body" },
273
+ { slug: "privacy", lang: "de", title: "P", body: "body" },
274
+ ]);
275
+ const infos: string[] = [];
276
+ const warns: string[] = [];
277
+ await expect(
278
+ runLegalPagesBootCheck({
279
+ textContent: api,
280
+ log: { info: (m) => infos.push(m), warn: (m) => warns.push(m) },
281
+ }),
282
+ ).resolves.toBeUndefined();
283
+ expect(infos).toHaveLength(1);
284
+ expect(infos[0]).toContain("alle Pflicht-Blocks vorhanden");
285
+ expect(warns).toHaveLength(0);
286
+ });
287
+
288
+ test("missing blocks + NODE_ENV=production → throws mit slug-Liste", async () => {
289
+ const { api } = fakeTextContent([]);
290
+ const originalEnv = process.env["NODE_ENV"];
291
+ process.env["NODE_ENV"] = "production";
292
+ try {
293
+ await expect(runLegalPagesBootCheck({ textContent: api })).rejects.toThrow(
294
+ /Boot-Validation failed.*imprint\/de.*privacy\/de/s,
295
+ );
296
+ } finally {
297
+ if (originalEnv === undefined) delete process.env["NODE_ENV"];
298
+ else process.env["NODE_ENV"] = originalEnv;
299
+ }
300
+ });
301
+
302
+ test("missing blocks + NODE_ENV!=production → log.warn, kein throw", async () => {
303
+ const { api } = fakeTextContent([]);
304
+ const warns: string[] = [];
305
+ const originalEnv = process.env["NODE_ENV"];
306
+ process.env["NODE_ENV"] = "development";
307
+ try {
308
+ await expect(
309
+ runLegalPagesBootCheck({
310
+ textContent: api,
311
+ log: { warn: (m) => warns.push(m) },
312
+ }),
313
+ ).resolves.toBeUndefined();
314
+ expect(warns).toHaveLength(1);
315
+ expect(warns[0]).toContain("missing 2 required text-block(s)");
316
+ expect(warns[0]).toContain("imprint/de");
317
+ expect(warns[0]).toContain("privacy/de");
318
+ } finally {
319
+ if (originalEnv === undefined) delete process.env["NODE_ENV"];
320
+ else process.env["NODE_ENV"] = originalEnv;
321
+ }
322
+ });
323
+
324
+ test("ctx ohne textContent → InternalError mit Wiring-Hinweis", async () => {
325
+ await expect(runLegalPagesBootCheck({})).rejects.toThrow(/textContent missing.*extraContext/s);
326
+ });
327
+
328
+ test("Block existiert aber body ist null → wird als missing gezählt", async () => {
329
+ const { api } = fakeTextContent([
330
+ { slug: "imprint", lang: "de", title: "I", body: null },
331
+ { slug: "privacy", lang: "de", title: "P", body: "body" },
332
+ ]);
333
+ const warns: string[] = [];
334
+ const originalEnv = process.env["NODE_ENV"];
335
+ process.env["NODE_ENV"] = "development";
336
+ try {
337
+ await runLegalPagesBootCheck({
338
+ textContent: api,
339
+ log: { warn: (m) => warns.push(m) },
340
+ });
341
+ expect(warns[0]).toContain("missing 1 required text-block(s)");
342
+ expect(warns[0]).toContain("imprint/de");
343
+ expect(warns[0]).not.toContain("privacy/de");
344
+ } finally {
345
+ if (originalEnv === undefined) delete process.env["NODE_ENV"];
346
+ else process.env["NODE_ENV"] = originalEnv;
347
+ }
348
+ });
349
+
350
+ test("alle Lookups erfolgen gegen SYSTEM_TENANT_ID (nie tenant-scoped)", async () => {
351
+ const { api, calls } = fakeTextContent([
352
+ { slug: "imprint", lang: "de", title: "I", body: "x" },
353
+ { slug: "privacy", lang: "de", title: "P", body: "x" },
354
+ ]);
355
+ await runLegalPagesBootCheck({ textContent: api });
356
+ expect(calls).toHaveLength(2);
357
+ for (const call of calls) {
358
+ expect(call.tenantId).toBe(SYSTEM_TENANT_ID);
359
+ }
360
+ });
361
+ });
@@ -0,0 +1,36 @@
1
+ // Feature name
2
+ export const LEGAL_PAGES_FEATURE = "legal-pages" as const;
3
+
4
+ // Required slugs that must exist as text-blocks for production-boot.
5
+ // Pro Sprache + Slug eine Pflicht-Kombo. Wer mehr Sprachen will, ergänzt
6
+ // die Liste — Boot-Check wird dynamisch aus der Liste generiert.
7
+ export const LEGAL_REQUIRED_BLOCKS = [
8
+ { slug: "imprint", lang: "de" },
9
+ { slug: "privacy", lang: "de" },
10
+ ] as const;
11
+
12
+ // Optionale Blocks die NICHT Boot-fail-relevant sind, aber die Routes
13
+ // servieren falls vorhanden. EN-Versionen sind in DACH-Apps oft nur
14
+ // "nice-to-have".
15
+ export const LEGAL_OPTIONAL_BLOCKS = [
16
+ { slug: "imprint", lang: "en" },
17
+ { slug: "privacy", lang: "en" },
18
+ ] as const;
19
+
20
+ // Public-Route-Mapping: URL-Path → (slug, lang). DE nutzt die deutschen
21
+ // Standard-Bezeichnungen, EN die englischen.
22
+ export const LEGAL_ROUTES = [
23
+ { path: "/legal/impressum", slug: "imprint", lang: "de", titleFallback: "Impressum" },
24
+ {
25
+ path: "/legal/datenschutz",
26
+ slug: "privacy",
27
+ lang: "de",
28
+ titleFallback: "Datenschutzerklärung",
29
+ },
30
+ { path: "/legal/imprint", slug: "imprint", lang: "en", titleFallback: "Imprint" },
31
+ { path: "/legal/privacy", slug: "privacy", lang: "en", titleFallback: "Privacy Policy" },
32
+ ] as const;
33
+
34
+ export const LegalPagesErrors = {
35
+ bootMissingBlock: "legal_pages_boot_missing_block",
36
+ } as const;
@@ -0,0 +1,187 @@
1
+ import {
2
+ requireTextContent,
3
+ type TextContentApi,
4
+ } from "@cosmicdrift/kumiko-bundled-features/text-content";
5
+ import {
6
+ defineFeature,
7
+ type FeatureDefinition,
8
+ SYSTEM_TENANT_ID,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES } from "./constants";
11
+ import { renderMarkdownToHtml, wrapInLayout } from "./markdown";
12
+
13
+ // QN-Konstante als dokumentierter Public-Contract des text-content-
14
+ // Features. Ein magic-string statt eines Code-Imports ist hier explizit
15
+ // gewollt: Cross-Feature-Calls gehen nur über stable Public-API
16
+ // (handler-name + payload-shape), nicht über interne Module-Refs. Wenn
17
+ // text-content's Handler-Name sich ändert, ist das ein semver-major
18
+ // und muss in beiden Features synchronisiert werden — gleiches Risiko
19
+ // wie bei jedem API-Endpunkt.
20
+ const TEXT_CONTENT_BY_SLUG_QN = "text-content:query:by-slug";
21
+
22
+ // Wire-Body-Shape von /api/query — das, was bySlugQuery returnt.
23
+ type ByslugQueryBody = {
24
+ data: { title: string; body: string | null } | null;
25
+ };
26
+
27
+ // legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
28
+ // Liefert vier feste Public-HTML-Routes (/legal/impressum,
29
+ // /legal/datenschutz, /legal/imprint, /legal/privacy) mit
30
+ // Markdown→HTML-Rendering und einen Boot-Check der in Production hart
31
+ // fehlt wenn die DE-Pflicht-Blocks nicht geseedet sind.
32
+ //
33
+ // Cross-Feature-Decoupling:
34
+ // • Routes nutzen app.fetch zu "/api/query" mit dem QN-string
35
+ // `text-content:query:by-slug` — kein Code-Import von text-content
36
+ // • Boot-Check nutzt ctx.textContent (über extraContext) — symmetrisch
37
+ // zum config/tenant-Pattern
38
+ // • Single Type-Import (TextContentApi) bleibt — type-only verstößt
39
+ // nicht gegen Cross-Feature-Coupling
40
+ //
41
+ // Voraussetzungen für Production:
42
+ // • App-Bootstrap muss extraContext: { textContent: createTextContentApi(db) }
43
+ // setzen — sonst wirft Boot-Check beim Start
44
+ // • anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID } — sonst
45
+ // antworten die Routes mit 503
46
+ export type LegalPagesWrapLayout = (opts: {
47
+ readonly title: string;
48
+ readonly bodyHtml: string;
49
+ readonly lang: string;
50
+ }) => string;
51
+
52
+ export type LegalPagesOptions = {
53
+ /** Custom Layout-Wrapper für die /legal/*-Routes. Default: minimaler
54
+ * HTML-Skeleton aus markdown.ts (`wrapInLayout`). Apps die ihr eigenes
55
+ * Marketing-Layout (Header/Footer/Theme) auch um Legal-Body legen
56
+ * wollen, übergeben hier ihre Render-Function. */
57
+ readonly wrapLayout?: LegalPagesWrapLayout;
58
+ };
59
+
60
+ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
61
+ const wrapLayout = opts.wrapLayout ?? wrapInLayout;
62
+ return defineFeature("legal-pages", (r) => {
63
+ r.requires("text-content");
64
+
65
+ // 4 Public-HTML-Routes
66
+ for (const route of LEGAL_ROUTES) {
67
+ r.httpRoute({
68
+ method: "GET",
69
+ path: route.path,
70
+ anonymous: true,
71
+ handler: async (c, { app }) => {
72
+ const url = new URL(c.req.url);
73
+ // Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
74
+ // Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
75
+ // legal-pages serven IMMER die SYSTEM_TENANT-Texte. Deshalb
76
+ // explizit X-Tenant-Header setzen statt host weiterreichen
77
+ // (sonst würde ein host-basierter anonymousAccess-Resolver
78
+ // die tenant-Subdomain auf tenant-tenantId resolven und
79
+ // tenant-x's leere imprint-Tabelle abfragen → 404).
80
+ const queryRes = await app.fetch(
81
+ new Request(`${url.origin}/api/query`, {
82
+ method: "POST",
83
+ headers: {
84
+ "content-type": "application/json",
85
+ "X-Tenant": SYSTEM_TENANT_ID,
86
+ },
87
+ body: JSON.stringify({
88
+ type: TEXT_CONTENT_BY_SLUG_QN,
89
+ payload: { slug: route.slug, lang: route.lang },
90
+ }),
91
+ }),
92
+ );
93
+
94
+ if (!queryRes.ok) {
95
+ return c.text("legal page unavailable", 503);
96
+ }
97
+
98
+ const body: ByslugQueryBody = await queryRes.json();
99
+ const data = body.data;
100
+ if (!data?.body) {
101
+ return c.text(
102
+ `${route.titleFallback} not configured. Tenant-Admin must set this text-block.`,
103
+ 404,
104
+ );
105
+ }
106
+
107
+ const html = wrapLayout({
108
+ title: data.title || route.titleFallback,
109
+ bodyHtml: renderMarkdownToHtml(data.body),
110
+ lang: route.lang,
111
+ });
112
+
113
+ return c.body(html, 200, {
114
+ "content-type": "text/html; charset=utf-8",
115
+ "cache-control": "public, max-age=300",
116
+ });
117
+ },
118
+ });
119
+ }
120
+
121
+ // Boot-Check via ctx.textContent (extraContext-Pattern, symmetrisch
122
+ // zu requireConfigResolver in config). App-Bootstrap muss textContent
123
+ // wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
124
+ //
125
+ // Body als named function extrahiert (`runLegalPagesBootCheck`) damit
126
+ // die Logik direkt unit-testbar ist statt nur indirekt über Routes.
127
+ // Pattern: thin job-shell ruft testable function — keine Test-Coupling
128
+ // zum JobRunner.
129
+ r.job(
130
+ "legal-pages-boot-check",
131
+ {
132
+ trigger: { manual: true },
133
+ runOnBoot: true,
134
+ runIn: "api",
135
+ },
136
+ async (_payload, ctx) => runLegalPagesBootCheck(ctx),
137
+ );
138
+
139
+ return {};
140
+ });
141
+ }
142
+
143
+ // Minimal-shape für die Boot-Check-Logik — nur die Felder die der Check
144
+ // braucht. Akzeptiert HandlerContext + AppContext + jeden anderen
145
+ // Container der textContent + log mitbringt. Macht den Check direkt
146
+ // unit-testbar mit constructed ctx-Objects.
147
+ export type LegalPagesBootCheckCtx = {
148
+ readonly textContent?: TextContentApi;
149
+ readonly log?: {
150
+ readonly info?: (msg: string) => void;
151
+ readonly warn?: (msg: string) => void;
152
+ };
153
+ };
154
+
155
+ // Exportiert für direkte Tests. Wirft InternalError wenn ctx.textContent
156
+ // nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
157
+ // in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
158
+ // Logged log.info wenn alles vorhanden ist (kein silent-skip).
159
+ export async function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx): Promise<void> {
160
+ const textContent: TextContentApi = requireTextContent(ctx, "legal-pages-boot-check");
161
+ const missing: { slug: string; lang: string }[] = [];
162
+
163
+ for (const required of LEGAL_REQUIRED_BLOCKS) {
164
+ const block = await textContent.getBlock({
165
+ tenantId: SYSTEM_TENANT_ID,
166
+ slug: required.slug,
167
+ lang: required.lang,
168
+ });
169
+ if (!block?.body) {
170
+ missing.push({ slug: required.slug, lang: required.lang });
171
+ }
172
+ }
173
+
174
+ if (missing.length === 0) {
175
+ ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
176
+ } else {
177
+ const message =
178
+ `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
179
+ missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
180
+ ". Seed via text-content:write:set or seedTextBlock helper.";
181
+
182
+ if (process.env["NODE_ENV"] === "production") {
183
+ throw new Error(`Boot-Validation failed: ${message}`);
184
+ }
185
+ ctx.log?.warn?.(message);
186
+ }
187
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ LEGAL_OPTIONAL_BLOCKS,
3
+ LEGAL_PAGES_FEATURE,
4
+ LEGAL_REQUIRED_BLOCKS,
5
+ LEGAL_ROUTES,
6
+ LegalPagesErrors,
7
+ } from "./constants";
8
+ export {
9
+ createLegalPagesFeature,
10
+ type LegalPagesBootCheckCtx,
11
+ runLegalPagesBootCheck,
12
+ } from "./feature";
13
+ export { renderMarkdownToHtml, wrapInLayout } from "./markdown";
@@ -0,0 +1,69 @@
1
+ import { Marked } from "marked";
2
+
3
+ // Markdown→HTML mit eigener `marked`-Instance. GFM aus, breaks aus —
4
+ // Legal-Pages sind strukturiert genug dass GFM-Tables/Strikethrough/
5
+ // Task-Lists nicht nötig sind. Headers + Listen + Links + Code reichen.
6
+ //
7
+ // Instance statt globaler `marked.setOptions()` damit andere Features
8
+ // die `marked` als runtime-dep nutzen ihre eigenen Optionen behalten —
9
+ // modul-level side-effect auf shared library wäre brittle bei mehreren
10
+ // Konsumenten.
11
+ //
12
+ // XSS-Schutz: marked rendered tags 1:1, also kann ein böswilliger Text-
13
+ // Editor (TenantAdmin) <script>-Tags reinschreiben. Aktuell akzeptiert
14
+ // weil nur trusted Roles (TenantAdmin/SystemAdmin) Texte setzen können —
15
+ // bei einem Multi-Author-Setup müsste DOMPurify oder isomorphic-dompurify
16
+ // dazu. Dokumentiert in README, Phase-2-Hardening.
17
+ const markdownRenderer = new Marked({
18
+ gfm: false,
19
+ breaks: false,
20
+ });
21
+
22
+ export function renderMarkdownToHtml(markdown: string): string {
23
+ // @cast-boundary render-helper marked.parse return-type ist
24
+ // `string | Promise<string>` — mit `{ async: false }` runtime-garantiert
25
+ // sync (string). Cast nur API-Vertragsfix, kein Type-Loss.
26
+ return markdownRenderer.parse(markdown, { async: false }) as string;
27
+ }
28
+
29
+ // Layout-Wrapper für Legal-Pages — minimaler HTML-Skeleton mit Inline-
30
+ // CSS damit die Pages auch ohne App-Layout sauber aussehen. Apps die
31
+ // das in ihr eigenes Layout integrieren wollen, nutzen text-content's
32
+ // by-slug-query direkt und rendern selbst.
33
+ export function wrapInLayout(opts: { title: string; bodyHtml: string; lang: string }): string {
34
+ return `<!doctype html>
35
+ <html lang="${escapeHtmlAttr(opts.lang)}">
36
+ <head>
37
+ <meta charset="utf-8">
38
+ <meta name="viewport" content="width=device-width, initial-scale=1">
39
+ <title>${escapeHtml(opts.title)}</title>
40
+ <style>
41
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px;
42
+ margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #222; }
43
+ h1, h2, h3 { line-height: 1.2; margin-top: 2rem; }
44
+ h1 { font-size: 1.8rem; } h2 { font-size: 1.4rem; } h3 { font-size: 1.15rem; }
45
+ a { color: #0066cc; }
46
+ code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
47
+ hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <main>
52
+ ${opts.bodyHtml}
53
+ </main>
54
+ </body>
55
+ </html>`;
56
+ }
57
+
58
+ function escapeHtml(s: string): string {
59
+ return s
60
+ .replace(/&/g, "&amp;")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;")
63
+ .replace(/"/g, "&quot;")
64
+ .replace(/'/g, "&#39;");
65
+ }
66
+
67
+ function escapeHtmlAttr(s: string): string {
68
+ return escapeHtml(s);
69
+ }