@cosmicdrift/kumiko-bundled-features 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 (320) hide show
  1. package/dist/agent-tools/index.d.ts +1 -1
  2. package/dist/agent-tools/index.js +1 -1
  3. package/dist/agent-tools/tool-dispatch.d.ts +12 -7
  4. package/dist/agent-tools/tool-dispatch.js +42 -31
  5. package/dist/audit/feature.js +1 -0
  6. package/dist/audit/handlers/audit-events.js +1 -1
  7. package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
  8. package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
  9. package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
  10. package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
  11. package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
  12. package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
  13. package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
  14. package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
  15. package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
  16. package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
  17. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  18. package/dist/auth-email-password/magic-link-mail.js +7 -3
  19. package/dist/auth-email-password/signup-activation.d.ts +1 -0
  20. package/dist/auth-email-password/signup-activation.js +1 -0
  21. package/dist/auth-email-password/web/auth-client.d.ts +2 -2
  22. package/dist/auth-email-password/web/auth-client.js +8 -4
  23. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
  24. package/dist/auth-email-password/web/auth-redirect.js +20 -25
  25. package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
  26. package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
  27. package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
  28. package/dist/auth-email-password/web/index.d.ts +1 -0
  29. package/dist/auth-email-password/web/index.js +1 -0
  30. package/dist/auth-email-password/web/login-screen.js +11 -6
  31. package/dist/auth-email-password/web/reset-password-screen.js +7 -1
  32. package/dist/auth-email-password/web/return-path.d.ts +4 -0
  33. package/dist/auth-email-password/web/return-path.js +34 -0
  34. package/dist/auth-email-password/web/session.js +6 -1
  35. package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
  36. package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
  37. package/dist/auth-email-password/web/signup-screen.js +8 -3
  38. package/dist/auth-mfa/config.d.ts +1 -1
  39. package/dist/auth-mfa/config.js +3 -7
  40. package/dist/auth-mfa/feature-exports.d.ts +2 -2
  41. package/dist/auth-mfa/feature.js +8 -0
  42. package/dist/auth-mfa/mfa-status-checker.js +5 -3
  43. package/dist/auth-mfa/web/i18n.js +5 -1
  44. package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
  45. package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
  46. package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
  47. package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
  48. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
  49. package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
  50. package/dist/billing-foundation/checkout-core.d.ts +9 -14
  51. package/dist/billing-foundation/checkout-core.js +24 -39
  52. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
  53. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
  54. package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
  55. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
  56. package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
  57. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
  58. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
  59. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
  60. package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
  61. package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
  62. package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
  63. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
  64. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
  65. package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
  66. package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
  67. package/dist/billing-foundation/events.d.ts +1 -0
  68. package/dist/billing-foundation/events.js +1 -0
  69. package/dist/billing-foundation/feature.js +3 -1
  70. package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
  71. package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
  72. package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
  73. package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
  74. package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
  75. package/dist/billing-foundation/handlers/process-event.write.js +8 -2
  76. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
  77. package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
  78. package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
  79. package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
  80. package/dist/billing-foundation/i18n.js +22 -0
  81. package/dist/billing-foundation/index.d.ts +1 -1
  82. package/dist/billing-foundation/plan-catalog.d.ts +21 -5
  83. package/dist/billing-foundation/plan-catalog.js +25 -16
  84. package/dist/billing-foundation/plan-switch.d.ts +12 -0
  85. package/dist/billing-foundation/plan-switch.js +60 -0
  86. package/dist/billing-foundation/types.d.ts +57 -1
  87. package/dist/billing-foundation/types.js +9 -0
  88. package/dist/billing-foundation/validate-options.js +17 -0
  89. package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
  90. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
  91. package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
  92. package/dist/billing-foundation/web/constants.d.ts +4 -0
  93. package/dist/billing-foundation/web/constants.js +4 -0
  94. package/dist/billing-foundation/web/plan-price.d.ts +15 -0
  95. package/dist/billing-foundation/web/plan-price.js +32 -0
  96. package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
  97. package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
  98. package/dist/cap-counter/cap-reservation.js +20 -7
  99. package/dist/cap-counter/enforce-cap.js +2 -1
  100. package/dist/cap-counter/feature.js +1 -0
  101. package/dist/cap-counter/stock-cap-guard.js +7 -1
  102. package/dist/cap-overview/feature.js +1 -0
  103. package/dist/channel-email/feature.js +1 -1
  104. package/dist/config/handlers/cascade.query.d.ts +5 -0
  105. package/dist/config/handlers/cascade.query.js +4 -3
  106. package/dist/config/handlers/set.write.js +12 -2
  107. package/dist/config/handlers/values.query.d.ts +5 -0
  108. package/dist/config/handlers/values.query.js +4 -3
  109. package/dist/config/write-helpers.d.ts +1 -0
  110. package/dist/config/write-helpers.js +18 -9
  111. package/dist/crypto-shredding/handlers/forget-subject.write.js +14 -11
  112. package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
  113. package/dist/delivery/channel-context.js +6 -3
  114. package/dist/delivery/db/queries/preferences.js +2 -2
  115. package/dist/delivery/delivery-service.js +22 -7
  116. package/dist/delivery/feature.js +1 -0
  117. package/dist/delivery/handlers/preferences.query.d.ts +1 -1
  118. package/dist/delivery/handlers/preferences.query.js +20 -1
  119. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
  120. package/dist/delivery/index.d.ts +1 -0
  121. package/dist/delivery/index.js +1 -0
  122. package/dist/delivery/system-channel-context.d.ts +15 -0
  123. package/dist/delivery/system-channel-context.js +5 -0
  124. package/dist/delivery/unsubscribe-link.d.ts +3 -1
  125. package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
  126. package/dist/delivery/unsubscribe-page-strings.js +1 -0
  127. package/dist/delivery/unsubscribe-page.d.ts +2 -0
  128. package/dist/delivery/unsubscribe-page.js +5 -0
  129. package/dist/delivery/unsubscribe.js +25 -7
  130. package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
  131. package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
  132. package/dist/document-ingest-foundation/events.d.ts +11 -0
  133. package/dist/document-ingest-foundation/events.js +11 -0
  134. package/dist/document-ingest-foundation/index.d.ts +2 -1
  135. package/dist/document-ingest-foundation/index.js +2 -1
  136. package/dist/feature-toggles/feature.js +1 -0
  137. package/dist/file-foundation/index.d.ts +1 -1
  138. package/dist/file-foundation/index.js +1 -1
  139. package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
  140. package/dist/file-foundation/read-file-ref-content.js +6 -1
  141. package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
  142. package/dist/form-draft/db/queries/cleanup.js +2 -2
  143. package/dist/form-draft/decrypt-draft.js +5 -1
  144. package/dist/host-routing/dispatch.js +11 -20
  145. package/dist/host-routing/validate-options.js +3 -0
  146. package/dist/inbound-mail-foundation/feature.js +1 -0
  147. package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
  148. package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
  149. package/dist/inbound-mail-foundation/i18n.js +0 -1
  150. package/dist/inbound-mail-foundation/screens.js +0 -3
  151. package/dist/inbound-mail-foundation/types.d.ts +4 -3
  152. package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
  153. package/dist/jobs/feature.d.ts +1 -0
  154. package/dist/jobs/feature.js +9 -4
  155. package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
  156. package/dist/jobs/handlers/tenant-failures.query.js +45 -41
  157. package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
  158. package/dist/jobs/handlers/tenant-runs.query.js +54 -50
  159. package/dist/jobs/job-run-logger.js +5 -4
  160. package/dist/ledger/web/index.js +1 -1
  161. package/dist/legal-pages/seed.d.ts +3 -1
  162. package/dist/legal-pages/seed.js +2 -2
  163. package/dist/mail-foundation/feature.d.ts +3 -3
  164. package/dist/mail-foundation/feature.js +3 -0
  165. package/dist/mail-transport-smtp/config-keys.js +1 -1
  166. package/dist/mail-transport-smtp/feature.js +6 -5
  167. package/dist/managed-pages/constants.d.ts +1 -0
  168. package/dist/managed-pages/constants.js +1 -0
  169. package/dist/managed-pages/feature.js +3 -1
  170. package/dist/managed-pages/index.d.ts +1 -0
  171. package/dist/managed-pages/index.js +1 -0
  172. package/dist/notes-history/handlers/add-note.write.js +1 -1
  173. package/dist/personal-access-tokens/feature.d.ts +1 -1
  174. package/dist/personal-access-tokens/feature.js +5 -0
  175. package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
  176. package/dist/renderer-pdf/render-table-pdf.js +22 -10
  177. package/dist/secrets/write-gate.js +8 -2
  178. package/dist/sessions/feature.d.ts +2 -2
  179. package/dist/sessions/feature.js +5 -0
  180. package/dist/sessions/index.d.ts +1 -1
  181. package/dist/sessions/index.js +1 -1
  182. package/dist/sessions/session-callbacks.d.ts +0 -2
  183. package/dist/sessions/session-callbacks.js +1 -3
  184. package/dist/shared/decrypt-stored-pii.d.ts +1 -0
  185. package/dist/shared/decrypt-stored-pii.js +6 -1
  186. package/dist/shared/index.d.ts +1 -1
  187. package/dist/shared/index.js +1 -1
  188. package/dist/shared/mail-template-body.d.ts +6 -2
  189. package/dist/shared/mail-template-body.js +7 -4
  190. package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
  191. package/dist/shared/purge-files-reaching-subject.js +19 -2
  192. package/dist/shared/run-in-sub-transaction.js +5 -5
  193. package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
  194. package/dist/step-dispatcher/feature.js +1 -0
  195. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  196. package/dist/subscription-dodo/client.d.ts +17 -0
  197. package/dist/subscription-dodo/client.js +31 -0
  198. package/dist/subscription-dodo/constants.d.ts +12 -0
  199. package/dist/subscription-dodo/constants.js +19 -0
  200. package/dist/subscription-dodo/feature.d.ts +37 -0
  201. package/dist/subscription-dodo/feature.js +144 -0
  202. package/dist/subscription-dodo/index.d.ts +2 -0
  203. package/dist/subscription-dodo/index.js +4 -0
  204. package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
  205. package/dist/subscription-dodo/plugin-methods.js +260 -0
  206. package/dist/subscription-dodo/runtime.d.ts +27 -0
  207. package/dist/subscription-dodo/runtime.js +89 -0
  208. package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
  209. package/dist/subscription-dodo/verify-webhook.js +189 -0
  210. package/dist/subscription-stripe/feature.js +1 -0
  211. package/dist/subscription-stripe/runtime.js +7 -6
  212. package/dist/tags/feature.js +1 -0
  213. package/dist/template-resolver/api.d.ts +3 -2
  214. package/dist/template-resolver/api.js +24 -2
  215. package/dist/template-resolver/compile-document-body.js +1 -0
  216. package/dist/template-resolver/document-ops.d.ts +5 -0
  217. package/dist/template-resolver/document-ops.js +18 -2
  218. package/dist/template-resolver/feature.js +4 -4
  219. package/dist/template-resolver/handlers/bundle-import.js +3 -3
  220. package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
  221. package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
  222. package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
  223. package/dist/template-resolver/handlers/document-base.js +3 -2
  224. package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
  225. package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
  226. package/dist/template-resolver/handlers/layout-source.query.js +26 -6
  227. package/dist/template-resolver/handlers/list.query.js +5 -6
  228. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  229. package/dist/template-resolver/handlers/overview.query.js +21 -10
  230. package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
  231. package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
  232. package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
  233. package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
  234. package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
  235. package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
  236. package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
  237. package/dist/template-resolver/handlers/usage.query.js +26 -16
  238. package/dist/template-resolver/handlers/validate.query.js +7 -2
  239. package/dist/template-resolver/seeding.d.ts +1 -0
  240. package/dist/template-resolver/seeding.js +31 -12
  241. package/dist/template-resolver/store-template-resource.d.ts +1 -0
  242. package/dist/template-resolver/store-template-resource.js +65 -10
  243. package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
  244. package/dist/template-resolver/template-delivery-policy.js +0 -6
  245. package/dist/template-resolver/template-meta.js +7 -4
  246. package/dist/template-resolver/template-preview.js +4 -3
  247. package/dist/template-resolver/web/constants.d.ts +2 -2
  248. package/dist/template-resolver/web/constants.js +3 -3
  249. package/dist/template-resolver/web/i18n.js +3 -0
  250. package/dist/template-resolver/web/index.d.ts +1 -0
  251. package/dist/template-resolver/web/index.js +1 -0
  252. package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
  253. package/dist/template-resolver/web/mail-layout-constants.js +7 -0
  254. package/dist/template-resolver/web/qualified-names.d.ts +1 -0
  255. package/dist/template-resolver/web/qualified-names.js +1 -0
  256. package/dist/tenant/feature.js +1 -0
  257. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  258. package/dist/tenant/handlers/member-directory.query.js +10 -3
  259. package/dist/tenant/handlers/team-list.query.js +3 -3
  260. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  261. package/dist/tenant/handlers/tenant-directory.query.js +8 -4
  262. package/dist/tenant-lifecycle/constants.d.ts +1 -0
  263. package/dist/tenant-lifecycle/constants.js +4 -0
  264. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  265. package/dist/tenant-lifecycle/stages.js +22 -1
  266. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
  267. package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
  268. package/dist/tier-engine/web/constants.js +2 -2
  269. package/dist/user/feature.js +1 -0
  270. package/dist/user-data-rights/feature.js +1 -0
  271. package/dist/user-data-rights/index.d.ts +1 -1
  272. package/dist/user-data-rights/index.js +1 -1
  273. package/dist/user-data-rights/run-export-jobs.js +3 -4
  274. package/dist/user-data-rights/run-forget-cleanup.js +2 -1
  275. package/dist/user-data-rights/web/constants.d.ts +6 -0
  276. package/dist/user-data-rights/web/constants.js +6 -0
  277. package/dist/user-data-rights/web/index.d.ts +1 -0
  278. package/dist/user-data-rights/web/index.js +1 -0
  279. package/dist/waitlist/feature.js +1 -0
  280. package/dist/waitlist/handlers/export-purge.write.js +2 -1
  281. package/dist/waitlist/handlers/submit.write.js +14 -10
  282. package/dist/waitlist/import.d.ts +3 -1
  283. package/dist/waitlist/import.js +26 -2
  284. package/dist/waitlist/legacy-migrate.js +18 -7
  285. package/dist/waitlist/link-on-signup.js +18 -6
  286. package/dist/waitlist/options.d.ts +5 -1
  287. package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
  288. package/package.json +19 -9
  289. package/src/agent-tools/changes.json +12 -0
  290. package/src/audit/changes.json +12 -0
  291. package/src/auth-email-password/changes.json +12 -0
  292. package/src/auth-mfa/changes.json +12 -0
  293. package/src/billing-foundation/changes.json +55 -0
  294. package/src/cap-counter/changes.json +30 -0
  295. package/src/channel-email/changes.json +6 -0
  296. package/src/config/changes.json +18 -0
  297. package/src/crypto-shredding/changes.json +18 -0
  298. package/src/delivery/changes.json +42 -0
  299. package/src/document-ingest-foundation/changes.json +6 -0
  300. package/src/form-draft/changes.json +6 -0
  301. package/src/host-routing/changes.json +12 -0
  302. package/src/inbound-mail-foundation/changes.json +18 -0
  303. package/src/jobs/changes.json +13 -0
  304. package/src/mail-foundation/changes.json +6 -0
  305. package/src/mail-transport-smtp/changes.json +6 -0
  306. package/src/managed-pages/changes.json +6 -0
  307. package/src/renderer-pdf/changes.json +6 -0
  308. package/src/secrets/changes.json +6 -0
  309. package/src/sessions/changes.json +7 -0
  310. package/src/step-dispatcher/changes.json +12 -0
  311. package/src/subscription-dodo/changes.json +14 -0
  312. package/src/subscription-stripe/changes.json +12 -0
  313. package/src/template-resolver/changes.json +84 -0
  314. package/src/tenant/changes.json +6 -0
  315. package/src/tenant-lifecycle/changes.json +12 -0
  316. package/src/tier-engine/changes.json +6 -0
  317. package/src/user-data-rights/changes.json +12 -0
  318. package/src/waitlist/changes.json +36 -0
  319. package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
  320. package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
@@ -40,6 +40,8 @@ export const memberDirectoryQuery = definePagedQueryHandler({
40
40
  limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
41
41
  // Sent by the reference combobox while the user types.
42
42
  search: z.string().trim().min(1).optional(),
43
+ // Resolves exactly these users; callers that know their ids must not depend on the cap.
44
+ ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
43
45
  }),
44
46
  access: { roles: access.admin },
45
47
  description: "Lists users as id/label pairs for resolving user references to display names: the caller's own tenant members for an admin, every user for a SystemAdmin; carries no email or role data.",
@@ -50,21 +52,26 @@ export const memberDirectoryQuery = definePagedQueryHandler({
50
52
  message: "tenant:query:member-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
51
53
  });
52
54
  }
53
- const { limit, search } = query.payload;
55
+ const { limit, search, ids } = query.payload;
56
+ if (ids?.length === 0)
57
+ return { rows: [], nextCursor: null };
54
58
  // Without a search the first `limit` rows are enough; with one, every
55
59
  // candidate must be decrypted before the limit can apply.
56
60
  const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
57
61
  let users;
58
62
  if (isSystemAdmin(query.user)) {
59
63
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as user:query:user:list did");
60
- users = await selectMany(db, userTable, undefined, {
64
+ users = await selectMany(db, userTable, ids === undefined ? undefined : { id: ids }, {
61
65
  limit: scanLimit,
62
66
  orderBy: { col: "id", direction: "asc" },
63
67
  });
64
68
  }
65
69
  else {
66
70
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
67
- const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, scanLimit);
71
+ const requestedIds = ids === undefined ? undefined : new Set(ids);
72
+ const userIds = (await loadMemberUserIds(db, query.user.tenantId))
73
+ .filter((id) => requestedIds === undefined || requestedIds.has(id))
74
+ .slice(0, scanLimit);
68
75
  users =
69
76
  userIds.length > 0
70
77
  ? await selectMany(db, userTable, { id: [...userIds] }, {
@@ -1,9 +1,9 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/db";
1
+ import { isUndefinedTable, selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
- import { errorCodeOf, isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
6
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
7
7
  import * as z from "zod";
8
8
  import { userSessionTable } from "../../sessions/index.js";
9
9
  import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
@@ -110,7 +110,7 @@ export const teamListQuery = definePagedQueryHandler({
110
110
  // ponytail: lean sample apps (admin-console) mount auth without
111
111
  // sessions — store_user_sessions never gets pushed. Integration tests
112
112
  // may seed the table without mounting the feature; prod mounts both.
113
- if (errorCodeOf(err) !== "42P01")
113
+ if (!isUndefinedTable(err))
114
114
  throw err;
115
115
  }
116
116
  }
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  export declare const tenantDirectoryQuery: import("@cosmicdrift/kumiko-framework/engine").PagedQueryHandlerDefinition<"tenantDirectory", z.ZodObject<{
3
3
  limit: z.ZodDefault<z.ZodNumber>;
4
4
  search: z.ZodOptional<z.ZodString>;
5
+ ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
5
6
  }, z.core.$strip>, {
6
7
  id: string;
7
8
  label: string;
@@ -21,9 +21,11 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
21
21
  limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
22
22
  // Sent by the reference combobox while the user types.
23
23
  search: z.string().trim().min(1).optional(),
24
+ // Resolves exactly these tenants; callers that know their ids must not depend on the cap.
25
+ ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
24
26
  }),
25
27
  access: { roles: access.admin },
26
- description: "Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin.",
28
+ description: "Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin. A search returns the first `limit` matches in tenant-key order (sorted by label afterwards), not the alphabetically best matches.",
27
29
  agent: { expose: false },
28
30
  handler: async (query, ctx) => {
29
31
  if (!ctx.systemDb) {
@@ -31,12 +33,14 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
31
33
  message: "tenant:query:tenant-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
32
34
  });
33
35
  }
34
- const { limit, search } = query.payload;
36
+ const { limit, search, ids } = query.payload;
37
+ if (ids?.length === 0)
38
+ return { rows: [], nextCursor: null };
35
39
  const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
36
40
  let tenants;
37
41
  if (isSystemAdmin(query.user)) {
38
42
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did");
39
- tenants = await selectMany(db, tenantTable, undefined, {
43
+ tenants = await selectMany(db, tenantTable, ids === undefined ? undefined : { id: ids }, {
40
44
  limit: scanLimit,
41
45
  orderBy: [
42
46
  { col: "key", direction: "asc" },
@@ -47,7 +51,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
47
51
  else {
48
52
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
49
53
  const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
50
- tenants = row ? [row] : [];
54
+ tenants = row && (ids === undefined || ids.includes(query.user.tenantId)) ? [row] : [];
51
55
  }
52
56
  // name is ciphertext at rest, so the SQL page is keyed by the plaintext
53
57
  // `key`; label order and search happen in memory after decrypting.
@@ -27,3 +27,4 @@ export declare const TenantLifecycleHandlers: {
27
27
  export declare const TENANT_DESTRUCTION_STAGES: readonly ["external-resources", "search-indices", "cache", "app-data", "subject-keys", "files", "infra-resources", "tenant-row"];
28
28
  export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[number];
29
29
  export declare const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30000;
30
+ export declare const DESTRUCTION_STAGE_MIN_SLICE_MS = 2000;
@@ -38,3 +38,7 @@ export const TENANT_DESTRUCTION_STAGES = [
38
38
  // The sweep cron fires every minute with concurrency "skip", so a tick must
39
39
  // finish well inside that window or the next one is skipped.
40
40
  export const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30_000;
41
+ // Lower bound of a tenant's share of the tick budget. Below it a chunking hook
42
+ // without a guaranteed first batch processes nothing, and a no-progress stage
43
+ // counts as a failed attempt, which would abandon the pipeline under load.
44
+ export const DESTRUCTION_STAGE_MIN_SLICE_MS = 2_000;
@@ -3,7 +3,7 @@ import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { append, loadAggregate, VersionConflictError, } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
5
5
  import { tenantEntity, tenantTable } from "../tenant/index.js";
6
- import { DESTRUCTION_STAGE_TICK_BUDGET_MS, TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
6
+ import { DESTRUCTION_STAGE_MIN_SLICE_MS, DESTRUCTION_STAGE_TICK_BUDGET_MS, TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
7
7
  import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
8
8
  import { isDestructionPipelineComplete, pickNextStage, } from "./stages.js";
9
9
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
@@ -251,7 +251,7 @@ export async function runTenantDestructionSweep(args) {
251
251
  // window and starve the ones behind it. Each slice starts when its tenant
252
252
  // starts: anchoring to a shared tick deadline would hand tenants after an
253
253
  // overrun a zero slice, and a no-progress stage counts as a failed attempt.
254
- const sliceMs = DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length);
254
+ const sliceMs = Math.max(DESTRUCTION_STAGE_MIN_SLICE_MS, DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length));
255
255
  for (const row of destroying) {
256
256
  const tenantDeadlineAt = Date.now() + sliceMs;
257
257
  try {
@@ -73,6 +73,27 @@ async function runTenantDataHooks(ctx) {
73
73
  }
74
74
  return total.outcome();
75
75
  }
76
+ // A user key outlives the tenant while the user still belongs to another live tenant:
77
+ // erasing it would tombstone the key and break every later PII write for that user.
78
+ async function userIdsWithoutOtherLiveTenant(ctx, userIds) {
79
+ // skip: no members, nothing to look up.
80
+ if (userIds.length === 0)
81
+ return [];
82
+ const otherMemberships = (await selectMany(ctx.db, tenantMembershipsTable, {
83
+ userId: { in: [...userIds] },
84
+ })).filter((membership) => membership.tenantId !== ctx.tenantId);
85
+ // skip: every member belongs to this tenant only.
86
+ if (otherMemberships.length === 0)
87
+ return userIds;
88
+ const otherTenants = await selectMany(ctx.db, tenantTable, {
89
+ id: { in: [...new Set(otherMemberships.map((membership) => membership.tenantId))] },
90
+ });
91
+ const liveTenantIds = new Set(otherTenants.filter((tenant) => tenant.status !== "destroyed").map((tenant) => tenant.id));
92
+ const retainedUserIds = new Set(otherMemberships
93
+ .filter((membership) => liveTenantIds.has(membership.tenantId))
94
+ .map((membership) => membership.userId));
95
+ return userIds.filter((userId) => !retainedUserIds.has(userId));
96
+ }
76
97
  async function eraseSubjectKeys(ctx) {
77
98
  const kms = configuredPiiSubjectKms();
78
99
  if (!kms) {
@@ -87,7 +108,7 @@ async function eraseSubjectKeys(ctx) {
87
108
  const subjects = [
88
109
  { kind: "tenant", tenantId: ctx.tenantId },
89
110
  tenantRecordSubject,
90
- ...memberships.map((m) => ({ kind: "user", userId: m.userId })),
111
+ ...(await userIdsWithoutOtherLiveTenant(ctx, memberships.map((m) => m.userId))).map((userId) => ({ kind: "user", userId })),
91
112
  ];
92
113
  for (const subject of subjects) {
93
114
  await kms.eraseKey(subject, {
@@ -16,9 +16,8 @@ export declare function createSetTenantTierWrite(opts?: SetTenantTierOptions): i
16
16
  tenantId: z.ZodString;
17
17
  tier: z.ZodString;
18
18
  source: z.ZodDefault<z.ZodEnum<{
19
- readonly manual: "manual";
20
- readonly billing: "billing";
21
- readonly default: "default";
19
+ billing: "billing";
20
+ manual: "manual";
22
21
  }>>;
23
22
  }, z.core.$strip>, {
24
23
  tenantId: string;
@@ -38,7 +38,10 @@ export function createSetTenantTierWrite(opts = {}) {
38
38
  .min(1)
39
39
  .max(50)
40
40
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
41
- source: z.enum(TierAssignmentSources).default(TierAssignmentSources.manual),
41
+ // `default` is reserved for the signup default tier; the billing sync treats it like `billing`.
42
+ source: z
43
+ .enum([TierAssignmentSources.manual, TierAssignmentSources.billing])
44
+ .default(TierAssignmentSources.manual),
42
45
  }),
43
46
  access: { roles: ["SystemAdmin"] },
44
47
  agent: { risk: "high" },
@@ -3,6 +3,7 @@
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";
6
7
  // Feature name
7
8
  export const TIER_ENGINE_FEATURE = "tier-engine";
8
9
  // Qualified write handler names (QN format: scope:type:name).
@@ -18,9 +19,8 @@ export const TierAssignmentSources = {
18
19
  billing: "billing",
19
20
  default: "default",
20
21
  };
21
- const TIER_ASSIGNMENT_SOURCE_VALUES = Object.values(TierAssignmentSources);
22
22
  export function isTierAssignmentSource(value) {
23
- return typeof value === "string" && TIER_ASSIGNMENT_SOURCE_VALUES.includes(value);
23
+ return isOneOf(Object.values(TierAssignmentSources), value);
24
24
  }
25
25
  // Qualified query handler names.
26
26
  export const TierEngineQueries = {
@@ -14,6 +14,7 @@ import { userEditScreen, userListScreen } from "./screens.js";
14
14
  // Membership + tenant-specific roles live in the tenant feature.
15
15
  export function createUserFeature() {
16
16
  return defineFeature("user", (r) => {
17
+ r.adoptsBundledLists();
17
18
  r.describe("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.");
18
19
  r.uiHints({
19
20
  displayLabel: "User Identity",
@@ -51,6 +51,7 @@ export function createUserDataRightsFeature(opts = {}) {
51
51
  let warnedMissingMailDelivery = false;
52
52
  const showDeletion = opts.privacyCenterShowDeletion ?? true;
53
53
  return defineFeature("user-data-rights", (r) => {
54
+ r.adoptsBundledLists();
54
55
  r.describe('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`.');
55
56
  r.uiHints({
56
57
  displayLabel: "User Data Rights \u00b7 GDPR",
@@ -11,4 +11,4 @@ export type { UserExportBundle } from "./run-user-export.ts";
11
11
  export { runUserExport } from "./run-user-export.ts";
12
12
  export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.ts";
13
13
  export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, type ExportJobStatus, exportJobEntity, exportJobsTable, } from "./schema/export-job.ts";
14
- export { TENANT_MODEL_CONFIG_KEY } from "./web/constants.ts";
14
+ export { TENANT_MODEL_CONFIG_KEY, USER_DATA_RIGHTS_FEATURE, UserDataRightsHandlers, UserDataRightsQueries, } from "./web/constants.ts";
@@ -15,4 +15,4 @@ export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
15
15
  export { runUserExport } from "./run-user-export.js";
16
16
  export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
17
17
  export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable, } from "./schema/export-job.js";
18
- export { TENANT_MODEL_CONFIG_KEY } from "./web/constants.js";
18
+ export { TENANT_MODEL_CONFIG_KEY, USER_DATA_RIGHTS_FEATURE, UserDataRightsHandlers, UserDataRightsQueries, } from "./web/constants.js";
@@ -19,12 +19,11 @@
19
19
  // expiresAt + exportStorageCleanupGraceHours, so expired ZIPs do not stay in
20
20
  // storage indefinitely.
21
21
  import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
22
- import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
23
22
  import { createEventStoreExecutor, createTenantDb, fetchOne, selectMany, } from "@cosmicdrift/kumiko-framework/db";
24
23
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
25
24
  import { createZipStream, tenantExportPrefix, } from "@cosmicdrift/kumiko-framework/files";
26
25
  import { resolveProfileForTenant } from "../compliance-profiles/index.js";
27
- import { decryptStoredPii } from "../shared/index.js";
26
+ import { decryptStoredUserEmail } from "../shared/index.js";
28
27
  import { userTable } from "../user/index.js";
29
28
  import { selectExportJobsForStorageCleanup } from "./db/queries/export-jobs.js";
30
29
  import { runUserExport } from "./run-user-export.js";
@@ -539,8 +538,8 @@ async function lookupUserContact(db, userId) {
539
538
  const row = (await fetchOne(db, userTable, { id: userId }));
540
539
  if (!row?.email)
541
540
  return null;
542
- const email = await decryptStoredPii(row.email, "email", "user-data-rights:export-ready");
543
- if (email === PII_ERASED_SENTINEL)
541
+ const email = await decryptStoredUserEmail(row.email, "user-data-rights:export-ready");
542
+ if (email === null)
544
543
  return null;
545
544
  return { email, locale: row.locale ?? null };
546
545
  }
@@ -274,7 +274,8 @@ async function processUser(args) {
274
274
  // is what makes this crash-safe: eraseKey-throw → tx rollback → user
275
275
  // stays DeletionRequested → next run retries; flip-throw after a
276
276
  // successful erase → retry re-runs the idempotent hooks and eraseKey
277
- // (contractually a no-op on an erased/unknown subject). Erasing after
277
+ // (idempotent: a repeat erase keeps the original tombstone, an unknown
278
+ // subject gets one). Erasing after
278
279
  // the flip instead would risk a permanent silent miss: user already
279
280
  // Deleted, no run ever picks him up again, key stays live.
280
281
  if (kms) {
@@ -6,13 +6,19 @@ export declare const UserDataRightsQueries: {
6
6
  readonly exportStatus: "user-data-rights:query:export-status";
7
7
  readonly myAuditLog: "user-data-rights:query:my-audit-log";
8
8
  readonly downloadByJob: "user-data-rights:query:download-by-job";
9
+ readonly downloadByToken: "user-data-rights:query:download-by-token";
10
+ readonly listDownloadAttempts: "user-data-rights:query:list-download-attempts";
9
11
  };
10
12
  export declare const TENANT_MODEL_CONFIG_KEY: "user-data-rights:config:tenant-model";
11
13
  export declare const UserDataRightsHandlers: {
12
14
  readonly requestExport: "user-data-rights:write:request-export";
13
15
  readonly requestDeletion: "user-data-rights:write:request-deletion";
16
+ readonly requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email";
17
+ readonly confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token";
14
18
  readonly cancelDeletion: "user-data-rights:write:cancel-deletion";
15
19
  readonly restrictAccount: "user-data-rights:write:restrict-account";
20
+ readonly liftRestriction: "user-data-rights:write:lift-restriction";
21
+ readonly runForgetCleanup: "user-data-rights:write:run-forget-cleanup";
16
22
  };
17
23
  export declare const USER_ME_QUERY: "user:query:user:me";
18
24
  export declare const EXPORT_JOB_STATUS: {
@@ -21,6 +21,8 @@ export const UserDataRightsQueries = {
21
21
  exportStatus: "user-data-rights:query:export-status",
22
22
  myAuditLog: "user-data-rights:query:my-audit-log",
23
23
  downloadByJob: "user-data-rights:query:download-by-job",
24
+ downloadByToken: "user-data-rights:query:download-by-token",
25
+ listDownloadAttempts: "user-data-rights:query:list-download-attempts",
24
26
  };
25
27
  // App-level config: tenant-occupancy model. "single-user" tells the forget
26
28
  // pipeline that each tenant has exactly one user, so tenant-scoped contributors
@@ -31,8 +33,12 @@ export const TENANT_MODEL_CONFIG_KEY = "user-data-rights:config:tenant-model";
31
33
  export const UserDataRightsHandlers = {
32
34
  requestExport: "user-data-rights:write:request-export",
33
35
  requestDeletion: "user-data-rights:write:request-deletion",
36
+ requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email",
37
+ confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token",
34
38
  cancelDeletion: "user-data-rights:write:cancel-deletion",
35
39
  restrictAccount: "user-data-rights:write:restrict-account",
40
+ liftRestriction: "user-data-rights:write:lift-restriction",
41
+ runForgetCleanup: "user-data-rights:write:run-forget-cleanup",
36
42
  };
37
43
  // Foreign QN: the lifecycle status (active / deletionRequested / restricted)
38
44
  // comes from the user feature. Pinned locally instead of importing the user
@@ -1,6 +1,7 @@
1
1
  export { type UserDataRightsClientOptions, userDataRightsClient } from "./client-plugin.tsx";
2
2
  export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen.tsx";
3
3
  export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.tsx";
4
+ export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.ts";
4
5
  export { defaultTranslations } from "./default-translations.ts";
5
6
  export { formatDate } from "./privacy-center-screen.tsx";
6
7
  export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate.tsx";
@@ -5,6 +5,7 @@
5
5
  // no React/DOM dependencies.
6
6
  export { userDataRightsClient } from "./client-plugin.js";
7
7
  export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.js";
8
+ export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.js";
8
9
  export { defaultTranslations } from "./default-translations.js";
9
10
  export { formatDate } from "./privacy-center-screen.js";
10
11
  export { makePublicDeletionGate } from "./public-deletion-gate.js";
@@ -15,6 +15,7 @@ import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-ho
15
15
  import { WAITLIST_EXPORTED_EVENT_SHORT, WAITLIST_FEATURE, WAITLIST_PURGED_EVENT_SHORT, } from "./web/constants.js";
16
16
  export function createWaitlistFeature(opts = {}) {
17
17
  return defineFeature(WAITLIST_FEATURE, (r) => {
18
+ r.adoptsBundledLists();
18
19
  r.describe("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.");
19
20
  r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
20
21
  r.systemScope();
@@ -54,8 +54,9 @@ export const purgeHandler = defineWriteHandler({
54
54
  details: { confirmCount: event.payload.confirmCount, actualCount: rows.length },
55
55
  }));
56
56
  }
57
- await purgeWaitlistRows(db, rows, event.user.id);
57
+ // Audit first: purgeWaitlistRows shreds KMS keys, which a later failure cannot roll back.
58
58
  await appendAudit(ctx, WAITLIST_PURGED_EVENT_QN, rows.length);
59
+ await purgeWaitlistRows(db, rows, event.user.id);
59
60
  return { isSuccess: true, data: { purged: rows.length } };
60
61
  },
61
62
  });
@@ -1,4 +1,4 @@
1
- import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
1
+ import { acquireNamespacedAdvisoryLock, createSystemDbView, createTenantDb, runInSavepointIfSupported, } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
@@ -10,7 +10,7 @@ import { WAITLIST_NOTIFICATION_TYPES, WAITLIST_STATUS } from "../web/constants.j
10
10
  // pg_advisory_xact_lock namespace (int4): 'wlst' as ASCII, disjoint from the
11
11
  // framework's other fixed advisory-lock keys.
12
12
  const SUBMIT_LOCK_NAMESPACE = 0x776c7374;
13
- const SUBMIT_LOCK_REASON = "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check; TenantDb has no lock API";
13
+ const SUBMIT_LOCK_REASON = "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";
14
14
  // Logged-in callers carry no anonymous role, so every signed-in role must be listed.
15
15
  const SUBMIT_ROLES = access.roles(...access.anonymous, ...access.authenticated, "Member", "TenantAdmin");
16
16
  const OPEN_STATUSES = [WAITLIST_STATUS.Pending, WAITLIST_STATUS.Invited];
@@ -40,12 +40,17 @@ async function hasOpenEntryForEmail(email, ctx) {
40
40
  }, platformActor(), waitlistDb(ctx));
41
41
  return found.rows.length > 0;
42
42
  }
43
- // The resolver usually reads config; run it outside the handler transaction's
44
- // critical section so a failing statement cannot abort the transaction that holds
45
- // the entry, and a slow resolver does not extend the per-email advisory lock.
46
- async function resolveAdminRecipient(opts, ctx) {
43
+ // The resolver usually reads config. It runs before the per-email advisory lock so a slow
44
+ // resolver does not extend the lock. Postgres aborts the whole transaction after a failed
45
+ // statement, even one whose JS error is caught here, so the resolver gets a ctx whose
46
+ // systemDb is bound to its own savepoint: a failing statement rolls back only that.
47
+ // Statements through any other ctx handle (ctx.query, ctx.write) stay in the handler transaction.
48
+ async function resolveAdminRecipient(opts, ctx, systemDb, tenantId) {
47
49
  try {
48
- return (await opts.notifyRecipient?.(ctx)) || null;
50
+ return await runInSavepointIfSupported(systemDb.unsafeRaw(SUBMIT_LOCK_REASON), async (sp) => {
51
+ const savepointSystemDb = createSystemDbView(createTenantDb({ db: sp, tenantId, mode: "system" }));
52
+ return (await opts.notifyRecipient?.({ ...ctx, systemDb: savepointSystemDb })) || null;
53
+ });
49
54
  }
50
55
  catch (err) {
51
56
  ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
@@ -88,7 +93,7 @@ export function createSubmitHandler(opts) {
88
93
  message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
89
94
  });
90
95
  }
91
- const adminRecipient = await resolveAdminRecipient(opts, ctx);
96
+ const adminRecipient = await resolveAdminRecipient(opts, ctx, ctx.systemDb, event.user.tenantId);
92
97
  await acquireNamespacedAdvisoryLock(ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON), SUBMIT_LOCK_NAMESPACE, email);
93
98
  if (await hasOpenEntryForEmail(email, ctx))
94
99
  return SUBMITTED;
@@ -124,8 +129,7 @@ export function createSubmitHandler(opts) {
124
129
  message: payload.message,
125
130
  }),
126
131
  priority: "normal",
127
- // Operator-facing: the request locale, not the one the entrant picked in the form.
128
- locale: resolveWaitlistMailLocale(ctx.locale),
132
+ locale: resolveWaitlistMailLocale(opts.adminLocale),
129
133
  });
130
134
  });
131
135
  return SUBMITTED;
@@ -1,6 +1,6 @@
1
1
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
- import type { WaitlistStatus } from "./web/constants.ts";
3
+ import { type WaitlistStatus } from "./web/constants.ts";
4
4
  export type WaitlistImportEntry = {
5
5
  /** Stable identity of the source row (e.g. `offlot:<uuid>`); the new entry id derives from it. */
6
6
  readonly sourceKey: string;
@@ -19,6 +19,8 @@ export type WaitlistImportEntry = {
19
19
  };
20
20
  export type WaitlistImportOutcome = "created" | "skipped";
21
21
  export declare function waitlistImportId(sourceKey: string): string;
22
+ export declare function isWaitlistImportId(id: string): boolean;
23
+ export declare function waitlistImportEntryWithinLimits(entry: WaitlistImportEntry): boolean;
22
24
  export declare function importWaitlistEntry(db: TenantDb, entry: WaitlistImportEntry, options?: {
23
25
  readonly dryRun?: boolean;
24
26
  }): Promise<WaitlistImportOutcome>;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { normalizeEmail, platformActor, waitlistExecutor } from "./lib.js";
4
+ import { WAITLIST_FIELD_LIMITS } from "./web/constants.js";
4
5
  // RFC 4122 name-based UUID (v5 layout, fixed namespace) so a re-run finds the entry
5
6
  // it created before without matching on encrypted personal data.
6
7
  const IMPORT_NAMESPACE = "kumiko-waitlist-import";
@@ -13,13 +14,36 @@ export function waitlistImportId(sourceKey) {
13
14
  const hex = bytes.subarray(0, 16).toString("hex");
14
15
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
15
16
  }
17
+ // The version nibble marks ids minted by waitlistImportId; legacy row ids are random UUIDs.
18
+ export function isWaitlistImportId(id) {
19
+ return id[14] === "5";
20
+ }
21
+ // The live submit schema caps these fields; an imported value above the cap would be
22
+ // stored but could never pass a later update through the same payload schema.
23
+ export function waitlistImportEntryWithinLimits(entry) {
24
+ const limited = [
25
+ [entry.name, WAITLIST_FIELD_LIMITS.name],
26
+ [entry.email, WAITLIST_FIELD_LIMITS.email],
27
+ [entry.locale, WAITLIST_FIELD_LIMITS.locale],
28
+ [entry.company, WAITLIST_FIELD_LIMITS.company],
29
+ [entry.portfolio, WAITLIST_FIELD_LIMITS.portfolio],
30
+ [entry.message, WAITLIST_FIELD_LIMITS.message],
31
+ ];
32
+ return limited.every(([value, max]) => value === undefined || value.length <= max);
33
+ }
16
34
  function presentOnly(fields) {
17
35
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
18
36
  }
19
- // Writes through the entity executor, so events, projection and subject encryption
20
- // behave exactly as for a live submit. No mail, no rate limit, no duplicate-by-email
37
+ // Writes through the entity executor, so events and projection match a live submit; subject
38
+ // encryption applies only when the caller configured a PII KMS (the CLI wires it from env
39
+ // and aborts without one). No mail, no rate limit, no duplicate-by-email
21
40
  // check: the source row is the truth and the id makes the call idempotent.
22
41
  export async function importWaitlistEntry(db, entry, options = {}) {
42
+ if (!waitlistImportEntryWithinLimits(entry)) {
43
+ throw new InternalError({
44
+ message: `waitlist import entry ${entry.sourceKey} exceeds a field length limit`,
45
+ });
46
+ }
23
47
  const id = waitlistImportId(entry.sourceKey);
24
48
  const existing = await waitlistExecutor.detail({ id }, platformActor(), db);
25
49
  if (existing)
@@ -1,10 +1,11 @@
1
1
  import { configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
2
- import { countWhere, createTenantDb, selectMany, table, text as textColumn, timestamp, transaction, uuid, } from "@cosmicdrift/kumiko-framework/db";
2
+ import { countWhere, createTenantDb, selectMany, table, tableExists, text as textColumn, timestamp, transaction, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { archiveStream, createArchivedStreamsTable, eventsTable, isStreamArchived, } from "@cosmicdrift/kumiko-framework/event-store";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
- import { importWaitlistEntry, waitlistImportId } from "./import.js";
6
+ import { importWaitlistEntry, isWaitlistImportId, waitlistImportEntryWithinLimits, waitlistImportId, } from "./import.js";
7
7
  import { WAITLIST_STATUS } from "./web/constants.js";
8
+ const ARCHIVED_STREAMS_TABLE = "public.kumiko_archived_streams";
8
9
  export const LEGACY_WAITLIST_SOURCES = ["offlot", "publicstatus"];
9
10
  export function isLegacyWaitlistSource(value) {
10
11
  return LEGACY_WAITLIST_SOURCES.some((known) => known === value);
@@ -152,10 +153,12 @@ async function readLegacyRows(db, from, sourceTable) {
152
153
  // new id, so a rebuild would replay the old stream next to it and bring every migrated entry back
153
154
  // twice; archived streams are excluded from replay. The offlot default source table is the entity
154
155
  // table itself, so its row ids are live entries by design and no id check can tell a wrong table.
155
- async function archiveLegacyStream(db, legacyId, importedId) {
156
+ async function archiveLegacyStream(db, legacyId, importedId, dryRun) {
156
157
  if (legacyId === importedId)
157
158
  return 0;
158
- if (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId))
159
+ // A dry run never creates the archive table, so a missing one means nothing is archived yet.
160
+ const archiveTableReady = !dryRun || (await tableExists(db, ARCHIVED_STREAMS_TABLE));
161
+ if (archiveTableReady && (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId)))
159
162
  return 0;
160
163
  const legacyEvents = await countWhere(db, eventsTable, {
161
164
  tenantId: SYSTEM_TENANT_ID,
@@ -164,6 +167,8 @@ async function archiveLegacyStream(db, legacyId, importedId) {
164
167
  });
165
168
  if (legacyEvents === 0)
166
169
  return 0;
170
+ if (dryRun)
171
+ return 1;
167
172
  await archiveStream(db, {
168
173
  tenantId: SYSTEM_TENANT_ID,
169
174
  aggregateId: legacyId,
@@ -185,10 +190,16 @@ export async function migrateLegacyWaitlist(db, options) {
185
190
  if (!options.dryRun && options.from === "offlot")
186
191
  await createArchivedStreamsTable(db);
187
192
  for (const row of rows) {
193
+ // The offlot default source is the entity table: rows a previous run imported
194
+ // are already migrated entries and must neither be re-imported nor archived.
195
+ if (options.from === "offlot" && isWaitlistImportId(String(row["id"]))) {
196
+ skipped += 1;
197
+ continue;
198
+ }
188
199
  const entry = options.from === "offlot"
189
200
  ? await mapOfflotRow(row)
190
201
  : mapPublicstatusRow(row, options.defaultLocale);
191
- if (!entry) {
202
+ if (!entry || !waitlistImportEntryWithinLimits(entry)) {
192
203
  unreadable += 1;
193
204
  continue;
194
205
  }
@@ -200,8 +211,8 @@ export async function migrateLegacyWaitlist(db, options) {
200
211
  else
201
212
  skipped += 1;
202
213
  });
203
- if (!options.dryRun && options.from === "offlot") {
204
- archivedStreams += await archiveLegacyStream(db, String(row["id"]), waitlistImportId(entry.sourceKey));
214
+ if (options.from === "offlot") {
215
+ archivedStreams += await archiveLegacyStream(db, String(row["id"]), waitlistImportId(entry.sourceKey), options.dryRun);
205
216
  }
206
217
  }
207
218
  return { read: rows.length, created, skipped, unreadable, archivedStreams };
@@ -59,11 +59,14 @@ export const linkWaitlistOnSignup = async (event, tx) => {
59
59
  if (typeof user?.email !== "string")
60
60
  return;
61
61
  const email = normalizeEmail(await decryptStoredPii(user.email, "email", PII_DECRYPT_REASON));
62
- const found = await waitlistExecutor.list({
63
- filter: { field: "email", op: "eq", value: email },
64
- filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
65
- }, platformActor(), db);
66
- const invited = [...found.rows].sort(newestSubmittedFirst)[0];
62
+ const findInvited = async () => {
63
+ const found = await waitlistExecutor.list({
64
+ filter: { field: "email", op: "eq", value: email },
65
+ filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
66
+ }, platformActor(), db);
67
+ return [...found.rows].sort(newestSubmittedFirst)[0];
68
+ };
69
+ const invited = await findInvited();
67
70
  // skip: the signup email has no invited waitlist entry (open signup or stale invite).
68
71
  if (!invited)
69
72
  return;
@@ -71,7 +74,7 @@ export const linkWaitlistOnSignup = async (event, tx) => {
71
74
  // skip: defensive guard on the row shape.
72
75
  if (typeof id !== "string" || typeof version !== "number")
73
76
  return;
74
- await waitlistExecutor.update({
77
+ const updated = await waitlistExecutor.update({
75
78
  id,
76
79
  version,
77
80
  changes: {
@@ -80,4 +83,13 @@ export const linkWaitlistOnSignup = async (event, tx) => {
80
83
  provisionedAt: Temporal.Now.instant(),
81
84
  },
82
85
  }, platformActor(), db);
86
+ // skip: linked.
87
+ if (updated.isSuccess)
88
+ return;
89
+ // A concurrent admin change that already moved the entry out of `invited` makes the link
90
+ // moot; otherwise throw so the at-least-once consumer redelivers.
91
+ // skip: the entry left `invited` concurrently.
92
+ if ((await findInvited())?.id !== id)
93
+ return;
94
+ throw new Error(`waitlist:link-on-signup: could not link waitlist entry ${id}`);
83
95
  };