@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.335.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 (242) hide show
  1. package/dist/admin-shell/overview-panels.js +2 -2
  2. package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
  3. package/dist/agent-tools/agent-doc-lint.js +33 -3
  4. package/dist/agent-tools/agent-manifest.js +47 -6
  5. package/dist/agent-tools/filter-ops.d.ts +2 -0
  6. package/dist/agent-tools/filter-ops.js +4 -0
  7. package/dist/agent-tools/tool-catalog.js +1 -4
  8. package/dist/agent-tools/tool-dispatch.js +1 -4
  9. package/dist/audit/constants.d.ts +1 -1
  10. package/dist/audit/constants.js +1 -1
  11. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  12. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  13. package/dist/auth-email-password/handlers/login.write.js +2 -0
  14. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  15. package/dist/auth-email-password/i18n.js +2 -0
  16. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  17. package/dist/auth-email-password/web/auth-client.js +1 -1
  18. package/dist/auth-email-password/web/auth-gate.js +6 -8
  19. package/dist/auth-email-password/web/index.d.ts +1 -1
  20. package/dist/auth-email-password/web/index.js +1 -1
  21. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  22. package/dist/auth-email-password/web/login-screen.js +1 -1
  23. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  25. package/dist/auth-email-password/web/session.d.ts +4 -0
  26. package/dist/auth-email-password/web/session.js +15 -0
  27. package/dist/auth-mfa/feature.js +3 -2
  28. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  29. package/dist/auth-mfa/handlers/verify.write.js +2 -0
  30. package/dist/auth-mfa/hmac-token-codec.js +3 -0
  31. package/dist/auth-mfa/mfa-challenge-token.js +4 -0
  32. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
  33. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  34. package/dist/auth-mfa/web/mfa-client.js +4 -10
  35. package/dist/billing-foundation/billing-info-query.js +1 -1
  36. package/dist/billing-foundation/checkout-core.d.ts +5 -5
  37. package/dist/billing-foundation/checkout-core.js +10 -6
  38. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  39. package/dist/cap-counter/book-cap-usage.js +15 -5
  40. package/dist/cap-counter/enforce-cap.js +9 -3
  41. package/dist/cap-counter/feature.js +3 -2
  42. package/dist/cap-counter/i18n.d.ts +1 -0
  43. package/dist/cap-counter/i18n.js +6 -5
  44. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  45. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  46. package/dist/cap-overview/i18n.js +5 -0
  47. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  48. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  49. package/dist/channel-email/email-channel.js +12 -1
  50. package/dist/channel-in-app/tables.js +2 -6
  51. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  52. package/dist/config/db/queries/resolver.js +3 -1
  53. package/dist/config/handlers/set.write.js +8 -4
  54. package/dist/config/web/i18n.js +1 -0
  55. package/dist/config/write-helpers.d.ts +1 -1
  56. package/dist/config/write-helpers.js +11 -5
  57. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  58. package/dist/custom-fields/constants.js +1 -4
  59. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  60. package/dist/data-retention/run-retention-cleanup.js +100 -44
  61. package/dist/delivery/types.d.ts +2 -1
  62. package/dist/delivery/types.js +7 -0
  63. package/dist/delivery/unsubscribe.js +5 -11
  64. package/dist/derivatives-sharp/render.js +27 -9
  65. package/dist/document-ingest-foundation/feature.js +5 -5
  66. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  67. package/dist/document-ingest-foundation/providers.js +6 -0
  68. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  69. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  70. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  71. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  72. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  73. package/dist/files-tenant-data/index.js +3 -2
  74. package/dist/folders/handlers/clear-folder.write.js +4 -11
  75. package/dist/folders/handlers/set-folder.write.js +4 -10
  76. package/dist/form-draft/feature.js +3 -2
  77. package/dist/form-draft/handlers/discard.write.js +7 -8
  78. package/dist/form-draft/handlers/get.query.js +7 -1
  79. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  80. package/dist/foundation-shared/host-allowlist.js +14 -0
  81. package/dist/foundation-shared/index.d.ts +1 -0
  82. package/dist/foundation-shared/index.js +1 -0
  83. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  84. package/dist/foundation-shared/mail-host-policy.js +3 -13
  85. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  86. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  87. package/dist/jobs/handlers/list.query.d.ts +7 -2
  88. package/dist/jobs/handlers/list.query.js +9 -2
  89. package/dist/jobs/handlers/retry.write.js +3 -2
  90. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  91. package/dist/jobs/job-run-logger.js +18 -2
  92. package/dist/jobs/tenant-job-failure-table.js +2 -6
  93. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  94. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  95. package/dist/ledger/subject-reference.d.ts +4 -0
  96. package/dist/ledger/subject-reference.js +7 -0
  97. package/dist/legal-pages/feature.js +8 -1
  98. package/dist/managed-pages/handlers/branding.query.js +1 -1
  99. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  100. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  101. package/dist/metrics/compute.js +7 -1
  102. package/dist/metrics/window.js +5 -0
  103. package/dist/notes-history/handlers/add-note.write.js +4 -9
  104. package/dist/notes-history-user-data/hooks.js +8 -1
  105. package/dist/personal-access-tokens/constants.d.ts +1 -0
  106. package/dist/personal-access-tokens/constants.js +1 -0
  107. package/dist/personal-access-tokens/feature.js +2 -2
  108. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  109. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  110. package/dist/personal-access-tokens/i18n.js +12 -13
  111. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  112. package/dist/personal-access-tokens/screens.js +5 -1
  113. package/dist/rate-limiting/feature.js +2 -0
  114. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  115. package/dist/rate-limiting/i18n.d.ts +10 -0
  116. package/dist/rate-limiting/i18n.js +10 -0
  117. package/dist/secrets/feature.js +3 -2
  118. package/dist/seo/handlers/seo-config.query.js +1 -1
  119. package/dist/sessions/constants.d.ts +1 -0
  120. package/dist/sessions/constants.js +1 -0
  121. package/dist/sessions/feature.js +5 -3
  122. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  123. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  124. package/dist/sessions/handlers/revoke.write.js +2 -2
  125. package/dist/sessions/schema/user-session.js +2 -1
  126. package/dist/sessions/screens.js +5 -1
  127. package/dist/sessions/session-callbacks.js +2 -2
  128. package/dist/shared/index.d.ts +1 -1
  129. package/dist/shared/index.js +1 -1
  130. package/dist/shared/lockout-counter.js +1 -1
  131. package/dist/shared/parent-visibility.d.ts +5 -0
  132. package/dist/shared/parent-visibility.js +17 -1
  133. package/dist/shared/row-bound-grant.js +20 -9
  134. package/dist/shared/single-use-token-store.js +5 -13
  135. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  136. package/dist/step-dispatcher/webhook-runner.js +18 -17
  137. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  138. package/dist/subscription-mollie/plugin-methods.js +7 -1
  139. package/dist/subscription-mollie/verify-webhook.js +6 -0
  140. package/dist/subscription-stripe/plugin-methods.js +25 -6
  141. package/dist/subscription-stripe/verify-webhook.js +13 -6
  142. package/dist/tags/handlers/assign-tag.write.js +4 -10
  143. package/dist/tags/handlers/remove-tag.write.js +4 -11
  144. package/dist/template-resolver/api.d.ts +2 -1
  145. package/dist/template-resolver/api.js +1 -1
  146. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  147. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  148. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  149. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  150. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  151. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  152. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  153. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  154. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  155. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  156. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  157. package/dist/template-resolver/handlers/list.query.js +1 -1
  158. package/dist/template-resolver/seeding.js +41 -39
  159. package/dist/template-resolver/table.d.ts +8 -5
  160. package/dist/template-resolver/testing.js +2 -2
  161. package/dist/template-resolver/user-content-table.d.ts +2 -5
  162. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  163. package/dist/template-resolver-user-data/hooks.js +1 -1
  164. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  165. package/dist/tenant/cancel-pending-invitation.js +22 -0
  166. package/dist/tenant/feature.js +4 -2
  167. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +17 -4
  170. package/dist/tenant/handlers/remove-member.write.js +9 -14
  171. package/dist/tenant/handlers/team-list.query.js +2 -2
  172. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  173. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  174. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  175. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  176. package/dist/tenant/i18n.js +4 -0
  177. package/dist/tenant/index.d.ts +1 -1
  178. package/dist/tenant/index.js +1 -1
  179. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  180. package/dist/tenant/last-tenant-admin.js +5 -3
  181. package/dist/tenant/seeding.d.ts +4 -0
  182. package/dist/tenant/seeding.js +9 -2
  183. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  184. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  185. package/dist/tenant-lifecycle/feature.js +2 -1
  186. package/dist/tenant-lifecycle/stages.js +2 -2
  187. package/dist/tier-engine/feature.js +3 -2
  188. package/dist/user/command-schemas.d.ts +2 -2
  189. package/dist/user/handlers/create.write.d.ts +1 -1
  190. package/dist/user/handlers/create.write.js +8 -2
  191. package/dist/user/handlers/update.write.d.ts +1 -1
  192. package/dist/user/handlers/update.write.js +8 -2
  193. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  194. package/dist/user-data-rights/feature.js +22 -9
  195. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  196. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  197. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  198. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  199. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  200. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  201. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  202. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  203. package/dist/user-data-rights/run-export-jobs.js +74 -41
  204. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  205. package/dist/user-data-rights/run-user-export.js +2 -2
  206. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
  207. package/dist/user-profile/feature.js +6 -1
  208. package/dist/user-profile/handlers/change-email.write.js +2 -0
  209. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  210. package/dist/workflow-runner/event-subscriber.js +29 -9
  211. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  212. package/dist/workflow-runner/pending-projection.js +8 -1
  213. package/package.json +11 -9
  214. package/src/agent-tools/changes.json +12 -0
  215. package/src/auth-email-password/changes.json +18 -0
  216. package/src/auth-mfa/changes.json +6 -0
  217. package/src/billing-foundation/changes.json +7 -0
  218. package/src/cap-counter/changes.json +7 -0
  219. package/src/cap-overview/changes.json +12 -0
  220. package/src/channel-email/changes.json +6 -0
  221. package/src/config/changes.json +6 -0
  222. package/src/crypto-shredding/changes.json +6 -0
  223. package/src/data-retention/changes.json +12 -0
  224. package/src/delivery/changes.json +7 -0
  225. package/src/derivatives-sharp/changes.json +11 -0
  226. package/src/document-ingest-foundation/changes.json +12 -0
  227. package/src/inbound-mail-foundation/changes.json +12 -0
  228. package/src/jobs/changes.json +13 -0
  229. package/src/legal-pages/changes.json +8 -1
  230. package/src/managed-pages/changes.json +5 -0
  231. package/src/metrics/changes.json +6 -0
  232. package/src/rate-limiting/changes.json +5 -0
  233. package/src/secrets/changes.json +7 -0
  234. package/src/sessions/changes.json +5 -0
  235. package/src/shared/changes.json +6 -0
  236. package/src/step-dispatcher/changes.json +5 -0
  237. package/src/subscription-mollie/changes.json +7 -0
  238. package/src/subscription-stripe/changes.json +11 -0
  239. package/src/template-resolver/changes.json +7 -0
  240. package/src/tenant/changes.json +29 -0
  241. package/src/user-data-rights/changes.json +22 -0
  242. package/src/workflow-runner/changes.json +6 -0
@@ -3,6 +3,9 @@ import { Temporal } from "temporal-polyfill";
3
3
  function hmacSign(input, secret) {
4
4
  return createHmac("sha256", secret).update(input).digest("base64url");
5
5
  }
6
+ // `domain` is part of the signed input: it is the only thing that stops a token
7
+ // minted for one MFA step from verifying as another (e.g. a preauth-setup token
8
+ // replayed as a challenge token), since both share the same secret and payload shape.
6
9
  export function createHmacTokenCodec(domain, isPayload) {
7
10
  function isEncodedBody(value) {
8
11
  if (typeof value !== "object" || value === null)
@@ -6,6 +6,10 @@ function isPayload(value) {
6
6
  const v = value;
7
7
  return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
8
8
  }
9
+ // Domain-separated from mfa-preauth-setup (see hmac-token-codec). Single-use is
10
+ // enforced by the consumer via burnToken (purpose "mfa-challenge"); this codec
11
+ // only signs and verifies. Brute-forcing the code behind a valid token is
12
+ // limited separately in mfa-verify-attempts.ts.
9
13
  const codec = createHmacTokenCodec("mfa-challenge", isPayload);
10
14
  export function signMfaChallengeToken(payload, ttlMinutes, secret, now = Temporal.Now.instant()) {
11
15
  return codec.sign(payload, ttlMinutes, secret, now);
@@ -6,6 +6,9 @@ function isPayload(value) {
6
6
  const v = value;
7
7
  return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
8
8
  }
9
+ // Domain "mfa-preauth-setup" must never equal the challenge domain: a setup
10
+ // token has to fail verification as a challenge token and vice versa. The
11
+ // challenge token secret is reused deliberately; the domain carries the separation.
9
12
  const codec = createHmacTokenCodec("mfa-preauth-setup", isPayload);
10
13
  export function signMfaPreauthSetupToken(payload, ttlMinutes, secret, now = Temporal.Now.instant()) {
11
14
  return codec.sign(payload, ttlMinutes, secret, now);
@@ -1,4 +1,4 @@
1
- import type { LoginFailure, LoginResponse } from "../../auth-email-password/web/index.js";
1
+ import { type LoginFailure, type LoginResponse } from "../../auth-email-password/web/index.js";
2
2
  export type MfaVerifyResult = {
3
3
  readonly kind: "success";
4
4
  readonly data: LoginResponse;
@@ -6,6 +6,8 @@
6
6
  // byte-identical to /auth/login's ({isSuccess, token, user} | {isSuccess:
7
7
  // false, error}) since auth-routes.ts mints via the same
8
8
  // mintSessionAndRespond() for both routes.
9
+ // kumiko-lint-ignore cross-feature-import client-only types, the feature's server barrel has no web/ re-export
10
+ import { toLoginResponse, } from "../../auth-email-password/web/index.js";
9
11
  export async function verifyMfaChallenge(challengeToken, code) {
10
12
  const res = await fetch("/api/auth/mfa/verify", {
11
13
  method: "POST",
@@ -21,11 +23,7 @@ export async function verifyMfaChallenge(challengeToken, code) {
21
23
  if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
22
24
  return {
23
25
  kind: "success",
24
- data: {
25
- token: body.token,
26
- user: body.user,
27
- ...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
28
- },
26
+ data: toLoginResponse(body.token, body.user, body.landingPath),
29
27
  };
30
28
  }
31
29
  const err = body.error;
@@ -109,11 +107,7 @@ export async function confirmMfaSetupPreauth(setupToken, code) {
109
107
  if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
110
108
  return {
111
109
  kind: "success",
112
- data: {
113
- token: body.token,
114
- user: body.user,
115
- ...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
116
- },
110
+ data: toLoginResponse(body.token, body.user, body.landingPath),
117
111
  };
118
112
  }
119
113
  const err = body.error;
@@ -1,6 +1,6 @@
1
1
  // Generic billing-info query-handler-config factory. Extracted from the
2
2
  // near-identical billing-info.query.ts app copies in show-pony and
3
- // publicstatus (infra#446) — the only per-app variables were the
3
+ // publicstatus — the only per-app variables were the
4
4
  // TTier union/resolver, the allowed roles, and how Stripe prices are read
5
5
  // off the app's extraContext.
6
6
  //
@@ -19,11 +19,11 @@ export declare function findProviderPlugin(ctx: HandlerContext, providerName: st
19
19
  export declare function resolveProviderPlugin(ctx: HandlerContext, providerName: string): ResolvedProvider;
20
20
  /** Picks the catalog's provider — an explicit `catalog.providerName`, or the
21
21
  * single registered provider exposing `priceToTier` when there is exactly
22
- * one. Returns null when no provider is available (unset `providerName` not
23
- * registered, or no registered plugin exposes `priceToTier`) — a config
24
- * state `billing-plans` renders as `enabled: false` instead of erroring.
25
- * More than one candidate is a config bug, not a "no provider yet" state,
26
- * and still throws `UnconfiguredError`. */
22
+ * one. Returns null when `providerName` is unset and no registered plugin
23
+ * exposes `priceToTier` — a state `billing-plans` renders as
24
+ * `enabled: false` instead of erroring. An explicit `providerName` that is
25
+ * not registered, or more than one candidate, is a config bug and throws
26
+ * `UnconfiguredError`. */
27
27
  export declare function findCatalogProvider(ctx: HandlerContext, catalog: BillingPlanCatalog): ResolvedProvider | null;
28
28
  /** `findCatalogProvider` for callers that require a provider to proceed —
29
29
  * start-plan-checkout and switch-plan. `FeatureDisabledError` (not
@@ -37,16 +37,20 @@ export function resolveProviderPlugin(ctx, providerName) {
37
37
  }
38
38
  /** Picks the catalog's provider — an explicit `catalog.providerName`, or the
39
39
  * single registered provider exposing `priceToTier` when there is exactly
40
- * one. Returns null when no provider is available (unset `providerName` not
41
- * registered, or no registered plugin exposes `priceToTier`) — a config
42
- * state `billing-plans` renders as `enabled: false` instead of erroring.
43
- * More than one candidate is a config bug, not a "no provider yet" state,
44
- * and still throws `UnconfiguredError`. */
40
+ * one. Returns null when `providerName` is unset and no registered plugin
41
+ * exposes `priceToTier` — a state `billing-plans` renders as
42
+ * `enabled: false` instead of erroring. An explicit `providerName` that is
43
+ * not registered, or more than one candidate, is a config bug and throws
44
+ * `UnconfiguredError`. */
45
45
  export function findCatalogProvider(ctx, catalog) {
46
46
  if (catalog.providerName) {
47
47
  const found = findProviderPlugin(ctx, catalog.providerName);
48
48
  if (!found) {
49
- ctx.log?.warn(`billing-foundation: catalog.providerName "${catalog.providerName}" is not a registered subscriptionProvider — billing-plans renders as disabled`);
49
+ throw new UnconfiguredError({
50
+ feature: "billing-foundation",
51
+ key: "catalog.providerName",
52
+ hint: `provider "${catalog.providerName}" not registered`,
53
+ });
50
54
  }
51
55
  return found;
52
56
  }
@@ -4,8 +4,8 @@
4
4
  // gets a `catalog`.
5
5
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import * as z from "zod";
7
- import { assertBillingEnabled, assertRedirectOrigins, isNonEmptyStringArray, joinBaseUrl, resolveCatalogProvider, resolveProviderPlugin, } from "../checkout-core.js";
8
- import { isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus } from "../constants.js";
7
+ import { assertBillingEnabled, assertRedirectOrigins, isNonEmptyStringArray, joinBaseUrl, resolveCatalogProvider, } from "../checkout-core.js";
8
+ import { isSwitchableSubscriptionStatus } from "../constants.js";
9
9
  import { getSubscriptionForTenant } from "../get-subscription-for-tenant.js";
10
10
  import { purchaseRolesOf, resolvePlanPrices } from "../plan-catalog.js";
11
11
  export function createSwitchPlanHandler(options, catalog) {
@@ -25,9 +25,7 @@ export function createSwitchPlanHandler(options, catalog) {
25
25
  const { name: catalogProviderName, plugin } = resolveCatalogProvider(ctx, catalog, "switch-plan");
26
26
  await assertBillingEnabled(ctx, plugin, "switch-plan");
27
27
  const sub = await getSubscriptionForTenant(ctx, event.user.tenantId);
28
- if (!sub ||
29
- isTerminalSubscriptionStatus(sub.status) ||
30
- !isSwitchableSubscriptionStatus(sub.status)) {
28
+ if (!sub || !isSwitchableSubscriptionStatus(sub.status)) {
31
29
  throw new ConflictError({
32
30
  i18nKey: "billing-foundation.errors.noActiveSubscription",
33
31
  message: "tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
@@ -47,14 +45,13 @@ export function createSwitchPlanHandler(options, catalog) {
47
45
  }
48
46
  // providerSubscriptionId only exists at the subscription's own provider; after a
49
47
  // provider migration the catalog provider can differ, which is not a plan switch.
50
- const { plugin: switchPlugin } = resolveProviderPlugin(ctx, sub.providerName);
51
48
  if (sub.providerName !== catalogProviderName) {
52
49
  throw new ConflictError({
53
50
  i18nKey: "billing-foundation.errors.providerMismatch",
54
51
  message: `tenant's active subscription is on provider "${sub.providerName}", the catalog resolves provider "${catalogProviderName}" — switch-plan cannot switch across providers`,
55
52
  });
56
53
  }
57
- if (!switchPlugin.createPlanSwitchSession) {
54
+ if (!plugin.createPlanSwitchSession) {
58
55
  throw new UnprocessableError("plan_switch_not_supported", {
59
56
  i18nKey: "billing-foundation.errors.planSwitchNotSupported",
60
57
  message: `subscription-foundation: provider "${sub.providerName}" has no createPlanSwitchSession-method`,
@@ -74,7 +71,7 @@ export function createSwitchPlanHandler(options, catalog) {
74
71
  const baseUrl = options.baseUrl ?? "";
75
72
  const returnUrl = joinBaseUrl(baseUrl, catalog.returnPath ?? catalog.successPath);
76
73
  assertRedirectOrigins([returnUrl], options.baseUrl);
77
- const result = await switchPlugin.createPlanSwitchSession(ctx, {
74
+ const result = await plugin.createPlanSwitchSession(ctx, {
78
75
  providerSubscriptionId: sub.providerSubscriptionId,
79
76
  targetPriceId: target.priceId,
80
77
  allowedPriceIds,
@@ -8,19 +8,28 @@ import { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js"
8
8
  import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "./constants.js";
9
9
  import { capCounterEntity } from "./entity.js";
10
10
  const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
11
- const capBookingSchema = z.object({
11
+ // periodStartIso feeds the aggregate id and Temporal.Instant.from(); a non-instant string would
12
+ // silently fork a counter row on update and throw a RangeError (500) on create.
13
+ const capPeriodSchema = z.object({
12
14
  capName: z.string().min(1).max(100),
13
- periodStartIso: z.string().min(1),
15
+ periodStartIso: z.iso.datetime({ offset: true }),
16
+ });
17
+ const capBookingSchema = capPeriodSchema.extend({
14
18
  amount: z.number().int().positive().default(1),
15
19
  });
16
20
  function isLostCounterRace(result) {
17
21
  return !result.isSuccess && result.error.code === "version_conflict";
18
22
  }
19
- // Each attempt writes event + projection atomically, so a conflict means another booker just committed — bounded by how many bookers run at once (pool max, default 10); this ceiling only guards against non-convergence.
23
+ // Each attempt writes event + projection atomically, so a conflict means another booker just committed. Contention is bounded by the bookers running at once across ALL replicas on the same DB (not per pool), so the jittered backoff spreads out losers instead of letting them collide again in lockstep; the ceiling only guards against non-convergence.
20
24
  const MAX_COUNTER_WRITE_ATTEMPTS = 20;
25
+ const BACKOFF_JITTER_MS_PER_ATTEMPT = 5;
26
+ function sleepMs(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
21
29
  async function retryCounterWriteOnVersionConflict(writeAttempt) {
22
30
  let result = await writeAttempt();
23
31
  for (let attempt = 1; attempt < MAX_COUNTER_WRITE_ATTEMPTS && isLostCounterRace(result); attempt++) {
32
+ await sleepMs(Math.random() * attempt * BACKOFF_JITTER_MS_PER_ATTEMPT);
24
33
  result = await writeAttempt();
25
34
  }
26
35
  return result;
@@ -64,11 +73,12 @@ export async function bookCapUsage(ctx, options) {
64
73
  return retryCounterWriteOnVersionConflict(() => attemptWrite(ctx.db));
65
74
  }
66
75
  export async function markCapSoftWarned(ctx, options) {
67
- const aggregateId = capCounterAggregateId(ctx.user.tenantId, options.capName, options.periodStartIso);
76
+ const parsed = capPeriodSchema.parse(options);
77
+ const aggregateId = capCounterAggregateId(ctx.user.tenantId, parsed.capName, parsed.periodStartIso);
68
78
  return retryCounterWriteOnVersionConflict(async () => {
69
79
  const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
70
80
  if (existing.length === 0) {
71
- throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${options.capName} period=${options.periodStartIso}`);
81
+ throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
72
82
  }
73
83
  const row = existing[0];
74
84
  if (!row) {
@@ -1,6 +1,6 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
3
- import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
5
5
  import { capCounterEntity } from "./entity.js";
6
6
  // Temporal globally provided by the framework's polyfill init
@@ -48,6 +48,10 @@ export async function enforceCap(ctx, options) {
48
48
  if (!ctx.db) {
49
49
  throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
50
50
  }
51
+ const amount = options.amount ?? 1;
52
+ if (!Number.isInteger(amount) || amount < 1) {
53
+ throw new Error(`cap-counter.enforceCap: amount must be a positive integer, got ${options.amount}`);
54
+ }
51
55
  const tolerance = CAP_TOLERANCES[options.profile];
52
56
  const softThreshold = options.limit * tolerance.soft;
53
57
  const hardThreshold = options.limit * tolerance.hard;
@@ -55,7 +59,7 @@ export async function enforceCap(ctx, options) {
55
59
  const row = rows[0];
56
60
  const storedValue = row ? row["value"] : 0; // @cast-boundary db-row
57
61
  // The last of `amount` units sees this value before its own increment.
58
- const value = storedValue + (options.amount ?? 1) - 1;
62
+ const value = storedValue + amount - 1;
59
63
  if (value >= hardThreshold) {
60
64
  throw new CapExceededError(options.capName, options.limit, value, tolerance);
61
65
  }
@@ -193,10 +197,12 @@ export async function enforceCapAndMaybeNotify(ctx, options) {
193
197
  tenantId: ctx.user.tenantId,
194
198
  });
195
199
  // Flip the soft-warned flag so the same period doesn't re-notify.
196
- await markCapSoftWarned(ctx, {
200
+ const marked = await markCapSoftWarned(ctx, {
197
201
  capName: options.capName,
198
202
  periodStartIso: options.periodStartIso,
199
203
  });
204
+ if (!marked.isSuccess)
205
+ throw reraiseAsKumikoError(marked.error);
200
206
  }
201
207
  return result;
202
208
  }
@@ -91,7 +91,9 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
91
91
  entity: "cap-counter",
92
92
  columns: ["tenantId", "capName", "value", "periodStart", "lastSoftWarnedAt"],
93
93
  defaultSort: { field: "capName", dir: "asc" },
94
- searchable: true,
94
+ // Search resolves against the caller's own tenant stream, so on this
95
+ // cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
96
+ searchable: false,
95
97
  access: { roles: ["SystemAdmin"] },
96
98
  });
97
99
  r.nav({
@@ -105,7 +107,6 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
105
107
  keys: {
106
108
  ...CAP_COUNTER_I18N,
107
109
  "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
108
- "cap-counter:entity:cap-counter:field:tenantId": { de: "Mandant", en: "Tenant" },
109
110
  },
110
111
  });
111
112
  });
@@ -1,4 +1,5 @@
1
1
  type LocalizedString = {
2
+ readonly de: string;
2
3
  readonly en: string;
3
4
  };
4
5
  export declare const CAP_COUNTER_I18N: Readonly<Record<string, LocalizedString>>;
@@ -1,10 +1,11 @@
1
1
  export const CAP_COUNTER_I18N = {
2
- "screen:cap-list.title": { en: "Usage caps" },
3
- "cap-counter:entity:cap-counter:field:tenantId": { en: "Tenant" },
4
- "cap-counter:entity:cap-counter:field:capName": { en: "Cap" },
5
- "cap-counter:entity:cap-counter:field:value": { en: "Value" },
6
- "cap-counter:entity:cap-counter:field:periodStart": { en: "Period start" },
2
+ "screen:cap-list.title": { de: "Nutzungslimits", en: "Usage caps" },
3
+ "cap-counter:entity:cap-counter:field:tenantId": { de: "Mandant", en: "Tenant" },
4
+ "cap-counter:entity:cap-counter:field:capName": { de: "Limit", en: "Cap" },
5
+ "cap-counter:entity:cap-counter:field:value": { de: "Wert", en: "Value" },
6
+ "cap-counter:entity:cap-counter:field:periodStart": { de: "Periodenbeginn", en: "Period start" },
7
7
  "cap-counter:entity:cap-counter:field:lastSoftWarnedAt": {
8
+ de: "Letzte Soft-Warnung",
8
9
  en: "Last soft warning",
9
10
  },
10
11
  };
@@ -51,30 +51,23 @@ export function createCapsUsageQuery(caps) {
51
51
  const rows = await Promise.all(caps.map(async (cap) => {
52
52
  const used = await cap.usage(db, targetTenantId);
53
53
  const limit = await cap.limit(tier, { config: ctx.config });
54
+ const base = {
55
+ id: cap.id,
56
+ label: cap.label,
57
+ limit,
58
+ ...(cap.icon !== undefined && { icon: cap.icon }),
59
+ ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
60
+ };
54
61
  if (used === null) {
55
- return {
56
- id: cap.id,
57
- label: cap.label,
58
- used: null,
59
- limit,
60
- fraction: 0,
61
- tone: "default",
62
- percent: null,
63
- ...(cap.icon !== undefined && { icon: cap.icon }),
64
- ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
65
- };
62
+ return { ...base, used: null, fraction: 0, tone: "default", percent: null };
66
63
  }
67
64
  const fraction = computeFraction(used, limit);
68
65
  return {
69
- id: cap.id,
70
- label: cap.label,
66
+ ...base,
71
67
  used,
72
- limit,
73
68
  fraction,
74
69
  tone: computeTone(fraction),
75
70
  percent: limit === null ? null : Math.round(computeUnclampedFraction(used, limit) * 100),
76
- ...(cap.icon !== undefined && { icon: cap.icon }),
77
- ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
78
71
  };
79
72
  }));
80
73
  return { rows };
@@ -52,6 +52,26 @@ function assertSupportedTierFilterOp(op) {
52
52
  });
53
53
  }
54
54
  }
55
+ function extractTierFilter(filters) {
56
+ const tierFilters = filters.filter((f) => f.field === "tier");
57
+ if (tierFilters.length !== filters.length || tierFilters.length > 1) {
58
+ const unsupportedField = filters.find((f) => f.field !== "tier")?.field ?? "tier";
59
+ throw new ValidationError({
60
+ fields: [
61
+ {
62
+ path: "filters",
63
+ code: "unsupported_field",
64
+ i18nKey: "cap-overview.errors.filterFieldUnsupported",
65
+ params: { field: unsupportedField },
66
+ },
67
+ ],
68
+ });
69
+ }
70
+ const tierFilter = tierFilters[0];
71
+ if (tierFilter !== undefined)
72
+ assertSupportedTierFilterOp(tierFilter.op);
73
+ return tierFilter;
74
+ }
55
75
  function matchesTierFilter(tier, filter) {
56
76
  if (filter === undefined)
57
77
  return true;
@@ -104,10 +124,7 @@ export function createTenantCapsListQuery(caps, listCaps) {
104
124
  // query builder (only `like`, SQL-LIKE case-sensitive) — filtered here
105
125
  // in JS, same as tenant/team-list.query.ts's search.
106
126
  const search = query.payload.search?.trim().toLowerCase();
107
- const filters = query.payload.filters ?? [];
108
- const tierFilter = filters.find((f) => f.field === "tier");
109
- if (tierFilter !== undefined)
110
- assertSupportedTierFilterOp(tierFilter.op);
127
+ const tierFilter = extractTierFilter(query.payload.filters ?? []);
111
128
  const merged = tenants
112
129
  .filter((tenant) => search === undefined || search === "" || tenant.name.toLowerCase().includes(search))
113
130
  .map((tenant) => {
@@ -144,24 +161,33 @@ export function createTenantCapsListQuery(caps, listCaps) {
144
161
  // that the fix is a real combined read-projection, not a cursor
145
162
  // bolted onto this merge.
146
163
  const offset = query.payload.cursor ? Number(decodeCursor(query.payload.cursor)) : 0;
164
+ if (!Number.isInteger(offset) || offset < 0) {
165
+ throw new ValidationError({
166
+ fields: [
167
+ {
168
+ path: "cursor",
169
+ code: "invalid_cursor",
170
+ i18nKey: "cap-overview.errors.invalidCursor",
171
+ },
172
+ ],
173
+ });
174
+ }
147
175
  const limit = query.payload.limit;
148
176
  const page = sorted.slice(offset, offset + limit);
149
- // N+1 avoidance: usage is computed only for this page's tenant ids,
150
- // never for the full tenant set.
177
+ // Usage is computed only for this page's tenant ids, never for the
178
+ // full tenant set; caps without usageBatch still cost one query per
179
+ // tenant of the page, so those run in parallel.
151
180
  const pageTenantIds = page.map((row) => row.tenantId);
152
181
  const usageByCap = new Map();
153
- for (const cap of listedCaps) {
182
+ await Promise.all(listedCaps.map(async (cap) => {
154
183
  if (cap.usageBatch) {
155
184
  usageByCap.set(cap.id, await cap.usageBatch(db, pageTenantIds));
185
+ // skip: batch result already stored, no per-tenant fallback needed
186
+ return;
156
187
  }
157
- else {
158
- const perTenant = new Map();
159
- for (const tenantId of pageTenantIds) {
160
- perTenant.set(tenantId, await cap.usage(db, tenantId));
161
- }
162
- usageByCap.set(cap.id, perTenant);
163
- }
164
- }
188
+ const perTenantEntries = await Promise.all(pageTenantIds.map(async (tenantId) => [tenantId, await cap.usage(db, tenantId)]));
189
+ usageByCap.set(cap.id, new Map(perTenantEntries));
190
+ }));
165
191
  const limitByCapAndTier = await resolveLimitsByCapAndTier(listedCaps, new Set(page.map((row) => row.tier)), { config: ctx.config });
166
192
  const rows = page.map((row) => {
167
193
  const capFields = {};
@@ -11,9 +11,14 @@ export const CAP_OVERVIEW_I18N = {
11
11
  "cap-overview.cards.empty": { en: "No caps configured for this tenant." },
12
12
  "cap-overview.cards.loading": { en: "Loading usage…" },
13
13
  "cap-overview.notMeasured": { en: "Not measured yet" },
14
+ "cap-overview.unlimited": { en: "{used} · unlimited" },
14
15
  "cap-overview.errors.progressPrimitiveMissing": {
15
16
  en: "Usage bar unavailable — Progress primitive is not registered.",
16
17
  },
18
+ "cap-overview.errors.invalidCursor": { en: "The pagination cursor is invalid." },
19
+ "cap-overview.errors.filterFieldUnsupported": {
20
+ en: "Filtering by this field is not supported, or tier was filtered more than once.",
21
+ },
17
22
  "cap-overview.errors.sortFieldUnsupported": { en: "Sorting by this field is not supported." },
18
23
  "cap-overview.errors.tenantOverrideRequiresSystemAdmin": {
19
24
  en: "Only SystemAdmin may query another tenant's data.",
@@ -52,10 +52,13 @@ export function CapCardsPanel({ filterParams }) {
52
52
  // rule exists, since that file IS in the scanned surface.
53
53
  className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", "data-testid": "cap-cards-panel", children: [query.loading && rows.length === 0 && (_jsx("div", { className: "text-sm text-muted-foreground", children: t("cap-overview.cards.loading") })), rows.map((cap) => {
54
54
  const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
55
+ // An unlimited, measured cap has neither a badge nor a bar; StatCard still
56
+ // wraps any defined child in a spacing div, so pass undefined then.
57
+ const body = !isMeasured(cap) || cap.limit !== null ? (_jsxs("div", { className: "flex flex-col gap-2", children: [cap.percent !== null && (_jsx("div", { className: "flex items-center justify-between gap-2", children: _jsx(StatusBadge, { tone: TONE_TO_STATUS[cap.tone], children: `${cap.percent}%` }) })), _jsx(CapUsageBar, { usage: cap, showLabel: false })] })) : undefined;
55
58
  return (_jsx(StatCard, { icon: Icon !== undefined ? _jsx(Icon, { className: "size-4" }) : undefined, label: t(cap.label), value: !isMeasured(cap)
56
59
  ? "—"
57
60
  : cap.limit === null
58
61
  ? String(cap.used)
59
- : `${cap.used} / ${cap.limit}`, ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }), testId: "cap-card", children: _jsxs("div", { className: "flex flex-col gap-2", children: [cap.percent !== null && (_jsx("div", { className: "flex items-center justify-between gap-2", children: _jsx(StatusBadge, { tone: TONE_TO_STATUS[cap.tone], children: `${cap.percent}%` }) })), _jsx(CapUsageBar, { usage: cap, showLabel: false })] }) }, cap.id));
62
+ : `${cap.used} / ${cap.limit}`, ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }), testId: "cap-card", children: body }, cap.id));
60
63
  })] }));
61
64
  }
@@ -10,7 +10,7 @@ export function CapUsageBar({ usage, showLabel = true, }) {
10
10
  }
11
11
  if (usage.limit === null) {
12
12
  // A progress bar with no denominator says nothing — show the raw count instead.
13
- return showLabel ? (_jsx(Text, { variant: "small", testId: "cap-usage-unlimited", children: String(usage.used) })) : null;
13
+ return showLabel ? (_jsx(Text, { variant: "small", testId: "cap-usage-unlimited", children: t("cap-overview.unlimited", { used: usage.used }) })) : null;
14
14
  }
15
15
  return (_jsxs("div", { className: "flex flex-col gap-1", children: [Progress ? (_jsx(Progress, { value: usage.fraction, tone: computeTone(usage.fraction), testId: "cap-usage-bar" })) : (_jsx(Banner, { variant: "error", testId: "cap-usage-bar-missing-primitive", children: t("cap-overview.errors.progressPrimitiveMissing") })), showLabel && _jsx(Text, { variant: "small", children: `${usage.used} / ${usage.limit}` })] }));
16
16
  }
@@ -15,6 +15,10 @@ function stringHeaders(raw) {
15
15
  }
16
16
  return Object.keys(out).length > 0 ? out : undefined;
17
17
  }
18
+ const UNSUBSCRIBE_HEADER_NAMES = new Set([
19
+ "list-unsubscribe",
20
+ "list-unsubscribe-post",
21
+ ]);
18
22
  // Only the framework's own unsubscribe route can honor a one-click POST.
19
23
  function listUnsubscribeHeaders(data) {
20
24
  const rawUrl = data?.["unsubscribeUrl"];
@@ -43,7 +47,14 @@ function emailEnvelopeFrom(data) {
43
47
  const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
44
48
  const autoHeaders = listUnsubscribeHeaders(data);
45
49
  const explicitHeaders = stringHeaders(data["headers"]);
46
- const headers = autoHeaders || explicitHeaders ? { ...autoHeaders, ...explicitHeaders } : undefined;
50
+ // Header names are case-insensitive, and the auto pair only works together:
51
+ // an explicit override of either half must drop both, or a mail client would
52
+ // one-click POST to a URL that cannot handle it.
53
+ const overridesUnsubscribe = explicitHeaders !== undefined &&
54
+ Object.keys(explicitHeaders).some((name) => UNSUBSCRIBE_HEADER_NAMES.has(name.toLowerCase()));
55
+ const headers = (!overridesUnsubscribe && autoHeaders) || explicitHeaders
56
+ ? { ...(overridesUnsubscribe ? undefined : autoHeaders), ...explicitHeaders }
57
+ : undefined;
47
58
  return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
48
59
  }
49
60
  export function createEmailChannel(options) {
@@ -1,4 +1,4 @@
1
- import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { boolean, instant, table as pgTable, serial, sql, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
3
  export const inAppMessagesTable = pgTable("in_app_messages", {
4
4
  id: serial("id").primaryKey(),
@@ -14,8 +14,4 @@ export const inAppMessagesTable = pgTable("in_app_messages", {
14
14
  });
15
15
  // Derived from inAppMessagesTable (table() attaches the meta as a Symbol) so
16
16
  // the two can't drift, instead of hand-duplicating the column list.
17
- const derivedInAppMessagesTableMeta = asEntityTableMeta(inAppMessagesTable);
18
- if (!derivedInAppMessagesTableMeta) {
19
- throw new Error("inAppMessagesTable: table carries no EntityTableMeta — built via table()?");
20
- }
21
- export const inAppMessagesTableMeta = derivedInAppMessagesTableMeta;
17
+ export const inAppMessagesTableMeta = requireEntityTableMeta(inAppMessagesTable, "inAppMessagesTable");
@@ -19,7 +19,7 @@ export const subProcessorsQuery = defineQueryHandler({
19
19
  name: "sub-processors",
20
20
  schema: z.object({}),
21
21
  access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
22
- rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
22
+ rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
23
23
  description: "Returns the publicly readable Kumiko sub-processor list split into active and planned entries, for the GDPR Art. 28(2) disclosure a privacy policy or data-processing agreement links to.",
24
24
  handler: async () => {
25
25
  return {
@@ -1,6 +1,8 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { configValuesTable } from "../../table.js";
3
- // Method-form so a tenant-mode TenantDb stays filtered.
3
+ // Method-form so a tenant-mode TenantDb stays filtered. Two parallel selects
4
+ // (disjoint via userId IS NULL) instead of one OR query: the buckets don't share
5
+ // a snapshot, so a concurrent config write can mix old scope and new user values.
4
6
  export async function selectConfigRowsForScope(db, systemTenantId, tenantId, userId) {
5
7
  const [scopeRows, userRows] = await Promise.all([
6
8
  selectMany(db, configValuesTable, {
@@ -1,10 +1,11 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { configuredPiiSubjectKms, encryptPiiValueForSubject, } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
4
- import { ConfigScopes, defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { ConfigScopes, defineWriteHandler, isEncryptedAtRest, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import * as z from "zod";
7
7
  import { requireConfigEncryption, requireSystemDb } from "../feature.js";
8
+ import { MASKED } from "../read-redaction.js";
8
9
  import { configValueEntity, configValuesTable } from "../table.js";
9
10
  import { findConfigRow, prepareConfigWrite, resolvePiiSubject, validateBounds, validateExtensionPlugin, validatePattern, validateScope, validateType, } from "../write-helpers.js";
10
11
  const scopeEnum = z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]);
@@ -53,7 +54,7 @@ export const setWrite = defineWriteHandler({
53
54
  const typeError = validateType(event.payload.value, keyDef);
54
55
  if (typeError)
55
56
  return writeFailure(typeError);
56
- const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value);
57
+ const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value, keyDef);
57
58
  if (pluginError)
58
59
  return writeFailure(pluginError);
59
60
  // Bounds enforcement: hard-reject (not silent-clamp). A caller that
@@ -66,6 +67,9 @@ export const setWrite = defineWriteHandler({
66
67
  const patternError = validatePattern(event.payload.value, keyDef);
67
68
  if (patternError)
68
69
  return writeFailure(patternError);
70
+ // The batch idempotency cache stores this result and only masks entity
71
+ // writeOnly fields, so an at-rest-encrypted value must not be echoed.
72
+ const echoedValue = isEncryptedAtRest(keyDef) ? MASKED : event.payload.value;
69
73
  // backing="secrets": persist into the secrets store (system tenant, own
70
74
  // envelope encryption + audit) instead of config_values. Same JSON
71
75
  // serialization as a config row so the read path round-trips via
@@ -82,7 +86,7 @@ export const setWrite = defineWriteHandler({
82
86
  });
83
87
  return {
84
88
  isSuccess: true,
85
- data: { key: event.payload.key, value: event.payload.value, scope },
89
+ data: { key: event.payload.key, value: echoedValue, scope },
86
90
  };
87
91
  }
88
92
  let serialized = JSON.stringify(event.payload.value);
@@ -139,7 +143,7 @@ export const setWrite = defineWriteHandler({
139
143
  }
140
144
  return {
141
145
  isSuccess: true,
142
- data: { key: event.payload.key, value: event.payload.value, scope },
146
+ data: { key: event.payload.key, value: echoedValue, scope },
143
147
  };
144
148
  },
145
149
  });
@@ -9,6 +9,7 @@
9
9
  export const defaultTranslations = {
10
10
  en: {
11
11
  "config.secrets.delete": "Remove",
12
+ "config.secrets.deleteConfirm": "Remove this secret? Anything using it stops working, and it cannot be restored.",
12
13
  "config.secrets.notSet": "Not set",
13
14
  "config.secrets.placeholder": "Enter a value",
14
15
  "config.secrets.replacePlaceholder": "Enter a new value to replace it",
@@ -38,7 +38,7 @@ export declare function resolveScopeIds(scope: ConfigScope, tenantId: TenantId,
38
38
  userId: string | null;
39
39
  };
40
40
  export declare function resolvePiiSubject(scope: Exclude<ConfigScope, "system">, tenantId: TenantId, userId: string | null): SubjectId;
41
- export declare function validateExtensionPlugin(registry: Registry, key: string, value: string | number | boolean): KumikoError | null;
41
+ export declare function validateExtensionPlugin(registry: Pick<Registry, "getAllExtensionSelectors" | "getExtensionUsages">, key: string, value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
42
42
  export declare function validateType(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
43
43
  export declare function validateBounds(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
44
44
  export declare function validatePattern(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;