@cosmicdrift/kumiko-samples 0.392.2 → 0.394.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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  133. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  134. package/packages/bundled-features/src/jobs/changes.json +18 -0
  135. package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
  136. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  137. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  138. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  139. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  140. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  141. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  142. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  143. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  144. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  145. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  146. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  147. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  148. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  149. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  150. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  151. package/packages/bundled-features/src/template-resolver/changes.json +12 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  153. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
  155. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
  156. package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
  157. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  158. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  161. package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
  162. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  163. package/samples/apps/use-all-bundled/package.json +1 -0
  164. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  165. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  166. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  167. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  168. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  169. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
  170. package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
  171. package/samples/recipes/cross-feature-events/README.md +10 -0
  172. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  173. package/samples/recipes/dashboard-stats/README.md +8 -0
  174. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  175. package/samples/recipes/env-composition/README.md +2 -2
  176. package/samples/recipes/env-composition/src/env.ts +2 -2
  177. package/samples/recipes/inbound-mail-triage/README.md +12 -0
  178. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  179. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  180. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  181. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  182. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  183. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  184. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  185. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  186. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -8,6 +8,7 @@
8
8
 
9
9
  // kumiko-lint-ignore cross-feature-import client-only types, the feature's server barrel has no web/ re-export
10
10
  import {
11
+ failureFromErrorField,
11
12
  type LoginFailure,
12
13
  type LoginResponse,
13
14
  toLoginResponse,
@@ -36,13 +37,7 @@ export async function verifyMfaChallenge(
36
37
  token?: string;
37
38
  user?: LoginResponse["user"];
38
39
  landingPath?: string;
39
- error?:
40
- | {
41
- code?: string;
42
- message?: string;
43
- details?: { reason?: string; retryAfterSeconds?: number };
44
- }
45
- | string;
40
+ error?: unknown;
46
41
  };
47
42
  if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
48
43
  return {
@@ -50,20 +45,7 @@ export async function verifyMfaChallenge(
50
45
  data: toLoginResponse(body.token, body.user, body.landingPath),
51
46
  };
52
47
  }
53
- const err = body.error;
54
- if (typeof err === "string") {
55
- return { kind: "failure", error: { reason: err } };
56
- }
57
- const reason = err?.details?.reason ?? err?.code ?? "mfa_verify_failed";
58
- const retry = err?.details?.retryAfterSeconds;
59
- return {
60
- kind: "failure",
61
- error: {
62
- reason,
63
- ...(err?.message !== undefined && { message: err.message }),
64
- ...(retry !== undefined && { retryAfterSeconds: retry }),
65
- },
66
- };
48
+ return { kind: "failure", error: failureFromErrorField(body.error, "mfa_verify_failed") };
67
49
  }
68
50
 
69
51
  export type MfaSetupPreauthStart = {
@@ -101,13 +83,7 @@ export async function startMfaSetupPreauth(
101
83
  setupToken?: string;
102
84
  otpauthUri?: string;
103
85
  recoveryCodes?: readonly string[];
104
- error?:
105
- | {
106
- code?: string;
107
- message?: string;
108
- details?: { reason?: string; retryAfterSeconds?: number };
109
- }
110
- | string;
86
+ error?: unknown;
111
87
  };
112
88
  if (
113
89
  body.isSuccess === true &&
@@ -124,20 +100,7 @@ export async function startMfaSetupPreauth(
124
100
  },
125
101
  };
126
102
  }
127
- const err = body.error;
128
- if (typeof err === "string") {
129
- return { kind: "failure", error: { reason: err } };
130
- }
131
- const reason = err?.details?.reason ?? err?.code ?? "setup_failed";
132
- const retry = err?.details?.retryAfterSeconds;
133
- return {
134
- kind: "failure",
135
- error: {
136
- reason,
137
- ...(err?.message !== undefined && { message: err.message }),
138
- ...(retry !== undefined && { retryAfterSeconds: retry }),
139
- },
140
- };
103
+ return { kind: "failure", error: failureFromErrorField(body.error, "setup_failed") };
141
104
  }
142
105
 
143
106
  // Browser side of POST /api/auth/mfa/preauth-confirm — completes both the
@@ -165,13 +128,7 @@ export async function confirmMfaSetupPreauth(
165
128
  token?: string;
166
129
  user?: LoginResponse["user"];
167
130
  landingPath?: string;
168
- error?:
169
- | {
170
- code?: string;
171
- message?: string;
172
- details?: { reason?: string; retryAfterSeconds?: number };
173
- }
174
- | string;
131
+ error?: unknown;
175
132
  };
176
133
  if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
177
134
  return {
@@ -179,18 +136,5 @@ export async function confirmMfaSetupPreauth(
179
136
  data: toLoginResponse(body.token, body.user, body.landingPath),
180
137
  };
181
138
  }
182
- const err = body.error;
183
- if (typeof err === "string") {
184
- return { kind: "failure", error: { reason: err } };
185
- }
186
- const reason = err?.details?.reason ?? err?.code ?? "mfa_setup_confirm_failed";
187
- const retry = err?.details?.retryAfterSeconds;
188
- return {
189
- kind: "failure",
190
- error: {
191
- reason,
192
- ...(err?.message !== undefined && { message: err.message }),
193
- ...(retry !== undefined && { retryAfterSeconds: retry }),
194
- },
195
- };
139
+ return { kind: "failure", error: failureFromErrorField(body.error, "mfa_setup_confirm_failed") };
196
140
  }
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.394.0",
4
+ "type": "improvement",
5
+ "title": "authorizeOneOffCheckout hook gates one-off checkouts per tenant",
6
+ "detail": "BillingFoundationOptions.authorizeOneOffCheckout runs before every one-off (mode payment) checkout, after the oneOffPriceIds gate and before any provider call. A rejection becomes an UnprocessableError with the hook's reason as code. Without the hook the behavior is unchanged."
7
+ },
8
+ {
9
+ "version": "0.394.0",
10
+ "type": "improvement",
11
+ "title": "One-off checkouts reuse the tenant's provider customer",
12
+ "detail": "create-checkout-session with mode payment and no providerCustomerId now passes the customer id of the tenant's own subscription at the same provider, so Stripe also receives the customer on one-off checkouts. The foreign-customer guard still applies."
13
+ },
14
+ {
15
+ "version": "0.394.0",
16
+ "type": "improvement",
17
+ "title": "PaymentEvent carries the bought quantity",
18
+ "detail": "PaymentEvent.quantity (absent means 1) reaches the stored payment-received event payload, which stays readable for events stored before. Apps that grant credits per pack have to multiply by it. No read-model column."
19
+ },
20
+ {
21
+ "version": "0.394.0",
22
+ "type": "improvement",
23
+ "title": "Plan-switch preview shows the recurring price gross",
24
+ "detail": "PlanSwitchPreview.recurringAmount and recurringTax carry the provider's gross recurring price; the confirm dialog shows them with the tax when set and falls back to the catalog price otherwise. subscription-paddle fills them from recurring_transaction_details."
25
+ },
26
+ {
27
+ "version": "0.394.0",
28
+ "type": "improvement",
29
+ "title": "Plan cards can show a note under the price",
30
+ "detail": "`BillingPlanCatalog.priceNoteKey` (an app i18n key) renders a small note under the price of every plan card, e.g. \"plus VAT\" when catalog prices are net. `PlanCardPrice` gets an optional `note`."
31
+ },
2
32
  {
3
33
  "version": "0.390.0",
4
34
  "type": "fix",
@@ -12,7 +12,12 @@ import {
12
12
  UnprocessableError,
13
13
  } from "@cosmicdrift/kumiko-framework/errors";
14
14
  import { getSubscriptionForTenant } from "./get-subscription-for-tenant.ts";
15
- import type { BillingPlanCatalog, SubscriptionProviderPlugin } from "./types.ts";
15
+ import { assertOneOffAuthorized, assertOneOffPriceAllowed } from "./one-off-checkout-gate.ts";
16
+ import type {
17
+ BillingFoundationOptions,
18
+ BillingPlanCatalog,
19
+ SubscriptionProviderPlugin,
20
+ } from "./types.ts";
16
21
  import {
17
22
  BILLING_FOUNDATION_FEATURE,
18
23
  isSubscriptionBlockingCheckout,
@@ -264,6 +269,7 @@ export type OpenCheckoutOptions = {
264
269
  readonly baseUrl?: string;
265
270
  readonly catalog?: BillingPlanCatalog;
266
271
  readonly now: () => Temporal.Instant;
272
+ readonly authorizeOneOffCheckout?: BillingFoundationOptions["authorizeOneOffCheckout"];
267
273
  /** The caller already ran `assertBillingEnabled` under its own handler name
268
274
  * (before a costly provider lookup), so the gate is not repeated here. */
269
275
  readonly billingEnabledChecked?: boolean;
@@ -311,21 +317,6 @@ function assertSubscriptionPriceAllowed(
311
317
  }
312
318
  }
313
319
 
314
- // Missing/empty oneOffPriceIds rejects every payment checkout: same
315
- // "no gate configured yet = closed" default as the priceToTier gate.
316
- function assertOneOffPriceAllowed(
317
- plugin: SubscriptionProviderPlugin,
318
- input: OpenCheckoutInput,
319
- ): void {
320
- const oneOffPriceIds = plugin.oneOffPriceIds ?? [];
321
- if (!oneOffPriceIds.includes(input.priceId)) {
322
- throw new UnprocessableError("unknown_price", {
323
- i18nKey: "billing-foundation.errors.unknownPrice",
324
- message: `subscription-foundation: priceId "${input.priceId}" is not in provider "${input.providerName}"'s oneOffPriceIds`,
325
- });
326
- }
327
- }
328
-
329
320
  type CheckoutCapableProvider = SubscriptionProviderPlugin & {
330
321
  readonly createCheckoutSession: NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>;
331
322
  };
@@ -373,6 +364,7 @@ export async function assertCheckoutAllowed(
373
364
  : await assertNoActiveSubscription(ctx, options.now());
374
365
  } else {
375
366
  assertOneOffPriceAllowed(plugin, input);
367
+ await assertOneOffAuthorized(ctx, options.authorizeOneOffCheckout, input);
376
368
  }
377
369
 
378
370
  // Rejects a foreign tenant's provider-customer id — otherwise the checkout
@@ -105,6 +105,8 @@ export const paymentEventPayloadSchema = z.object({
105
105
  // subscriptionEventPayloadSchema's same rationale.
106
106
  providerCustomerId: z.string().min(1).max(1000),
107
107
  priceId: z.string().min(1).max(200),
108
+ // Optional so payloads stored before quantity existed still parse; absent = 1.
109
+ quantity: z.number().int().positive().optional(),
108
110
  consentId: z.string().min(1).max(100).optional(),
109
111
  });
110
112
  export type PaymentEventPayload = z.infer<typeof paymentEventPayloadSchema>;
@@ -138,7 +138,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
138
138
  BILLING_FOUNDATION_FEATURE,
139
139
  (r) => {
140
140
  r.describe(
141
- "Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-dodo` unless you are writing a new payment provider.",
141
+ "Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-paddle` unless you are writing a new payment provider.",
142
142
  );
143
143
  r.uiHints({
144
144
  displayLabel: "Billing · Foundation",
@@ -39,6 +39,7 @@ import {
39
39
  prepareConsent,
40
40
  recordConsent,
41
41
  } from "../consumer-protection/consent-gate.ts";
42
+ import { withOwnProviderCustomer } from "../one-off-customer.ts";
42
43
  import type {
43
44
  ProviderPrice,
44
45
  ResolvedBillingFoundationOptions,
@@ -86,8 +87,13 @@ export function createCheckoutSessionHandler(
86
87
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
87
88
  const payload = event.payload as CreateCheckoutSessionPayload;
88
89
 
89
- const openOptions = { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now };
90
- const checkoutInput: OpenCheckoutInput = {
90
+ const openOptions = {
91
+ baseUrl: options.baseUrl,
92
+ catalog: options.catalog,
93
+ now: options.now,
94
+ authorizeOneOffCheckout: options.authorizeOneOffCheckout,
95
+ };
96
+ const requestedInput: OpenCheckoutInput = {
91
97
  providerName: payload.providerName,
92
98
  priceId: payload.priceId,
93
99
  successUrl: payload.successUrl,
@@ -95,6 +101,7 @@ export function createCheckoutSessionHandler(
95
101
  ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
96
102
  ...(payload.mode && { mode: payload.mode }),
97
103
  };
104
+ const checkoutInput = await withOwnProviderCustomer(ctx, requestedInput);
98
105
 
99
106
  if (!consumerProtection) {
100
107
  const result = await openCheckout(ctx, openOptions, checkoutInput);
@@ -31,6 +31,7 @@ export const processPaymentEventSchema = z.object({
31
31
  providerCheckoutId: z.string().min(1).max(200).optional(),
32
32
  providerCustomerId: z.string().min(1).max(200),
33
33
  priceId: z.string().min(1).max(200),
34
+ quantity: z.number().int().positive().optional(),
34
35
  consentId: z.string().min(1).max(100).optional(),
35
36
  });
36
37
  type ProcessPaymentEventPayload = z.infer<typeof processPaymentEventSchema>;
@@ -97,6 +98,7 @@ export const processPaymentEventHandler: WriteHandlerDef = {
97
98
  providerName: payload.providerName,
98
99
  providerCustomerId: encryptedFields.providerCustomerId,
99
100
  priceId: payload.priceId,
101
+ ...(payload.quantity !== undefined && { quantity: payload.quantity }),
100
102
  ...(payload.consentId !== undefined && { consentId: payload.consentId }),
101
103
  };
102
104
  const headers: PaymentEventHeaders = {
@@ -66,6 +66,8 @@ export {
66
66
  type ConsumerProtectionOptions,
67
67
  KNOWN_RECURRING_INTERVALS,
68
68
  type LegalLinkSet,
69
+ type OneOffCheckoutRequest,
70
+ type OneOffCheckoutVerdict,
69
71
  type PaymentEvent,
70
72
  type PlanSwitchPreview,
71
73
  type ProviderPrice,
@@ -0,0 +1,44 @@
1
+ // Gates for mode:"payment" checkouts, run before any provider call.
2
+
3
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
+ import type { BillingFoundationOptions, SubscriptionProviderPlugin } from "./types.ts";
6
+
7
+ type OneOffCheckoutInput = { readonly providerName: string; readonly priceId: string };
8
+
9
+ // Missing/empty oneOffPriceIds rejects every payment checkout: same
10
+ // "no gate configured yet = closed" default as the priceToTier gate.
11
+ export function assertOneOffPriceAllowed(
12
+ plugin: SubscriptionProviderPlugin,
13
+ input: OneOffCheckoutInput,
14
+ ): void {
15
+ const oneOffPriceIds = plugin.oneOffPriceIds ?? [];
16
+ if (!oneOffPriceIds.includes(input.priceId)) {
17
+ throw new UnprocessableError("unknown_price", {
18
+ i18nKey: "billing-foundation.errors.unknownPrice",
19
+ message: `subscription-foundation: priceId "${input.priceId}" is not in provider "${input.providerName}"'s oneOffPriceIds`,
20
+ });
21
+ }
22
+ }
23
+
24
+ // The app's own gate (for example a tier check): without it every price in
25
+ // oneOffPriceIds is open to any TenantAdmin.
26
+ export async function assertOneOffAuthorized(
27
+ ctx: HandlerContext,
28
+ authorize: BillingFoundationOptions["authorizeOneOffCheckout"],
29
+ input: OneOffCheckoutInput,
30
+ ): Promise<void> {
31
+ const verdict = authorize
32
+ ? await authorize(ctx, {
33
+ tenantId: ctx.user.tenantId,
34
+ providerName: input.providerName,
35
+ priceId: input.priceId,
36
+ })
37
+ : { allowed: true as const };
38
+ if (!verdict.allowed) {
39
+ throw new UnprocessableError(verdict.reason, {
40
+ i18nKey: verdict.i18nKey,
41
+ message: `billing-foundation: authorizeOneOffCheckout rejected the one-off checkout (${verdict.reason})`,
42
+ });
43
+ }
44
+ }
@@ -0,0 +1,17 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { OpenCheckoutInput } from "./checkout-core.ts";
3
+ import { getSubscriptionForTenant } from "./get-subscription-for-tenant.ts";
4
+
5
+ /** A one-off checkout without an explicit customer reuses the provider customer of the
6
+ * tenant's own subscription at the same provider, so the buyer is not asked again and
7
+ * does not become a second customer. The foreign-customer guard still runs afterwards. */
8
+ export async function withOwnProviderCustomer(
9
+ ctx: HandlerContext,
10
+ input: OpenCheckoutInput,
11
+ ): Promise<OpenCheckoutInput> {
12
+ if (input.mode !== "payment" || input.providerCustomerId) return input;
13
+ const own = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
14
+ return own && own.providerName === input.providerName && own.providerCustomerId
15
+ ? { ...input, providerCustomerId: own.providerCustomerId }
16
+ : input;
17
+ }
@@ -267,5 +267,6 @@ export function toBillingPlansResult(
267
267
  plans: views.plans,
268
268
  ...(views.inAppSwitch && { inAppPlanSwitch: true as const }),
269
269
  ...(catalog.contact !== undefined && { contact: catalog.contact }),
270
+ ...(catalog.priceNoteKey !== undefined && { priceNoteKey: catalog.priceNoteKey }),
270
271
  };
271
272
  }
@@ -129,6 +129,8 @@ export type PaymentEvent = {
129
129
  readonly providerCustomerId: string;
130
130
  /** Provider's own price/plan-ID of the purchased item. */
131
131
  readonly priceId: string;
132
+ /** Number of units bought; absent = 1. */
133
+ readonly quantity?: number;
132
134
  };
133
135
 
134
136
  // =============================================================================
@@ -368,6 +370,10 @@ export type PlanSwitchPreview = {
368
370
  readonly currency: string;
369
371
  /** Credit for the unused time on the old plan, smallest currency unit. */
370
372
  readonly credit: number;
373
+ /** Recurring price per period as charged by the provider, smallest unit, tax included. */
374
+ readonly recurringAmount?: number;
375
+ /** Tax part of `recurringAmount`; null when the provider reports none. */
376
+ readonly recurringTax?: number | null;
371
377
  readonly effectiveAt: string;
372
378
  };
373
379
 
@@ -445,6 +451,8 @@ export type BillingPlanCatalog<TTier extends string = string> = {
445
451
  readonly providerName?: string;
446
452
  /** Rendered as the last card, no price, visible to `viewRoles` regardless of `purchaseRoles`. */
447
453
  readonly contact?: BillingPlanContactCard;
454
+ /** App i18n key for a small note under every card's price, e.g. "plus VAT" when catalog prices are net. */
455
+ readonly priceNoteKey?: string;
448
456
  };
449
457
 
450
458
  export type LegalLinkSet = {
@@ -504,6 +512,16 @@ export type ConsumerProtectionOptions = {
504
512
  readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
505
513
  };
506
514
 
515
+ export type OneOffCheckoutRequest = {
516
+ readonly tenantId: TenantId;
517
+ readonly providerName: string;
518
+ readonly priceId: string;
519
+ };
520
+
521
+ export type OneOffCheckoutVerdict =
522
+ | { readonly allowed: true }
523
+ | { readonly allowed: false; readonly reason: string; readonly i18nKey: string };
524
+
507
525
  export type BillingFoundationOptions<TTier extends string = string> = {
508
526
  /** Absolute http(s) URL. Its origin is the only redirect origin
509
527
  * create-checkout-session/create-portal-session (when set) and the plan
@@ -513,6 +531,12 @@ export type BillingFoundationOptions<TTier extends string = string> = {
513
531
  * NOT via `new URL(path, baseUrl)` (which would drop the prefix). */
514
532
  readonly baseUrl?: string;
515
533
  readonly catalog?: BillingPlanCatalog<TTier>;
534
+ /** Runs before every one-off (mode "payment") checkout, after the oneOffPriceIds gate and
535
+ * before any provider call; a rejection becomes an UnprocessableError with `reason` as code. */
536
+ readonly authorizeOneOffCheckout?: (
537
+ ctx: HandlerContext,
538
+ request: OneOffCheckoutRequest,
539
+ ) => Promise<OneOffCheckoutVerdict>;
516
540
  /** Enables the consent gate on checkout; requires `baseUrl`. */
517
541
  readonly consumerProtection?: ConsumerProtectionOptions;
518
542
  /** Injectable clock for the stale-incomplete-subscription check
@@ -587,6 +611,8 @@ export type BillingPlansResult = {
587
611
  readonly inAppPlanSwitch?: true;
588
612
  /** Present only when `catalog.contact` is configured. */
589
613
  readonly contact?: BillingPlanContactCard;
614
+ /** Present only when `catalog.priceNoteKey` is configured. */
615
+ readonly priceNoteKey?: string;
590
616
  /** Present only when `consumerProtection` is configured. */
591
617
  readonly consumerProtection?: {
592
618
  readonly consentTexts: Readonly<
@@ -238,6 +238,7 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
238
238
  : {
239
239
  amount: formatMoney(shownPrice.unitAmount, shownPrice.currency, locale),
240
240
  ...(fromPrice !== undefined && { prefix: t("billing-foundation.plans.priceFrom") }),
241
+ ...(result.priceNoteKey !== undefined && { note: t(result.priceNoteKey) }),
241
242
  ...(key !== undefined && {
242
243
  period: t(key, {
243
244
  count: new Intl.NumberFormat(locale).format(shownPrice.intervalCount ?? 1),
@@ -15,6 +15,7 @@ import {
15
15
  import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.ts";
16
16
  import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
17
17
  import { intervalKey } from "./plan-price.ts";
18
+ import { recurringAmountsOf } from "./plan-switch-recurring.ts";
18
19
 
19
20
  type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
20
21
 
@@ -99,13 +100,18 @@ export function PlanSwitchConfirmDialog({
99
100
  const reply = previewQuery.data;
100
101
  const money = (amount: number, currency: string) => formatMoney(amount, currency, locale);
101
102
  const recurringKey = reply === null ? undefined : intervalKey(reply.target);
103
+ const recurring = reply === null ? null : recurringAmountsOf(reply);
102
104
  const recurringPrice =
103
- reply === null
105
+ reply === null || recurring === null
104
106
  ? ""
105
- : `${money(reply.target.unitAmount, reply.target.currency)}${
107
+ : `${money(recurring.amount, recurring.currency)}${
106
108
  recurringKey === undefined
107
109
  ? ""
108
110
  : ` ${t(recurringKey, { count: new Intl.NumberFormat(locale).format(reply.target.intervalCount ?? 1) })}`
111
+ }${
112
+ recurring.tax === null
113
+ ? ""
114
+ : ` (${t("billing-foundation.planSwitch.includesTax", { tax: money(recurring.tax, recurring.currency) })})`
109
115
  }`;
110
116
 
111
117
  return (
@@ -0,0 +1,23 @@
1
+ import type { PlanSwitchPreview } from "../types.ts";
2
+
3
+ type RecurringSource = {
4
+ readonly preview: PlanSwitchPreview;
5
+ readonly target: { readonly unitAmount: number; readonly currency: string };
6
+ };
7
+
8
+ export type RecurringAmounts = {
9
+ readonly amount: number;
10
+ readonly currency: string;
11
+ readonly tax: number | null;
12
+ };
13
+
14
+ /** The provider's gross recurring price when it reports one, else the catalog's unit amount. */
15
+ export function recurringAmountsOf({ preview, target }: RecurringSource): RecurringAmounts {
16
+ return preview.recurringAmount === undefined
17
+ ? { amount: target.unitAmount, currency: target.currency, tax: null }
18
+ : {
19
+ amount: preview.recurringAmount,
20
+ currency: preview.currency,
21
+ tax: preview.recurringTax ?? null,
22
+ };
23
+ }
@@ -105,6 +105,7 @@ export function createSubscriptionWebhookRoute(options: SubscriptionWebhookRoute
105
105
  providerName: parsed.providerName,
106
106
  providerCustomerId: parsed.providerCustomerId,
107
107
  priceId: parsed.priceId,
108
+ ...(parsed.quantity !== undefined && { quantity: parsed.quantity }),
108
109
  ...(parsed.consentId !== undefined && { consentId: parsed.consentId }),
109
110
  ...(parsed.providerCheckoutId !== undefined && {
110
111
  providerCheckoutId: parsed.providerCheckoutId,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.394.0",
4
+ "type": "improvement",
5
+ "title": "Add CapSpec.showLimit to hide a cap's limit from the dashboard",
6
+ "detail": "showLimit: false makes caps:usage send limit null so the card shows the counter only; cap-counter enforcement is unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.392.1",
4
10
  "type": "fix",
@@ -81,7 +81,7 @@ export function createCapsUsageQuery(
81
81
  const rows: CapUsageWithMeta[] = await Promise.all(
82
82
  caps.map(async (cap) => {
83
83
  const used = await cap.usage(db, targetTenantId);
84
- const limit = await cap.limit(tier, { config });
84
+ const limit = cap.showLimit === false ? null : await cap.limit(tier, { config });
85
85
  const base = {
86
86
  id: cap.id,
87
87
  label: cap.label,
@@ -90,8 +90,8 @@ export function createCapsUsageQuery(
90
90
  ...(cap.icon !== undefined && { icon: cap.icon }),
91
91
  ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
92
92
  };
93
- if (used === null) {
94
- return { ...base, used: null, fraction: 0, tone: "default" as const, percent: null };
93
+ if (used === null || cap.showLimit === false) {
94
+ return { ...base, used, fraction: 0, tone: "default" as const, percent: null };
95
95
  }
96
96
  const fraction = computeFraction(used, limit);
97
97
  return {
@@ -38,6 +38,8 @@ export type CapSpec = {
38
38
  tenantIds: readonly TenantId[],
39
39
  ) => Promise<Map<TenantId, number | null>>;
40
40
  readonly unit?: CapUnit;
41
+ /** `false` shows the counter only: the limit is not sent to the client; enforcement stays with cap-counter. Default true. */
42
+ readonly showLimit?: boolean;
41
43
  /** Icon chip on the dashboard cards. Optional — omitted renders the card
42
44
  * without an icon, same as before this field existed. */
43
45
  readonly icon?: CapIconKey;
@@ -22,6 +22,14 @@ type ListProfilesResponse = {
22
22
  readonly profiles: readonly ProfileSummary[];
23
23
  };
24
24
 
25
+ // The select options above carry locale translations; the catalog reuses them so both
26
+ // name a profile alike. A profile without an option key keeps its source label.
27
+ function profileLabel(profile: ProfileSummary, t: (key: string) => string): string {
28
+ const optionKey = `compliance-profiles:entity:__action-form__:field:profileKey:option:${profile.key}`;
29
+ const translated = t(optionKey);
30
+ return translated === optionKey ? profile.label : translated;
31
+ }
32
+
25
33
  export function ComplianceProfileCatalog(_props: ExtensionSectionProps): ReactNode {
26
34
  const { Banner, Text } = usePrimitives();
27
35
  const t = useTranslation();
@@ -46,7 +54,8 @@ export function ComplianceProfileCatalog(_props: ExtensionSectionProps): ReactNo
46
54
  <ul className="flex flex-col gap-2 text-sm" data-testid="compliance-profile-catalog">
47
55
  {(query.data?.profiles ?? []).map((profile) => (
48
56
  <li key={profile.key} data-profile-key={profile.key}>
49
- <strong>{profile.label}</strong> — {profile.region} — {profile.authorityContact}
57
+ <strong>{profileLabel(profile, t)}</strong> — {profile.region} —{" "}
58
+ {profile.authorityContact}
50
59
  </li>
51
60
  ))}
52
61
  </ul>
@@ -13,9 +13,12 @@ export type FileRefContent = {
13
13
  readonly contentType: string;
14
14
  readonly tenantId: string;
15
15
  readonly size: number;
16
+ /** As stored: personal data that may be PII ciphertext. Decrypt before showing or sending it. */
17
+ readonly fileName: string;
16
18
  };
17
19
 
18
20
  type FileRefRow = {
21
+ readonly fileName: string;
19
22
  readonly storageKey: string;
20
23
  readonly mimeType: string;
21
24
  readonly tenantId: string;
@@ -61,7 +64,6 @@ export class FileStorageUnavailableError extends Error {
61
64
  }
62
65
  }
63
66
 
64
- // fileName of the row is never used: it is personal data and may be a PII ciphertext.
65
67
  export async function readFileRefContent(args: {
66
68
  readonly db: DbRunner | TenantDb;
67
69
  readonly filesForTenant: (tenantId: string) => FileContext | undefined;
@@ -85,7 +87,8 @@ export async function readFileRefContent(args: {
85
87
  });
86
88
  if (row === undefined) return null;
87
89
  const size = Number(row.size);
88
- if (args.maxBytes !== undefined && size > args.maxBytes) {
90
+ // A size that is not a number counts as too large, so the limit cannot be skipped.
91
+ if (args.maxBytes !== undefined && !(size <= args.maxBytes)) {
89
92
  throw new FileRefTooLargeError(size);
90
93
  }
91
94
  const files = args.filesForTenant(row.tenantId);
@@ -93,5 +96,11 @@ export async function readFileRefContent(args: {
93
96
  throw new FileStorageUnavailableError();
94
97
  }
95
98
  const content = await files.refFromFileRef(row).read();
96
- return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
99
+ return {
100
+ content,
101
+ contentType: row.mimeType,
102
+ tenantId: row.tenantId,
103
+ size,
104
+ fileName: row.fileName,
105
+ };
97
106
  }
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.394.0",
4
+ "type": "improvement",
5
+ "title": "createFileRefFromBytes takes a per-call maxSize",
6
+ "detail": "`CreateFileRefInput.maxSize` (bytes) replaces the route default for that call, so server-side generated files such as mail attachments can exceed the 10 MB upload default without raising it for browsers. A file field's own maxSize still applies first. `configuredMaxUploadBytes(registry)` returns the configured route limit in bytes, or undefined."
7
+ },
8
+ {
9
+ "version": "0.394.0",
10
+ "type": "improvement",
11
+ "title": "canCallerReadFileRef answers the file read decision in handlers",
12
+ "detail": "`canCallerReadFileRef(ctx, fileRef)` applies the same rule as GET /files/:id: the configured accessGuard, otherwise the default guard with the configured privilegedRoles and the bound record's detail query. `createDefaultGuard` is exported from the files barrel. A handler that lists or links files no longer needs its own copy of the rule."
13
+ },
14
+ {
15
+ "version": "0.394.0",
16
+ "type": "improvement",
17
+ "title": "Projection entities are server-side attach targets for files",
18
+ "detail": "An entity declared on a projection (r.projection with entity) is a read-model entity: a files field on it can be the attachTo target of createFileRefFromBytes and uploadFileBytesBeforeTransaction, and the file read check uses the owning feature's `<name>:detail` query for it. The registry gets getReadModelEntity and getReadModelEntityOwner; two projections declaring an entity for the same source fail the boot. POST /files still accepts only r.entity targets, so a client can never attach to a read model. deleteFileRefsBoundToRecord soft-deletes the fileRefs of one record for owners that remove it without an r.entity delete."
19
+ },
2
20
  {
3
21
  "version": "0.379.0",
4
22
  "type": "improvement",