@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.387.0",
3
+ "version": "0.388.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.387.0",
3
+ "version": "0.388.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -63,6 +63,7 @@
63
63
  "./billing-foundation": "./src/billing-foundation/index.ts",
64
64
  "./billing-foundation/web": "./src/billing-foundation/web/index.ts",
65
65
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
66
+ "./subscription-dodo": "./src/subscription-dodo/index.ts",
66
67
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
67
68
  "./foundation-shared": "./src/foundation-shared/index.ts",
68
69
  "./auth-foundation": "./src/auth-foundation/index.ts",
@@ -359,6 +360,10 @@
359
360
  "types": "./dist/subscription-stripe/index.d.ts",
360
361
  "default": "./dist/subscription-stripe/index.js"
361
362
  },
363
+ "./subscription-dodo": {
364
+ "types": "./dist/subscription-dodo/index.d.ts",
365
+ "default": "./dist/subscription-dodo/index.js"
366
+ },
362
367
  "./subscription-mollie": {
363
368
  "types": "./dist/subscription-mollie/index.d.ts",
364
369
  "default": "./dist/subscription-mollie/index.js"
@@ -728,6 +733,8 @@
728
733
  "@testing-library/user-event": "^14.6.1",
729
734
  "@cosmicdrift/kumiko-locale-de": "workspace:*",
730
735
  "@cosmicdrift/kumiko-locale-es": "workspace:*",
736
+ "@cosmicdrift/kumiko-locale-fr": "workspace:*",
737
+ "@cosmicdrift/kumiko-locale-it": "workspace:*",
731
738
  "@cosmicdrift/kumiko-renderer-mail-html": "workspace:*",
732
739
  "jsqr": "^1.4.0",
733
740
  "unpdf": "^1.8.1",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "dispatchToolCall documents which failures carry a code",
6
+ "detail": "Only handler and dispatcher failures carry a code; local validation errors such as an unknown tool or bad arguments come back without one, and the JSDoc now says so."
7
+ },
2
8
  {
3
9
  "version": "0.366.0",
4
10
  "type": "improvement",
@@ -1,6 +1,6 @@
1
1
  import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { isKumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
- import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
3
+ import { runAsAgentToolCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
4
4
  import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
5
5
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
6
6
  import { FILTER_OPS, isRecord } from "./filter-ops.ts";
@@ -411,13 +411,13 @@ function dispatchClient(
411
411
  }
412
412
  }
413
413
 
414
- /** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
415
- * disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
416
- * back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
417
- * crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
418
- * `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
419
- * generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
420
- * through the same permission-checked dispatcher pipeline as a normal HTTP request. */
414
+ /** Executes one LLM-issued tool call. Never throws: every failure comes back as `{ ok: false }`
415
+ * so the agent loop can feed the error back to the model instead of crashing.
416
+ * Handler and dispatcher failures carry a `code` (429 adds `retryable`); client errors (< 500)
417
+ * keep their message, server failures (>= 500, unknown throws) are logged and returned as a
418
+ * generic message. Local validation errors (unknown tool, bad arguments) have no `code`.
419
+ * `request.user` must be the caller's real identity: server-kind tools run through the same
420
+ * permission-checked pipeline as an HTTP request. */
421
421
  export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult> {
422
422
  const { dispatcher, user, toolName, input, dispatchTable, runId, toolCallId } = request;
423
423
  const classifyFailure = createFailureClassifier(toolName, request.log ?? fallbackLog);
@@ -427,7 +427,7 @@ export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCa
427
427
  }
428
428
 
429
429
  try {
430
- return await runAsDirectCallEntry(async () => {
430
+ return await runAsAgentToolCallEntry(async () => {
431
431
  switch (descriptor.kind) {
432
432
  case "search":
433
433
  return await dispatchSearch(descriptor, dispatcher, user, toolName, input);
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Oversized audit cursor is documented as a 400 validation error",
6
+ "detail": "The cursor pattern comment now names the validation error (400) the API actually returns instead of 422."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Tests cover nested agent grants, erased subjects and anonymous change-password",
12
+ "detail": "Integration tests now check that a delegating tool handler keeps the cross-tenant-read marker closed, that an erased subject gets no consumer-protection mail, and that the anonymous change-password check runs once; the audit seed helper uses withEnv."
13
+ },
2
14
  {
3
15
  "version": "0.384.0",
4
16
  "type": "improvement",
@@ -20,7 +20,7 @@ import {
20
20
 
21
21
  const MAX_LIMIT = 100;
22
22
 
23
- // At most 18 digits keeps the id inside bigint, so an oversized cursor or id is a 422, not a 500.
23
+ // At most 18 digits keeps the id inside bigint, so an oversized cursor or id is a validation error (400), not a 500.
24
24
  const CURSOR_PATTERN = /^\d{1,18}$/;
25
25
 
26
26
  export const auditListPayloadShape = {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "Six-cell code input, autofocus, auto-submit and recovery switch",
6
+ "detail": "The MFA login, setup, disable and regenerate screens use the six-cell code input with autofocus. Login and setup submit automatically on the sixth digit; disable and regenerate keep the explicit confirm button. Verify, disable and regenerate can switch to a recovery-code field. New keys auth.mfa.verify.useRecoveryCode, useAuthenticatorCode, recoveryCode and recoverySubtitle in all locale packages; auth.mfa.disable.code now names the authenticator code only."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "fix",
@@ -8,7 +8,7 @@ import type { MfaTokenSecretsEnv } from "./token-secrets.ts";
8
8
 
9
9
  export type BindMfaRevokeAllOtherSessions = (
10
10
  revoker: (userId: string, currentSid: string | undefined) => Promise<number>,
11
- ) => void;
11
+ ) => () => void;
12
12
 
13
13
  // Reads the late-bind setter off a mounted auth-mfa feature's exports —
14
14
  // mirrors sessions' own bindAutoRevokeFromFeature. run{Prod,Dev}App call
@@ -79,7 +79,7 @@ export function bindMfaTokenSecretsFromFeature(
79
79
  return undefined;
80
80
  }
81
81
 
82
- export type BindRevokeAllPatTokens = (revoker: (userId: string) => Promise<number>) => void;
82
+ export type BindRevokeAllPatTokens = (revoker: (userId: string) => Promise<number>) => () => void;
83
83
 
84
84
  // Reads the late-bind setter off a mounted auth-mfa feature's exports —
85
85
  // run{Prod,Dev}App call this once the personal-access-tokens feature (if
@@ -220,10 +220,16 @@ export function createAuthMfaFeature(
220
220
 
221
221
  const bindRevokeAllOtherSessions: BindMfaRevokeAllOtherSessions = (revoker) => {
222
222
  revokeAllOtherSessions = revoker;
223
+ return () => {
224
+ if (revokeAllOtherSessions === revoker) revokeAllOtherSessions = undefined;
225
+ };
223
226
  };
224
227
 
225
228
  const bindRevokeAllPatTokens: BindRevokeAllPatTokens = (revoker) => {
226
229
  revokeAllPatTokens = revoker;
230
+ return () => {
231
+ if (revokeAllPatTokens === revoker) revokeAllPatTokens = undefined;
232
+ };
227
233
  };
228
234
 
229
235
  return {
@@ -13,6 +13,11 @@ export const defaultTranslations: TranslationsByLocale = {
13
13
  "auth.mfa.verify.submit": "Verify",
14
14
  "auth.mfa.verify.submitting": "…",
15
15
  "auth.mfa.verify.backToLogin": "Back to login",
16
+ "auth.mfa.verify.recoveryCode": "Recovery code",
17
+ "auth.mfa.verify.recoverySubtitle":
18
+ "Enter one of the recovery codes you saved when you set up two-factor authentication.",
19
+ "auth.mfa.verify.useAuthenticatorCode": "Use the code from your app",
20
+ "auth.mfa.verify.useRecoveryCode": "Use a recovery code instead",
16
21
  "auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
17
22
  "auth.mfa.errors.invalidTotpCode": "Invalid code. Please try again.",
18
23
  "auth.mfa.errors.invalidChallengeToken": "Your sign-in has expired. Please sign in again.",
@@ -43,7 +48,7 @@ export const defaultTranslations: TranslationsByLocale = {
43
48
  "auth.mfa.disable.title": "Disable two-factor authentication",
44
49
  "auth.mfa.disable.description":
45
50
  "Confirm with a code from your authenticator app or a recovery code. Your account will then be protected by your password alone.",
46
- "auth.mfa.disable.code": "Code from your authenticator app or a recovery code",
51
+ "auth.mfa.disable.code": "Code from your authenticator app",
47
52
  "auth.mfa.disable.confirm": "Disable",
48
53
  "auth.mfa.disable.cancel": "Cancel",
49
54
  "auth.mfa.disable.trigger": "Disable two-factor authentication",
@@ -0,0 +1,68 @@
1
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type MfaCodeOrRecoveryFieldProps = {
5
+ readonly id: string;
6
+ readonly codeLabel: string;
7
+ readonly code: string;
8
+ readonly onCodeChange: (code: string) => void;
9
+ readonly useRecovery: boolean;
10
+ readonly onUseRecoveryChange: (useRecovery: boolean) => void;
11
+ readonly disabled?: boolean;
12
+ };
13
+
14
+ export function MfaCodeOrRecoveryField({
15
+ id,
16
+ codeLabel,
17
+ code,
18
+ onCodeChange,
19
+ useRecovery,
20
+ onUseRecoveryChange,
21
+ disabled,
22
+ }: MfaCodeOrRecoveryFieldProps): ReactNode {
23
+ const t = useTranslation();
24
+ const { Button, Field, Input } = usePrimitives();
25
+
26
+ return (
27
+ <>
28
+ <Field id={id} label={useRecovery ? t("auth.mfa.verify.recoveryCode") : codeLabel} required>
29
+ {useRecovery ? (
30
+ <Input
31
+ kind="text"
32
+ id={id}
33
+ name={id}
34
+ value={code}
35
+ onChange={onCodeChange}
36
+ disabled={disabled}
37
+ autoFocus
38
+ autoComplete="off"
39
+ />
40
+ ) : (
41
+ <Input
42
+ kind="code"
43
+ length={6}
44
+ id={id}
45
+ name={id}
46
+ value={code}
47
+ onChange={onCodeChange}
48
+ disabled={disabled}
49
+ autoFocus
50
+ />
51
+ )}
52
+ </Field>
53
+ <Button
54
+ type="button"
55
+ variant="link"
56
+ disabled={disabled}
57
+ onClick={() => {
58
+ onUseRecoveryChange(!useRecovery);
59
+ onCodeChange("");
60
+ }}
61
+ >
62
+ {useRecovery
63
+ ? t("auth.mfa.verify.useAuthenticatorCode")
64
+ : t("auth.mfa.verify.useRecoveryCode")}
65
+ </Button>
66
+ </>
67
+ );
68
+ }
@@ -12,6 +12,7 @@
12
12
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
13
13
  import { type ReactNode, useState } from "react";
14
14
  import { AuthMfaHandlers } from "./constants.ts";
15
+ import { MfaCodeOrRecoveryField } from "./mfa-code-or-recovery-field.tsx";
15
16
 
16
17
  export type MfaDisableDialogProps = {
17
18
  readonly open: boolean;
@@ -28,13 +29,24 @@ export function MfaDisableDialog({
28
29
  onError,
29
30
  }: MfaDisableDialogProps): ReactNode {
30
31
  const t = useTranslation();
31
- const { Dialog, Field, Input } = usePrimitives();
32
+ const { Dialog } = usePrimitives();
32
33
  const dispatcher = useDispatcher();
33
34
  const [code, setCode] = useState("");
35
+ const [useRecovery, setUseRecovery] = useState(false);
36
+
37
+ const reset = (): void => {
38
+ setCode("");
39
+ setUseRecovery(false);
40
+ };
41
+
42
+ const handleOpenChange = (nextOpen: boolean): void => {
43
+ if (!nextOpen) reset();
44
+ onOpenChange(nextOpen);
45
+ };
34
46
 
35
47
  const confirm = async (): Promise<void> => {
36
48
  const res = await dispatcher.write<{ disabled: boolean }>(AuthMfaHandlers.disable, { code });
37
- setCode("");
49
+ reset();
38
50
  if (!res.isSuccess) {
39
51
  onError(res.error.i18nKey);
40
52
  return;
@@ -45,7 +57,7 @@ export function MfaDisableDialog({
45
57
  return (
46
58
  <Dialog
47
59
  open={open}
48
- onOpenChange={onOpenChange}
60
+ onOpenChange={handleOpenChange}
49
61
  variant="danger"
50
62
  title={t("auth.mfa.disable.title")}
51
63
  description={t("auth.mfa.disable.description")}
@@ -54,16 +66,14 @@ export function MfaDisableDialog({
54
66
  onConfirm={confirm}
55
67
  testId="mfa-disable-dialog"
56
68
  >
57
- <Field id="mfa-disable-code" label={t("auth.mfa.disable.code")} required>
58
- <Input
59
- kind="text"
60
- id="mfa-disable-code"
61
- name="mfa-disable-code"
62
- value={code}
63
- onChange={setCode}
64
- autoComplete="one-time-code"
65
- />
66
- </Field>
69
+ <MfaCodeOrRecoveryField
70
+ id="mfa-disable-code"
71
+ codeLabel={t("auth.mfa.disable.code")}
72
+ code={code}
73
+ onCodeChange={setCode}
74
+ useRecovery={useRecovery}
75
+ onUseRecoveryChange={setUseRecovery}
76
+ />
67
77
  </Dialog>
68
78
  );
69
79
  }
@@ -9,6 +9,7 @@
9
9
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
10
10
  import { type ReactNode, useState } from "react";
11
11
  import { AuthMfaHandlers } from "./constants.ts";
12
+ import { MfaCodeOrRecoveryField } from "./mfa-code-or-recovery-field.tsx";
12
13
 
13
14
  export type MfaRegenerateRecoveryDialogProps = {
14
15
  readonly open: boolean;
@@ -25,16 +26,27 @@ export function MfaRegenerateRecoveryDialog({
25
26
  onError,
26
27
  }: MfaRegenerateRecoveryDialogProps): ReactNode {
27
28
  const t = useTranslation();
28
- const { Dialog, Field, Input } = usePrimitives();
29
+ const { Dialog } = usePrimitives();
29
30
  const dispatcher = useDispatcher();
30
31
  const [code, setCode] = useState("");
32
+ const [useRecovery, setUseRecovery] = useState(false);
33
+
34
+ const reset = (): void => {
35
+ setCode("");
36
+ setUseRecovery(false);
37
+ };
38
+
39
+ const handleOpenChange = (nextOpen: boolean): void => {
40
+ if (!nextOpen) reset();
41
+ onOpenChange(nextOpen);
42
+ };
31
43
 
32
44
  const confirm = async (): Promise<void> => {
33
45
  const res = await dispatcher.write<{ recoveryCodes: readonly string[] }>(
34
46
  AuthMfaHandlers.regenerateRecovery,
35
47
  { code },
36
48
  );
37
- setCode("");
49
+ reset();
38
50
  if (!res.isSuccess) {
39
51
  onError(res.error.i18nKey);
40
52
  return;
@@ -45,7 +57,7 @@ export function MfaRegenerateRecoveryDialog({
45
57
  return (
46
58
  <Dialog
47
59
  open={open}
48
- onOpenChange={onOpenChange}
60
+ onOpenChange={handleOpenChange}
49
61
  title={t("auth.mfa.regenerate.title")}
50
62
  description={t("auth.mfa.regenerate.description")}
51
63
  confirmLabel={t("auth.mfa.regenerate.confirm")}
@@ -53,16 +65,14 @@ export function MfaRegenerateRecoveryDialog({
53
65
  onConfirm={confirm}
54
66
  testId="mfa-regenerate-recovery-dialog"
55
67
  >
56
- <Field id="mfa-regenerate-code" label={t("auth.mfa.regenerate.code")} required>
57
- <Input
58
- kind="text"
59
- id="mfa-regenerate-code"
60
- name="mfa-regenerate-code"
61
- value={code}
62
- onChange={setCode}
63
- autoComplete="one-time-code"
64
- />
65
- </Field>
68
+ <MfaCodeOrRecoveryField
69
+ id="mfa-regenerate-code"
70
+ codeLabel={t("auth.mfa.regenerate.code")}
71
+ code={code}
72
+ onCodeChange={setCode}
73
+ useRecovery={useRecovery}
74
+ onUseRecoveryChange={setUseRecovery}
75
+ />
66
76
  </Dialog>
67
77
  );
68
78
  }
@@ -79,6 +79,7 @@ export function MfaSetupPreauthScreen({
79
79
  const [setup, setSetup] = useState<SetupState | null>(null);
80
80
  const [acknowledged, setAcknowledged] = useState(false);
81
81
  const [code, setCode] = useState("");
82
+ const [attempt, setAttempt] = useState(0);
82
83
  const [busy, setBusy] = useState(false);
83
84
  const [error, setError] = useState<string | null>(null);
84
85
  const [retryAfterSeconds, setRetryAfterSeconds] = useState<number | null>(null);
@@ -109,13 +110,13 @@ export function MfaSetupPreauthScreen({
109
110
  }
110
111
  };
111
112
 
112
- const confirmSetup = async (): Promise<void> => {
113
- if (!setup) return;
113
+ const confirmSetup = async (submittedCode: string): Promise<void> => {
114
+ if (!setup || busy) return;
114
115
  setBusy(true);
115
116
  setError(null);
116
117
  setRetryAfterSeconds(null);
117
118
  try {
118
- const res = await confirmMfaSetupPreauth(setup.setupToken, code);
119
+ const res = await confirmMfaSetupPreauth(setup.setupToken, submittedCode);
119
120
  if (res.kind !== "success") {
120
121
  setError(res.error.reason);
121
122
  setRetryAfterSeconds(res.error.retryAfterSeconds ?? null);
@@ -130,7 +131,11 @@ export function MfaSetupPreauthScreen({
130
131
  setSetup(null);
131
132
  setAcknowledged(false);
132
133
  setCode("");
134
+ return;
133
135
  }
136
+ // Remount: the field was disabled during confirm and lost focus.
137
+ setCode("");
138
+ setAttempt((n) => n + 1);
134
139
  return;
135
140
  }
136
141
  onSuccess?.(res.data.landingPath);
@@ -143,7 +148,7 @@ export function MfaSetupPreauthScreen({
143
148
 
144
149
  const onSubmit = (e?: FormEvent): void => {
145
150
  e?.preventDefault();
146
- void confirmSetup();
151
+ void confirmSetup(code);
147
152
  };
148
153
 
149
154
  return (
@@ -226,13 +231,17 @@ export function MfaSetupPreauthScreen({
226
231
 
227
232
  <Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
228
233
  <Input
229
- kind="text"
234
+ key={attempt}
235
+ kind="code"
236
+ length={6}
230
237
  id="mfa-setup-preauth-code"
231
238
  name="mfa-setup-preauth-code"
232
239
  value={code}
233
240
  onChange={setCode}
241
+ onComplete={(next) => void confirmSetup(next)}
234
242
  disabled={busy || !acknowledged}
235
- autoComplete="one-time-code"
243
+ hasError={error !== null}
244
+ autoFocus={attempt > 0}
236
245
  />
237
246
  </Field>
238
247
 
@@ -27,6 +27,10 @@ export type MfaVerifyScreenProps = {
27
27
  readonly onCancel?: () => void;
28
28
  };
29
29
 
30
+ type CodeEntryMode = "authenticator" | "recovery";
31
+
32
+ const MFA_CODE_LENGTH = 6;
33
+
30
34
  function reasonToKey(reason: string): string {
31
35
  switch (reason) {
32
36
  case "invalid_totp_code":
@@ -56,14 +60,17 @@ export function MfaVerifyScreen({
56
60
  const t = useTranslation();
57
61
  const { Form, Field, Input, Button, Banner } = usePrimitives();
58
62
  const session = useSession();
63
+ const [mode, setMode] = useState<CodeEntryMode>("authenticator");
59
64
  const [code, setCode] = useState("");
65
+ const [attempt, setAttempt] = useState(0);
60
66
  const [submitting, setSubmitting] = useState(false);
61
67
  const [error, setError] = useState<string | null>(null);
62
68
 
63
- const doSubmit = async (): Promise<void> => {
69
+ const doSubmit = async (submittedCode: string): Promise<void> => {
70
+ if (submitting) return;
64
71
  setSubmitting(true);
65
72
  setError(null);
66
- const res = await verifyMfaChallenge(challengeToken, code);
73
+ const res = await verifyMfaChallenge(challengeToken, submittedCode);
67
74
  if (res.kind === "success") {
68
75
  await session.refresh();
69
76
  setSubmitting(false);
@@ -72,30 +79,65 @@ export function MfaVerifyScreen({
72
79
  }
73
80
  setSubmitting(false);
74
81
  setError(res.error.reason);
82
+ // Remount: the input was disabled during submit and lost focus.
83
+ setAttempt((n) => n + 1);
84
+ if (mode === "authenticator") setCode("");
75
85
  };
76
86
 
77
87
  const onSubmit = (e?: FormEvent): void => {
78
88
  e?.preventDefault();
79
- void doSubmit();
89
+ void doSubmit(code);
80
90
  };
81
91
 
92
+ const toggleMode = (): void => {
93
+ setMode(mode === "authenticator" ? "recovery" : "authenticator");
94
+ setCode("");
95
+ setError(null);
96
+ };
97
+
98
+ const isRecovery = mode === "recovery";
99
+ const fieldId = isRecovery ? "mfa-verify-recovery-code" : "mfa-verify-code";
100
+ const defaultSubtitle = isRecovery
101
+ ? t("auth.mfa.verify.recoverySubtitle")
102
+ : t("auth.mfa.verify.subtitle");
103
+
82
104
  return (
83
- <AuthCard
84
- title={title ?? t("auth.mfa.verify.title")}
85
- subtitle={subtitle ?? t("auth.mfa.verify.subtitle")}
86
- >
105
+ <AuthCard title={title ?? t("auth.mfa.verify.title")} subtitle={subtitle ?? defaultSubtitle}>
87
106
  <Form onSubmit={onSubmit}>
88
- <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
89
- <Input
90
- kind="text"
91
- id="mfa-verify-code"
92
- name="mfa-verify-code"
93
- value={code}
94
- onChange={setCode}
95
- disabled={submitting}
96
- required
97
- autoComplete="one-time-code"
98
- />
107
+ <Field
108
+ id={fieldId}
109
+ label={isRecovery ? t("auth.mfa.verify.recoveryCode") : t("auth.mfa.verify.code")}
110
+ required
111
+ >
112
+ {isRecovery ? (
113
+ <Input
114
+ key={`${mode}-${attempt}`}
115
+ kind="text"
116
+ id={fieldId}
117
+ name={fieldId}
118
+ value={code}
119
+ onChange={setCode}
120
+ disabled={submitting}
121
+ required
122
+ autoFocus
123
+ autoComplete="off"
124
+ />
125
+ ) : (
126
+ <Input
127
+ key={`${mode}-${attempt}`}
128
+ kind="code"
129
+ length={MFA_CODE_LENGTH}
130
+ id={fieldId}
131
+ name={fieldId}
132
+ value={code}
133
+ onChange={setCode}
134
+ onComplete={(next) => void doSubmit(next)}
135
+ disabled={submitting}
136
+ hasError={error !== null}
137
+ required
138
+ autoFocus
139
+ />
140
+ )}
99
141
  </Field>
100
142
  {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
101
143
  <Button type="submit" loading={submitting} disabled={submitting}>
@@ -103,6 +145,11 @@ export function MfaVerifyScreen({
103
145
  ? t("auth.mfa.verify.submitting")
104
146
  : (submitLabel ?? t("auth.mfa.verify.submit"))}
105
147
  </Button>
148
+ <Button type="button" variant="link" onClick={toggleMode} disabled={submitting}>
149
+ {isRecovery
150
+ ? t("auth.mfa.verify.useAuthenticatorCode")
151
+ : t("auth.mfa.verify.useRecoveryCode")}
152
+ </Button>
106
153
  {onCancel ? (
107
154
  <Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
108
155
  {t("auth.mfa.verify.backToLogin")}
@@ -1,4 +1,59 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Concurrent termination runs, revocation resume and sync ordering",
6
+ "detail": "record-contract-termination re-checks the requestId after the provider call so a concurrent run appends nothing. Revocation resumes a period-end cancel that was recorded between its two stream reads. sync-subscription stamps the snapshot time as providerEventAtIso so a late older webhook cannot reactivate a synced cancellation."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Ordered terms lists keep their numbers in the confirmation mail",
12
+ "detail": "The contract confirmation mail prefixes each item of an ordered Markdown list with its number, so references such as \"see no. 3\" in terms and cancellation policies stay intact."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Operator notice tells an unavailable receipt limiter from a reached limit",
18
+ "detail": "When the termination receipt mail is withheld because the rate limiter failed or has no digest key, the operator notice reports \"receipt_not_sent\" instead of the per-recipient limit reason."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "An abandoned plan-change checkout no longer restarts the withdrawal period",
24
+ "detail": "The withdrawal period now runs from the latest subscription start only; a checkout consent without a following subscription start does not reopen it, so a later withdrawal of an old contract is cancelled at period end."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Same-second provider update cannot revive a canceled subscription",
30
+ "detail": "When a non-terminal state event carries the same provider timestamp as a stored cancellation, it is treated as late and discarded."
31
+ },
32
+ {
33
+ "version": "0.388.0",
34
+ "type": "improvement",
35
+ "title": "Several subscription providers in one catalog",
36
+ "detail": "The billing-plans query returns providers (name, displayName, plans, note) when more than one provider is offered, and start-plan-checkout takes providerName. consumerProtection.providerTexts overrides checkoutNote and vatNote per provider, and the consent record carries providerName. switch-plan always uses the provider of the existing subscription."
37
+ },
38
+ {
39
+ "version": "0.388.0",
40
+ "type": "improvement",
41
+ "title": "In-app plan switch with a preview and consent",
42
+ "detail": "A provider plugin that implements previewPlanSwitch and applyPlanSwitch switches plans without a hosted portal. The new plan-switch-preview query returns the proration, the billing-plans result carries inAppPlanSwitch, and the panel shows PlanSwitchConfirmDialog with the withdrawal consent checkboxes. The confirmation mail is sent when the plan_changed webhook arrives with the consent id."
43
+ },
44
+ {
45
+ "version": "0.388.0",
46
+ "type": "breaking",
47
+ "title": "A catalog with several providers no longer throws an ambiguous-provider error",
48
+ "detail": "With more than one subscription provider mounted, billing-plans and start-plan-checkout used to throw. They now list the providers; start-plan-checkout answers with errors.providerRequired when several are offered and no providerName is given, and errors.providerNotOffered for an unknown one. Plugins have to set displayName.",
49
+ "migration": "Set displayName on custom subscriptionProvider plugins. Callers that send start-plan-checkout with several providers mounted must pass providerName."
50
+ },
51
+ {
52
+ "version": "0.388.0",
53
+ "type": "improvement",
54
+ "title": "Provider choice moved into the checkout dialog",
55
+ "detail": "The plan panel no longer shows a provider switch. Cards show the first provider's price, or the cheapest one with a \"from\" prefix when providers disagree. With several providers, or with consumerProtection, choosing a plan opens CheckoutConsentDialog, which now has an optional providerChoice (ChoiceCards with the checkout note and, when prices differ, the price), an optional note, and an optional consumerProtection (without it there are no checkboxes and the order carries no consent). The plan switch dialog shows the recurring price with its interval."
56
+ },
2
57
  {
3
58
  "version": "0.379.0",
4
59
  "type": "fix",