@cosmicdrift/kumiko-samples 0.387.0 → 0.389.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 (274) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
  6. package/packages/bundled-features/src/audit/changes.json +12 -0
  7. package/packages/bundled-features/src/audit/feature.ts +1 -0
  8. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  15. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  16. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  17. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  18. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  19. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  20. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  21. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  22. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  23. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  24. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  25. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  26. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  27. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  28. package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
  29. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  30. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  32. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  33. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  37. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  39. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  40. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  51. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  52. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  53. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  55. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  57. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  58. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  59. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  60. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  61. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  62. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  63. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  64. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  65. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  66. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  67. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  68. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  69. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  70. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  71. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
  72. package/packages/bundled-features/src/cap-counter/changes.json +30 -0
  73. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  74. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  75. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  76. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  77. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  78. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  79. package/packages/bundled-features/src/config/changes.json +18 -0
  80. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  81. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  82. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  83. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  84. package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
  85. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
  86. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  87. package/packages/bundled-features/src/delivery/changes.json +42 -0
  88. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  89. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  90. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  91. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  92. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  93. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  94. package/packages/bundled-features/src/delivery/index.ts +5 -0
  95. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  96. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  97. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  98. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  99. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  100. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  101. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  102. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  103. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  104. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  105. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  106. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  107. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  108. package/packages/bundled-features/src/files/README.md +1 -1
  109. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  110. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  111. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  112. package/packages/bundled-features/src/host-routing/README.md +1 -1
  113. package/packages/bundled-features/src/host-routing/changes.json +12 -0
  114. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  115. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  116. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  118. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  120. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  122. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  123. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  124. package/packages/bundled-features/src/jobs/changes.json +13 -0
  125. package/packages/bundled-features/src/jobs/feature.ts +15 -4
  126. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  127. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  128. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  129. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  130. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  131. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  132. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  133. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  134. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  135. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  136. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  137. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  138. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  139. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  140. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  141. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  142. package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
  143. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  144. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  145. package/packages/bundled-features/src/secrets/changes.json +6 -0
  146. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  147. package/packages/bundled-features/src/sessions/changes.json +7 -0
  148. package/packages/bundled-features/src/sessions/feature.ts +8 -2
  149. package/packages/bundled-features/src/sessions/index.ts +1 -1
  150. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  151. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  152. package/packages/bundled-features/src/shared/index.ts +1 -1
  153. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  154. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  155. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  156. package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
  157. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  158. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  159. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  160. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  161. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  162. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  163. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  164. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  165. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  166. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  167. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  168. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  169. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  170. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  171. package/packages/bundled-features/src/tags/feature.ts +1 -0
  172. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  173. package/packages/bundled-features/src/template-resolver/changes.json +84 -0
  174. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  175. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  176. package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
  177. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  178. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  179. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  180. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  181. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  182. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  183. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  184. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
  185. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  186. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  187. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  188. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  189. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  190. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  191. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  192. package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
  193. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
  194. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  195. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  196. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  197. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  198. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  199. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  200. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  201. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  202. package/packages/bundled-features/src/tenant/changes.json +6 -0
  203. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  204. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  205. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  206. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  207. package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
  208. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  209. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  210. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  211. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  212. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  213. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  214. package/packages/bundled-features/src/user/feature.ts +1 -0
  215. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  216. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  217. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  218. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  219. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  220. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  221. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  222. package/packages/bundled-features/src/waitlist/changes.json +36 -0
  223. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  224. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  225. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
  226. package/packages/bundled-features/src/waitlist/import.ts +28 -3
  227. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
  228. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  229. package/packages/bundled-features/src/waitlist/options.ts +5 -1
  230. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  231. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  232. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  233. package/samples/apps/styleguide/src/app/server.ts +4 -1
  234. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  235. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  236. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  237. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  238. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  239. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  240. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  241. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  242. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  243. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  244. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  245. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  246. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  247. package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
  248. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  249. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  250. package/samples/apps/use-all-bundled/package.json +2 -1
  251. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  252. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  253. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  254. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  255. package/samples/recipes/auth-mfa/README.md +5 -0
  256. package/samples/recipes/auth-mfa/package.json +5 -2
  257. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  258. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  259. package/samples/recipes/env-composition/README.md +3 -1
  260. package/samples/recipes/env-composition/src/env.ts +2 -0
  261. package/samples/recipes/golden-path/src/feature.ts +2 -1
  262. package/samples/recipes/legal-pages/README.md +1 -1
  263. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  264. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  265. package/samples/recipes/list-table-options/README.md +50 -0
  266. package/samples/recipes/list-table-options/package.json +11 -0
  267. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  268. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  269. package/samples/recipes/rate-limiting/README.md +18 -0
  270. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  271. package/samples/recipes/state-machine/src/events.ts +3 -2
  272. package/samples/recipes/tenant-isolation/README.md +1 -0
  273. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
  274. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -163,6 +163,35 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<StageRunOut
163
163
  return total.outcome();
164
164
  }
165
165
 
166
+ // A user key outlives the tenant while the user still belongs to another live tenant:
167
+ // erasing it would tombstone the key and break every later PII write for that user.
168
+ async function userIdsWithoutOtherLiveTenant(
169
+ ctx: DestructionStageCtx,
170
+ userIds: readonly string[],
171
+ ): Promise<readonly string[]> {
172
+ // skip: no members, nothing to look up.
173
+ if (userIds.length === 0) return [];
174
+ const otherMemberships = (
175
+ await selectMany<{ userId: string; tenantId: string }>(ctx.db, tenantMembershipsTable, {
176
+ userId: { in: [...userIds] },
177
+ })
178
+ ).filter((membership) => membership.tenantId !== ctx.tenantId);
179
+ // skip: every member belongs to this tenant only.
180
+ if (otherMemberships.length === 0) return userIds;
181
+ const otherTenants = await selectMany<{ id: string; status: string }>(ctx.db, tenantTable, {
182
+ id: { in: [...new Set(otherMemberships.map((membership) => membership.tenantId))] },
183
+ });
184
+ const liveTenantIds = new Set(
185
+ otherTenants.filter((tenant) => tenant.status !== "destroyed").map((tenant) => tenant.id),
186
+ );
187
+ const retainedUserIds = new Set(
188
+ otherMemberships
189
+ .filter((membership) => liveTenantIds.has(membership.tenantId))
190
+ .map((membership) => membership.userId),
191
+ );
192
+ return userIds.filter((userId) => !retainedUserIds.has(userId));
193
+ }
194
+
166
195
  async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
167
196
  const kms = configuredPiiSubjectKms();
168
197
  if (!kms) {
@@ -177,7 +206,12 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutco
177
206
  const subjects: SubjectId[] = [
178
207
  { kind: "tenant", tenantId: ctx.tenantId },
179
208
  tenantRecordSubject,
180
- ...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
209
+ ...(
210
+ await userIdsWithoutOtherLiveTenant(
211
+ ctx,
212
+ memberships.map((m) => m.userId),
213
+ )
214
+ ).map((userId) => ({ kind: "user" as const, userId })),
181
215
  ];
182
216
  for (const subject of subjects) {
183
217
  await kms.eraseKey(subject, {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "set-tenant-tier no longer accepts source default",
6
+ "detail": "The source field of set-tenant-tier is limited to manual and billing. The value default is reserved for the signup default tier and made a manual grant look like a billing row that the next sync overwrites."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "breaking",
@@ -59,7 +59,10 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
59
59
  .min(1)
60
60
  .max(50)
61
61
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
62
- source: z.enum(TierAssignmentSources).default(TierAssignmentSources.manual),
62
+ // `default` is reserved for the signup default tier; the billing sync treats it like `billing`.
63
+ source: z
64
+ .enum([TierAssignmentSources.manual, TierAssignmentSources.billing])
65
+ .default(TierAssignmentSources.manual),
63
66
  }),
64
67
  access: { roles: ["SystemAdmin"] },
65
68
  agent: { risk: "high" },
@@ -3,6 +3,8 @@
3
3
  // category in the kumiko isolation model (runtime may import client, client
4
4
  // may only import client) — otherwise the runtime isolation guard throws on
5
5
  // the client imports. Same pattern as text-content/constants.
6
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
7
+
6
8
  // Feature name
7
9
  export const TIER_ENGINE_FEATURE = "tier-engine" as const;
8
10
 
@@ -24,10 +26,8 @@ export const TierAssignmentSources = {
24
26
  export type TierAssignmentSource =
25
27
  (typeof TierAssignmentSources)[keyof typeof TierAssignmentSources];
26
28
 
27
- const TIER_ASSIGNMENT_SOURCE_VALUES: readonly string[] = Object.values(TierAssignmentSources);
28
-
29
29
  export function isTierAssignmentSource(value: unknown): value is TierAssignmentSource {
30
- return typeof value === "string" && TIER_ASSIGNMENT_SOURCE_VALUES.includes(value);
30
+ return isOneOf(Object.values(TierAssignmentSources), value);
31
31
  }
32
32
 
33
33
  // Qualified query handler names.
@@ -19,6 +19,7 @@ import { userEditScreen, userListScreen } from "./screens.ts";
19
19
  // Membership + tenant-specific roles live in the tenant feature.
20
20
  export function createUserFeature(): FeatureDefinition {
21
21
  return defineFeature("user", (r) => {
22
+ r.adoptsBundledLists();
22
23
  r.describe(
23
24
  "Manages the cross-tenant user identity: the `read_users` table holds each user's email, `displayName`, global `roles`, `emailVerified` flag, and lifecycle `status` (active / restricted / deletionRequested / deleted). Because users exist above any individual tenant, the feature runs with `r.systemScope()` \u2014 membership and tenant-specific roles live in the `tenant` feature instead. Add this feature whenever your app needs a persistent, tenant-agnostic user record that auth and GDPR pipelines can reference.",
24
25
  );
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "USER_DATA_RIGHTS_FEATURE is exported from the barrel",
6
+ "detail": "The feature name constant that lived in web/constants.ts is now part of the public barrel."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "improvement",
11
+ "title": "Complete, exported handler name constants",
12
+ "detail": "UserDataRightsHandlers now covers all eight write handlers and UserDataRightsQueries all five explicit query handlers, and both are exported from user-data-rights and user-data-rights/web. A test compares them with the feature's registered handlers."
13
+ },
2
14
  {
3
15
  "version": "0.384.0",
4
16
  "type": "fix",
@@ -188,6 +188,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
188
188
  let warnedMissingMailDelivery = false;
189
189
  const showDeletion = opts.privacyCenterShowDeletion ?? true;
190
190
  return defineFeature("user-data-rights", (r) => {
191
+ r.adoptsBundledLists();
191
192
  r.describe(
192
193
  'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. When `mail-foundation` and a `mail-transport-*` are mounted, it also sends the four GDPR notifications (export ready/failed, deletion requested/executed) itself with no app callback code, rendered in each recipient’s locale. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
193
194
  );
@@ -31,4 +31,9 @@ export {
31
31
  exportJobEntity,
32
32
  exportJobsTable,
33
33
  } from "./schema/export-job.ts";
34
- export { TENANT_MODEL_CONFIG_KEY } from "./web/constants.ts";
34
+ export {
35
+ TENANT_MODEL_CONFIG_KEY,
36
+ USER_DATA_RIGHTS_FEATURE,
37
+ UserDataRightsHandlers,
38
+ UserDataRightsQueries,
39
+ } from "./web/constants.ts";
@@ -21,7 +21,6 @@ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
21
21
  // storage indefinitely.
22
22
 
23
23
  import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
24
- import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
25
24
  import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
26
25
  import {
27
26
  createEventStoreExecutor,
@@ -44,7 +43,7 @@ import {
44
43
  import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
45
44
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
46
45
  import { resolveProfileForTenant } from "../compliance-profiles/index.ts";
47
- import { decryptStoredPii } from "../shared/index.ts";
46
+ import { decryptStoredUserEmail } from "../shared/index.ts";
48
47
  import { userTable } from "../user/index.ts";
49
48
  import { selectExportJobsForStorageCleanup } from "./db/queries/export-jobs.ts";
50
49
  import { runUserExport, type UserExportBundle } from "./run-user-export.ts";
@@ -822,8 +821,8 @@ async function lookupUserContact(
822
821
  locale: string | null;
823
822
  } | null;
824
823
  if (!row?.email) return null;
825
- const email = await decryptStoredPii(row.email, "email", "user-data-rights:export-ready");
826
- if (email === PII_ERASED_SENTINEL) return null;
824
+ const email = await decryptStoredUserEmail(row.email, "user-data-rights:export-ready");
825
+ if (email === null) return null;
827
826
  return { email, locale: row.locale ?? null };
828
827
  }
829
828
 
@@ -518,7 +518,8 @@ async function processUser(args: {
518
518
  // is what makes this crash-safe: eraseKey-throw → tx rollback → user
519
519
  // stays DeletionRequested → next run retries; flip-throw after a
520
520
  // successful erase → retry re-runs the idempotent hooks and eraseKey
521
- // (contractually a no-op on an erased/unknown subject). Erasing after
521
+ // (idempotent: a repeat erase keeps the original tombstone, an unknown
522
+ // subject gets one). Erasing after
522
523
  // the flip instead would risk a permanent silent miss: user already
523
524
  // Deleted, no run ever picks him up again, key stays live.
524
525
  if (kms) {
@@ -27,6 +27,8 @@ export const UserDataRightsQueries = {
27
27
  exportStatus: "user-data-rights:query:export-status",
28
28
  myAuditLog: "user-data-rights:query:my-audit-log",
29
29
  downloadByJob: "user-data-rights:query:download-by-job",
30
+ downloadByToken: "user-data-rights:query:download-by-token",
31
+ listDownloadAttempts: "user-data-rights:query:list-download-attempts",
30
32
  } as const;
31
33
 
32
34
  // App-level config: tenant-occupancy model. "single-user" tells the forget
@@ -39,8 +41,12 @@ export const TENANT_MODEL_CONFIG_KEY = "user-data-rights:config:tenant-model" as
39
41
  export const UserDataRightsHandlers = {
40
42
  requestExport: "user-data-rights:write:request-export",
41
43
  requestDeletion: "user-data-rights:write:request-deletion",
44
+ requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email",
45
+ confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token",
42
46
  cancelDeletion: "user-data-rights:write:cancel-deletion",
43
47
  restrictAccount: "user-data-rights:write:restrict-account",
48
+ liftRestriction: "user-data-rights:write:lift-restriction",
49
+ runForgetCleanup: "user-data-rights:write:run-forget-cleanup",
44
50
  } as const;
45
51
 
46
52
  // Foreign QN: the lifecycle status (active / deletionRequested / restricted)
@@ -7,6 +7,7 @@
7
7
  export { type UserDataRightsClientOptions, userDataRightsClient } from "./client-plugin.tsx";
8
8
  export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen.tsx";
9
9
  export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.tsx";
10
+ export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.ts";
10
11
  export { defaultTranslations } from "./default-translations.ts";
11
12
  export { formatDate } from "./privacy-center-screen.tsx";
12
13
  export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate.tsx";
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "A failing notifyRecipient SQL statement no longer aborts the submit",
6
+ "detail": "The resolver receives a ctx whose systemDb runs in a savepoint, so a failed statement is rolled back and the submit answers 200 with the entry stored. Only ctx.systemDb is savepoint-scoped; other ctx handles stay in the handler transaction."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "improvement",
11
+ "title": "waitlist purge retry after a failed shred is covered",
12
+ "detail": "An integration test fails the second key shred of a purge, checks that the entries stay and no audit event is written, and that the retried purge erases every entry and records its audit event. No runtime change."
13
+ },
14
+ {
15
+ "version": "0.389.0",
16
+ "type": "fix",
17
+ "title": "Waitlist import enforces the live field limits",
18
+ "detail": "migrateLegacyWaitlist counts a source row with a value above the live field limit as unreadable instead of importing it; importWaitlistEntry throws for it."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "Link-on-signup failures, legacy re-run and purge audit order",
24
+ "detail": "link-on-signup throws when the entry update fails and the entry is still invited, so the consumer redelivers. The offlot legacy migration skips rows that are already imports. purge writes its audit event before shredding keys."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Admin notice language comes from adminLocale",
30
+ "detail": "The admin notice mail used the request locale, which is the entrant's browser language. The new adminLocale option (en, de or es, default en) sets the operator's language."
31
+ },
32
+ {
33
+ "version": "0.388.0",
34
+ "type": "fix",
35
+ "title": "Legacy migration dry run counts the streams it would archive",
36
+ "detail": "The dry run now runs the read-only archive checks and reports archivedStreams without writing; the CLI prints \"would archive\" for it."
37
+ },
2
38
  {
3
39
  "version": "0.379.0",
4
40
  "type": "improvement",
@@ -32,6 +32,7 @@ import {
32
32
 
33
33
  export function createWaitlistFeature(opts: WaitlistOptions = {}): FeatureDefinition {
34
34
  return defineFeature(WAITLIST_FEATURE, (r) => {
35
+ r.adoptsBundledLists();
35
36
  r.describe(
36
37
  "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
37
38
  );
@@ -74,8 +74,9 @@ export const purgeHandler = defineWriteHandler<"purge", typeof PurgeSchema, Wait
74
74
  }),
75
75
  );
76
76
  }
77
- await purgeWaitlistRows(db, rows, event.user.id);
77
+ // Audit first: purgeWaitlistRows shreds KMS keys, which a later failure cannot roll back.
78
78
  await appendAudit(ctx, WAITLIST_PURGED_EVENT_QN, rows.length);
79
+ await purgeWaitlistRows(db, rows, event.user.id);
79
80
  return { isSuccess: true, data: { purged: rows.length } };
80
81
  },
81
82
  });
@@ -1,8 +1,14 @@
1
- import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ acquireNamespacedAdvisoryLock,
3
+ createSystemDbView,
4
+ createTenantDb,
5
+ runInSavepointIfSupported,
6
+ } from "@cosmicdrift/kumiko-framework/db";
2
7
  import {
3
8
  access,
4
9
  defineWriteHandler,
5
10
  type HandlerContext,
11
+ type TenantId,
6
12
  type WriteResult,
7
13
  } from "@cosmicdrift/kumiko-framework/engine";
8
14
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
@@ -25,7 +31,7 @@ import { WAITLIST_NOTIFICATION_TYPES, WAITLIST_STATUS } from "../web/constants.t
25
31
  // framework's other fixed advisory-lock keys.
26
32
  const SUBMIT_LOCK_NAMESPACE = 0x776c7374;
27
33
  const SUBMIT_LOCK_REASON =
28
- "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check; TenantDb has no lock API";
34
+ "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check and wraps the admin-recipient resolver in a savepoint so a failing statement cannot abort the submit transaction; TenantDb has neither API";
29
35
 
30
36
  // Logged-in callers carry no anonymous role, so every signed-in role must be listed.
31
37
  const SUBMIT_ROLES = access.roles(
@@ -73,15 +79,24 @@ async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise
73
79
  return found.rows.length > 0;
74
80
  }
75
81
 
76
- // The resolver usually reads config; run it outside the handler transaction's
77
- // critical section so a failing statement cannot abort the transaction that holds
78
- // the entry, and a slow resolver does not extend the per-email advisory lock.
82
+ // The resolver usually reads config. It runs before the per-email advisory lock so a slow
83
+ // resolver does not extend the lock. Postgres aborts the whole transaction after a failed
84
+ // statement, even one whose JS error is caught here, so the resolver gets a ctx whose
85
+ // systemDb is bound to its own savepoint: a failing statement rolls back only that.
86
+ // Statements through any other ctx handle (ctx.query, ctx.write) stay in the handler transaction.
79
87
  async function resolveAdminRecipient(
80
88
  opts: WaitlistOptions,
81
89
  ctx: HandlerContext,
90
+ systemDb: NonNullable<HandlerContext["systemDb"]>,
91
+ tenantId: TenantId,
82
92
  ): Promise<string | null> {
83
93
  try {
84
- return (await opts.notifyRecipient?.(ctx)) || null;
94
+ return await runInSavepointIfSupported(systemDb.unsafeRaw(SUBMIT_LOCK_REASON), async (sp) => {
95
+ const savepointSystemDb = createSystemDbView(
96
+ createTenantDb({ db: sp, tenantId, mode: "system" }),
97
+ );
98
+ return (await opts.notifyRecipient?.({ ...ctx, systemDb: savepointSystemDb })) || null;
99
+ });
85
100
  } catch (err) {
86
101
  ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
87
102
  error: err instanceof Error ? err.message : String(err),
@@ -129,7 +144,12 @@ export function createSubmitHandler(opts: WaitlistOptions) {
129
144
  message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
130
145
  });
131
146
  }
132
- const adminRecipient = await resolveAdminRecipient(opts, ctx);
147
+ const adminRecipient = await resolveAdminRecipient(
148
+ opts,
149
+ ctx,
150
+ ctx.systemDb,
151
+ event.user.tenantId,
152
+ );
133
153
  await acquireNamespacedAdvisoryLock(
134
154
  ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON),
135
155
  SUBMIT_LOCK_NAMESPACE,
@@ -175,8 +195,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
175
195
  message: payload.message,
176
196
  }),
177
197
  priority: "normal",
178
- // Operator-facing: the request locale, not the one the entrant picked in the form.
179
- locale: resolveWaitlistMailLocale(ctx.locale),
198
+ locale: resolveWaitlistMailLocale(opts.adminLocale),
180
199
  });
181
200
  });
182
201
 
@@ -3,7 +3,7 @@ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  import { normalizeEmail, platformActor, waitlistExecutor } from "./lib.ts";
6
- import type { WaitlistStatus } from "./web/constants.ts";
6
+ import { WAITLIST_FIELD_LIMITS, type WaitlistStatus } from "./web/constants.ts";
7
7
 
8
8
  export type WaitlistImportEntry = {
9
9
  /** Stable identity of the source row (e.g. `offlot:<uuid>`); the new entry id derives from it. */
@@ -38,18 +38,43 @@ export function waitlistImportId(sourceKey: string): string {
38
38
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
39
39
  }
40
40
 
41
+ // The version nibble marks ids minted by waitlistImportId; legacy row ids are random UUIDs.
42
+ export function isWaitlistImportId(id: string): boolean {
43
+ return id[14] === "5";
44
+ }
45
+
46
+ // The live submit schema caps these fields; an imported value above the cap would be
47
+ // stored but could never pass a later update through the same payload schema.
48
+ export function waitlistImportEntryWithinLimits(entry: WaitlistImportEntry): boolean {
49
+ const limited: ReadonlyArray<readonly [string | undefined, number]> = [
50
+ [entry.name, WAITLIST_FIELD_LIMITS.name],
51
+ [entry.email, WAITLIST_FIELD_LIMITS.email],
52
+ [entry.locale, WAITLIST_FIELD_LIMITS.locale],
53
+ [entry.company, WAITLIST_FIELD_LIMITS.company],
54
+ [entry.portfolio, WAITLIST_FIELD_LIMITS.portfolio],
55
+ [entry.message, WAITLIST_FIELD_LIMITS.message],
56
+ ];
57
+ return limited.every(([value, max]) => value === undefined || value.length <= max);
58
+ }
59
+
41
60
  function presentOnly(fields: Record<string, unknown>): Record<string, unknown> {
42
61
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
43
62
  }
44
63
 
45
- // Writes through the entity executor, so events, projection and subject encryption
46
- // behave exactly as for a live submit. No mail, no rate limit, no duplicate-by-email
64
+ // Writes through the entity executor, so events and projection match a live submit; subject
65
+ // encryption applies only when the caller configured a PII KMS (the CLI wires it from env
66
+ // and aborts without one). No mail, no rate limit, no duplicate-by-email
47
67
  // check: the source row is the truth and the id makes the call idempotent.
48
68
  export async function importWaitlistEntry(
49
69
  db: TenantDb,
50
70
  entry: WaitlistImportEntry,
51
71
  options: { readonly dryRun?: boolean } = {},
52
72
  ): Promise<WaitlistImportOutcome> {
73
+ if (!waitlistImportEntryWithinLimits(entry)) {
74
+ throw new InternalError({
75
+ message: `waitlist import entry ${entry.sourceKey} exceeds a field length limit`,
76
+ });
77
+ }
53
78
  const id = waitlistImportId(entry.sourceKey);
54
79
  const existing = await waitlistExecutor.detail({ id }, platformActor(), db);
55
80
  if (existing) return "skipped";
@@ -9,6 +9,7 @@ import {
9
9
  createTenantDb,
10
10
  selectMany,
11
11
  table,
12
+ tableExists,
12
13
  text as textColumn,
13
14
  timestamp,
14
15
  transaction,
@@ -23,9 +24,17 @@ import {
23
24
  } from "@cosmicdrift/kumiko-framework/event-store";
24
25
  import type { DbConnection } from "@cosmicdrift/kumiko-types/db-connection";
25
26
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
26
- import { importWaitlistEntry, type WaitlistImportEntry, waitlistImportId } from "./import.ts";
27
+ import {
28
+ importWaitlistEntry,
29
+ isWaitlistImportId,
30
+ type WaitlistImportEntry,
31
+ waitlistImportEntryWithinLimits,
32
+ waitlistImportId,
33
+ } from "./import.ts";
27
34
  import { WAITLIST_STATUS, type WaitlistStatus } from "./web/constants.ts";
28
35
 
36
+ const ARCHIVED_STREAMS_TABLE = "public.kumiko_archived_streams";
37
+
29
38
  export const LEGACY_WAITLIST_SOURCES = ["offlot", "publicstatus"] as const;
30
39
  export type LegacyWaitlistSource = (typeof LEGACY_WAITLIST_SOURCES)[number];
31
40
 
@@ -210,15 +219,19 @@ async function archiveLegacyStream(
210
219
  db: DbConnection,
211
220
  legacyId: string,
212
221
  importedId: string,
222
+ dryRun: boolean,
213
223
  ): Promise<number> {
214
224
  if (legacyId === importedId) return 0;
215
- if (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId)) return 0;
225
+ // A dry run never creates the archive table, so a missing one means nothing is archived yet.
226
+ const archiveTableReady = !dryRun || (await tableExists(db, ARCHIVED_STREAMS_TABLE));
227
+ if (archiveTableReady && (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId))) return 0;
216
228
  const legacyEvents = await countWhere(db, eventsTable, {
217
229
  tenantId: SYSTEM_TENANT_ID,
218
230
  aggregateId: legacyId,
219
231
  aggregateType: LEGACY_AGGREGATE_TYPE,
220
232
  });
221
233
  if (legacyEvents === 0) return 0;
234
+ if (dryRun) return 1;
222
235
  await archiveStream(db, {
223
236
  tenantId: SYSTEM_TENANT_ID,
224
237
  aggregateId: legacyId,
@@ -243,11 +256,17 @@ export async function migrateLegacyWaitlist(
243
256
  let archivedStreams = 0;
244
257
  if (!options.dryRun && options.from === "offlot") await createArchivedStreamsTable(db);
245
258
  for (const row of rows) {
259
+ // The offlot default source is the entity table: rows a previous run imported
260
+ // are already migrated entries and must neither be re-imported nor archived.
261
+ if (options.from === "offlot" && isWaitlistImportId(String(row["id"]))) {
262
+ skipped += 1;
263
+ continue;
264
+ }
246
265
  const entry =
247
266
  options.from === "offlot"
248
267
  ? await mapOfflotRow(row)
249
268
  : mapPublicstatusRow(row, options.defaultLocale);
250
- if (!entry) {
269
+ if (!entry || !waitlistImportEntryWithinLimits(entry)) {
251
270
  unreadable += 1;
252
271
  continue;
253
272
  }
@@ -257,11 +276,12 @@ export async function migrateLegacyWaitlist(
257
276
  if (outcome === "created") created += 1;
258
277
  else skipped += 1;
259
278
  });
260
- if (!options.dryRun && options.from === "offlot") {
279
+ if (options.from === "offlot") {
261
280
  archivedStreams += await archiveLegacyStream(
262
281
  db,
263
282
  String(row["id"]),
264
283
  waitlistImportId(entry.sourceKey),
284
+ options.dryRun,
265
285
  );
266
286
  }
267
287
  }
@@ -75,22 +75,25 @@ export const linkWaitlistOnSignup: MultiStreamApplyFn = async (event, tx) => {
75
75
  if (typeof user?.email !== "string") return;
76
76
  const email = normalizeEmail(await decryptStoredPii(user.email, "email", PII_DECRYPT_REASON));
77
77
 
78
- const found = await waitlistExecutor.list(
79
- {
80
- filter: { field: "email", op: "eq", value: email },
81
- filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
82
- },
83
- platformActor(),
84
- db,
85
- );
86
- const invited = [...found.rows].sort(newestSubmittedFirst)[0];
78
+ const findInvited = async () => {
79
+ const found = await waitlistExecutor.list(
80
+ {
81
+ filter: { field: "email", op: "eq", value: email },
82
+ filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
83
+ },
84
+ platformActor(),
85
+ db,
86
+ );
87
+ return [...found.rows].sort(newestSubmittedFirst)[0];
88
+ };
89
+ const invited = await findInvited();
87
90
  // skip: the signup email has no invited waitlist entry (open signup or stale invite).
88
91
  if (!invited) return;
89
92
  const { id, version } = invited;
90
93
  // skip: defensive guard on the row shape.
91
94
  if (typeof id !== "string" || typeof version !== "number") return;
92
95
 
93
- await waitlistExecutor.update(
96
+ const updated = await waitlistExecutor.update(
94
97
  {
95
98
  id,
96
99
  version,
@@ -103,4 +106,11 @@ export const linkWaitlistOnSignup: MultiStreamApplyFn = async (event, tx) => {
103
106
  platformActor(),
104
107
  db,
105
108
  );
109
+ // skip: linked.
110
+ if (updated.isSuccess) return;
111
+ // A concurrent admin change that already moved the entry out of `invited` makes the link
112
+ // moot; otherwise throw so the at-least-once consumer redelivers.
113
+ // skip: the entry left `invited` concurrently.
114
+ if ((await findInvited())?.id !== id) return;
115
+ throw new Error(`waitlist:link-on-signup: could not link waitlist entry ${id}`);
106
116
  };
@@ -22,8 +22,12 @@ export type WaitlistInviteOptions =
22
22
  export type WaitlistOptions = {
23
23
  /** Address for an admin notification mail on each new entry. Null, an empty
24
24
  * string or an absent option sends none. Best effort, never fails the submit,
25
- * also when the resolver throws or rejects. */
25
+ * also when the resolver throws or rejects. `ctx.systemDb` is bound to a savepoint, so a
26
+ * failing statement through it is rolled back without aborting the submit transaction. */
26
27
  readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
28
+ /** Language of the admin notice mail (en, de or es; anything else falls back to en).
29
+ * Not taken from the request: a submit comes from the entrant, not the operator. */
30
+ readonly adminLocale?: string;
27
31
  /** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
28
32
  readonly invite?: WaitlistInviteOptions;
29
33
  /** Default: 5 submits per IP per 10 minutes. Pass `{ disabled: true, reason }`
@@ -229,7 +229,7 @@ function readTriggerSource(startedPayload: unknown): TriggerSource | null {
229
229
  }
230
230
 
231
231
  async function loadReferencedEvent(ctx: HandlerContext, ref: TriggerEventRef) {
232
- const events = await ctx.loadAggregate(ref.aggregateId);
232
+ const events = await ctx.loadAggregate(ref.aggregateId, { atVersion: ref.version });
233
233
  return events.find((e) => e.id === ref.eventId);
234
234
  }
235
235
 
@@ -7,6 +7,7 @@ import { styleguideClient } from "../features/demo/web";
7
7
  import { examplesClient } from "../features/examples/web";
8
8
  import { galleryClient } from "../features/gallery/web";
9
9
  import { widgetsClient } from "../features/widgets/web";
10
+ import { workboardClient } from "../features/workboard/web";
10
11
  import { AppShell } from "./shell";
11
12
 
12
13
  // createKumikoApp fetches the schema itself from the authenticated
@@ -24,5 +25,6 @@ createKumikoApp({
24
25
  widgetsClient,
25
26
  examplesClient,
26
27
  contentClient(),
28
+ workboardClient,
27
29
  ],
28
30
  });
@@ -1,5 +1,5 @@
1
1
  import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
2
- import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { buildEntityTable, createTenantDb, fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
5
5
 
@@ -8,9 +8,6 @@ import { demoEntity } from "../features/demo/feature";
8
8
  // Gibt der List-View echte Rows mit Status-/Flag-Vielfalt. price (money)
9
9
  // bleibt leer — der Money-Input zeigt sich im Edit-Screen, im Seed lassen wir
10
10
  // ihn weg um nicht von der money-Payload-Form abzuhängen.
11
- // ponytail: kein Idempotenz-Check — die Screenshot-DB ist ephemer (frisch pro
12
- // Boot). Beim `bun run dev` gegen die persistente Dev-DB dupliziert ein
13
- // Neustart die Rows; das ist hier nur eine Demo, daher egal.
14
11
  const STATUSES = ["draft", "review", "published", "archived"] as const;
15
12
 
16
13
  // Fixed across boots (unlike the framework-minted ids for every other seed
@@ -19,7 +16,12 @@ const STATUSES = ["draft", "review", "published", "archived"] as const;
19
16
  // otherwise change every run.
20
17
  export const FIRST_STYLEGUIDE_ITEM_ID = "00000000-0000-4000-8000-0000000000f1";
21
18
 
19
+ const demoItemTable = buildEntityTable("item", demoEntity);
20
+
22
21
  export const seedStyleguideItems: SeedFn = async (stack) => {
22
+ // The first row has a fixed id, so its presence means a persistent dev DB was seeded already.
23
+ if (await fetchOne(stack.db, demoItemTable, { id: FIRST_STYLEGUIDE_ITEM_ID })) return;
24
+
23
25
  // Bypass the write-handler HTTP path for the first row — its schema has
24
26
  // no `id` field (buildInsertSchema only exposes entity fields), so an
25
27
  // explicit id would be silently stripped before reaching the executor.
@@ -20,6 +20,8 @@ import { examplesFeature } from "../features/examples/feature";
20
20
  import { galleryFeature } from "../features/gallery/feature";
21
21
  import { aiTextDemoFeature } from "../features/widgets/ai-text-demo";
22
22
  import { widgetsFeature } from "../features/widgets/feature";
23
+ import { workboardFeature } from "../features/workboard/feature";
24
+ import { seedWorkboardTasks } from "../features/workboard/seed";
23
25
  import { seedStyleguideItems } from "./seed";
24
26
 
25
27
  const configResolver = createConfigResolver();
@@ -35,6 +37,7 @@ await runDevApp({
35
37
  contentFeature,
36
38
  aiTextDemoFeature,
37
39
  aiSettingsFeature,
40
+ workboardFeature,
38
41
  ],
39
42
  // Aktiviert die Upload-Routes (/api/files) + ctx.files für den Avatar-
40
43
  // Stresstest. In-Memory reicht für die Demo (nicht-persistent).
@@ -43,7 +46,7 @@ await runDevApp({
43
46
  clientEntry: "./src/app/client.tsx",
44
47
  htmlPath: "./public/index.html",
45
48
  watchDirs: ["./src", "../../../packages/*/src"],
46
- seeds: [seedStyleguideItems, seedAiConnections],
49
+ seeds: [seedStyleguideItems, seedAiConnections, seedWorkboardTasks],
47
50
  extraContext: ({ registry }) => ({
48
51
  configResolver,
49
52
  _configAccessorFactory: createConfigAccessorFactory(registry, configResolver),