@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
package/LICENSE CHANGED
@@ -4,7 +4,7 @@ Parameters
4
4
 
5
5
  Licensor: Marc Frost
6
6
 
7
- Licensed Work: @cosmicdrift/kumiko-testing
7
+ Licensed Work: @cosmicdrift/kumiko-samples
8
8
  The Licensed Work is © 2026 Marc Frost.
9
9
 
10
10
  Additional Use Grant:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.363.0",
3
+ "version": "0.365.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.363.0",
3
+ "version": "0.365.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -106,6 +106,7 @@
106
106
  "./shared/single-use-token-store": "./src/shared/single-use-token-store.ts",
107
107
  "./shared/signed-token": "./src/shared/signed-token.ts",
108
108
  "./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
109
+ "./shared/mail-template-body": "./src/shared/mail-template-body.ts",
109
110
  "./delivery": "./src/delivery/index.ts",
110
111
  "./delivery/web": "./src/delivery/web/index.ts",
111
112
  "./channel-in-app": "./src/channel-in-app/index.ts",
@@ -180,6 +181,7 @@
180
181
  "pdf-lib": "^1.17.1",
181
182
  "qrcode": "^1.5.4",
182
183
  "react": "^19.2.6",
184
+ "react-dom": "^19.2.6",
183
185
  "sharp": "^0.35.5",
184
186
  "stripe": "^22.6.2",
185
187
  "tailwind-merge": "^3.6.0",
@@ -527,6 +529,10 @@
527
529
  "types": "./dist/shared/row-bound-grant.d.ts",
528
530
  "default": "./dist/shared/row-bound-grant.js"
529
531
  },
532
+ "./shared/mail-template-body": {
533
+ "types": "./dist/shared/mail-template-body.d.ts",
534
+ "default": "./dist/shared/mail-template-body.js"
535
+ },
530
536
  "./delivery": {
531
537
  "types": "./dist/delivery/index.d.ts",
532
538
  "default": "./dist/delivery/index.js"
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Escape-hatch retention keeps the operator floor and deletes in bounded batches",
6
+ "detail": "An explicitly configured retention key now acts as a lower bound for tenant compliance-profile cutoffs, so a tenant profile can no longer delete events younger than the operator allows. A key left at its default does not count. Pruning runs in batches of 5000 events, each in its own transaction, until a batch comes back short."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Audit retention events always use the system user",
12
+ "detail": "Events written by the audit feature no longer fall back to the calling user id and always record the system user."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "Audit list pages by id unless sorted by type",
18
+ "detail": "An explicit createdAt sort pages in id order so keyset pagination no longer skips rows."
19
+ },
2
20
  {
3
21
  "version": "0.361.0",
4
22
  "type": "fix",
@@ -76,7 +76,7 @@ export function createAuditFeature(): FeatureDefinition {
76
76
  db: ctx.db.unsafeRaw(),
77
77
  registry: ctx.registry,
78
78
  configResolver: ctx.configResolver,
79
- userId: ctx._userId ?? SYSTEM_USER_ID,
79
+ userId: SYSTEM_USER_ID,
80
80
  });
81
81
  },
82
82
  });
@@ -117,11 +117,16 @@ export const listQuery = defineQueryHandler({
117
117
  createdAt: unknown;
118
118
  createdBy: string;
119
119
  }>(ctx.db, eventsTable, where, {
120
- // `id` as tie-breaker keeps the order total, so pages never overlap.
121
- orderBy: [
122
- { col: p.sort === "type" ? "type" : "createdAt", direction },
123
- { col: "id", direction },
124
- ],
120
+ // The id cursor only resumes an id-ordered listing. created_at comes from the
121
+ // transaction start and can run against insert order, so ordering by it would let
122
+ // a page boundary skip rows; only the type sort leaves id order (and is one page).
123
+ orderBy:
124
+ p.sort === "type"
125
+ ? [
126
+ { col: "type", direction },
127
+ { col: "id", direction },
128
+ ]
129
+ : [{ col: "id", direction }],
125
130
  limit: p.limit,
126
131
  });
127
132
 
@@ -1,4 +1,8 @@
1
- import { aggregateWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import {
2
+ aggregateWhere,
3
+ runnerCanBeginTransaction,
4
+ selectMany,
5
+ } from "@cosmicdrift/kumiko-framework/bun-db";
2
6
  import {
3
7
  COMPLIANCE_PROFILES,
4
8
  subtractRetentionSpec,
@@ -27,7 +31,7 @@ import {
27
31
 
28
32
  // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
29
33
  export function isDbConnection(runner: DbRunner): runner is DbConnection {
30
- return "begin" in runner && typeof runner.begin === "function";
34
+ return runnerCanBeginTransaction(runner);
31
35
  }
32
36
 
33
37
  async function resolveRetentionDays(args: {
@@ -48,9 +52,30 @@ async function resolveRetentionDays(args: {
48
52
  return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
49
53
  }
50
54
 
55
+ // Only an operator-set value counts: the key's declared default must not override the tenant profiles.
56
+ async function resolveExplicitRetentionDays(args: {
57
+ readonly registry: Registry;
58
+ readonly configResolver: ConfigResolver | undefined;
59
+ readonly db: DbConnection;
60
+ readonly userId: string;
61
+ }): Promise<number | undefined> {
62
+ const keyDef = args.registry.getConfigKey(ESCAPE_HATCH_RETENTION_DAYS_KEY);
63
+ if (!args.configResolver || !keyDef) return undefined;
64
+ const { value, source } = await args.configResolver.getWithSource(
65
+ ESCAPE_HATCH_RETENTION_DAYS_KEY,
66
+ keyDef,
67
+ SYSTEM_TENANT_ID,
68
+ args.userId,
69
+ args.db,
70
+ );
71
+ const isOperatorSet = source !== "default" && source !== "missing";
72
+ return isOperatorSet && typeof value === "number" && value >= 1 ? value : undefined;
73
+ }
74
+
51
75
  // Soft dependency: audit must stay mountable without compliance-profiles, so we probe its entity.
52
76
  const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
53
77
  const PRUNE_CHUNK_SIZE = 1000;
78
+ const PRUNE_BATCH_SIZE = 5000;
54
79
 
55
80
  type AuditEventRow = {
56
81
  readonly aggregateId: string;
@@ -68,11 +93,13 @@ async function resolveTenantCutoff(
68
93
  db: DbConnection,
69
94
  tenantId: TenantId,
70
95
  now: Temporal.Instant,
96
+ operatorFloor: Temporal.Instant | undefined,
71
97
  ): Promise<Temporal.Instant> {
72
98
  const { profile } = await resolveProfileForTenant({ db, tenantId });
73
99
  const effective = subtractRetentionSpec(now, profile.auditLog.retention);
74
100
  const base = subtractRetentionSpec(now, COMPLIANCE_PROFILES[profile.key].auditLog.retention);
75
- return earlierInstant(effective, base);
101
+ const profileCutoff = earlierInstant(effective, base);
102
+ return operatorFloor ? earlierInstant(profileCutoff, operatorFloor) : profileCutoff;
76
103
  }
77
104
 
78
105
  // A cross-tenant audit (e.g. identity-switch) lives under the actor's tenant but must also
@@ -95,13 +122,20 @@ async function storingTenantIds(db: DbConnection): Promise<readonly TenantId[]>
95
122
  });
96
123
  }
97
124
 
98
- async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
125
+ async function pruneByTenantProfiles(
126
+ db: DbConnection,
127
+ operatorRetentionDays: number | undefined,
128
+ ): Promise<number> {
99
129
  const now = getTemporal().Now.instant();
130
+ const operatorFloor =
131
+ operatorRetentionDays === undefined
132
+ ? undefined
133
+ : now.subtract({ hours: operatorRetentionDays * 24 });
100
134
  const cutoffByTenant = new Map<TenantId, Temporal.Instant>();
101
135
  const cutoffOf = async (tenantId: TenantId): Promise<Temporal.Instant> => {
102
136
  const cached = cutoffByTenant.get(tenantId);
103
137
  if (cached) return cached;
104
- const cutoff = await resolveTenantCutoff(db, tenantId, now);
138
+ const cutoff = await resolveTenantCutoff(db, tenantId, now, operatorFloor);
105
139
  cutoffByTenant.set(tenantId, cutoff);
106
140
  return cutoff;
107
141
  };
@@ -139,11 +173,32 @@ async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
139
173
  return deletedCount;
140
174
  }
141
175
 
176
+ async function pruneInBatches(
177
+ db: DbConnection,
178
+ olderThanDays: number,
179
+ batchSize = PRUNE_BATCH_SIZE,
180
+ ): Promise<number> {
181
+ const olderThan = getTemporal()
182
+ .Now.instant()
183
+ .subtract({ hours: olderThanDays * 24 });
184
+ let deletedCount = 0;
185
+ for (;;) {
186
+ const batch = await pruneEvents(db, {
187
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
188
+ olderThan,
189
+ batchSize,
190
+ });
191
+ deletedCount += batch.deletedCount;
192
+ if (batch.deletedCount < batchSize) return deletedCount;
193
+ }
194
+ }
195
+
142
196
  export async function runEscapeHatchRetention(args: {
143
197
  readonly db: DbRunner;
144
198
  readonly registry: Registry;
145
199
  readonly configResolver: ConfigResolver | undefined;
146
200
  readonly userId: string;
201
+ readonly batchSize?: number;
147
202
  }): Promise<{ readonly deletedCount: number }> {
148
203
  const { db } = args;
149
204
  if (!isDbConnection(db)) {
@@ -152,12 +207,9 @@ export async function runEscapeHatchRetention(args: {
152
207
 
153
208
  if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
154
209
  const olderThanDays = await resolveRetentionDays({ ...args, db });
155
- const { deletedCount } = await pruneEvents(db, {
156
- aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
157
- olderThanDays,
158
- });
159
- return { deletedCount };
210
+ return { deletedCount: await pruneInBatches(db, olderThanDays, args.batchSize) };
160
211
  }
161
212
 
162
- return { deletedCount: await pruneByTenantProfiles(db) };
213
+ const operatorRetentionDays = await resolveExplicitRetentionDays({ ...args, db });
214
+ return { deletedCount: await pruneByTenantProfiles(db, operatorRetentionDays) };
163
215
  }
@@ -7,23 +7,26 @@
7
7
 
8
8
  import {
9
9
  makeDispatchSystemWrite,
10
- type SystemDispatchArgs,
10
+ makeDispatchWriteAs,
11
+ type RoleDispatchArgs,
11
12
  } from "@cosmicdrift/kumiko-framework/api";
12
13
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
13
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
15
  import {
15
16
  ConfigScopes,
16
17
  createSystemUser,
18
+ type Registry,
17
19
  type TenantId,
18
20
  type WriteResult,
19
21
  } from "@cosmicdrift/kumiko-framework/engine";
20
22
  import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
23
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
21
24
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
22
25
  import type { Redis } from "ioredis";
23
26
  // kumiko-lint-ignore cross-feature-import bootstrap sets per-tenant config through the config feature's write handler
24
27
  import { ConfigHandlers } from "../config/constants.js";
25
28
  // kumiko-lint-ignore cross-feature-import bootstrap compares against the stored config row to stay idempotent
26
- import { findConfigRow } from "../config/write-helpers.js";
29
+ import { configRowHoldsValue, findConfigRow } from "../config/write-helpers.js";
27
30
  // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
28
31
  import { TenantHandlers } from "../tenant/constants.js";
29
32
  // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
@@ -33,14 +36,12 @@ import { tenantMembershipsTable } from "../tenant/membership-table.js";
33
36
  // kumiko-lint-ignore cross-feature-import bootstrap checks for an existing tenant
34
37
  import { tenantTable } from "../tenant/schema/tenant.js";
35
38
  // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
36
- import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
39
+ import type { GlobalRole } from "../user/constants.js";
37
40
  // kumiko-lint-ignore cross-feature-import bootstrap skips already activated users
38
41
  import { userTable } from "../user/schema/user.js";
39
42
  import { AuthHandlers } from "./constants.js";
40
43
  import { hasLiveInviteToken } from "./invite-token-store.js";
41
44
 
42
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
43
-
44
45
  export type BootstrapInvite = {
45
46
  readonly email: string;
46
47
  /** Membership role in the tenant, validated like a tenant-admin invite
@@ -55,9 +56,7 @@ export type BootstrapSeedDeps = {
55
56
  /** Writes as the system user with the given tenant roles in the seeded
56
57
  * tenant; use it when the app gates tenant data on its own roles rather
57
58
  * than SystemAdmin. */
58
- readonly dispatchWriteAs: (
59
- args: Pick<SystemDispatchArgs, "handlerQn" | "payload"> & { readonly roles: readonly string[] },
60
- ) => Promise<WriteResult>;
59
+ readonly dispatchWriteAs: (args: Omit<RoleDispatchArgs, "tenantId">) => Promise<WriteResult>;
61
60
  };
62
61
 
63
62
  export type BootstrapConfigValue = string | number | boolean;
@@ -96,6 +95,10 @@ export type BootstrapDeps = {
96
95
  readonly db: DbConnection;
97
96
  readonly redis: Redis;
98
97
  readonly dispatcher: Dispatcher;
98
+ /** With these, a rerun can compare `encrypted`/`piiEncrypted` config keys by
99
+ * their decrypted value; without them such keys are rewritten on every run. */
100
+ readonly registry?: Registry;
101
+ readonly configCipher?: EnvelopeCipher;
99
102
  };
100
103
 
101
104
  export type BootstrapTenantOutcome = "created" | "exists";
@@ -216,7 +219,18 @@ async function applyTenantConfig(
216
219
  const applied: string[] = [];
217
220
  for (const [key, value] of Object.entries(tenant.config ?? {})) {
218
221
  const existing = await findConfigRow(deps.db, key, tenant.id, null);
219
- if (existing?.value === JSON.stringify(value)) continue;
222
+ const serialized = JSON.stringify(value);
223
+ if (
224
+ await configRowHoldsValue(
225
+ existing,
226
+ deps.registry?.getConfigKey(key),
227
+ deps.configCipher,
228
+ key,
229
+ serialized,
230
+ )
231
+ ) {
232
+ continue;
233
+ }
220
234
  const result = await deps.dispatcher.write(
221
235
  ConfigHandlers.set,
222
236
  { key, value, scope: ConfigScopes.tenant },
@@ -266,6 +280,11 @@ async function decideInvite(
266
280
  return "role-mismatch";
267
281
  }
268
282
  if (invitation.status === INVITATION_STATUS.accepted) return "active";
283
+ // The plan dropped a global role (e.g. an admin moved to tenants[].invites):
284
+ // the stored invitation would still grant it on accept, so it is re-issued.
285
+ if (storedGlobalRoles.some((role) => !invite.globalRoles.some((planned) => planned === role))) {
286
+ return "resent";
287
+ }
269
288
  if (await hasLiveInviteToken(deps.redis, invitation.id)) return "pending";
270
289
  return "resent";
271
290
  }
@@ -280,6 +299,7 @@ export async function bootstrapTenants(
280
299
  ): Promise<BootstrapReport> {
281
300
  const plannedInvites = planInvites(plan);
282
301
  const dispatchSystemWrite = makeDispatchSystemWrite(deps.dispatcher);
302
+ const dispatchWriteAsRoles = makeDispatchWriteAs(deps.dispatcher);
283
303
 
284
304
  const tenants: Array<BootstrapReport["tenants"][number]> = [];
285
305
  for (const tenant of plan.tenants) {
@@ -290,8 +310,7 @@ export async function bootstrapTenants(
290
310
  tenantId: tenant.id,
291
311
  db: deps.db,
292
312
  dispatchSystemWrite,
293
- dispatchWriteAs: ({ handlerQn, payload, roles }) =>
294
- deps.dispatcher.write(handlerQn, payload, createSystemUser(tenant.id, roles)),
313
+ dispatchWriteAs: (args) => dispatchWriteAsRoles({ ...args, tenantId: tenant.id }),
295
314
  });
296
315
  }
297
316
  const configApplied = await applyTenantConfig(deps, tenant);
@@ -1,4 +1,59 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Magic-link mail expiry and app name use the template locale",
6
+ "detail": "The expiry text, the app-name fallback and the notify locale follow the locale the auth mail template actually renders in, while the link keeps the request locale."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "improvement",
11
+ "title": "systemInviteCreate takes an optional locale tag",
12
+ "detail": "The payload accepts a locale (BCP 47 tag) that wins over the request locale when rendering the invitation mail, so system callers such as the waitlist can invite in the recipient's language."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "improvement",
17
+ "title": "Auth mails carry designer labels",
18
+ "detail": "The feature registers <feature>:feature.label and notify labels for password-reset, account-unlock, email-verification, signup-activation and invite, so the document designer lists them by name. German and Spanish ship in the locale packages."
19
+ },
20
+ {
21
+ "version": "0.364.0",
22
+ "type": "fix",
23
+ "title": "A loginUrl builder that throws no longer crashes the app tree",
24
+ "detail": "The auth gate resolves the login redirect once per location change and, when the builder returns an invalid URL or throws, logs the error and shows the built-in login screen instead of throwing during render."
25
+ },
26
+ {
27
+ "version": "0.364.0",
28
+ "type": "fix",
29
+ "title": "How to send an auth mail after the render*Email helpers were removed in 0.363.0",
30
+ "detail": "No runtime change. The 0.363.0 breaking entry lists renderActivationEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail and renderInviteEmail as removed but does not say how app code that rendered one of these mails itself now sends it. This entry adds that recipe.",
31
+ "migration": "App code that rendered an auth mail itself and passed the result as `data`, for example `notify(\"auth-email-password:signup-activation\", { route: { email }, data: renderActivationEmail({ url, expiresAt, locale, appName }), priority: \"critical\" })`, breaks twice since 0.363.0: the helper is gone and the notification type is now `auth-email-password:notify:signup-activation`. The stored template renders subject and body; the app only passes template variables.\nFor a signup activation link, call `issueSignupActivation({ redis, notify: ctx.notify, email, appUrl, appName, locale, timeZone, tokenTtlMinutes, onTokenStored })` from `@cosmicdrift/kumiko-bundled-features/auth-email-password` (only redis, notify, email and appUrl are required). It drops any live signup token for the email, stores a new one under the keys signup-confirm reads and sends the mail. `appUrl` is the bare activation page; a function `(locale) => url` picks the page per locale.\nIf the app stores the token itself and only needs the mail, call `ctx.notify(AuthMailNotificationTypes.signupActivation, { route: { email }, data: { appName, actionUrl, validFor, validUntil }, locale, priority: \"critical\" })`. `AuthMailNotificationTypes` comes from `@cosmicdrift/kumiko-bundled-features/auth-email-password`. All four variables are required strings: ctx.notify checks them against the declared template variables and throws an InternalError on a missing or invalid one, which fails the write. `actionUrl` is the page URL with `token=<encodeURIComponent(token)>` appended as a query parameter and must be an http, https or mailto URL. `validFor` and `validUntil` come from `tokenMailExpiry({ expiresAt, issuedAt, timeZone }, locale)` in `@cosmicdrift/kumiko-bundled-features/auth-email-password/email-templates`. Only send a `locale` that has a template for the type, because the renderer has no locale fallback and a locale without a template sends nothing: use `resolveMailLocale(locale)` when `hasMailTranslationKey(resolveMailLocale(locale), \"auth.mail.activation.subject\")` is true (both from `@cosmicdrift/kumiko-framework/i18n`), otherwise `\"en\"`. The other mails use the subject keys `auth.mail.reset.subject`, `auth.mail.unlock.subject`, `auth.mail.verify.subject` and `auth.mail.invite.subject`.\nThe other auth mails work the same way with `AuthMailNotificationTypes.passwordReset`, `.accountUnlock`, `.emailVerification` and `.invite`; the invite mail also needs a `role` variable."
32
+ },
33
+ {
34
+ "version": "0.364.0",
35
+ "type": "fix",
36
+ "title": "seedAdminGuarded rewrites the admin email only when its blind index is missing or stale",
37
+ "detail": "rewriteSeededUserEmail returns early when the stored email blind index already matches the expected value, so repeated boots no longer re-encrypt the email and grow the user event stream. The row version is now checked with a type guard instead of a cast."
38
+ },
39
+ {
40
+ "version": "0.364.0",
41
+ "type": "fix",
42
+ "title": "Bootstrap reruns no longer rewrite encrypted config or restore dropped global roles",
43
+ "detail": "Declarative bootstrap compares encrypted and piiEncrypted config keys by their decrypted value, so a matching rerun writes nothing, and re-issues an invitation whose stored global roles are no longer in the plan. makeDispatchWriteAs is exported from the framework api."
44
+ },
45
+ {
46
+ "version": "0.364.0",
47
+ "type": "fix",
48
+ "title": "Email payload buckets ignore plus-tags and case; auth requests are capped at 3 per 15 minutes",
49
+ "detail": "additionalRateLimits accepts normalize: email, which lowercases and strips the plus-tag before hashing, and bucket keys are scoped by tenant and handler. Signup and token requests (reset, unlock, verification) and the waitlist default use it with a 3-request, 900-second window."
50
+ },
51
+ {
52
+ "version": "0.364.0",
53
+ "type": "fix",
54
+ "title": "Password change and logout work for members with only an app role or TenantAdmin",
55
+ "detail": "Both handlers required User, Admin or SystemAdmin and answered 403 access_denied for invited members holding a single custom role. They are now open to every signed-in user, like the other self-service handlers; anonymous callers are still rejected."
56
+ },
2
57
  {
3
58
  "version": "0.363.0",
4
59
  "type": "breaking",
@@ -0,0 +1,16 @@
1
+ import type { PayloadRateLimitOption } from "@cosmicdrift/kumiko-types/handlers";
2
+
3
+ // The bucket counts across all IPs, so a long window would let an attacker who
4
+ // knows an address lock its owner out of recovery/signup for the whole window.
5
+ // A short one still throttles mail bombing but clears within minutes.
6
+ export const AUTH_EMAIL_RATE_LIMIT_WINDOW_SECONDS = 15 * 60;
7
+ export const AUTH_EMAIL_RATE_LIMIT_MAX_REQUESTS = 3;
8
+
9
+ export const AUTH_EMAIL_PAYLOAD_RATE_LIMITS: readonly PayloadRateLimitOption[] = [
10
+ {
11
+ per: { payloadField: "email" },
12
+ limit: AUTH_EMAIL_RATE_LIMIT_MAX_REQUESTS,
13
+ windowSeconds: AUTH_EMAIL_RATE_LIMIT_WINDOW_SECONDS,
14
+ normalize: "email",
15
+ },
16
+ ];
@@ -30,6 +30,7 @@ import {
30
30
  } from "./handlers/signup-request.write.js";
31
31
  import { createSwitchTenantMfaGateHandler } from "./handlers/switch-tenant-mfa-gate.write.js";
32
32
  import { createVerifyEmailHandler } from "./handlers/verify-email.write.js";
33
+ import { AUTH_EMAIL_PASSWORD_MAIL_I18N } from "./mail-i18n.js";
33
34
  import {
34
35
  AUTH_MAIL_VARIABLES,
35
36
  authMailLocalizedTemplate,
@@ -113,13 +114,8 @@ export type AccountLockoutOptions = {
113
114
  readonly lockoutDurationMinutes?: number;
114
115
  };
115
116
 
116
- // Magic-Link Self-Signup. Wenn gesetzt, registriert das Feature die
117
- // signup-request + signup-confirm-Handler. Der Token-Store (Redis)
118
- // kommt aus ctx.redis — tokenTtlMinutes ist der einzige Knopf
119
- // (Token-Material ist generateToken() = 256 Bit randomBytes, fest;
120
- // Memory feedback_no_options_without_need: keine Knöpfe ohne Bedarf).
121
- // Anders als reset/verify gibt's kein hmacSecret hier, weil der Token
122
- // opaque random ist (Redis ist Source of Truth).
117
+ // Unlike reset/verify there is no hmacSecret: the signup token is opaque random
118
+ // (256 bit) and Redis is the source of truth, so tokenTtlMinutes is the only knob.
123
119
  export type SignupOptions = SignupRequestOptions;
124
120
 
125
121
  // Tenant-Invite Magic-Link. Wenn gesetzt, registriert das Feature die
@@ -207,6 +203,7 @@ export function createAuthEmailPasswordFeature(
207
203
  // of silently dropping the mail.
208
204
  if (sendsMagicLinkMail(opts)) r.requires("delivery");
209
205
  r.envSchema(authEmailPasswordEnvSchema);
206
+ r.translations({ keys: AUTH_EMAIL_PASSWORD_MAIL_I18N });
210
207
 
211
208
  // Declared unconditionally: apps also issue the activation link from their
212
209
  // own flows (issueSignupActivation) without enabling the signup option.
@@ -1,5 +1,4 @@
1
1
  import {
2
- access,
3
2
  createSystemUser,
4
3
  defineWriteHandler,
5
4
  SYSTEM_TENANT_ID,
@@ -20,7 +19,12 @@ export const changePasswordWrite = defineWriteHandler({
20
19
  oldPassword: z.string().min(1),
21
20
  newPassword: passwordSchema,
22
21
  }),
23
- access: { roles: access.authenticated },
22
+ access: {
23
+ openToAll: {
24
+ reason:
25
+ "Self-service: acts only on event.user.id and re-checks the caller's current password; invited members hold only an app role, so a fixed role list would lock them out.",
26
+ },
27
+ },
24
28
  escapeHatch: {
25
29
  grants: ["systemIdentity"],
26
30
  reason:
@@ -121,9 +121,8 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
121
121
  escapeHatch: {
122
122
  grants: ["unsafeRaw"],
123
123
  reason:
124
- "Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
125
- "membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant. " +
126
- "Also reads the pending invitation by id (the invitee is not yet a member of the " +
124
+ "Anonymous invite-accept has no session in the invited tenant yet — reads the pending " +
125
+ "invitation by id (the invitee is not yet a member of the " +
127
126
  "invitation's tenant), adds the membership and accepts the invitation in the " +
128
127
  "invitation's tenant, which differs from the caller's tenant, and reads the MFA " +
129
128
  "enrollment of that tenant via the mfaStatusChecker callback.",
@@ -32,6 +32,7 @@ import {
32
32
  type WriteFailure,
33
33
  writeFailure,
34
34
  } from "@cosmicdrift/kumiko-framework/errors";
35
+ import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
35
36
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
36
37
  import * as z from "zod";
37
38
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
@@ -50,7 +51,7 @@ import {
50
51
  unassignableMembershipRoleError,
51
52
  } from "../../tenant/membership-roles.js";
52
53
  // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
53
- import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
54
+ import { GLOBAL_ROLE_OPTIONS, type GlobalRole } from "../../user/constants.js";
54
55
  // kumiko-lint-ignore cross-feature-import invitee lookup by email reads the user row
55
56
  import { userTable } from "../../user/schema/user.js";
56
57
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
@@ -60,8 +61,6 @@ import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token
60
61
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
61
62
  import { AuthMailNotificationTypes } from "../mail-notifications.js";
62
63
 
63
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
64
-
65
64
  const InviteCreateSchema = z.object({
66
65
  email: z.email(),
67
66
  role: z.string().min(1).max(50),
@@ -116,6 +115,7 @@ type InviteIssueRequest = {
116
115
  readonly email: string;
117
116
  readonly role: string;
118
117
  readonly globalRoles: readonly GlobalRole[];
118
+ readonly locale?: string | undefined;
119
119
  };
120
120
 
121
121
  function checkAssignableInviteRole(
@@ -239,7 +239,7 @@ async function issueInvitation(
239
239
  const token = generateToken();
240
240
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
241
241
 
242
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
242
+ const locale = request.locale ?? resolveHandlerMailLocale(ctx, opts.locale);
243
243
 
244
244
  await dispatchMagicLinkMail(
245
245
  ctx.notify,
@@ -317,6 +317,8 @@ const SystemInviteCreateSchema = z.object({
317
317
  email: z.email(),
318
318
  role: z.string().min(1).max(50),
319
319
  globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
320
+ // Preferred over the request locale: provisioning callers know the invitee's language.
321
+ locale: z.string().max(35).refine(isValidLocaleTag, "invalid locale tag").optional(),
320
322
  });
321
323
 
322
324
  // System-only sibling of invite-create: the only path that can put global
@@ -1,4 +1,4 @@
1
- import { access, defineWriteHandler, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineWriteHandler, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
 
4
4
  // Logout — JWT is stateless, so server-side we only return OK. A future
@@ -7,7 +7,12 @@ import * as z from "zod";
7
7
  export const logoutWrite = defineWriteHandler({
8
8
  name: "logout",
9
9
  schema: z.object({}),
10
- access: { roles: access.authenticated },
10
+ access: {
11
+ openToAll: {
12
+ reason:
13
+ "Self-service: touches no data, any signed-in member must be able to sign out regardless of which role they hold.",
14
+ },
15
+ },
11
16
  description:
12
17
  "Acknowledges a sign-out for the authenticated caller so the client can discard its token; the JWT is stateless, so no server-side session state changes here.",
13
18
  perform: stepsPipeline(({ r }) => [
@@ -25,6 +25,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
25
25
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
26
26
  import * as z from "zod";
27
27
  import { findSignupHandoverProvider, type SignupHandoverBinding } from "../../shared/index.js";
28
+ import { AUTH_EMAIL_PAYLOAD_RATE_LIMITS } from "../email-rate-limits.js";
28
29
  import type { AuthMailLocale } from "../email-templates.js";
29
30
  import { resolveHandlerMailLocale } from "../magic-link-mail.js";
30
31
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle.js";
@@ -79,7 +80,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
79
80
  schema: SignupRequestSchema,
80
81
  access: { roles: ["anonymous"] },
81
82
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
82
- additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
83
+ additionalRateLimits: AUTH_EMAIL_PAYLOAD_RATE_LIMITS,
83
84
  description:
84
85
  "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
85
86
  escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
@@ -21,7 +21,7 @@ const SwitchTenantMfaGateSchema = z.object({
21
21
  });
22
22
 
23
23
  export type SwitchTenantMfaGateData =
24
- | { readonly kind: "mfa-gate-clear" }
24
+ | { readonly kind: "mfa-gate-clear"; readonly roles: readonly string[] }
25
25
  | NonNullable<Awaited<ReturnType<typeof gateEnforceMfa>>>;
26
26
 
27
27
  // Dispatched by POST /auth/switch-tenant with the system identity. The handler
@@ -54,7 +54,7 @@ export function createSwitchTenantMfaGateHandler(
54
54
  );
55
55
  const roles = buildSessionRoles(parseRoles(userRow?.roles ?? null), active.membership.roles);
56
56
  const mfaGate = await gateEnforceMfa(ctx, opts, userId, tenantId, roles);
57
- return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear" } };
57
+ return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear", roles } };
58
58
  },
59
59
  });
60
60
  }
@@ -19,6 +19,7 @@ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
19
19
  import * as z from "zod";
20
20
  import { UserQueries } from "../../user/index.js";
21
21
  import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row.js";
22
+ import { AUTH_EMAIL_PAYLOAD_RATE_LIMITS } from "../email-rate-limits.js";
22
23
  import type { AuthMailLocale } from "../email-templates.js";
23
24
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
24
25
 
@@ -99,7 +100,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
99
100
  schema: RequestTokenSchema,
100
101
  access: { roles: ["anonymous"] },
101
102
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
102
- additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
103
+ additionalRateLimits: AUTH_EMAIL_PAYLOAD_RATE_LIMITS,
103
104
  escapeHatch: {
104
105
  grants: ["systemIdentity"],
105
106
  reason: