@cosmicdrift/kumiko-bundled-features 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 (212) hide show
  1. package/dist/agent-tools/tool-dispatch.d.ts +7 -7
  2. package/dist/agent-tools/tool-dispatch.js +9 -9
  3. package/dist/audit/handlers/audit-events.js +1 -1
  4. package/dist/auth-mfa/feature-exports.d.ts +2 -2
  5. package/dist/auth-mfa/feature.js +8 -0
  6. package/dist/auth-mfa/web/i18n.js +5 -1
  7. package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
  8. package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
  9. package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
  10. package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
  11. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
  12. package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
  13. package/dist/billing-foundation/checkout-core.d.ts +9 -14
  14. package/dist/billing-foundation/checkout-core.js +24 -39
  15. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
  16. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
  17. package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
  18. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
  19. package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
  20. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
  21. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
  22. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
  23. package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
  24. package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
  25. package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
  26. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
  27. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
  28. package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
  29. package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
  30. package/dist/billing-foundation/events.d.ts +1 -0
  31. package/dist/billing-foundation/events.js +1 -0
  32. package/dist/billing-foundation/feature.js +3 -1
  33. package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
  34. package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
  35. package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
  36. package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
  37. package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
  38. package/dist/billing-foundation/handlers/process-event.write.js +8 -2
  39. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
  40. package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
  41. package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
  42. package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
  43. package/dist/billing-foundation/i18n.js +22 -0
  44. package/dist/billing-foundation/index.d.ts +1 -1
  45. package/dist/billing-foundation/plan-catalog.d.ts +21 -5
  46. package/dist/billing-foundation/plan-catalog.js +25 -16
  47. package/dist/billing-foundation/plan-switch.d.ts +12 -0
  48. package/dist/billing-foundation/plan-switch.js +60 -0
  49. package/dist/billing-foundation/types.d.ts +57 -1
  50. package/dist/billing-foundation/types.js +9 -0
  51. package/dist/billing-foundation/validate-options.js +17 -0
  52. package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
  53. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
  54. package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
  55. package/dist/billing-foundation/web/constants.d.ts +4 -0
  56. package/dist/billing-foundation/web/constants.js +4 -0
  57. package/dist/billing-foundation/web/plan-price.d.ts +15 -0
  58. package/dist/billing-foundation/web/plan-price.js +32 -0
  59. package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
  60. package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
  61. package/dist/cap-counter/cap-reservation.js +5 -1
  62. package/dist/cap-counter/enforce-cap.js +2 -1
  63. package/dist/cap-counter/stock-cap-guard.js +7 -1
  64. package/dist/channel-email/feature.js +1 -1
  65. package/dist/config/handlers/cascade.query.d.ts +5 -0
  66. package/dist/config/handlers/cascade.query.js +4 -3
  67. package/dist/config/handlers/values.query.d.ts +5 -0
  68. package/dist/config/handlers/values.query.js +4 -3
  69. package/dist/config/write-helpers.d.ts +1 -0
  70. package/dist/config/write-helpers.js +18 -9
  71. package/dist/crypto-shredding/handlers/forget-subject.write.js +13 -10
  72. package/dist/delivery/channel-context.js +6 -3
  73. package/dist/delivery/db/queries/preferences.js +2 -2
  74. package/dist/delivery/delivery-service.js +22 -7
  75. package/dist/delivery/handlers/preferences.query.d.ts +1 -1
  76. package/dist/delivery/handlers/preferences.query.js +20 -1
  77. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
  78. package/dist/delivery/unsubscribe-link.d.ts +3 -1
  79. package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
  80. package/dist/delivery/unsubscribe-page-strings.js +1 -0
  81. package/dist/delivery/unsubscribe-page.d.ts +2 -0
  82. package/dist/delivery/unsubscribe-page.js +5 -0
  83. package/dist/delivery/unsubscribe.js +25 -7
  84. package/dist/file-foundation/index.d.ts +1 -1
  85. package/dist/file-foundation/index.js +1 -1
  86. package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
  87. package/dist/file-foundation/read-file-ref-content.js +6 -1
  88. package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
  89. package/dist/form-draft/db/queries/cleanup.js +2 -2
  90. package/dist/form-draft/decrypt-draft.js +5 -1
  91. package/dist/host-routing/dispatch.js +11 -20
  92. package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
  93. package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
  94. package/dist/inbound-mail-foundation/i18n.js +0 -1
  95. package/dist/inbound-mail-foundation/screens.js +0 -3
  96. package/dist/inbound-mail-foundation/types.d.ts +4 -3
  97. package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
  98. package/dist/jobs/feature.d.ts +1 -0
  99. package/dist/jobs/feature.js +8 -4
  100. package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
  101. package/dist/jobs/handlers/tenant-failures.query.js +45 -41
  102. package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
  103. package/dist/jobs/handlers/tenant-runs.query.js +54 -50
  104. package/dist/jobs/job-run-logger.js +5 -4
  105. package/dist/mail-foundation/feature.d.ts +3 -3
  106. package/dist/mail-foundation/feature.js +3 -0
  107. package/dist/personal-access-tokens/feature.d.ts +1 -1
  108. package/dist/personal-access-tokens/feature.js +4 -0
  109. package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
  110. package/dist/renderer-pdf/render-table-pdf.js +22 -10
  111. package/dist/sessions/feature.d.ts +2 -2
  112. package/dist/sessions/feature.js +4 -0
  113. package/dist/shared/decrypt-stored-pii.d.ts +1 -0
  114. package/dist/shared/decrypt-stored-pii.js +6 -1
  115. package/dist/shared/index.d.ts +1 -1
  116. package/dist/shared/index.js +1 -1
  117. package/dist/shared/mail-template-body.d.ts +6 -2
  118. package/dist/shared/mail-template-body.js +7 -4
  119. package/dist/step-dispatcher/feature.js +1 -0
  120. package/dist/subscription-dodo/client.d.ts +17 -0
  121. package/dist/subscription-dodo/client.js +31 -0
  122. package/dist/subscription-dodo/constants.d.ts +12 -0
  123. package/dist/subscription-dodo/constants.js +19 -0
  124. package/dist/subscription-dodo/feature.d.ts +37 -0
  125. package/dist/subscription-dodo/feature.js +144 -0
  126. package/dist/subscription-dodo/index.d.ts +2 -0
  127. package/dist/subscription-dodo/index.js +4 -0
  128. package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
  129. package/dist/subscription-dodo/plugin-methods.js +260 -0
  130. package/dist/subscription-dodo/runtime.d.ts +27 -0
  131. package/dist/subscription-dodo/runtime.js +89 -0
  132. package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
  133. package/dist/subscription-dodo/verify-webhook.js +189 -0
  134. package/dist/subscription-stripe/feature.js +1 -0
  135. package/dist/subscription-stripe/runtime.js +7 -6
  136. package/dist/template-resolver/api.d.ts +3 -2
  137. package/dist/template-resolver/api.js +24 -2
  138. package/dist/template-resolver/compile-document-body.js +1 -0
  139. package/dist/template-resolver/feature.js +2 -2
  140. package/dist/template-resolver/handlers/bundle-import.js +3 -3
  141. package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
  142. package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
  143. package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
  144. package/dist/template-resolver/handlers/document-base.js +3 -2
  145. package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
  146. package/dist/template-resolver/handlers/list.query.js +5 -6
  147. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  148. package/dist/template-resolver/handlers/overview.query.js +6 -3
  149. package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
  150. package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
  151. package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
  152. package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
  153. package/dist/template-resolver/handlers/usage.query.js +26 -16
  154. package/dist/template-resolver/handlers/validate.query.js +7 -2
  155. package/dist/template-resolver/seeding.js +26 -10
  156. package/dist/template-resolver/store-template-resource.d.ts +1 -0
  157. package/dist/template-resolver/store-template-resource.js +19 -4
  158. package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
  159. package/dist/template-resolver/template-delivery-policy.js +0 -6
  160. package/dist/template-resolver/template-meta.js +7 -4
  161. package/dist/template-resolver/template-preview.js +4 -3
  162. package/dist/template-resolver/web/constants.d.ts +2 -2
  163. package/dist/template-resolver/web/constants.js +3 -3
  164. package/dist/template-resolver/web/index.d.ts +1 -0
  165. package/dist/template-resolver/web/index.js +1 -0
  166. package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
  167. package/dist/template-resolver/web/mail-layout-constants.js +7 -0
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +10 -3
  170. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  171. package/dist/tenant/handlers/tenant-directory.query.js +8 -4
  172. package/dist/tenant-lifecycle/constants.d.ts +1 -0
  173. package/dist/tenant-lifecycle/constants.js +4 -0
  174. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  175. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
  176. package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
  177. package/dist/tier-engine/web/constants.js +2 -2
  178. package/dist/user-data-rights/run-export-jobs.js +3 -4
  179. package/dist/user-data-rights/run-forget-cleanup.js +2 -1
  180. package/dist/waitlist/handlers/export-purge.write.js +2 -1
  181. package/dist/waitlist/handlers/submit.write.js +5 -5
  182. package/dist/waitlist/import.d.ts +1 -0
  183. package/dist/waitlist/import.js +4 -0
  184. package/dist/waitlist/legacy-migrate.js +17 -6
  185. package/dist/waitlist/link-on-signup.js +18 -6
  186. package/dist/waitlist/options.d.ts +3 -0
  187. package/package.json +19 -9
  188. package/src/agent-tools/changes.json +6 -0
  189. package/src/audit/changes.json +12 -0
  190. package/src/auth-mfa/changes.json +6 -0
  191. package/src/billing-foundation/changes.json +55 -0
  192. package/src/cap-counter/changes.json +18 -0
  193. package/src/channel-email/changes.json +6 -0
  194. package/src/config/changes.json +12 -0
  195. package/src/crypto-shredding/changes.json +6 -0
  196. package/src/delivery/changes.json +30 -0
  197. package/src/form-draft/changes.json +6 -0
  198. package/src/host-routing/changes.json +6 -0
  199. package/src/inbound-mail-foundation/changes.json +18 -0
  200. package/src/jobs/changes.json +13 -0
  201. package/src/mail-foundation/changes.json +6 -0
  202. package/src/renderer-pdf/changes.json +6 -0
  203. package/src/step-dispatcher/changes.json +6 -0
  204. package/src/subscription-dodo/changes.json +14 -0
  205. package/src/subscription-stripe/changes.json +12 -0
  206. package/src/template-resolver/changes.json +48 -0
  207. package/src/tenant/changes.json +6 -0
  208. package/src/tenant-lifecycle/changes.json +6 -0
  209. package/src/tier-engine/changes.json +6 -0
  210. package/src/waitlist/changes.json +18 -0
  211. package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
  212. package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
@@ -37,11 +37,11 @@ export type ToolCallRequest = {
37
37
  /** Receives the cause of unexpected (5xx) failures, which the tool result never carries. */
38
38
  readonly log?: Pick<Logger, "error">;
39
39
  };
40
- /** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
41
- * disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
42
- * back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
43
- * crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
44
- * `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
45
- * generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
46
- * through the same permission-checked dispatcher pipeline as a normal HTTP request. */
40
+ /** Executes one LLM-issued tool call. Never throws: every failure comes back as `{ ok: false }`
41
+ * so the agent loop can feed the error back to the model instead of crashing.
42
+ * Handler and dispatcher failures carry a `code` (429 adds `retryable`); client errors (< 500)
43
+ * keep their message, server failures (>= 500, unknown throws) are logged and returned as a
44
+ * generic message. Local validation errors (unknown tool, bad arguments) have no `code`.
45
+ * `request.user` must be the caller's real identity: server-kind tools run through the same
46
+ * permission-checked pipeline as an HTTP request. */
47
47
  export declare function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult>;
@@ -1,5 +1,5 @@
1
1
  import { isKumikoError } from "@cosmicdrift/kumiko-framework/errors";
2
- import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
2
+ import { runAsAgentToolCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
3
3
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import { FILTER_OPS, isRecord } from "./filter-ops.js";
@@ -274,13 +274,13 @@ function dispatchClient(descriptor, input) {
274
274
  return dispatchClientAskUser(input);
275
275
  }
276
276
  }
277
- /** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
278
- * disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
279
- * back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
280
- * crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
281
- * `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
282
- * generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
283
- * through the same permission-checked dispatcher pipeline as a normal HTTP request. */
277
+ /** Executes one LLM-issued tool call. Never throws: every failure comes back as `{ ok: false }`
278
+ * so the agent loop can feed the error back to the model instead of crashing.
279
+ * Handler and dispatcher failures carry a `code` (429 adds `retryable`); client errors (< 500)
280
+ * keep their message, server failures (>= 500, unknown throws) are logged and returned as a
281
+ * generic message. Local validation errors (unknown tool, bad arguments) have no `code`.
282
+ * `request.user` must be the caller's real identity: server-kind tools run through the same
283
+ * permission-checked pipeline as an HTTP request. */
284
284
  export async function dispatchToolCall(request) {
285
285
  const { dispatcher, user, toolName, input, dispatchTable, runId, toolCallId } = request;
286
286
  const classifyFailure = createFailureClassifier(toolName, request.log ?? fallbackLog);
@@ -289,7 +289,7 @@ export async function dispatchToolCall(request) {
289
289
  return { ok: false, error: `Unknown tool "${toolName}"` };
290
290
  }
291
291
  try {
292
- return await runAsDirectCallEntry(async () => {
292
+ return await runAsAgentToolCallEntry(async () => {
293
293
  switch (descriptor.kind) {
294
294
  case "search":
295
295
  return await dispatchSearch(descriptor, dispatcher, user, toolName, input);
@@ -12,7 +12,7 @@ import { redactPiiCiphertext } from "../../shared/index.js";
12
12
  import { eventLabelKeyOf, TECHNICAL_EVENT_TYPES } from "../event-classification.js";
13
13
  import { AUDIT_EVENT_ACTIONS, INCLUDE_TECHNICAL_FILTER, } from "../web/constants.js";
14
14
  const MAX_LIMIT = 100;
15
- // At most 18 digits keeps the id inside bigint, so an oversized cursor or id is a 422, not a 500.
15
+ // At most 18 digits keeps the id inside bigint, so an oversized cursor or id is a validation error (400), not a 500.
16
16
  const CURSOR_PATTERN = /^\d{1,18}$/;
17
17
  export const auditListPayloadShape = {
18
18
  cursor: z.string().regex(CURSOR_PATTERN, "cursor must be a positive integer").optional(),
@@ -2,11 +2,11 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type { MfaCodeVerifier } from "./mfa-code-verifier.ts";
3
3
  import type { MfaStatusChecker } from "./mfa-status-checker.ts";
4
4
  import type { MfaTokenSecretsEnv } from "./token-secrets.ts";
5
- export type BindMfaRevokeAllOtherSessions = (revoker: (userId: string, currentSid: string | undefined) => Promise<number>) => void;
5
+ export type BindMfaRevokeAllOtherSessions = (revoker: (userId: string, currentSid: string | undefined) => Promise<number>) => () => void;
6
6
  export declare function bindMfaRevokeAllOtherSessionsFromFeature(feature: FeatureDefinition): BindMfaRevokeAllOtherSessions | undefined;
7
7
  export declare function mfaStatusCheckerFromFeature(feature: FeatureDefinition): MfaStatusChecker | undefined;
8
8
  export declare function mfaVerifierFromFeature(feature: FeatureDefinition): MfaCodeVerifier | undefined;
9
9
  export type BindMfaTokenSecrets = (masterSecret: string, env: MfaTokenSecretsEnv) => void;
10
10
  export declare function bindMfaTokenSecretsFromFeature(feature: FeatureDefinition): BindMfaTokenSecrets | undefined;
11
- export type BindRevokeAllPatTokens = (revoker: (userId: string) => Promise<number>) => void;
11
+ export type BindRevokeAllPatTokens = (revoker: (userId: string) => Promise<number>) => () => void;
12
12
  export declare function bindRevokeAllPatTokensFromFeature(feature: FeatureDefinition): BindRevokeAllPatTokens | undefined;
@@ -147,9 +147,17 @@ export function createAuthMfaFeature(opts) {
147
147
  const verifyMfaCode = createMfaCodeVerifier();
148
148
  const bindRevokeAllOtherSessions = (revoker) => {
149
149
  revokeAllOtherSessions = revoker;
150
+ return () => {
151
+ if (revokeAllOtherSessions === revoker)
152
+ revokeAllOtherSessions = undefined;
153
+ };
150
154
  };
151
155
  const bindRevokeAllPatTokens = (revoker) => {
152
156
  revokeAllPatTokens = revoker;
157
+ return () => {
158
+ if (revokeAllPatTokens === revoker)
159
+ revokeAllPatTokens = undefined;
160
+ };
153
161
  };
154
162
  return {
155
163
  handlers,
@@ -10,6 +10,10 @@ export const defaultTranslations = {
10
10
  "auth.mfa.verify.submit": "Verify",
11
11
  "auth.mfa.verify.submitting": "…",
12
12
  "auth.mfa.verify.backToLogin": "Back to login",
13
+ "auth.mfa.verify.recoveryCode": "Recovery code",
14
+ "auth.mfa.verify.recoverySubtitle": "Enter one of the recovery codes you saved when you set up two-factor authentication.",
15
+ "auth.mfa.verify.useAuthenticatorCode": "Use the code from your app",
16
+ "auth.mfa.verify.useRecoveryCode": "Use a recovery code instead",
13
17
  "auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
14
18
  "auth.mfa.errors.invalidTotpCode": "Invalid code. Please try again.",
15
19
  "auth.mfa.errors.invalidChallengeToken": "Your sign-in has expired. Please sign in again.",
@@ -35,7 +39,7 @@ export const defaultTranslations = {
35
39
  "auth.mfa.setup.confirm": "Complete setup",
36
40
  "auth.mfa.disable.title": "Disable two-factor authentication",
37
41
  "auth.mfa.disable.description": "Confirm with a code from your authenticator app or a recovery code. Your account will then be protected by your password alone.",
38
- "auth.mfa.disable.code": "Code from your authenticator app or a recovery code",
42
+ "auth.mfa.disable.code": "Code from your authenticator app",
39
43
  "auth.mfa.disable.confirm": "Disable",
40
44
  "auth.mfa.disable.cancel": "Cancel",
41
45
  "auth.mfa.disable.trigger": "Disable two-factor authentication",
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from "react";
2
+ export type MfaCodeOrRecoveryFieldProps = {
3
+ readonly id: string;
4
+ readonly codeLabel: string;
5
+ readonly code: string;
6
+ readonly onCodeChange: (code: string) => void;
7
+ readonly useRecovery: boolean;
8
+ readonly onUseRecoveryChange: (useRecovery: boolean) => void;
9
+ readonly disabled?: boolean;
10
+ };
11
+ export declare function MfaCodeOrRecoveryField({ id, codeLabel, code, onCodeChange, useRecovery, onUseRecoveryChange, disabled, }: MfaCodeOrRecoveryFieldProps): ReactNode;
@@ -0,0 +1,12 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ export function MfaCodeOrRecoveryField({ id, codeLabel, code, onCodeChange, useRecovery, onUseRecoveryChange, disabled, }) {
4
+ const t = useTranslation();
5
+ const { Button, Field, Input } = usePrimitives();
6
+ return (_jsxs(_Fragment, { children: [_jsx(Field, { id: id, label: useRecovery ? t("auth.mfa.verify.recoveryCode") : codeLabel, required: true, children: useRecovery ? (_jsx(Input, { kind: "text", id: id, name: id, value: code, onChange: onCodeChange, disabled: disabled, autoFocus: true, autoComplete: "off" })) : (_jsx(Input, { kind: "code", length: 6, id: id, name: id, value: code, onChange: onCodeChange, disabled: disabled, autoFocus: true })) }), _jsx(Button, { type: "button", variant: "link", disabled: disabled, onClick: () => {
7
+ onUseRecoveryChange(!useRecovery);
8
+ onCodeChange("");
9
+ }, children: useRecovery
10
+ ? t("auth.mfa.verify.useAuthenticatorCode")
11
+ : t("auth.mfa.verify.useRecoveryCode") })] }));
12
+ }
@@ -12,20 +12,31 @@ import { jsx as _jsx } from "react/jsx-runtime";
12
12
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
13
13
  import { useState } from "react";
14
14
  import { AuthMfaHandlers } from "./constants.js";
15
+ import { MfaCodeOrRecoveryField } from "./mfa-code-or-recovery-field.js";
15
16
  // kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Dialog internally, name is an MFA domain action, not a primitive reimplementation
16
17
  export function MfaDisableDialog({ open, onOpenChange, onDisabled, onError, }) {
17
18
  const t = useTranslation();
18
- const { Dialog, Field, Input } = usePrimitives();
19
+ const { Dialog } = usePrimitives();
19
20
  const dispatcher = useDispatcher();
20
21
  const [code, setCode] = useState("");
22
+ const [useRecovery, setUseRecovery] = useState(false);
23
+ const reset = () => {
24
+ setCode("");
25
+ setUseRecovery(false);
26
+ };
27
+ const handleOpenChange = (nextOpen) => {
28
+ if (!nextOpen)
29
+ reset();
30
+ onOpenChange(nextOpen);
31
+ };
21
32
  const confirm = async () => {
22
33
  const res = await dispatcher.write(AuthMfaHandlers.disable, { code });
23
- setCode("");
34
+ reset();
24
35
  if (!res.isSuccess) {
25
36
  onError(res.error.i18nKey);
26
37
  return;
27
38
  }
28
39
  onDisabled();
29
40
  };
30
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, variant: "danger", title: t("auth.mfa.disable.title"), description: t("auth.mfa.disable.description"), confirmLabel: t("auth.mfa.disable.confirm"), cancelLabel: t("auth.mfa.disable.cancel"), onConfirm: confirm, testId: "mfa-disable-dialog", children: _jsx(Field, { id: "mfa-disable-code", label: t("auth.mfa.disable.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-disable-code", name: "mfa-disable-code", value: code, onChange: setCode, autoComplete: "one-time-code" }) }) }));
41
+ return (_jsx(Dialog, { open: open, onOpenChange: handleOpenChange, variant: "danger", title: t("auth.mfa.disable.title"), description: t("auth.mfa.disable.description"), confirmLabel: t("auth.mfa.disable.confirm"), cancelLabel: t("auth.mfa.disable.cancel"), onConfirm: confirm, testId: "mfa-disable-dialog", children: _jsx(MfaCodeOrRecoveryField, { id: "mfa-disable-code", codeLabel: t("auth.mfa.disable.code"), code: code, onCodeChange: setCode, useRecovery: useRecovery, onUseRecoveryChange: setUseRecovery }) }));
31
42
  }
@@ -9,20 +9,31 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
10
10
  import { useState } from "react";
11
11
  import { AuthMfaHandlers } from "./constants.js";
12
+ import { MfaCodeOrRecoveryField } from "./mfa-code-or-recovery-field.js";
12
13
  // kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Dialog internally, name is an MFA domain action, not a primitive reimplementation
13
14
  export function MfaRegenerateRecoveryDialog({ open, onOpenChange, onRegenerated, onError, }) {
14
15
  const t = useTranslation();
15
- const { Dialog, Field, Input } = usePrimitives();
16
+ const { Dialog } = usePrimitives();
16
17
  const dispatcher = useDispatcher();
17
18
  const [code, setCode] = useState("");
19
+ const [useRecovery, setUseRecovery] = useState(false);
20
+ const reset = () => {
21
+ setCode("");
22
+ setUseRecovery(false);
23
+ };
24
+ const handleOpenChange = (nextOpen) => {
25
+ if (!nextOpen)
26
+ reset();
27
+ onOpenChange(nextOpen);
28
+ };
18
29
  const confirm = async () => {
19
30
  const res = await dispatcher.write(AuthMfaHandlers.regenerateRecovery, { code });
20
- setCode("");
31
+ reset();
21
32
  if (!res.isSuccess) {
22
33
  onError(res.error.i18nKey);
23
34
  return;
24
35
  }
25
36
  onRegenerated(res.data.recoveryCodes);
26
37
  };
27
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, title: t("auth.mfa.regenerate.title"), description: t("auth.mfa.regenerate.description"), confirmLabel: t("auth.mfa.regenerate.confirm"), cancelLabel: t("auth.mfa.regenerate.cancel"), onConfirm: confirm, testId: "mfa-regenerate-recovery-dialog", children: _jsx(Field, { id: "mfa-regenerate-code", label: t("auth.mfa.regenerate.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-regenerate-code", name: "mfa-regenerate-code", value: code, onChange: setCode, autoComplete: "one-time-code" }) }) }));
38
+ return (_jsx(Dialog, { open: open, onOpenChange: handleOpenChange, title: t("auth.mfa.regenerate.title"), description: t("auth.mfa.regenerate.description"), confirmLabel: t("auth.mfa.regenerate.confirm"), cancelLabel: t("auth.mfa.regenerate.cancel"), onConfirm: confirm, testId: "mfa-regenerate-recovery-dialog", children: _jsx(MfaCodeOrRecoveryField, { id: "mfa-regenerate-code", codeLabel: t("auth.mfa.regenerate.code"), code: code, onCodeChange: setCode, useRecovery: useRecovery, onUseRecoveryChange: setUseRecovery }) }));
28
39
  }
@@ -48,6 +48,7 @@ export function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title,
48
48
  const [setup, setSetup] = useState(null);
49
49
  const [acknowledged, setAcknowledged] = useState(false);
50
50
  const [code, setCode] = useState("");
51
+ const [attempt, setAttempt] = useState(0);
51
52
  const [busy, setBusy] = useState(false);
52
53
  const [error, setError] = useState(null);
53
54
  const [retryAfterSeconds, setRetryAfterSeconds] = useState(null);
@@ -78,14 +79,14 @@ export function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title,
78
79
  setBusy(false);
79
80
  }
80
81
  };
81
- const confirmSetup = async () => {
82
- if (!setup)
82
+ const confirmSetup = async (submittedCode) => {
83
+ if (!setup || busy)
83
84
  return;
84
85
  setBusy(true);
85
86
  setError(null);
86
87
  setRetryAfterSeconds(null);
87
88
  try {
88
- const res = await confirmMfaSetupPreauth(setup.setupToken, code);
89
+ const res = await confirmMfaSetupPreauth(setup.setupToken, submittedCode);
89
90
  if (res.kind !== "success") {
90
91
  setError(res.error.reason);
91
92
  setRetryAfterSeconds(res.error.retryAfterSeconds ?? null);
@@ -98,7 +99,11 @@ export function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title,
98
99
  setSetup(null);
99
100
  setAcknowledged(false);
100
101
  setCode("");
102
+ return;
101
103
  }
104
+ // Remount: the field was disabled during confirm and lost focus.
105
+ setCode("");
106
+ setAttempt((n) => n + 1);
102
107
  return;
103
108
  }
104
109
  onSuccess?.(res.data.landingPath);
@@ -112,7 +117,7 @@ export function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title,
112
117
  };
113
118
  const onSubmit = (e) => {
114
119
  e?.preventDefault();
115
- void confirmSetup();
120
+ void confirmSetup(code);
116
121
  };
117
122
  return (_jsxs(AuthCard, { title: title ?? t("auth.mfa.setup.title"), subtitle: subtitle ?? t("auth.mfa.setup.subtitle"), children: [error !== null ? (_jsx(Banner, { variant: "error", children: error === "too_many_attempts" && retryAfterSeconds !== null
118
123
  ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
@@ -124,5 +129,5 @@ export function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title,
124
129
  copyable: false,
125
130
  multiline: false,
126
131
  },
127
- ], copyLabel: t("kumiko.secretMint.copy"), copiedLabel: t("kumiko.secretMint.copied"), testId: "mfa-setup-preauth-qr" })) : (_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.scanTitle") })), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.manualEntry") }), _jsx("code", { className: "inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.secretParam })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.recoveryTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.recoveryHint") }), _jsx("code", { className: "block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.recoveryCodes.join("\n") }), _jsx(Field, { id: "mfa-setup-preauth-ack", label: t("auth.mfa.enable.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-setup-preauth-ack", name: "mfa-setup-preauth-ack", value: acknowledged, onChange: setAcknowledged }) })] }), _jsx(Field, { id: "mfa-setup-preauth-code", label: t("auth.mfa.enable.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-setup-preauth-code", name: "mfa-setup-preauth-code", value: code, onChange: setCode, disabled: busy || !acknowledged, autoComplete: "one-time-code" }) }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", variant: "primary", loading: busy, disabled: busy || !acknowledged || code.length !== 6, children: t("auth.mfa.setup.confirm") }) })] }) })), onCancel ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: busy, children: t("auth.mfa.verify.backToLogin") }) })) : null] }));
132
+ ], copyLabel: t("kumiko.secretMint.copy"), copiedLabel: t("kumiko.secretMint.copied"), testId: "mfa-setup-preauth-qr" })) : (_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.scanTitle") })), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.manualEntry") }), _jsx("code", { className: "inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.secretParam })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.recoveryTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.recoveryHint") }), _jsx("code", { className: "block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.recoveryCodes.join("\n") }), _jsx(Field, { id: "mfa-setup-preauth-ack", label: t("auth.mfa.enable.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-setup-preauth-ack", name: "mfa-setup-preauth-ack", value: acknowledged, onChange: setAcknowledged }) })] }), _jsx(Field, { id: "mfa-setup-preauth-code", label: t("auth.mfa.enable.code"), required: true, children: _jsx(Input, { kind: "code", length: 6, id: "mfa-setup-preauth-code", name: "mfa-setup-preauth-code", value: code, onChange: setCode, onComplete: (next) => void confirmSetup(next), disabled: busy || !acknowledged, hasError: error !== null, autoFocus: attempt > 0 }, attempt) }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", variant: "primary", loading: busy, disabled: busy || !acknowledged || code.length !== 6, children: t("auth.mfa.setup.confirm") }) })] }) })), onCancel ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: busy, children: t("auth.mfa.verify.backToLogin") }) })) : null] }));
128
133
  }
@@ -10,6 +10,7 @@ import { useState } from "react";
10
10
  // kumiko-lint-ignore cross-feature-import client-only barrel, the feature's server barrel has no web/ re-export
11
11
  import { AuthCard, useSession } from "../../auth-email-password/web/index.js";
12
12
  import { verifyMfaChallenge } from "./mfa-client.js";
13
+ const MFA_CODE_LENGTH = 6;
13
14
  function reasonToKey(reason) {
14
15
  switch (reason) {
15
16
  case "invalid_totp_code":
@@ -31,13 +32,17 @@ export function MfaVerifyScreen({ challengeToken, title, subtitle, submitLabel,
31
32
  const t = useTranslation();
32
33
  const { Form, Field, Input, Button, Banner } = usePrimitives();
33
34
  const session = useSession();
35
+ const [mode, setMode] = useState("authenticator");
34
36
  const [code, setCode] = useState("");
37
+ const [attempt, setAttempt] = useState(0);
35
38
  const [submitting, setSubmitting] = useState(false);
36
39
  const [error, setError] = useState(null);
37
- const doSubmit = async () => {
40
+ const doSubmit = async (submittedCode) => {
41
+ if (submitting)
42
+ return;
38
43
  setSubmitting(true);
39
44
  setError(null);
40
- const res = await verifyMfaChallenge(challengeToken, code);
45
+ const res = await verifyMfaChallenge(challengeToken, submittedCode);
41
46
  if (res.kind === "success") {
42
47
  await session.refresh();
43
48
  setSubmitting(false);
@@ -46,12 +51,28 @@ export function MfaVerifyScreen({ challengeToken, title, subtitle, submitLabel,
46
51
  }
47
52
  setSubmitting(false);
48
53
  setError(res.error.reason);
54
+ // Remount: the input was disabled during submit and lost focus.
55
+ setAttempt((n) => n + 1);
56
+ if (mode === "authenticator")
57
+ setCode("");
49
58
  };
50
59
  const onSubmit = (e) => {
51
60
  e?.preventDefault();
52
- void doSubmit();
61
+ void doSubmit(code);
62
+ };
63
+ const toggleMode = () => {
64
+ setMode(mode === "authenticator" ? "recovery" : "authenticator");
65
+ setCode("");
66
+ setError(null);
53
67
  };
54
- return (_jsx(AuthCard, { title: title ?? t("auth.mfa.verify.title"), subtitle: subtitle ?? t("auth.mfa.verify.subtitle"), children: _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "mfa-verify-code", label: t("auth.mfa.verify.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-verify-code", name: "mfa-verify-code", value: code, onChange: setCode, disabled: submitting, required: true, autoComplete: "one-time-code" }) }), error !== null ? _jsx(Banner, { variant: "error", children: t(reasonToKey(error)) }) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
68
+ const isRecovery = mode === "recovery";
69
+ const fieldId = isRecovery ? "mfa-verify-recovery-code" : "mfa-verify-code";
70
+ const defaultSubtitle = isRecovery
71
+ ? t("auth.mfa.verify.recoverySubtitle")
72
+ : t("auth.mfa.verify.subtitle");
73
+ return (_jsx(AuthCard, { title: title ?? t("auth.mfa.verify.title"), subtitle: subtitle ?? defaultSubtitle, children: _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: fieldId, label: isRecovery ? t("auth.mfa.verify.recoveryCode") : t("auth.mfa.verify.code"), required: true, children: isRecovery ? (_jsx(Input, { kind: "text", id: fieldId, name: fieldId, value: code, onChange: setCode, disabled: submitting, required: true, autoFocus: true, autoComplete: "off" }, `${mode}-${attempt}`)) : (_jsx(Input, { kind: "code", length: MFA_CODE_LENGTH, id: fieldId, name: fieldId, value: code, onChange: setCode, onComplete: (next) => void doSubmit(next), disabled: submitting, hasError: error !== null, required: true, autoFocus: true }, `${mode}-${attempt}`)) }), error !== null ? _jsx(Banner, { variant: "error", children: t(reasonToKey(error)) }) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
55
74
  ? t("auth.mfa.verify.submitting")
56
- : (submitLabel ?? t("auth.mfa.verify.submit")) }), onCancel ? (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: submitting, children: t("auth.mfa.verify.backToLogin") })) : null] }) }));
75
+ : (submitLabel ?? t("auth.mfa.verify.submit")) }), _jsx(Button, { type: "button", variant: "link", onClick: toggleMode, disabled: submitting, children: isRecovery
76
+ ? t("auth.mfa.verify.useAuthenticatorCode")
77
+ : t("auth.mfa.verify.useRecoveryCode") }), onCancel ? (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: submitting, children: t("auth.mfa.verify.backToLogin") })) : null] }) }));
57
78
  }
@@ -17,20 +17,15 @@ export type ResolvedProvider = {
17
17
  * entityName is registered. */
18
18
  export declare function findProviderPlugin(ctx: HandlerContext, providerName: string): ResolvedProvider | null;
19
19
  export declare function resolveProviderPlugin(ctx: HandlerContext, providerName: string): ResolvedProvider;
20
- /** Picks the catalog's provider — an explicit `catalog.providerName`, or the
21
- * single registered provider exposing `priceToTier` when there is exactly
22
- * one. Returns null when `providerName` is unset and no registered plugin
23
- * exposes `priceToTier` — a state `billing-plans` renders as
24
- * `enabled: false` instead of erroring. An explicit `providerName` that is
25
- * not registered, or more than one candidate, is a config bug and throws
26
- * `UnconfiguredError`. */
27
- export declare function findCatalogProvider(ctx: HandlerContext, catalog: BillingPlanCatalog): ResolvedProvider | null;
28
- /** `findCatalogProvider` for callers that require a provider to proceed —
29
- * start-plan-checkout and switch-plan. `FeatureDisabledError` (not
30
- * `UnconfiguredError`) when none is found: an unconfigured provider is
31
- * the same "billing isn't live yet" state as `isPluginBillingEnabled`
32
- * returning false, and the panel already renders both the same way. */
33
- export declare function resolveCatalogProvider(ctx: HandlerContext, catalog: BillingPlanCatalog, handlerName: string): ResolvedProvider;
20
+ /** The providers a catalog sells through, in registration order: an explicit
21
+ * `catalog.providerName` (only that one), otherwise every registered provider
22
+ * exposing `priceToTier`. Empty when none qualifies, a state `billing-plans`
23
+ * renders as `enabled: false` instead of erroring. An explicit `providerName`
24
+ * that is not registered is a config bug and throws `UnconfiguredError`. */
25
+ export declare function listCatalogProviders(ctx: HandlerContext, catalog: BillingPlanCatalog): readonly ResolvedProvider[];
26
+ export declare function filterOfferedProviders(ctx: HandlerContext, providers: readonly ResolvedProvider[]): Promise<readonly ResolvedProvider[]>;
27
+ /** The catalog providers that are live right now: what a buyer can choose from. */
28
+ export declare function listOfferedCatalogProviders(ctx: HandlerContext, catalog: BillingPlanCatalog): Promise<readonly ResolvedProvider[]>;
34
29
  /** `plugin.isBillingEnabled?.(ctx) ?? true` — the one place this fallback is
35
30
  * spelled out, so no call-site forgets to await the optional method before
36
31
  * applying the `?? true` default. */
@@ -15,14 +15,18 @@ import { BILLING_FOUNDATION_FEATURE, isSubscriptionBlockingCheckout, SUBSCRIPTIO
15
15
  export function isNonEmptyStringArray(arr) {
16
16
  return arr.length > 0;
17
17
  }
18
+ /** Every registered provider plugin, in registration order. */
19
+ function listProviderPlugins(ctx) {
20
+ return ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION).map((usage) => ({
21
+ name: usage.entityName,
22
+ // @cast-boundary engine-payload — extension-usage carries unknown options
23
+ plugin: usage.options,
24
+ }));
25
+ }
18
26
  /** Non-throwing provider lookup by name — null when no plugin with that
19
27
  * entityName is registered. */
20
28
  export function findProviderPlugin(ctx, providerName) {
21
- const usage = ctx.registry
22
- .getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
23
- .find((u) => u.entityName === providerName);
24
- // @cast-boundary engine-payload — extension-usage carries unknown options
25
- return usage ? { name: providerName, plugin: usage.options } : null;
29
+ return listProviderPlugins(ctx).find((provider) => provider.name === providerName) ?? null;
26
30
  }
27
31
  export function resolveProviderPlugin(ctx, providerName) {
28
32
  const found = findProviderPlugin(ctx, providerName);
@@ -35,14 +39,12 @@ export function resolveProviderPlugin(ctx, providerName) {
35
39
  }
36
40
  return found;
37
41
  }
38
- /** Picks the catalog's provider — an explicit `catalog.providerName`, or the
39
- * single registered provider exposing `priceToTier` when there is exactly
40
- * one. Returns null when `providerName` is unset and no registered plugin
41
- * exposes `priceToTier` — a state `billing-plans` renders as
42
- * `enabled: false` instead of erroring. An explicit `providerName` that is
43
- * not registered, or more than one candidate, is a config bug and throws
44
- * `UnconfiguredError`. */
45
- export function findCatalogProvider(ctx, catalog) {
42
+ /** The providers a catalog sells through, in registration order: an explicit
43
+ * `catalog.providerName` (only that one), otherwise every registered provider
44
+ * exposing `priceToTier`. Empty when none qualifies, a state `billing-plans`
45
+ * renders as `enabled: false` instead of erroring. An explicit `providerName`
46
+ * that is not registered is a config bug and throws `UnconfiguredError`. */
47
+ export function listCatalogProviders(ctx, catalog) {
46
48
  if (catalog.providerName) {
47
49
  const found = findProviderPlugin(ctx, catalog.providerName);
48
50
  if (!found) {
@@ -52,34 +54,17 @@ export function findCatalogProvider(ctx, catalog) {
52
54
  hint: `provider "${catalog.providerName}" not registered`,
53
55
  });
54
56
  }
55
- return found;
56
- }
57
- const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
58
- const withPriceCatalog = usages.filter((u) => u.options.priceToTier !== undefined);
59
- if (withPriceCatalog.length > 1) {
60
- throw new UnconfiguredError({
61
- feature: "billing-foundation",
62
- key: "catalog.providerName",
63
- hint: `ambiguous — ${withPriceCatalog.length} registered plugins expose priceToTier, set catalog.providerName explicitly`,
64
- });
57
+ return [found];
65
58
  }
66
- const usage = withPriceCatalog[0];
67
- if (!usage)
68
- return null;
69
- // @cast-boundary engine-payload — extension-usage carries unknown options
70
- return { name: usage.entityName, plugin: usage.options };
59
+ return listProviderPlugins(ctx).filter(({ plugin }) => plugin.priceToTier !== undefined);
71
60
  }
72
- /** `findCatalogProvider` for callers that require a provider to proceed —
73
- * start-plan-checkout and switch-plan. `FeatureDisabledError` (not
74
- * `UnconfiguredError`) when none is found: an unconfigured provider is
75
- * the same "billing isn't live yet" state as `isPluginBillingEnabled`
76
- * returning false, and the panel already renders both the same way. */
77
- export function resolveCatalogProvider(ctx, catalog, handlerName) {
78
- const found = findCatalogProvider(ctx, catalog);
79
- if (!found) {
80
- throw new FeatureDisabledError(BILLING_FOUNDATION_FEATURE, handlerName);
81
- }
82
- return found;
61
+ export async function filterOfferedProviders(ctx, providers) {
62
+ const enabledFlags = await Promise.all(providers.map(({ plugin }) => isPluginBillingEnabled(ctx, plugin)));
63
+ return providers.filter((_, index) => enabledFlags[index]);
64
+ }
65
+ /** The catalog providers that are live right now: what a buyer can choose from. */
66
+ export async function listOfferedCatalogProviders(ctx, catalog) {
67
+ return filterOfferedProviders(ctx, listCatalogProviders(ctx, catalog));
83
68
  }
84
69
  /** `plugin.isBillingEnabled?.(ctx) ?? true` — the one place this fallback is
85
70
  * spelled out, so no call-site forgets to await the optional method before
@@ -112,8 +112,14 @@ inlineHtmlMarkdown.use({
112
112
  blockquote({ tokens }) {
113
113
  return this.parser.parse(tokens);
114
114
  },
115
- list({ items }) {
116
- return items.map((item) => this.parser.parse(item.tokens)).join("");
115
+ list({ items, ordered, start }) {
116
+ const firstNumber = typeof start === "number" ? start : 1;
117
+ return items
118
+ .map((item, index) => {
119
+ const marker = ordered ? `${firstNumber + index}. ` : "";
120
+ return `${marker}${this.parser.parse(item.tokens)}`;
121
+ })
122
+ .join("");
117
123
  },
118
124
  hr: () => "",
119
125
  space: () => "",
@@ -30,6 +30,7 @@ export type ConsentPriceDetails = {
30
30
  readonly priceId: string;
31
31
  readonly price: Pick<ProviderPrice, "unitAmount" | "currency" | "interval" | "intervalCount"> | null;
32
32
  readonly orderItem?: OrderItem;
33
+ readonly providerName?: string;
33
34
  };
34
35
  export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload | undefined, mode: CheckoutMode): Promise<PreparedConsent>;
35
36
  export declare function consentCheckoutFields(prepared: PreparedConsent): {
@@ -104,6 +104,7 @@ export async function recordConsent(ctx, prepared, details) {
104
104
  termsTemplateVersion: prepared.termsTemplateVersion,
105
105
  locale: prepared.locale,
106
106
  actorUserId: String(ctx.user.id),
107
+ ...(details.providerName !== undefined && { providerName: details.providerName }),
107
108
  ...(details.orderItem && {
108
109
  itemLabelKey: details.orderItem.labelKey,
109
110
  ...(details.orderItem.params && { itemLabelParams: details.orderItem.params }),
@@ -6,6 +6,7 @@ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework
6
6
  import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
7
7
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
8
8
  import * as z from "zod";
9
+ import { decryptStoredUserEmail } from "../../shared/index.js";
9
10
  import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.js";
10
11
  import { UserQueries } from "../../user/index.js";
11
12
  import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
@@ -13,7 +14,6 @@ import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, CONTRACT_CONFIRMATION_ISSUED_EVENT_
13
14
  import { CONTRACT_CONFIRMATION_NOTIFICATION_TYPE } from "../web/constants.js";
14
15
  import { contractConfirmationVariables } from "./confirmation-mail.js";
15
16
  import { resolveConsentLocale } from "./consent-text.js";
16
- import { openStoredUserEmail } from "./stored-user-email.js";
17
17
  export const issueContractConfirmationSchema = z
18
18
  .object({
19
19
  consentId: z.string().min(1).max(100),
@@ -31,6 +31,12 @@ const recipientRowSchema = z.object({
31
31
  function translatedOrUndefined(translation, key) {
32
32
  return translation === key ? undefined : translation;
33
33
  }
34
+ function vatNoteFor(consumerProtection, providerName) {
35
+ const providerNote = providerName === undefined
36
+ ? undefined
37
+ : consumerProtection.providerTexts?.[providerName]?.vatNote;
38
+ return providerNote ?? consumerProtection.vatNote;
39
+ }
34
40
  function pickTimeZone(userZone, tenantZone) {
35
41
  if (userZone && isValidIanaTimeZone(userZone))
36
42
  return userZone;
@@ -102,7 +108,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
102
108
  id: consent.actorUserId,
103
109
  }));
104
110
  const recipientEmail = recipient.success && recipient.data.isDeleted !== true
105
- ? await openStoredUserEmail(recipient.data.email, "billing-foundation:issue-contract-confirmation")
111
+ ? await decryptStoredUserEmail(recipient.data.email, "billing-foundation:issue-contract-confirmation")
106
112
  : null;
107
113
  if (!recipient.success || recipientEmail === null) {
108
114
  ctx.log?.warn(`[billing-foundation:issue-contract-confirmation] recipient of consent ${payload.consentId} not found; no mail sent`);
@@ -142,7 +148,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
142
148
  ...(payload.currentPeriodEndIso !== undefined && {
143
149
  currentPeriodEndIso: payload.currentPeriodEndIso,
144
150
  }),
145
- vatNote: consumerProtection.vatNote,
151
+ vatNote: vatNoteFor(consumerProtection, consent.providerName),
146
152
  operatorEmail: consumerProtection.operatorEmail,
147
153
  termsContent: terms.content,
148
154
  });
@@ -44,7 +44,9 @@ export function terminationReceiptTemplate() {
44
44
  mailRepeatedRows("details", "text"),
45
45
  mailParagraph(texts.receiptNextSteps),
46
46
  mailParagraph(texts.receiptRevoke(OPERATOR_EMAIL_SLOT)),
47
- mailParagraphWithLink(texts.receiptNotMe, texts.receiptNotMeLink, "revokeUrl").replace("<p>", '<p data-if="revokeUrl">'),
47
+ mailParagraphWithLink(texts.receiptNotMe, texts.receiptNotMeLink, "revokeUrl", {
48
+ showIf: "revokeUrl",
49
+ }),
48
50
  mailParagraph(`${texts.contact} {operatorEmail}`),
49
51
  ].join(""),
50
52
  };
@@ -1,6 +1,8 @@
1
1
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const RECEIPT_MAILS_PER_RECIPIENT = 3;
3
3
  export declare const RECEIPT_WINDOW_SECONDS: number;
4
+ export type ReceiptMailVerdict = "allowed" | "limited" | "unavailable";
4
5
  /** Consumes one receipt token for the recipient. Fails closed when the limiter
5
- * errors or no digest key is wired: the declaration is already recorded, a missing receipt is the lesser harm. */
6
- export declare function mayMailReceipt(ctx: HandlerContext, email: string): Promise<boolean>;
6
+ * errors or no digest key is wired ("unavailable"): the declaration is already
7
+ * recorded, a missing receipt is the lesser harm. */
8
+ export declare function mayMailReceipt(ctx: HandlerContext, email: string): Promise<ReceiptMailVerdict>;
@@ -10,25 +10,26 @@ function receiptBucketKey(ctx, email) {
10
10
  return digest === undefined ? undefined : `${RECEIPT_BUCKET_PREFIX}:${digest}`;
11
11
  }
12
12
  /** Consumes one receipt token for the recipient. Fails closed when the limiter
13
- * errors or no digest key is wired: the declaration is already recorded, a missing receipt is the lesser harm. */
13
+ * errors or no digest key is wired ("unavailable"): the declaration is already
14
+ * recorded, a missing receipt is the lesser harm. */
14
15
  export async function mayMailReceipt(ctx, email) {
15
16
  // skip: no rate limiter wired, nothing to count against
16
17
  if (!ctx.rateLimit)
17
- return true;
18
+ return "allowed";
18
19
  try {
19
20
  const bucketKey = receiptBucketKey(ctx, email);
20
21
  if (bucketKey === undefined) {
21
22
  ctx.log?.warn("[billing-foundation:termination] no rate-limit digest available, receipt mail withheld");
22
- return false;
23
+ return "unavailable";
23
24
  }
24
25
  const decision = await ctx.rateLimit.check(bucketKey, {
25
26
  limit: RECEIPT_MAILS_PER_RECIPIENT,
26
27
  windowSeconds: RECEIPT_WINDOW_SECONDS,
27
28
  });
28
- return decision.allowed;
29
+ return decision.allowed ? "allowed" : "limited";
29
30
  }
30
31
  catch (error) {
31
32
  ctx.log?.warn(`[billing-foundation:termination] receipt rate limiter failed: ${error instanceof Error ? error.message : String(error)}`);
32
- return false;
33
+ return "unavailable";
33
34
  }
34
35
  }