@cosmicdrift/kumiko-samples 0.387.0 → 0.388.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 (173) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
  5. package/packages/bundled-features/src/audit/changes.json +12 -0
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  7. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  9. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  10. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  11. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  12. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  13. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  14. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  16. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  28. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  29. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  30. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  31. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  32. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  34. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  35. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  36. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  37. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  38. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  39. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  40. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  41. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  42. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  43. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  44. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  45. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  46. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  47. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  48. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
  49. package/packages/bundled-features/src/cap-counter/changes.json +18 -0
  50. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  51. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  52. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  53. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  54. package/packages/bundled-features/src/config/changes.json +12 -0
  55. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  56. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  57. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  58. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  59. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
  60. package/packages/bundled-features/src/delivery/changes.json +30 -0
  61. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  62. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  64. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  65. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  66. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  67. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  68. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  69. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  70. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  71. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  72. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  73. package/packages/bundled-features/src/files/README.md +1 -1
  74. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  75. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  76. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  77. package/packages/bundled-features/src/host-routing/README.md +1 -1
  78. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  79. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  80. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  83. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  85. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  86. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  87. package/packages/bundled-features/src/jobs/changes.json +13 -0
  88. package/packages/bundled-features/src/jobs/feature.ts +14 -4
  89. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  90. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  92. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  93. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  94. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  95. package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
  96. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  97. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  98. package/packages/bundled-features/src/sessions/feature.ts +7 -2
  99. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  100. package/packages/bundled-features/src/shared/index.ts +1 -1
  101. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  102. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  103. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  104. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  105. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  106. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  107. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  108. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  109. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  110. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  111. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  112. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  113. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  114. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  115. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  116. package/packages/bundled-features/src/template-resolver/changes.json +48 -0
  117. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  118. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  119. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  120. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  121. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  122. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  123. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  125. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
  126. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  127. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  128. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  129. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  131. package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
  132. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
  133. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  134. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  135. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  136. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  137. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  138. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  139. package/packages/bundled-features/src/tenant/changes.json +6 -0
  140. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  141. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  145. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  146. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  147. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  148. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  149. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  150. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  151. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  152. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
  153. package/packages/bundled-features/src/waitlist/import.ts +5 -0
  154. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
  155. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  156. package/packages/bundled-features/src/waitlist/options.ts +3 -0
  157. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  158. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  161. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  162. package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
  163. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  164. package/samples/recipes/auth-mfa/README.md +5 -0
  165. package/samples/recipes/auth-mfa/package.json +5 -2
  166. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  167. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  168. package/samples/recipes/env-composition/README.md +3 -1
  169. package/samples/recipes/env-composition/src/env.ts +2 -0
  170. package/samples/recipes/legal-pages/README.md +1 -1
  171. package/samples/recipes/rate-limiting/README.md +18 -0
  172. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  173. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -50,6 +50,8 @@ export const memberDirectoryQuery = definePagedQueryHandler({
50
50
  limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
51
51
  // Sent by the reference combobox while the user types.
52
52
  search: z.string().trim().min(1).optional(),
53
+ // Resolves exactly these users; callers that know their ids must not depend on the cap.
54
+ ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
53
55
  }),
54
56
  access: { roles: access.admin },
55
57
  description:
@@ -62,7 +64,8 @@ export const memberDirectoryQuery = definePagedQueryHandler({
62
64
  "tenant:query:member-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
63
65
  });
64
66
  }
65
- const { limit, search } = query.payload;
67
+ const { limit, search, ids } = query.payload;
68
+ if (ids?.length === 0) return { rows: [], nextCursor: null };
66
69
  // Without a search the first `limit` rows are enough; with one, every
67
70
  // candidate must be decrypted before the limit can apply.
68
71
  const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
@@ -71,13 +74,16 @@ export const memberDirectoryQuery = definePagedQueryHandler({
71
74
  const db = ctx.systemDb.acknowledgeCrossTenant(
72
75
  "SystemAdmin reference labels span every tenant, as user:query:user:list did",
73
76
  );
74
- users = await selectMany(db, userTable, undefined, {
77
+ users = await selectMany(db, userTable, ids === undefined ? undefined : { id: ids }, {
75
78
  limit: scanLimit,
76
79
  orderBy: { col: "id", direction: "asc" },
77
80
  });
78
81
  } else {
79
82
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
80
- const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, scanLimit);
83
+ const requestedIds = ids === undefined ? undefined : new Set(ids);
84
+ const userIds = (await loadMemberUserIds(db, query.user.tenantId))
85
+ .filter((id) => requestedIds === undefined || requestedIds.has(id))
86
+ .slice(0, scanLimit);
81
87
  users =
82
88
  userIds.length > 0
83
89
  ? await selectMany(
@@ -27,10 +27,12 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
27
27
  limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
28
28
  // Sent by the reference combobox while the user types.
29
29
  search: z.string().trim().min(1).optional(),
30
+ // Resolves exactly these tenants; callers that know their ids must not depend on the cap.
31
+ ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
30
32
  }),
31
33
  access: { roles: access.admin },
32
34
  description:
33
- "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.",
35
+ "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.",
34
36
  agent: { expose: false },
35
37
  handler: async (query, ctx) => {
36
38
  if (!ctx.systemDb) {
@@ -39,14 +41,15 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
39
41
  "tenant:query:tenant-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
40
42
  });
41
43
  }
42
- const { limit, search } = query.payload;
44
+ const { limit, search, ids } = query.payload;
45
+ if (ids?.length === 0) return { rows: [], nextCursor: null };
43
46
  const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
44
47
  let tenants: readonly { id: unknown; name?: unknown }[];
45
48
  if (isSystemAdmin(query.user)) {
46
49
  const db = ctx.systemDb.acknowledgeCrossTenant(
47
50
  "SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did",
48
51
  );
49
- tenants = await selectMany(db, tenantTable, undefined, {
52
+ tenants = await selectMany(db, tenantTable, ids === undefined ? undefined : { id: ids }, {
50
53
  limit: scanLimit,
51
54
  orderBy: [
52
55
  { col: "key", direction: "asc" },
@@ -56,7 +59,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
56
59
  } else {
57
60
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
58
61
  const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
59
- tenants = row ? [row] : [];
62
+ tenants = row && (ids === undefined || ids.includes(query.user.tenantId)) ? [row] : [];
60
63
  }
61
64
  // name is ciphertext at rest, so the SQL page is keyed by the plaintext
62
65
  // `key`; label order and search happen in memory after decrypting.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Destroy sweep keeps a minimum time slice per tenant",
6
+ "detail": "With many tenants in destroying state the shared tick budget no longer shrinks a tenant's slice below two seconds, so chunking hooks make progress instead of failing the stage as no-progress attempts."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",
@@ -62,3 +62,8 @@ export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[numb
62
62
  // The sweep cron fires every minute with concurrency "skip", so a tick must
63
63
  // finish well inside that window or the next one is skipped.
64
64
  export const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30_000;
65
+
66
+ // Lower bound of a tenant's share of the tick budget. Below it a chunking hook
67
+ // without a guaranteed first batch processes nothing, and a no-progress stage
68
+ // counts as a failed attempt, which would abandon the pipeline under load.
69
+ export const DESTRUCTION_STAGE_MIN_SLICE_MS = 2_000;
@@ -23,6 +23,7 @@ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
23
23
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
24
  import { tenantEntity, tenantTable } from "../tenant/index.ts";
25
25
  import {
26
+ DESTRUCTION_STAGE_MIN_SLICE_MS,
26
27
  DESTRUCTION_STAGE_TICK_BUDGET_MS,
27
28
  TENANT_AGGREGATE_TYPE,
28
29
  TENANT_DESTRUCTION_COMPLETED_EVENT_QN,
@@ -461,7 +462,10 @@ export async function runTenantDestructionSweep(args: {
461
462
  // window and starve the ones behind it. Each slice starts when its tenant
462
463
  // starts: anchoring to a shared tick deadline would hand tenants after an
463
464
  // overrun a zero slice, and a no-progress stage counts as a failed attempt.
464
- const sliceMs = DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length);
465
+ const sliceMs = Math.max(
466
+ DESTRUCTION_STAGE_MIN_SLICE_MS,
467
+ DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length),
468
+ );
465
469
  for (const row of destroying) {
466
470
  const tenantDeadlineAt = Date.now() + sliceMs;
467
471
  try {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "set-tenant-tier no longer accepts source default",
6
+ "detail": "The source field of set-tenant-tier is limited to manual and billing. The value default is reserved for the signup default tier and made a manual grant look like a billing row that the next sync overwrites."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "breaking",
@@ -59,7 +59,10 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
59
59
  .min(1)
60
60
  .max(50)
61
61
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
62
- source: z.enum(TierAssignmentSources).default(TierAssignmentSources.manual),
62
+ // `default` is reserved for the signup default tier; the billing sync treats it like `billing`.
63
+ source: z
64
+ .enum([TierAssignmentSources.manual, TierAssignmentSources.billing])
65
+ .default(TierAssignmentSources.manual),
63
66
  }),
64
67
  access: { roles: ["SystemAdmin"] },
65
68
  agent: { risk: "high" },
@@ -3,6 +3,8 @@
3
3
  // category in the kumiko isolation model (runtime may import client, client
4
4
  // may only import client) — otherwise the runtime isolation guard throws on
5
5
  // the client imports. Same pattern as text-content/constants.
6
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
7
+
6
8
  // Feature name
7
9
  export const TIER_ENGINE_FEATURE = "tier-engine" as const;
8
10
 
@@ -24,10 +26,8 @@ export const TierAssignmentSources = {
24
26
  export type TierAssignmentSource =
25
27
  (typeof TierAssignmentSources)[keyof typeof TierAssignmentSources];
26
28
 
27
- const TIER_ASSIGNMENT_SOURCE_VALUES: readonly string[] = Object.values(TierAssignmentSources);
28
-
29
29
  export function isTierAssignmentSource(value: unknown): value is TierAssignmentSource {
30
- return typeof value === "string" && TIER_ASSIGNMENT_SOURCE_VALUES.includes(value);
30
+ return isOneOf(Object.values(TierAssignmentSources), value);
31
31
  }
32
32
 
33
33
  // Qualified query handler names.
@@ -21,7 +21,6 @@ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
21
21
  // storage indefinitely.
22
22
 
23
23
  import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
24
- import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
25
24
  import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
26
25
  import {
27
26
  createEventStoreExecutor,
@@ -44,7 +43,7 @@ import {
44
43
  import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
45
44
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
46
45
  import { resolveProfileForTenant } from "../compliance-profiles/index.ts";
47
- import { decryptStoredPii } from "../shared/index.ts";
46
+ import { decryptStoredUserEmail } from "../shared/index.ts";
48
47
  import { userTable } from "../user/index.ts";
49
48
  import { selectExportJobsForStorageCleanup } from "./db/queries/export-jobs.ts";
50
49
  import { runUserExport, type UserExportBundle } from "./run-user-export.ts";
@@ -822,8 +821,8 @@ async function lookupUserContact(
822
821
  locale: string | null;
823
822
  } | null;
824
823
  if (!row?.email) return null;
825
- const email = await decryptStoredPii(row.email, "email", "user-data-rights:export-ready");
826
- if (email === PII_ERASED_SENTINEL) return null;
824
+ const email = await decryptStoredUserEmail(row.email, "user-data-rights:export-ready");
825
+ if (email === null) return null;
827
826
  return { email, locale: row.locale ?? null };
828
827
  }
829
828
 
@@ -518,7 +518,8 @@ async function processUser(args: {
518
518
  // is what makes this crash-safe: eraseKey-throw → tx rollback → user
519
519
  // stays DeletionRequested → next run retries; flip-throw after a
520
520
  // successful erase → retry re-runs the idempotent hooks and eraseKey
521
- // (contractually a no-op on an erased/unknown subject). Erasing after
521
+ // (idempotent: a repeat erase keeps the original tombstone, an unknown
522
+ // subject gets one). Erasing after
522
523
  // the flip instead would risk a permanent silent miss: user already
523
524
  // Deleted, no run ever picks him up again, key stays live.
524
525
  if (kms) {
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Link-on-signup failures, legacy re-run and purge audit order",
6
+ "detail": "link-on-signup throws when the entry update fails and the entry is still invited, so the consumer redelivers. The offlot legacy migration skips rows that are already imports. purge writes its audit event before shredding keys."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Admin notice language comes from adminLocale",
12
+ "detail": "The admin notice mail used the request locale, which is the entrant's browser language. The new adminLocale option (en, de or es, default en) sets the operator's language."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Legacy migration dry run counts the streams it would archive",
18
+ "detail": "The dry run now runs the read-only archive checks and reports archivedStreams without writing; the CLI prints \"would archive\" for it."
19
+ },
2
20
  {
3
21
  "version": "0.379.0",
4
22
  "type": "improvement",
@@ -74,8 +74,9 @@ export const purgeHandler = defineWriteHandler<"purge", typeof PurgeSchema, Wait
74
74
  }),
75
75
  );
76
76
  }
77
- await purgeWaitlistRows(db, rows, event.user.id);
77
+ // Audit first: purgeWaitlistRows shreds KMS keys, which a later failure cannot roll back.
78
78
  await appendAudit(ctx, WAITLIST_PURGED_EVENT_QN, rows.length);
79
+ await purgeWaitlistRows(db, rows, event.user.id);
79
80
  return { isSuccess: true, data: { purged: rows.length } };
80
81
  },
81
82
  });
@@ -73,9 +73,10 @@ async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise
73
73
  return found.rows.length > 0;
74
74
  }
75
75
 
76
- // The resolver usually reads config; run it outside the handler transaction's
77
- // critical section so a failing statement cannot abort the transaction that holds
78
- // the entry, and a slow resolver does not extend the per-email advisory lock.
76
+ // The resolver usually reads config. It runs before the per-email advisory lock so a
77
+ // slow resolver does not extend the lock. It still shares the handler transaction: a
78
+ // thrown JS error is swallowed here, but a failed SQL statement inside it would abort
79
+ // the transaction, so resolvers must not issue statements that can fail.
79
80
  async function resolveAdminRecipient(
80
81
  opts: WaitlistOptions,
81
82
  ctx: HandlerContext,
@@ -175,8 +176,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
175
176
  message: payload.message,
176
177
  }),
177
178
  priority: "normal",
178
- // Operator-facing: the request locale, not the one the entrant picked in the form.
179
- locale: resolveWaitlistMailLocale(ctx.locale),
179
+ locale: resolveWaitlistMailLocale(opts.adminLocale),
180
180
  });
181
181
  });
182
182
 
@@ -38,6 +38,11 @@ export function waitlistImportId(sourceKey: string): string {
38
38
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
39
39
  }
40
40
 
41
+ // The version nibble marks ids minted by waitlistImportId; legacy row ids are random UUIDs.
42
+ export function isWaitlistImportId(id: string): boolean {
43
+ return id[14] === "5";
44
+ }
45
+
41
46
  function presentOnly(fields: Record<string, unknown>): Record<string, unknown> {
42
47
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
43
48
  }
@@ -9,6 +9,7 @@ import {
9
9
  createTenantDb,
10
10
  selectMany,
11
11
  table,
12
+ tableExists,
12
13
  text as textColumn,
13
14
  timestamp,
14
15
  transaction,
@@ -23,9 +24,16 @@ import {
23
24
  } from "@cosmicdrift/kumiko-framework/event-store";
24
25
  import type { DbConnection } from "@cosmicdrift/kumiko-types/db-connection";
25
26
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
26
- import { importWaitlistEntry, type WaitlistImportEntry, waitlistImportId } from "./import.ts";
27
+ import {
28
+ importWaitlistEntry,
29
+ isWaitlistImportId,
30
+ type WaitlistImportEntry,
31
+ waitlistImportId,
32
+ } from "./import.ts";
27
33
  import { WAITLIST_STATUS, type WaitlistStatus } from "./web/constants.ts";
28
34
 
35
+ const ARCHIVED_STREAMS_TABLE = "public.kumiko_archived_streams";
36
+
29
37
  export const LEGACY_WAITLIST_SOURCES = ["offlot", "publicstatus"] as const;
30
38
  export type LegacyWaitlistSource = (typeof LEGACY_WAITLIST_SOURCES)[number];
31
39
 
@@ -210,15 +218,19 @@ async function archiveLegacyStream(
210
218
  db: DbConnection,
211
219
  legacyId: string,
212
220
  importedId: string,
221
+ dryRun: boolean,
213
222
  ): Promise<number> {
214
223
  if (legacyId === importedId) return 0;
215
- if (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId)) return 0;
224
+ // A dry run never creates the archive table, so a missing one means nothing is archived yet.
225
+ const archiveTableReady = !dryRun || (await tableExists(db, ARCHIVED_STREAMS_TABLE));
226
+ if (archiveTableReady && (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId))) return 0;
216
227
  const legacyEvents = await countWhere(db, eventsTable, {
217
228
  tenantId: SYSTEM_TENANT_ID,
218
229
  aggregateId: legacyId,
219
230
  aggregateType: LEGACY_AGGREGATE_TYPE,
220
231
  });
221
232
  if (legacyEvents === 0) return 0;
233
+ if (dryRun) return 1;
222
234
  await archiveStream(db, {
223
235
  tenantId: SYSTEM_TENANT_ID,
224
236
  aggregateId: legacyId,
@@ -243,6 +255,12 @@ export async function migrateLegacyWaitlist(
243
255
  let archivedStreams = 0;
244
256
  if (!options.dryRun && options.from === "offlot") await createArchivedStreamsTable(db);
245
257
  for (const row of rows) {
258
+ // The offlot default source is the entity table: rows a previous run imported
259
+ // are already migrated entries and must neither be re-imported nor archived.
260
+ if (options.from === "offlot" && isWaitlistImportId(String(row["id"]))) {
261
+ skipped += 1;
262
+ continue;
263
+ }
246
264
  const entry =
247
265
  options.from === "offlot"
248
266
  ? await mapOfflotRow(row)
@@ -257,11 +275,12 @@ export async function migrateLegacyWaitlist(
257
275
  if (outcome === "created") created += 1;
258
276
  else skipped += 1;
259
277
  });
260
- if (!options.dryRun && options.from === "offlot") {
278
+ if (options.from === "offlot") {
261
279
  archivedStreams += await archiveLegacyStream(
262
280
  db,
263
281
  String(row["id"]),
264
282
  waitlistImportId(entry.sourceKey),
283
+ options.dryRun,
265
284
  );
266
285
  }
267
286
  }
@@ -75,22 +75,25 @@ export const linkWaitlistOnSignup: MultiStreamApplyFn = async (event, tx) => {
75
75
  if (typeof user?.email !== "string") return;
76
76
  const email = normalizeEmail(await decryptStoredPii(user.email, "email", PII_DECRYPT_REASON));
77
77
 
78
- const found = await waitlistExecutor.list(
79
- {
80
- filter: { field: "email", op: "eq", value: email },
81
- filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
82
- },
83
- platformActor(),
84
- db,
85
- );
86
- const invited = [...found.rows].sort(newestSubmittedFirst)[0];
78
+ const findInvited = async () => {
79
+ const found = await waitlistExecutor.list(
80
+ {
81
+ filter: { field: "email", op: "eq", value: email },
82
+ filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
83
+ },
84
+ platformActor(),
85
+ db,
86
+ );
87
+ return [...found.rows].sort(newestSubmittedFirst)[0];
88
+ };
89
+ const invited = await findInvited();
87
90
  // skip: the signup email has no invited waitlist entry (open signup or stale invite).
88
91
  if (!invited) return;
89
92
  const { id, version } = invited;
90
93
  // skip: defensive guard on the row shape.
91
94
  if (typeof id !== "string" || typeof version !== "number") return;
92
95
 
93
- await waitlistExecutor.update(
96
+ const updated = await waitlistExecutor.update(
94
97
  {
95
98
  id,
96
99
  version,
@@ -103,4 +106,11 @@ export const linkWaitlistOnSignup: MultiStreamApplyFn = async (event, tx) => {
103
106
  platformActor(),
104
107
  db,
105
108
  );
109
+ // skip: linked.
110
+ if (updated.isSuccess) return;
111
+ // A concurrent admin change that already moved the entry out of `invited` makes the link
112
+ // moot; otherwise throw so the at-least-once consumer redelivers.
113
+ // skip: the entry left `invited` concurrently.
114
+ if ((await findInvited())?.id !== id) return;
115
+ throw new Error(`waitlist:link-on-signup: could not link waitlist entry ${id}`);
106
116
  };
@@ -24,6 +24,9 @@ export type WaitlistOptions = {
24
24
  * string or an absent option sends none. Best effort, never fails the submit,
25
25
  * also when the resolver throws or rejects. */
26
26
  readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
27
+ /** Language of the admin notice mail (en, de or es; anything else falls back to en).
28
+ * Not taken from the request: a submit comes from the entrant, not the operator. */
29
+ readonly adminLocale?: string;
27
30
  /** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
28
31
  readonly invite?: WaitlistInviteOptions;
29
32
  /** Default: 5 submits per IP per 10 minutes. Pass `{ disabled: true, reason }`
@@ -119,6 +119,17 @@ export const WIDGETS_I18N = {
119
119
  "widgets:catalog:status-maintenance": { de: "maintenance", en: "maintenance" },
120
120
  "widgets:catalog:repayment-model": { de: "Tilgungsmodell", en: "Repayment model" },
121
121
  "widgets:catalog:mode-annuity": { de: "Annuität", en: "Annuity" },
122
+ "widgets:catalog:choice-cards": { de: "Zahlungsanbieter", en: "Payment provider" },
123
+ "widgets:catalog:choice-direct": { de: "Direkt", en: "Direct" },
124
+ "widgets:catalog:choice-direct-description": {
125
+ de: "Rechnung vom Anbieter der App",
126
+ en: "Invoiced by the app operator",
127
+ },
128
+ "widgets:catalog:choice-reseller": { de: "Reseller", en: "Reseller" },
129
+ "widgets:catalog:choice-reseller-description": {
130
+ de: "Verkäufer ist der Reseller, inklusive Umsatzsteuer",
131
+ en: "Sold by the reseller, VAT included",
132
+ },
122
133
  "widgets:catalog:mode-fixed": { de: "Feste Rate", en: "Fixed rate" },
123
134
  "widgets:catalog:model": { de: "Modell", en: "Model" },
124
135
  "widgets:catalog:nominal-rate": { de: "Sollzins", en: "Nominal rate" },
@@ -6,6 +6,7 @@ import {
6
6
  AiTextArea,
7
7
  AiTextField,
8
8
  BooleanField,
9
+ ChoiceCards,
9
10
  CollapsibleSection,
10
11
  ComparisonTable,
11
12
  DateField,
@@ -50,6 +51,7 @@ export function Widgets(): ReactNode {
50
51
  const t = useTranslation();
51
52
  const locale = useLocale().locale();
52
53
  const [mode, setMode] = useState<"annuity" | "fixed">("annuity");
54
+ const [provider, setProvider] = useState<"direct" | "reseller">("direct");
53
55
  const [drawerOpen, setDrawerOpen] = useState(false);
54
56
  const [belowHeaderDrawerOpen, setBelowHeaderDrawerOpen] = useState(false);
55
57
  const [floatingPanelOpen, setFloatingPanelOpen] = useState(false);
@@ -181,6 +183,28 @@ export function Widgets(): ReactNode {
181
183
  />
182
184
  </SectionCard>
183
185
 
186
+ <SectionCard title={t("widgets:catalog:choice-cards")}>
187
+ <ChoiceCards
188
+ value={provider}
189
+ onChange={setProvider}
190
+ ariaLabel={t("widgets:catalog:choice-cards")}
191
+ options={[
192
+ {
193
+ value: "direct",
194
+ title: t("widgets:catalog:choice-direct"),
195
+ description: t("widgets:catalog:choice-direct-description"),
196
+ aside: "9,00 €",
197
+ },
198
+ {
199
+ value: "reseller",
200
+ title: t("widgets:catalog:choice-reseller"),
201
+ description: t("widgets:catalog:choice-reseller-description"),
202
+ aside: "9,90 €",
203
+ },
204
+ ]}
205
+ />
206
+ </SectionCard>
207
+
184
208
  <CollapsibleSection title={t("widgets:catalog:advanced-settings")}>
185
209
  <EmptyState
186
210
  title={t("widgets:catalog:no-extra-repayments-title")}
@@ -4,6 +4,7 @@
4
4
  // runs in a Playwright worker and must not pull the server import chain).
5
5
 
6
6
  export const GALLERY_PROVIDER = "gallery-mock-provider";
7
+ export const GALLERY_MOR_PROVIDER = "gallery-mock-mor";
7
8
  export const GALLERY_TERMS_SLUG = "gallery-terms";
8
9
  export const GALLERY_OPERATOR_EMAIL = "billing@gallery.example";
9
10
  export const GALLERY_PLAN_PATH = "/tenant-admin/billing-plans";
@@ -14,3 +15,7 @@ export const GALLERY_TENANT_HOST = "localhost";
14
15
  export const GALLERY_APEX_HOST = "127.0.0.1";
15
16
 
16
17
  export const GALLERY_PRICE_IDS = { starter: "price_gallery_starter", pro: "price_gallery_pro" };
18
+ export const GALLERY_MOR_PRICE_IDS = {
19
+ starter: "price_gallery_mor_starter",
20
+ pro: "price_gallery_mor_pro",
21
+ };