@cosmicdrift/kumiko-samples 0.363.0 → 0.365.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -4,7 +4,7 @@ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework
4
4
  import * as z from "zod";
5
5
  import { requireForTenant } from "../../shared/index.js";
6
6
  import { tierAssignmentAggregateId } from "../aggregate-id.js";
7
- import { TierAssignmentSources } from "../constants.js";
7
+ import { type TierAssignmentSource, TierAssignmentSources } from "../constants.js";
8
8
  import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
9
9
 
10
10
  // SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
@@ -14,7 +14,8 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
14
14
  // Cross-tenant mechanics: ctx.forTenant(tenantId) yields a tenant-mode db bound to the target plus
15
15
  // the streamTenantId, so the event lands in the target's stream with the operator as actor.
16
16
  //
17
- // `source: TierAssignmentSources.manual` marks the grant so the billing sync skips it.
17
+ // `source` defaults to manual and marks the grant so the billing sync skips it; passing
18
+ // `billing` hands the row back to the sync, which overwrites it on the next webhook.
18
19
  // Upsert: one aggregate per tenant (deterministic aggregate id).
19
20
  //
20
21
  // Effective-set invalidation: the executor write does not fire the
@@ -47,7 +48,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
47
48
  return defineWriteHandler({
48
49
  name: "set-tenant-tier",
49
50
  description:
50
- "Assigns a tier to any tenant as a manual grant without a billing purchase, marking it so a later billing sync will not overwrite it, and applies the new feature set immediately.",
51
+ "Assigns a tier to any tenant as a manual grant without a billing purchase, marking it so a later billing sync will not overwrite it (or, with source billing, handing the row back to the sync), and applies the new feature set immediately.",
51
52
  schema: z.object({
52
53
  tenantId: z.string().min(1),
53
54
  tier: z
@@ -55,6 +56,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
55
56
  .min(1)
56
57
  .max(50)
57
58
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
59
+ source: z.enum(TierAssignmentSources).default(TierAssignmentSources.manual),
58
60
  }),
59
61
  access: { roles: ["SystemAdmin"] },
60
62
  agent: { risk: "high" },
@@ -68,6 +70,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
68
70
  const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
69
71
  const { db: tdb, streamTenantId } = requireForTenant(ctx, tenantId);
70
72
  const tier = event.payload.tier;
73
+ const source: TierAssignmentSource = event.payload.source;
71
74
 
72
75
  const existing = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
73
76
 
@@ -76,7 +79,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
76
79
  {
77
80
  id: existing.id,
78
81
  version: existing.version,
79
- changes: { tier, source: TierAssignmentSources.manual },
82
+ changes: { tier, source },
80
83
  },
81
84
  event.user,
82
85
  tdb,
@@ -91,7 +94,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
91
94
  {
92
95
  id: tierAssignmentAggregateId(tenantId),
93
96
  tier,
94
- source: TierAssignmentSources.manual,
97
+ source,
95
98
  tenantId,
96
99
  },
97
100
  event.user,
@@ -18,7 +18,9 @@ export {
18
18
  type TierMap,
19
19
  } from "./compose-app.js";
20
20
  export {
21
+ isTierAssignmentSource,
21
22
  TIER_ENGINE_FEATURE,
23
+ type TierAssignmentSource,
22
24
  TierAssignmentSources,
23
25
  TierEngineHandlers,
24
26
  TierEngineQueries,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "resolveUserDisplayNames no longer swallows decrypt errors",
6
+ "detail": "A misconfigured or unavailable KMS made the delivery log silently show addresses or ids instead of names. Decrypt errors now propagate like in the member directory; users that are missing or have no display name are still left out."
7
+ },
2
8
  {
3
9
  "version": "0.361.0",
4
10
  "type": "improvement",
@@ -6,6 +6,7 @@ export const USER_FEATURE = "user" as const;
6
6
  * platform operator. Membership roles (TenantAdmin/Admin/User/…) live on
7
7
  * tenant-membership, not here. */
8
8
  export const GLOBAL_ROLE_OPTIONS = ["SystemAdmin"] as const;
9
+ export type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
9
10
 
10
11
  // Qualified write handler names. Handlers carry the "user:" entity prefix so
11
12
  // field-level access rules (passwordHash system-only etc.) are wired up.
@@ -7,7 +7,7 @@ import {
7
7
  import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
9
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
10
- import { GLOBAL_ROLE_OPTIONS } from "./constants.js";
10
+ import { GLOBAL_ROLE_OPTIONS, type GlobalRole } from "./constants.js";
11
11
  import { userEntity, userTable } from "./schema/user.js";
12
12
 
13
13
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
@@ -18,8 +18,6 @@ type UserRolesRow = {
18
18
  readonly roles: readonly string[] | string | null;
19
19
  };
20
20
 
21
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
22
-
23
21
  function isGlobalRole(role: string): role is GlobalRole {
24
22
  return GLOBAL_ROLE_OPTIONS.some((option) => option === role);
25
23
  }
@@ -34,7 +32,7 @@ export function parseInvitedGlobalRoles(raw: unknown): readonly GlobalRole[] {
34
32
  // already holds. Users live on SYSTEM_TENANT_ID (tenancy "global").
35
33
  export async function grantInvitedGlobalRoles(
36
34
  db: DbRunner,
37
- options: { readonly userId: string; readonly globalRoles: readonly string[] },
35
+ options: { readonly userId: string; readonly globalRoles: readonly GlobalRole[] },
38
36
  ) {
39
37
  const existing = await fetchOne<UserRolesRow>(db, userTable, { id: options.userId });
40
38
  if (!existing) return writeFailure(new NotFoundError("user", options.userId));
@@ -6,9 +6,10 @@ import { userTable } from "./schema/user.js";
6
6
  // Shares the KMS adapter's small dedicated pool, same as member-directory.
7
7
  const KMS_POOL_CONCURRENCY = 4;
8
8
 
9
- // One batched lookup for a page of user ids. Users that are missing, have no
10
- // display name, or whose name cannot be decrypted (e.g. crypto-shredded) are
11
- // left out, so callers fall back to the raw id instead of failing the page.
9
+ // One batched lookup for a page of user ids. Users that are missing or have no
10
+ // display name are left out, so callers fall back to the raw id. Decrypt errors
11
+ // propagate (a misconfigured or unavailable KMS fails loudly, like member-directory);
12
+ // crypto-shredding is handled by the KMS layer.
12
13
  export async function resolveUserDisplayNames(
13
14
  db: TenantDb,
14
15
  userIds: readonly string[],
@@ -18,18 +19,10 @@ export async function resolveUserDisplayNames(
18
19
  const users = await selectMany(db, userTable, { id: [...userIds] });
19
20
  const named = users.filter((user) => typeof user.displayName === "string");
20
21
  await mapWithConcurrency(named, KMS_POOL_CONCURRENCY, async (user) => {
21
- try {
22
- names.set(
23
- String(user.id),
24
- await decryptStoredPii(
25
- String(user.displayName),
26
- "displayName",
27
- "user:resolve-display-names",
28
- ),
29
- );
30
- } catch {
31
- // Unreadable name: the caller's fallback applies.
32
- }
22
+ names.set(
23
+ String(user.id),
24
+ await decryptStoredPii(String(user.displayName), "displayName", "user:resolve-display-names"),
25
+ );
33
26
  });
34
27
  return names;
35
28
  }
@@ -11,6 +11,7 @@
11
11
  // das Event UND schreibt die Projection-Zeile in einer TX.
12
12
 
13
13
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { computeBlindIndex, configuredBlindIndexKey } from "@cosmicdrift/kumiko-framework/crypto";
14
15
  import {
15
16
  createEventStoreExecutor,
16
17
  createTenantDb,
@@ -139,12 +140,25 @@ export async function rewriteSeededUserEmail(
139
140
  if (typeof id !== "string") {
140
141
  throw new Error("rewriteSeededUserEmail: existing user row has no string id");
141
142
  }
143
+ const version = existingRow["version"];
144
+ if (typeof version !== "number") {
145
+ throw new Error("rewriteSeededUserEmail: existing user row has no numeric version");
146
+ }
147
+ // Every executor.update appends a user:updated event; skip when the stored
148
+ // blind index already matches so repeated boots do not grow the stream.
149
+ // Queried by the bidx column itself: fetchOne does not return it, and an
150
+ // `email` condition would also match a plaintext row with a blank index.
151
+ const blindIndexKey = configuredBlindIndexKey();
152
+ if (blindIndexKey !== undefined) {
153
+ const indexed = await fetchOne(db, userTable, {
154
+ id,
155
+ emailBidx: computeBlindIndex(blindIndexKey, email),
156
+ });
157
+ // skip: the stored blind index already matches, a rewrite would only append a no-op event.
158
+ if (indexed) return;
159
+ }
142
160
  const tdb = createTenantDb(db, by.tenantId, "system");
143
- const result = await userExecutor.update(
144
- { id, version: existingRow["version"] as number, changes: { email } },
145
- by,
146
- tdb,
147
- );
161
+ const result = await userExecutor.update({ id, version, changes: { email } }, by, tdb);
148
162
  // version_conflict: a concurrent write already changed the row — fine for a seed helper.
149
163
  // unique_violation: a duplicate row already holds the blind index; the repair
150
164
  // has to wait for a manual duplicate cleanup.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "improvement",
5
+ "title": "GDPR mails carry designer labels",
6
+ "detail": "Feature and notify labels for export-ready, export-failed, deletion-requested and deletion-executed name the GDPR mails in the document designer."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "Regression tests prove that forget also shreds the previous email in user.updated events",
12
+ "detail": "No runtime change. Integration tests with a configured subject KMS show that after an email change followed by forget, the old and new email in user.updated changes and previous decrypt only to the erased sentinel and the subject key is gone. Without a configured KMS, event payloads stay plaintext by design."
13
+ },
2
14
  {
3
15
  "version": "0.363.0",
4
16
  "type": "breaking",
@@ -36,7 +36,6 @@ export const GDPR_MAIL_EN: Readonly<Record<string, string>> = {
36
36
  "gdpr.mail.deletionExecuted.subject": "{app} — Your account has been deleted",
37
37
  "gdpr.mail.deletionExecuted.intro":
38
38
  "your {app} account and the associated personal data were deleted on {when}. This action is permanent.",
39
- "gdpr.mail.fallbackUrl": "If the button doesn't work, copy this link into your browser:",
40
39
  };
41
40
 
42
41
  registerMailTranslations("en", GDPR_MAIL_EN);
@@ -4,6 +4,11 @@
4
4
  type LocalizedString = { readonly en: string };
5
5
 
6
6
  export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
7
+ "user-data-rights:feature.label": { en: "Privacy" },
8
+ "user-data-rights:notify.export-ready.label": { en: "Data export ready" },
9
+ "user-data-rights:notify.export-failed.label": { en: "Data export failed" },
10
+ "user-data-rights:notify.deletion-requested.label": { en: "Deletion requested" },
11
+ "user-data-rights:notify.deletion-executed.label": { en: "Deletion completed" },
7
12
  "user-data-rights:nav.exportJobs": { en: "GDPR exports" },
8
13
  "screen:export-job-list.title": { en: "GDPR exports" },
9
14
  "screen:export-job-detail.title": { en: "Export job" },
@@ -1,6 +1,10 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ createEventStoreExecutor,
5
+ createTenantDb,
6
+ type WritePurpose,
7
+ } from "@cosmicdrift/kumiko-framework/db";
4
8
  import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
5
9
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
10
  import { USER_STATUS, userEntity, userTable } from "../../user/index.js";
@@ -27,6 +31,9 @@ export type UpdateUserLifecycleOptions = {
27
31
  // executor's `expect:`. Omitted: unconditional overwrite (skipOptimisticLock).
28
32
  // Scalars only, see the executor's `expect:` doc.
29
33
  readonly expect?: Readonly<Record<string, string | number | boolean | null>>;
34
+ // Erasure flows run after the subject key is erased; the write then stores the
35
+ // erased sentinel instead of failing on re-encrypt.
36
+ readonly writePurpose?: WritePurpose;
30
37
  };
31
38
 
32
39
  // `conn` is ctx.db.unsafeRaw() (regular handlers) or the open tx
@@ -59,7 +66,11 @@ export async function updateUserLifecycle(
59
66
  { id: userId, changes },
60
67
  createSystemUser(SYSTEM_TENANT_ID),
61
68
  tenantDb,
62
- { skipOptimisticLock: true, ...(options?.expect && { expect: options.expect }) },
69
+ {
70
+ skipOptimisticLock: true,
71
+ ...(options?.expect && { expect: options.expect }),
72
+ ...(options?.writePurpose && { writePurpose: options.writePurpose }),
73
+ },
63
74
  );
64
75
 
65
76
  if (result.isSuccess) return { applied: true };
@@ -47,6 +47,7 @@ import {
47
47
  entityEventName,
48
48
  executeRawQuery,
49
49
  nullBlindIndexesForSubject,
50
+ WritePurposes,
50
51
  } from "@cosmicdrift/kumiko-framework/db";
51
52
  import {
52
53
  type EscapeHatchAuditSink,
@@ -531,7 +532,12 @@ async function processUser(args: {
531
532
  // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
532
533
  // alles, der User bleibt im DeletionRequested-Status, naechster
533
534
  // Run retried.
534
- await updateUserLifecycle(tx, userId, { status: USER_STATUS.Deleted });
535
+ await updateUserLifecycle(
536
+ tx,
537
+ userId,
538
+ { status: USER_STATUS.Deleted },
539
+ { writePurpose: WritePurposes.erasure },
540
+ );
535
541
  txSucceeded = true;
536
542
  });
537
543
  } catch (e) {
@@ -16,7 +16,7 @@
16
16
  "type": "breaking",
17
17
  "title": "user-profile now registers its own profile screen (id user-profile:screen:profile); app-side profile screen registrations must be removed (fw#2312).",
18
18
  "detail": "`profile` is now a declarative `projectionDetail` screen bound to `user:query:user:me` instead of a custom React component: change-password and change-email stay `EditExtensionSection` components (re-auth flows a declarative action can't express), account deletion (request/cancel via `user-data-rights`, grace period) is now fully declarative fields + `actions: RowAction[]`, preserving the original confirm dialog and `visible: {field, eq/ne}` toggle 1:1. `userProfileClient()` now registers the two extension-section components via `extensionSectionComponents` instead of exposing a `ProfileScreen` component for apps to place in a `components` map. `ProfileScreen` is a dead export. New exports: `ChangeEmailSection`, `ChangePasswordSection` (the two surviving extension components, importable for tests but not meant to be placed manually). Known UI regression, accepted for this pass (same trade-off as `privacy-center`): the deletion confirm-dialog title is now always the action's `label`; the dynamically composed grace-period banner sentence is gone, replaced by a plain `gracePeriodEnd` date field; there is no cancel-deletion success toast.",
19
- "migration": "The bundled feature now registers the `profile` screen itself (id `user-profile:screen:profile`) — an app's own `profile` custom-screen registration collides on boot with a duplicate short-id error. Delete the app-side registration entirely: `r.screen({ id: \"profile\", type: \"custom\", renderer: { react: { __component: \"UserProfileScreen\" } }, access: { openToAll: true } })` has no replacement to write, just remove the call. If you have an `r.nav({ screen: \"profile\" })` (or any other reference to the app-local `profile` screen id), repoint it at `\"user-profile:screen:profile\"`. Also remove `components: { UserProfileScreen: ProfileScreen }` from your renderer/client setup and the now-unused `ProfileScreen` import — the client no longer exposes that component. Known affected consumers (not modified here, out of this PR's scope): `money-horse` (`src/features/money-horse/feature.ts:286-292`) and `offlot-app` (`src/features/account/feature.ts:24-30`) both currently register their own `profile` custom screen this way."
19
+ "migration": "The bundled feature now registers the `profile` screen itself (id `user-profile:screen:profile`) — an app's own `profile` custom-screen registration collides on boot with a duplicate short-id error. Delete the app-side registration entirely: `r.screen({ id: \"profile\", type: \"custom\", renderer: { react: { __component: \"UserProfileScreen\" } }, access: { openToAll: true } })` has no replacement to write, just remove the call. If you have an `r.nav({ screen: \"profile\" })` (or any other reference to the app-local `profile` screen id), repoint it at `\"user-profile:screen:profile\"`. Also remove `components: { UserProfileScreen: ProfileScreen }` from your renderer/client setup and the now-unused `ProfileScreen` import — the client no longer exposes that component. Known affected consumers (not modified here, out of this PR's scope): `money-horse` and `offlot-app` both currently register their own `profile` custom screen this way."
20
20
  },
21
21
  {
22
22
  "version": "0.123.2",
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "improvement",
5
+ "title": "Waitlist mails carry designer labels",
6
+ "detail": "Feature and notify labels for confirmation and admin-notice name the waitlist mails in the document designer."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Waitlist invite no longer double-invites, uses the entry locale and refuses members",
12
+ "detail": "Two parallel invite calls for one entry now queue on a per-entry advisory lock, so exactly one tenant and one invitation are created. The invitation mail is rendered in the entry's locale instead of the caller's. Inviting an email that already is a member of the target tenant fails with 422 waitlist_not_invitable, as reject already did."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "fix",
17
+ "title": "Waitlist submit honors emailRateLimits: [], resolves notifyRecipient before the advisory lock and checks only open entries",
18
+ "detail": "createWaitlistFeature({ emailRateLimits: [] }) no longer fails boot validation; the per-email bucket is dropped as documented. notifyRecipient runs before the per-email advisory lock and the entry insert, so a failing resolver can no longer abort the submit transaction or hold the lock; a throwing resolver only skips the admin notice. The duplicate check queries status pending or invited with limit 1 instead of scanning the first 50 rows of the address. The feature description now states that the response body is identical for known and unknown addresses while response timing can differ."
19
+ },
2
20
  {
3
21
  "version": "0.363.0",
4
22
  "type": "breaking",
@@ -26,7 +26,7 @@ import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-ho
26
26
  export function createWaitlistFeature(opts: WaitlistOptions = {}): FeatureDefinition {
27
27
  return defineFeature(WAITLIST_FEATURE, (r) => {
28
28
  r.describe(
29
- "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 never reveals whether 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.",
29
+ "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.",
30
30
  );
31
31
  r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
32
32
  r.systemScope();
@@ -1,4 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
2
3
  import {
3
4
  createSystemUser,
4
5
  defineWriteHandler,
@@ -18,7 +19,13 @@ import { AuthErrors, AuthHandlers } from "../../auth-email-password/index.js";
18
19
  import { TenantHandlers, tenantTable } from "../../tenant/index.js";
19
20
  import { DEFAULT_OWN_TENANT_INVITE_ROLE, WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
20
21
  import { isOpenWaitlistStatus } from "../entity.js";
21
- import { adminAccess, platformActor, waitlistDb, waitlistExecutor } from "../lib.js";
22
+ import {
23
+ adminAccess,
24
+ isTenantMember,
25
+ platformActor,
26
+ waitlistDb,
27
+ waitlistExecutor,
28
+ } from "../lib.js";
22
29
  import type { WaitlistInviteOptions } from "../options.js";
23
30
  import { WaitlistIdSchema } from "../payloads.js";
24
31
 
@@ -30,6 +37,11 @@ export type WaitlistInviteData = {
30
37
 
31
38
  type InviteTarget = { readonly tenantId: TenantId; readonly role: string };
32
39
 
40
+ // pg_advisory_xact_lock namespace (int4): 'wlin' as ASCII, disjoint from the submit lock.
41
+ const INVITE_LOCK_NAMESPACE = 0x776c696e;
42
+ const INVITE_LOCK_REASON =
43
+ "takes the per-entry advisory lock so parallel invites cannot both create a tenant and invitation; TenantDb has no lock API";
44
+
33
45
  const DEFAULT_INVITE: WaitlistInviteOptions = {
34
46
  mode: "own-tenant",
35
47
  role: DEFAULT_OWN_TENANT_INVITE_ROLE,
@@ -48,10 +60,11 @@ type EntryRow = {
48
60
  readonly name: string;
49
61
  readonly company: string | null;
50
62
  readonly linkedTenantId: string | null;
63
+ readonly locale: string | undefined;
51
64
  };
52
65
 
53
66
  function readEntryRow(row: Record<string, unknown>): EntryRow {
54
- const { id, version, email, name, company, linkedTenantId } = row;
67
+ const { id, version, email, name, company, linkedTenantId, locale } = row;
55
68
  if (
56
69
  typeof id !== "string" ||
57
70
  typeof version !== "number" ||
@@ -68,6 +81,7 @@ function readEntryRow(row: Record<string, unknown>): EntryRow {
68
81
  name,
69
82
  company: typeof company === "string" && company !== "" ? company : null,
70
83
  linkedTenantId: typeof linkedTenantId === "string" ? linkedTenantId : null,
84
+ locale: typeof locale === "string" && locale !== "" ? locale : undefined,
71
85
  };
72
86
  }
73
87
 
@@ -122,8 +136,17 @@ export function createInviteHandler(invite: WaitlistInviteOptions = DEFAULT_INVI
122
136
  "Invites a pending or already-invited waitlist entry: creates its tenant (or uses the configured shared tenant), mails the entrant a fresh accept link with the configured role and marks the entry invited; rejected entries cannot be invited. The invitee never receives a global role.",
123
137
  // Sends an invite link by email, which cannot be recalled.
124
138
  agent: { risk: "high" },
139
+ escapeHatch: { grants: ["unsafeRaw"], reason: INVITE_LOCK_REASON },
125
140
  handler: async (event, ctx) => {
126
141
  const db = waitlistDb(ctx);
142
+ if (!ctx.systemDb) {
143
+ throw new InternalError({ message: "waitlist:invite requires ctx.systemDb" });
144
+ }
145
+ await acquireNamespacedAdvisoryLock(
146
+ ctx.systemDb.unsafeRaw(INVITE_LOCK_REASON),
147
+ INVITE_LOCK_NAMESPACE,
148
+ event.payload.id,
149
+ );
127
150
  const row = await waitlistExecutor.detail({ id: event.payload.id }, platformActor(), db);
128
151
  if (!row) return writeFailure(new NotFoundError("waitlistEntry", event.payload.id));
129
152
  const entry = readEntryRow(row);
@@ -134,13 +157,22 @@ export function createInviteHandler(invite: WaitlistInviteOptions = DEFAULT_INVI
134
157
  const target = await resolveInviteTarget(invite, entry, ctx);
135
158
  if ("isSuccess" in target) return target;
136
159
 
160
+ if (await isTenantMember(db, entry.email, target.tenantId)) {
161
+ return writeFailure(new UnprocessableError(WaitlistErrors.notInvitable));
162
+ }
163
+
137
164
  // inviteCreate is admin-only (no system role); the system variant with an
138
165
  // empty globalRoles list is the only in-process path, and a waitlist
139
166
  // invite must never grant a global role.
140
167
  const invited = await ctx.writeAs(
141
168
  createSystemUser(target.tenantId),
142
169
  AuthHandlers.systemInviteCreate,
143
- { email: entry.email.toLowerCase(), role: target.role, globalRoles: [] },
170
+ {
171
+ email: entry.email.toLowerCase(),
172
+ role: target.role,
173
+ globalRoles: [],
174
+ ...(entry.locale !== undefined && { locale: entry.locale }),
175
+ },
144
176
  );
145
177
  if (!invited.isSuccess) {
146
178
  // Re-inviting a person who already accepted would reset their live invitation.
@@ -1,11 +1,9 @@
1
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
2
  import {
4
3
  createSystemUser,
5
4
  defineWriteHandler,
6
5
  type HandlerContext,
7
6
  parseTenantId,
8
- type TenantId,
9
7
  } from "@cosmicdrift/kumiko-framework/engine";
10
8
  import {
11
9
  InternalError,
@@ -18,13 +16,12 @@ import {
18
16
  cancelPendingInvitation,
19
17
  INVITATION_STATUS,
20
18
  tenantInvitationsTable,
21
- tenantMembershipsTable,
22
19
  } from "../../tenant/index.js";
23
- import { userTable } from "../../user/index.js";
24
20
  import { WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
25
21
  import { isOpenWaitlistStatus } from "../entity.js";
26
22
  import {
27
23
  adminAccess,
24
+ isTenantMember,
28
25
  normalizeEmail,
29
26
  platformActor,
30
27
  waitlistDb,
@@ -34,18 +31,6 @@ import { WaitlistIdSchema } from "../payloads.js";
34
31
 
35
32
  export type WaitlistRejectData = { readonly kind: "rejected"; readonly id: string };
36
33
 
37
- // An admin re-invite can flip an accepted invitation back to pending, so the
38
- // invitation status alone cannot prove the person holds no access.
39
- async function isTenantMember(db: TenantDb, email: string, tenantId: TenantId): Promise<boolean> {
40
- const user = await fetchOne<{ id: string }>(db, userTable, {
41
- email: normalizeEmail(email),
42
- isDeleted: false,
43
- });
44
- if (!user) return false;
45
- const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
46
- return membership !== undefined && membership !== null;
47
- }
48
-
49
34
  // An invited entry already has a live invitation link; rejecting must kill it,
50
35
  // otherwise the rejected person can still sign up. Once accepted they hold
51
36
  // access, which only tenant member removal may revoke.
@@ -36,10 +36,13 @@ const SUBMIT_ROLES = access.roles(
36
36
  "TenantAdmin",
37
37
  );
38
38
 
39
+ const OPEN_STATUSES = [WAITLIST_STATUS.Pending, WAITLIST_STATUS.Invited];
40
+
39
41
  export type WaitlistSubmitData = { readonly kind: "submitted" };
40
42
 
41
- // Identical for new, duplicate and honeypot submits: the response must not
42
- // reveal whether the email is already on the list.
43
+ // Identical body for new, duplicate and honeypot submits so the response does not
44
+ // state whether the email is already on the list. Timing still differs: only the
45
+ // new-entry path writes and sends mail.
43
46
  const SUBMITTED: WriteResult<WaitlistSubmitData> = {
44
47
  isSuccess: true,
45
48
  data: { kind: "submitted" },
@@ -60,13 +63,32 @@ function isHoneypotFilled(payload: WaitlistSubmitInput): boolean {
60
63
 
61
64
  async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise<boolean> {
62
65
  const found = await waitlistExecutor.list(
63
- { filter: { field: "email", op: "eq", value: email }, limit: 50 },
66
+ {
67
+ filter: { field: "email", op: "eq", value: email },
68
+ filters: [{ field: "status", op: "in", value: OPEN_STATUSES }],
69
+ limit: 1,
70
+ },
64
71
  platformActor(),
65
72
  waitlistDb(ctx),
66
73
  );
67
- return found.rows.some(
68
- (row) => row["status"] === WAITLIST_STATUS.Pending || row["status"] === WAITLIST_STATUS.Invited,
69
- );
74
+ return found.rows.length > 0;
75
+ }
76
+
77
+ // The resolver usually reads config; run it outside the handler transaction's
78
+ // critical section so a failing statement cannot abort the transaction that holds
79
+ // the entry, and a slow resolver does not extend the per-email advisory lock.
80
+ async function resolveAdminRecipient(
81
+ opts: WaitlistOptions,
82
+ ctx: HandlerContext,
83
+ ): Promise<string | null> {
84
+ try {
85
+ return (await opts.notifyRecipient?.(ctx)) || null;
86
+ } catch (err) {
87
+ ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
88
+ error: err instanceof Error ? err.message : String(err),
89
+ });
90
+ return null;
91
+ }
70
92
  }
71
93
 
72
94
  async function sendBestEffort(
@@ -87,14 +109,16 @@ async function sendBestEffort(
87
109
  }
88
110
 
89
111
  export function createSubmitHandler(opts: WaitlistOptions) {
112
+ const emailRateLimits = opts.emailRateLimits ?? DEFAULT_SUBMIT_EMAIL_RATE_LIMITS;
90
113
  return defineWriteHandler<"submit", typeof WaitlistSubmitSchema, WaitlistSubmitData>({
91
114
  name: "submit",
92
115
  schema: WaitlistSubmitSchema,
93
116
  access: { roles: SUBMIT_ROLES, personalData: "public-intake" },
94
117
  rateLimit: opts.rateLimit ?? DEFAULT_SUBMIT_RATE_LIMIT,
95
- additionalRateLimits: opts.emailRateLimits ?? DEFAULT_SUBMIT_EMAIL_RATE_LIMITS,
118
+ // An empty array means "disabled"; boot validation rejects an empty additionalRateLimits.
119
+ ...(emailRateLimits.length > 0 && { additionalRateLimits: emailRateLimits }),
96
120
  description:
97
- "Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail. Always reports success, whether the address is new or already on the list.",
121
+ "Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail. Reports success whether the address is new or already on the list; the response body is identical, response timing can still differ.",
98
122
  escapeHatch: { grants: ["unsafeRaw"], reason: SUBMIT_LOCK_REASON },
99
123
  handler: async (event, ctx) => {
100
124
  const payload = event.payload;
@@ -106,6 +130,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
106
130
  message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
107
131
  });
108
132
  }
133
+ const adminRecipient = await resolveAdminRecipient(opts, ctx);
109
134
  await acquireNamespacedAdvisoryLock(
110
135
  ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON),
111
136
  SUBMIT_LOCK_NAMESPACE,
@@ -140,7 +165,6 @@ export function createSubmitHandler(opts: WaitlistOptions) {
140
165
  );
141
166
 
142
167
  await sendBestEffort(ctx, "admin notice", async (notify) => {
143
- const adminRecipient = await opts.notifyRecipient?.(ctx);
144
168
  // skip: no admin recipient configured
145
169
  if (!adminRecipient) return;
146
170
  await notify(WAITLIST_NOTIFICATION_TYPES.adminNotice, {
@@ -1,6 +1,15 @@
1
1
  type LocalizedString = { readonly en: string; readonly de: string };
2
2
 
3
3
  export const WAITLIST_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "waitlist:feature.label": { en: "Waitlist", de: "Warteliste" },
5
+ "waitlist:notify.confirmation.label": {
6
+ en: "Waitlist confirmation",
7
+ de: "Bestätigung der Warteliste",
8
+ },
9
+ "waitlist:notify.admin-notice.label": {
10
+ en: "New waitlist entry",
11
+ de: "Neuer Eintrag auf der Warteliste",
12
+ },
4
13
  "screen:waitlist-list.title": { en: "Waitlist", de: "Warteliste" },
5
14
  "waitlist.nav.waitlist": { en: "Waitlist", de: "Warteliste" },
6
15
  "waitlist:entity:waitlistEntry:field:name": { en: "Name", de: "Name" },
@@ -1,3 +1,4 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
3
  import {
3
4
  access,
@@ -5,8 +6,11 @@ import {
5
6
  type HandlerContext,
6
7
  type SessionUser,
7
8
  SYSTEM_TENANT_ID,
9
+ type TenantId,
8
10
  } from "@cosmicdrift/kumiko-framework/engine";
9
11
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { tenantMembershipsTable } from "../tenant/index.js";
13
+ import { userTable } from "../user/index.js";
10
14
  import { waitlistEntryEntity, waitlistEntryTable } from "./entity.js";
11
15
 
12
16
  export const WAITLIST_CROSS_TENANT_REASON = "waitlist is platform-wide (systemScope)";
@@ -34,3 +38,19 @@ export function waitlistDb(ctx: HandlerContext): TenantDb {
34
38
  export function platformActor(): SessionUser {
35
39
  return createSystemUser(SYSTEM_TENANT_ID);
36
40
  }
41
+
42
+ // An admin re-invite can flip an accepted invitation back to pending, so the
43
+ // invitation status alone cannot prove the person holds no access.
44
+ export async function isTenantMember(
45
+ db: TenantDb,
46
+ email: string,
47
+ tenantId: TenantId,
48
+ ): Promise<boolean> {
49
+ const user = await fetchOne<{ id: string }>(db, userTable, {
50
+ email: normalizeEmail(email),
51
+ isDeleted: false,
52
+ });
53
+ if (!user) return false;
54
+ const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
55
+ return membership !== undefined && membership !== null;
56
+ }
@@ -38,5 +38,5 @@ export const DEFAULT_SUBMIT_RATE_LIMIT: RateLimitOption = {
38
38
  };
39
39
 
40
40
  export const DEFAULT_SUBMIT_EMAIL_RATE_LIMITS: readonly PayloadRateLimitOption[] = [
41
- { per: { payloadField: "email" }, limit: 3, windowSeconds: 24 * 60 * 60 },
41
+ { per: { payloadField: "email" }, limit: 3, windowSeconds: 24 * 60 * 60, normalize: "email" },
42
42
  ];