@cosmicdrift/kumiko-samples 0.404.0 → 0.405.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 (156) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +25 -25
  3. package/packages/bundled-features/src/account-security/README.md +7 -5
  4. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  5. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  6. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
  7. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  8. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  11. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  12. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  13. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  14. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  15. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  16. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  17. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  18. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  19. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  20. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  23. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  24. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  26. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  27. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  28. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  29. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  30. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  31. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  32. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  33. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  34. package/packages/bundled-features/src/documents/changes.json +8 -0
  35. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  36. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  37. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  38. package/packages/bundled-features/src/folders/feature.ts +2 -8
  39. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  40. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  41. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  42. package/packages/bundled-features/src/host-routing/README.md +10 -10
  43. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  44. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  46. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  47. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  48. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  49. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  50. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  51. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  52. package/packages/bundled-features/src/legal-pages/README.md +21 -13
  53. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  54. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  55. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  56. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  57. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  58. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  59. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  60. package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
  61. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  62. package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
  63. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  64. package/packages/bundled-features/src/shared/changes.json +6 -0
  65. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  66. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  67. package/packages/bundled-features/src/shared/index.ts +6 -0
  68. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  69. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  70. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  71. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  72. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  73. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  74. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  75. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  76. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  77. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  78. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  79. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  80. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  81. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  82. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  83. package/packages/bundled-features/src/tags/feature.ts +2 -8
  84. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  85. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  86. package/packages/bundled-features/src/template-resolver/README.md +29 -29
  87. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  88. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  89. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
  90. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  91. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  92. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  93. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  94. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
  97. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  98. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
  99. package/packages/bundled-features/src/user-data-rights/README.md +33 -33
  100. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  101. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  102. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  103. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  104. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  105. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  106. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  107. package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
  108. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
  109. package/samples/apps/admin-console/README.md +5 -5
  110. package/samples/apps/admin-console/package.json +3 -3
  111. package/samples/apps/cap-billing-demo/README.md +15 -15
  112. package/samples/apps/config-demo/README.md +7 -7
  113. package/samples/apps/config-demo/package.json +2 -2
  114. package/samples/apps/marketing-demo/README.md +2 -2
  115. package/samples/apps/marketing-demo/package.json +2 -2
  116. package/samples/apps/showcase/package.json +3 -3
  117. package/samples/apps/styleguide/package.json +2 -2
  118. package/samples/apps/ui-walkthrough/README.md +8 -9
  119. package/samples/apps/ui-walkthrough/package.json +2 -2
  120. package/samples/apps/use-all-bundled/README.md +2 -2
  121. package/samples/apps/use-all-bundled/package.json +2 -2
  122. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
  123. package/samples/apps/user-data-rights-demo/README.md +12 -12
  124. package/samples/apps/workspaces/package.json +2 -2
  125. package/samples/recipes/apex-landing/README.md +3 -3
  126. package/samples/recipes/apex-landing/package.json +1 -1
  127. package/samples/recipes/apex-surface-auth/README.md +13 -9
  128. package/samples/recipes/auth-mfa/README.md +2 -4
  129. package/samples/recipes/auth-mfa/package.json +2 -2
  130. package/samples/recipes/boot-check/README.md +2 -2
  131. package/samples/recipes/compliance-profiles-demo/README.md +5 -5
  132. package/samples/recipes/compose-stacks/README.md +8 -8
  133. package/samples/recipes/cross-feature-events/README.md +1 -0
  134. package/samples/recipes/crypto-shredding-hr/README.md +3 -3
  135. package/samples/recipes/custom-fields-basic/README.md +1 -1
  136. package/samples/recipes/documents/README.md +25 -0
  137. package/samples/recipes/embedded-entity-form/package.json +2 -2
  138. package/samples/recipes/encrypted-tenant-config/README.md +8 -8
  139. package/samples/recipes/env-composition/README.md +10 -10
  140. package/samples/recipes/error-contract/README.md +9 -9
  141. package/samples/recipes/event-sourcing/README.md +9 -9
  142. package/samples/recipes/ledger-invoicing/README.md +6 -6
  143. package/samples/recipes/legal-pages/README.md +16 -16
  144. package/samples/recipes/managed-config/README.md +15 -11
  145. package/samples/recipes/managed-pages/README.md +7 -7
  146. package/samples/recipes/pdf-audit-export/package.json +1 -1
  147. package/samples/recipes/pipeline-basics/README.md +13 -13
  148. package/samples/recipes/public-share-token/README.md +4 -4
  149. package/samples/recipes/record-detail-layout/package.json +3 -3
  150. package/samples/recipes/security-baseline/README.md +4 -1
  151. package/samples/recipes/session-revocation/README.md +1 -1
  152. package/samples/recipes/tenant-settings/README.md +4 -4
  153. package/samples/recipes/tier-admin/README.md +2 -2
  154. package/samples/recipes/user-data-rights/README.md +21 -19
  155. package/samples/recipes/wizard-form/package.json +3 -3
  156. package/samples/recipes/writeform-section/package.json +3 -3
@@ -2,10 +2,13 @@
2
2
  // cache misses, cached per price id.
3
3
 
4
4
  import {
5
+ createTtlPriceCache,
5
6
  KNOWN_RECURRING_INTERVALS,
6
7
  type ProviderPrice,
8
+ type TtlPriceCache,
7
9
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
8
10
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { chunk } from "@cosmicdrift/kumiko-framework/utils";
9
12
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
10
13
  import * as z from "zod";
11
14
  import type { PaddleCtxRuntime, PaddlePluginMethod } from "./runtime.ts";
@@ -61,10 +64,7 @@ function mapPaddlePrice(price: PaddlePrice): ProviderPrice {
61
64
  return { ...base, interval, intervalCount: interval ? cycle.frequency : null };
62
65
  }
63
66
 
64
- export type PaddlePriceCache = {
65
- readonly get: (priceId: string) => ProviderPrice | undefined;
66
- readonly set: (priceId: string, price: ProviderPrice) => void;
67
- };
67
+ export type PaddlePriceCache = TtlPriceCache<ProviderPrice>;
68
68
 
69
69
  export type PaddlePriceCacheOptions = {
70
70
  readonly ttlMs?: number;
@@ -75,32 +75,10 @@ export type PaddlePriceCacheOptions = {
75
75
  // trigger an audited secret read on every billing-plans render. Failed lookups
76
76
  // are never cached.
77
77
  export function createPaddlePriceCache({
78
- ttlMs = 600_000,
78
+ ttlMs,
79
79
  now = () => Temporal.Now.instant().epochMilliseconds,
80
80
  }: PaddlePriceCacheOptions = {}): PaddlePriceCache {
81
- const store = new Map<string, { readonly price: ProviderPrice; readonly expiresAt: number }>();
82
- return {
83
- get: (priceId) => {
84
- const entry = store.get(priceId);
85
- if (!entry) return undefined;
86
- if (entry.expiresAt <= now()) {
87
- store.delete(priceId);
88
- return undefined;
89
- }
90
- return entry.price;
91
- },
92
- set: (priceId, price) => {
93
- store.set(priceId, { price, expiresAt: now() + ttlMs });
94
- },
95
- };
96
- }
97
-
98
- function chunked<T>(items: readonly T[], size: number): T[][] {
99
- const chunks: T[][] = [];
100
- for (let start = 0; start < items.length; start += size) {
101
- chunks.push(items.slice(start, start + size));
102
- }
103
- return chunks;
81
+ return createTtlPriceCache<ProviderPrice>({ ttlMs, now });
104
82
  }
105
83
 
106
84
  async function fillPriceCache(
@@ -111,7 +89,7 @@ async function fillPriceCache(
111
89
  ): Promise<void> {
112
90
  const misses = [...new Set(priceIds.filter((priceId) => cache.get(priceId) === undefined))];
113
91
  const settled = await Promise.allSettled(
114
- chunked(misses, PRICES_PER_REQUEST).map(async (ids) =>
92
+ chunk(misses, PRICES_PER_REQUEST).map(async (ids) =>
115
93
  pricesResponseSchema.parse(
116
94
  await runtime.call(ctx, {
117
95
  method: "GET",
@@ -7,7 +7,10 @@
7
7
  // HandlerContext; the pre-tenant webhook reads secrets un-audited from the
8
8
  // system SecretsContext the foundation passes in.
9
9
 
10
- import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
10
+ import {
11
+ parseStoredSecret,
12
+ type SubscriptionProviderPlugin,
13
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
11
14
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
12
15
  import {
13
16
  type ConfigKeyHandle,
@@ -16,7 +19,6 @@ import {
16
19
  } from "@cosmicdrift/kumiko-framework/engine";
17
20
  import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
18
21
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
19
- import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
20
22
  import { createPaddleHttp, type PaddleRequest, type PaddleSend } from "./client.ts";
21
23
  import {
22
24
  PADDLE_BASE_URLS,
@@ -47,19 +49,6 @@ export type PaddleRuntimeDeps = {
47
49
  readonly fallback: PaddleFallbacks;
48
50
  };
49
51
 
50
- // backing:"secrets" values are stored JSON-serialized, so the store read has to
51
- // be parsed back; the env fallback is a plain string and stays raw. The error
52
- // must not carry the input: the JSON parser's own message would repeat the
53
- // credential into logs and, for webhooks, into the 401 body.
54
- function parseStoredSecret(raw: string | undefined): string | undefined {
55
- if (raw === undefined || raw.length === 0) return undefined;
56
- const parsed = parseJsonSafe<unknown>(raw, undefined);
57
- if (parsed === undefined) {
58
- throw new Error("subscription-paddle credential (backing:secrets) is not valid JSON");
59
- }
60
- return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
61
- }
62
-
63
52
  function hintFor(shortName: string): string {
64
53
  return `Set it via config:write:set on \`${SUBSCRIPTION_PADDLE_FEATURE}:config:${shortName}\` (or seed it from the matching PADDLE_* variable during the env bridge).`;
65
54
  }
@@ -108,7 +97,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
108
97
  { secrets: ctx.services.secrets, user: ctx.user },
109
98
  SUBSCRIPTION_PADDLE_FEATURE,
110
99
  ).get(SYSTEM_TENANT_ID, handle.name);
111
- value = parseStoredSecret(got?.reveal());
100
+ value = parseStoredSecret("subscription-paddle", got?.reveal());
112
101
  }
113
102
  if (!value && fallback && fallback.length > 0) value = fallback;
114
103
  if (!value) {
@@ -129,7 +118,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
129
118
  if (systemSecrets) {
130
119
  // No auditCtx: every webhook would otherwise spam the audit trail.
131
120
  const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
132
- const stored = parseStoredSecret(got?.reveal());
121
+ const stored = parseStoredSecret("subscription-paddle", got?.reveal());
133
122
  if (stored) return stored;
134
123
  }
135
124
  if (fallback && fallback.length > 0) return fallback;
@@ -32,7 +32,10 @@
32
32
  // api key + webhook secret without auditing (a sanctioned framework-internal
33
33
  // read).
34
34
 
35
- import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
35
+ import {
36
+ type SubscriptionProviderPlugin,
37
+ validateOneOffPriceIds,
38
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
36
39
  import {
37
40
  access,
38
41
  createSystemConfig,
@@ -144,23 +147,6 @@ export type SubscriptionStripeOptions = {
144
147
  readonly oneOffPriceIds?: readonly string[];
145
148
  };
146
149
 
147
- // Validated once at feature-build time (not per-request) so a config typo
148
- // (a pasted-twice priceId, a blank string from a bad env-join) fails app
149
- // startup instead of silently widening — or narrowing — the one-off
150
- // allowlist at request time. Mirrors legal-pages' validateRoutes.
151
- function validateOneOffPriceIds(priceIds: readonly string[]): void {
152
- const seen = new Set<string>();
153
- for (const priceId of priceIds) {
154
- if (priceId.length === 0) {
155
- throw new Error("subscription-stripe: oneOffPriceIds must not contain an empty string");
156
- }
157
- if (seen.has(priceId)) {
158
- throw new Error(`subscription-stripe: duplicate oneOffPriceIds entry "${priceId}"`);
159
- }
160
- seen.add(priceId);
161
- }
162
- }
163
-
164
150
  /**
165
151
  * Factory for the subscription-stripe feature. Always mounts (no
166
152
  * key-presence guard) — activity is gated at runtime through config/secrets
@@ -171,7 +157,7 @@ function validateOneOffPriceIds(priceIds: readonly string[]): void {
171
157
  export function createSubscriptionStripeFeature(
172
158
  options: SubscriptionStripeOptions = {},
173
159
  ): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionStripeEnvSchema.shape> {
174
- validateOneOffPriceIds(options.oneOffPriceIds ?? []);
160
+ validateOneOffPriceIds("subscription-stripe", options.oneOffPriceIds ?? []);
175
161
  const feature: FeatureDefinition = defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
176
162
  r.describe(
177
163
  'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog), `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), `retrieveSubscription` (live provider-side snapshot for the `sync-subscriptions` backfill job, mapped identically to the webhook path), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',
@@ -17,12 +17,14 @@
17
17
 
18
18
  import { createHash } from "node:crypto";
19
19
  import {
20
+ createTtlPriceCache,
20
21
  KNOWN_RECURRING_INTERVALS,
21
22
  type ProviderPrice,
22
23
  type ProviderSubscriptionSnapshot,
23
24
  type RecurringInterval,
24
25
  SubscriptionCancelTimings,
25
26
  type SubscriptionProviderPlugin,
27
+ type TtlPriceCache,
26
28
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
27
29
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
28
30
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
@@ -332,10 +334,7 @@ function mapStripePrice(price: Stripe.Price): ProviderPrice {
332
334
  // requests the billing-plans catalog re-renders on every page load. Keyed by
333
335
  // priceId alone: Stripe priceIds are account-wide unique. Failed lookups are
334
336
  // never cached, so a transient Stripe outage self-heals on the next call.
335
- export type StripePriceCache = {
336
- readonly get: (priceId: string) => Stripe.Price | undefined;
337
- readonly set: (priceId: string, price: Stripe.Price) => void;
338
- };
337
+ export type StripePriceCache = TtlPriceCache<Stripe.Price>;
339
338
 
340
339
  export type StripePriceCacheOptions = {
341
340
  readonly ttlMs?: number;
@@ -343,24 +342,10 @@ export type StripePriceCacheOptions = {
343
342
  };
344
343
 
345
344
  export function createStripePriceCache({
346
- ttlMs = 600_000,
345
+ ttlMs,
347
346
  now = () => Date.now(),
348
347
  }: StripePriceCacheOptions = {}): StripePriceCache {
349
- const store = new Map<string, { readonly price: Stripe.Price; readonly expiresAt: number }>();
350
- return {
351
- get: (priceId) => {
352
- const entry = store.get(priceId);
353
- if (!entry) return undefined;
354
- if (entry.expiresAt <= now()) {
355
- store.delete(priceId);
356
- return undefined;
357
- }
358
- return entry.price;
359
- },
360
- set: (priceId, price) => {
361
- store.set(priceId, { price, expiresAt: now() + ttlMs });
362
- },
363
- };
348
+ return createTtlPriceCache<Stripe.Price>({ ttlMs, now });
364
349
  }
365
350
 
366
351
  /** Fills `cache` for every priceId not already resolved — the only place
@@ -25,6 +25,7 @@
25
25
  // un-audited framework-internal path). The foundation builds and passes
26
26
  // this SecretsContext as the 3rd webhook arg.
27
27
 
28
+ import { parseStoredSecret } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
28
29
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
29
30
  import {
30
31
  type ConfigKeyHandle,
@@ -33,7 +34,6 @@ import {
33
34
  } from "@cosmicdrift/kumiko-framework/engine";
34
35
  import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
35
36
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
36
- import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
37
37
  import Stripe from "stripe";
38
38
  import { SUBSCRIPTION_STRIPE_FEATURE } from "./constants.ts";
39
39
 
@@ -63,22 +63,6 @@ export type StripeRuntimeDeps = {
63
63
  readonly fallback: { readonly apiKey?: string; readonly webhookSecret?: string };
64
64
  };
65
65
 
66
- // backing:"secrets" config keys store their value JSON-serialized in the
67
- // secrets store (config:write:set → JSON.stringify). A raw SecretsContext.get
68
- // (the un-audited webhook path + the audited ctx read here) therefore returns
69
- // the JSON-quoted string and must parse it back. The factory fallback is a
70
- // plain env string and stays raw — so parsing is applied only to the store
71
- // read, never to the fallback. The error must not carry the input: the JSON
72
- // parser's own message would repeat the credential into logs and the 401 body.
73
- function parseStoredSecret(raw: string | undefined): string | undefined {
74
- if (raw === undefined || raw.length === 0) return undefined;
75
- const parsed = parseJsonSafe<unknown>(raw, undefined);
76
- if (parsed === undefined) {
77
- throw new Error("subscription-stripe credential (backing:secrets) is not valid JSON");
78
- }
79
- return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
80
- }
81
-
82
66
  /** Post-tenant runtime: used by checkout/portal/cancel which carry a full
83
67
  * HandlerContext (audited secret reads, config-gate). */
84
68
  export type StripeCtxRuntime = {
@@ -119,7 +103,7 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
119
103
  { secrets: ctx.services.secrets, user: ctx.user },
120
104
  SUBSCRIPTION_STRIPE_FEATURE,
121
105
  ).get(SYSTEM_TENANT_ID, deps.apiKeyHandle.name);
122
- key = parseStoredSecret(got?.reveal());
106
+ key = parseStoredSecret("subscription-stripe", got?.reveal());
123
107
  }
124
108
  if (!key && deps.fallback.apiKey && deps.fallback.apiKey.length > 0) {
125
109
  key = deps.fallback.apiKey;
@@ -143,7 +127,7 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
143
127
  // No auditCtx → un-audited framework-internal read (every webhook would
144
128
  // otherwise spam the audit trail). This is the sanctioned no-ctx path.
145
129
  const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
146
- const value = parseStoredSecret(got?.reveal());
130
+ const value = parseStoredSecret("subscription-stripe", got?.reveal());
147
131
  if (value && value.length > 0) return value;
148
132
  }
149
133
  return fallback && fallback.length > 0 ? fallback : undefined;
@@ -27,7 +27,7 @@ import {
27
27
  type EntityDefinition,
28
28
  type FeatureRegistrar,
29
29
  } from "@cosmicdrift/kumiko-framework/engine";
30
- import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.ts";
30
+ import { assertNoWhereRuleInOwnershipWrite, resolveFeatureAccess } from "../shared/index.ts";
31
31
  import { createTagAssignmentEntity, tagEntity } from "./entity.ts";
32
32
  import { createTagAssignmentExecutor } from "./executor.ts";
33
33
  import { createAssignTagHandler } from "./handlers/assign-tag.write.ts";
@@ -175,12 +175,6 @@ export type TagsFeatureOptions = {
175
175
  readonly parents?: readonly string[];
176
176
  };
177
177
 
178
- function resolveAccess(opts: TagsFeatureOptions): AccessRule {
179
- if (opts.access !== undefined) return opts.access;
180
- if (opts.roles !== undefined) return { roles: opts.roles };
181
- return DEFAULT_TAG_ACCESS;
182
- }
183
-
184
178
  // Backwards-compat / options wrapper. Without options returns the module-level
185
179
  // singleton (no rebuild). access/roles/toggleable/ownership build a fresh
186
180
  // feature-definition.
@@ -203,7 +197,7 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof default
203
197
  );
204
198
  }
205
199
  assertNoWhereRuleInOwnershipWrite("createTagsFeature", opts.ownership?.write);
206
- const access = resolveAccess(opts);
200
+ const access = resolveFeatureAccess(opts, DEFAULT_TAG_ACCESS);
207
201
  return defineFeature(TAGS_FEATURE_NAME, (r) =>
208
202
  registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
209
203
  );
@@ -17,6 +17,7 @@ import {
17
17
  } from "@cosmicdrift/kumiko-renderer";
18
18
  import { pluralKey } from "@cosmicdrift/kumiko-renderer/headless";
19
19
  import { type ReactNode, useState } from "react";
20
+ import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
20
21
  import { TagsHandlers, TagsQueries } from "./constants.ts";
21
22
  import { TagChip } from "./tag-chip.tsx";
22
23
 
@@ -117,8 +118,10 @@ export function TagManager({
117
118
  const [editColor, setEditColor] = useState("");
118
119
  const [editScope, setEditScope] = useState("");
119
120
  const [deleting, setDeleting] = useState<TagRow | null>(null);
120
- const [busy, setBusy] = useState(false);
121
- const [errorKey, setErrorKey] = useState<string | null>(null);
121
+ const refetch = async (): Promise<void> => {
122
+ await Promise.all([catalog.refetch(), assignments.refetch()]);
123
+ };
124
+ const { busy, errorKey, setErrorKey, writeOk, apply } = useDispatchedWrites(refetch);
122
125
 
123
126
  if (catalog.loading && catalog.data === null) {
124
127
  return (
@@ -149,29 +152,6 @@ export function TagManager({
149
152
  })
150
153
  : allTags;
151
154
 
152
- const refetch = async (): Promise<void> => {
153
- await Promise.all([catalog.refetch(), assignments.refetch()]);
154
- };
155
-
156
- const apply = async (writes: () => Promise<boolean>): Promise<void> => {
157
- setBusy(true);
158
- setErrorKey(null);
159
- try {
160
- if (await writes()) await refetch();
161
- } finally {
162
- setBusy(false);
163
- }
164
- };
165
-
166
- const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
167
- const result = await dispatcher.write(type, payload);
168
- if (!result.isSuccess) {
169
- setErrorKey(result.error.i18nKey);
170
- return false;
171
- }
172
- return true;
173
- };
174
-
175
155
  const createTag = (): void => {
176
156
  const name = newName.trim();
177
157
  if (name === "") return;
@@ -11,13 +11,9 @@
11
11
  // component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } }
12
12
  // (RenderEdit passes { entityName, entityId }).
13
13
 
14
- import {
15
- useDispatcher,
16
- usePrimitives,
17
- useQuery,
18
- useTranslation,
19
- } from "@cosmicdrift/kumiko-renderer";
14
+ import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
20
15
  import { type ReactNode, useState } from "react";
16
+ import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
21
17
  import { TagsHandlers, TagsQueries } from "./constants.ts";
22
18
  import { TagChip } from "./tag-chip.tsx";
23
19
  import { TagPicker } from "./tag-picker.tsx";
@@ -53,7 +49,6 @@ export function TagSection({
53
49
  }): ReactNode {
54
50
  const { Banner, Button, Text } = usePrimitives();
55
51
  const t = useTranslation();
56
- const dispatcher = useDispatcher();
57
52
  const enabled = entityId !== null;
58
53
  const catalog = useQuery<TagListResponse>(TagsQueries.tagList, {}, { enabled });
59
54
  // entityType server-side, not just entityId: it collapses the parent-ref
@@ -69,8 +64,7 @@ export function TagSection({
69
64
  { enabled },
70
65
  );
71
66
  const [pickerOpen, setPickerOpen] = useState(false);
72
- const [busy, setBusy] = useState(false);
73
- const [errorKey, setErrorKey] = useState<string | null>(null);
67
+ const { busy, setBusy, errorKey, setErrorKey, writeOk } = useDispatchedWrites();
74
68
 
75
69
  if (entityId === null) {
76
70
  return (
@@ -105,15 +99,6 @@ export function TagSection({
105
99
  await Promise.all([catalog.refetch(), assignments.refetch()]);
106
100
  };
107
101
 
108
- const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
109
- const result = await dispatcher.write(type, payload);
110
- if (!result.isSuccess) {
111
- setErrorKey(result.error.i18nKey);
112
- return false;
113
- }
114
- return true;
115
- };
116
-
117
102
  const onPicked = (next: readonly string[]): void => {
118
103
  const { added, removed } = tagSelectionDelta(assignedIds, next);
119
104
  if (added.length === 0 && removed.length === 0) return;
@@ -44,13 +44,13 @@ async function someHandler(ctx) {
44
44
 
45
45
  ## Kinds, format, and meta
46
46
 
47
- | kind | contentFormat | meta |
48
- |---|---|---|
49
- | `mail` | `html` (document schema, rebuilt server-side) | Required: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
50
- | `mail-layout` | `mjml` | empty (`{}`) or missing |
51
- | `pdf-layout` | `html` | empty (`{}`) or missing |
52
- | `document-pdf` | `html` | missing |
53
- | remaining kinds | arbitrary | missing |
47
+ | kind | contentFormat | meta |
48
+ | --------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
49
+ | `mail` | `html` (document schema, rebuilt server-side) | Required: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
50
+ | `mail-layout` | `mjml` | empty (`{}`) or missing |
51
+ | `pdf-layout` | `html` | empty (`{}`) or missing |
52
+ | `document-pdf` | `html` | missing |
53
+ | remaining kinds | arbitrary | missing |
54
54
 
55
55
  `meta` is stored as JSON in the `meta` column and is validated on write per kind against
56
56
  `template-meta.ts` (errors as field issues on `meta.*`). Subject and preview text allow only simple
@@ -105,22 +105,22 @@ await seedSystemTemplate(db, {
105
105
 
106
106
  ## Admin workflows (write handlers + queries)
107
107
 
108
- | Handler | QN | Who | What |
109
- |---|---|---|---|
110
- | `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Creates or updates system default templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
111
- | `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (own tenant) + SystemAdmin via `tenantIdOverride` | Creates or updates tenant overrides (scope='tenant'), default status='draft' |
112
- | `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (own tenant) | Sets status='active' |
113
- | `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (own tenant) | Sets status='archived' (the resolver ignores it afterward) |
114
- | `TemplateResolverHandlers.publishSystem` | `template-resolver:write:publish-system` | SystemAdmin | Sets status='active' on a system default, with the same mail validation as `publish` |
115
- | `TemplateResolverHandlers.archiveSystem` | `template-resolver:write:archive-system` | SystemAdmin | Sets status='archived' on a system default; tenants without an override fall through |
116
- | `TemplateResolverHandlers.resetToSystem` | `template-resolver:write:reset-to-system` | TenantAdmin (own tenant) | Archives the own override so that the active system template applies again; without an active system template, `no_system_template` |
117
- | `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (own tenant, system templates visible) | Raw lookup for the edit UI |
118
- | `TemplateResolverQueries.list` | `template-resolver:query:list` | same | Filters by kind/locale/status, optionally includeSystem |
119
- | `TemplateResolverQueries.usage` | `template-resolver:query:usage` | TenantAdmin | Which notifications use a mail template or mail layout, from the registry |
120
- | `TemplateResolverQueries.overview` | `template-resolver:query:overview` | TenantAdmin + SystemAdmin | One row per slug and type, mail, PDF, layouts, with origin, languages, status, version, layout, and usage; searchable, filterable, sortable, and pageable |
121
- | `TemplateResolverQueries.layoutSource` | `template-resolver:query:layout-source` | TenantAdmin + SystemAdmin | Own and system version of a layout, header/signature/footer regions of the effective HTML version, storage locale; MJML source only for SystemAdmin |
122
- | `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin | Number of active/draft overrides of a system template and total active tenants, numbers only |
123
- | `TemplateResolverQueries.diffToSystem` | `template-resolver:query:diff-to-system` | TenantAdmin (own tenant) | Block and meta comparison of a tenant override with the system template |
108
+ | Handler | QN | Who | What |
109
+ | ----------------------------------------- | ------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Creates or updates system default templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
111
+ | `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (own tenant) + SystemAdmin via `tenantIdOverride` | Creates or updates tenant overrides (scope='tenant'), default status='draft' |
112
+ | `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (own tenant) | Sets status='active' |
113
+ | `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (own tenant) | Sets status='archived' (the resolver ignores it afterward) |
114
+ | `TemplateResolverHandlers.publishSystem` | `template-resolver:write:publish-system` | SystemAdmin | Sets status='active' on a system default, with the same mail validation as `publish` |
115
+ | `TemplateResolverHandlers.archiveSystem` | `template-resolver:write:archive-system` | SystemAdmin | Sets status='archived' on a system default; tenants without an override fall through |
116
+ | `TemplateResolverHandlers.resetToSystem` | `template-resolver:write:reset-to-system` | TenantAdmin (own tenant) | Archives the own override so that the active system template applies again; without an active system template, `no_system_template` |
117
+ | `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (own tenant, system templates visible) | Raw lookup for the edit UI |
118
+ | `TemplateResolverQueries.list` | `template-resolver:query:list` | same | Filters by kind/locale/status, optionally includeSystem |
119
+ | `TemplateResolverQueries.usage` | `template-resolver:query:usage` | TenantAdmin | Which notifications use a mail template or mail layout, from the registry |
120
+ | `TemplateResolverQueries.overview` | `template-resolver:query:overview` | TenantAdmin + SystemAdmin | One row per slug and type, mail, PDF, layouts, with origin, languages, status, version, layout, and usage; searchable, filterable, sortable, and pageable |
121
+ | `TemplateResolverQueries.layoutSource` | `template-resolver:query:layout-source` | TenantAdmin + SystemAdmin | Own and system version of a layout, header/signature/footer regions of the effective HTML version, storage locale; MJML source only for SystemAdmin |
122
+ | `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin | Number of active/draft overrides of a system template and total active tenants, numbers only |
123
+ | `TemplateResolverQueries.diffToSystem` | `template-resolver:query:diff-to-system` | TenantAdmin (own tenant) | Block and meta comparison of a tenant override with the system template |
124
124
 
125
125
  **SystemAdmin cross-tenant access for publish/archive/findById:** deferred (Task 8), `upsertTenant` already has `tenantIdOverride`; publish/archive only need this when a system admin panel curates templates belonging to other tenants. `ctx.db` is tenant-scoped, foreign IDs result in NotFound without an override.
126
126
 
@@ -166,12 +166,12 @@ The harness checks `TemplateNotFoundError` propagation, locale fallback, and, wh
166
166
  Marketing, FAQ, and legal texts use the same store, but a separate write path: `set.write` carries
167
167
  `title` + `folder` (content tree) and has no draft status, saving publishes.
168
168
 
169
- | Handler | QN | Who | What |
170
- |---|---|---|---|
171
- | `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride`, also on `SYSTEM_TENANT_ID`) | Upserts one resource per `(tenantId, slug, kind, locale)` |
172
- | `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | One text block, the public read for landing/legal pages |
173
- | `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | All text blocks of a tenant for the content tree |
174
- | `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | as declared | Per content collection, see below |
169
+ | Handler | QN | Who | What |
170
+ | -------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- |
171
+ | `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride`, also on `SYSTEM_TENANT_ID`) | Upserts one resource per `(tenantId, slug, kind, locale)` |
172
+ | `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | One text block, the public read for landing/legal pages |
173
+ | `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | All text blocks of a tenant for the content tree |
174
+ | `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | as declared | Per content collection, see below |
175
175
 
176
176
  The two public queries are hardcoded to `kind: "text-block"` and accept **no** kind parameter. Mail
177
177
  templates and AI prompts are stored in the same table and use the handlers of their collection. This
@@ -248,8 +248,9 @@ export function withParsedDocument<T>(html: string, read: (body: ScanElement) =>
248
248
  );
249
249
  return read(parsed.body);
250
250
  } finally {
251
- window.happyDOM.abort();
252
- window.happyDOM.close();
251
+ // The window is never reused, so this sync API need not await the teardown promises.
252
+ void window.happyDOM.abort();
253
+ void window.happyDOM.close();
253
254
  }
254
255
  }
255
256
 
@@ -4,6 +4,7 @@ import {
4
4
  MAX_LIST_LIMIT,
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { chunk } from "@cosmicdrift/kumiko-framework/utils";
7
8
  import * as z from "zod";
8
9
  import { TenantQueries } from "../../tenant/index.ts";
9
10
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
@@ -22,14 +23,6 @@ const directorySchema = z.object({
22
23
 
23
24
  type OverrideStatus = "active" | "draft";
24
25
 
25
- function chunksOf<T>(items: readonly T[], size: number): T[][] {
26
- const chunks: T[][] = [];
27
- for (let start = 0; start < items.length; start += size) {
28
- chunks.push(items.slice(start, start + size));
29
- }
30
- return chunks;
31
- }
32
-
33
26
  type DirectoryQueryContext = {
34
27
  readonly dispatch: {
35
28
  readonly query: (name: string, payload: { ids: string[] }) => Promise<unknown>;
@@ -43,8 +36,8 @@ async function labelsFor(
43
36
  ids: ReadonlySet<string>,
44
37
  ): Promise<ReadonlyMap<string, string>> {
45
38
  const labels = new Map<string, string>();
46
- for (const chunk of chunksOf([...ids], MAX_LIST_LIMIT)) {
47
- const raw = await ctx.dispatch.query(queryName, { ids: chunk });
39
+ for (const idChunk of chunk([...ids], MAX_LIST_LIMIT)) {
40
+ const raw = await ctx.dispatch.query(queryName, { ids: idChunk });
48
41
  for (const row of directorySchema.parse(raw).rows) labels.set(row.id, row.label);
49
42
  }
50
43
  return labels;
@@ -17,6 +17,7 @@ const SLUG_INVALID_ISSUE: FieldIssue = {
17
17
  export function createCollectionEntryCreateForm(
18
18
  tenantId: string | undefined,
19
19
  ): (props: { readonly target: TargetRef; readonly onClose: () => void }) => ReactNode {
20
+ // biome-ignore lint/correctness/noComponentHookFactories: setup-time factory, called once per templateResolverClient(); the resolver slot only passes target/onClose, so tenantId is bound here
20
21
  return function BoundCollectionEntryCreateForm(props) {
21
22
  return <CollectionEntryCreateForm {...props} tenantId={tenantId} />;
22
23
  };
@@ -0,0 +1,30 @@
1
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
2
+
3
+ export type MemberUserRow = {
4
+ readonly id: unknown;
5
+ readonly email?: unknown;
6
+ readonly displayName?: unknown;
7
+ };
8
+
9
+ // Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
10
+ // own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
11
+ // one call per user unbounded and exhausts the pool once membership counts
12
+ // exceed a handful, surfacing as "the connection was closed"; a strict
13
+ // sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
14
+ export const KMS_POOL_CONCURRENCY = 4;
15
+
16
+ export async function decryptMemberUsers(
17
+ users: readonly MemberUserRow[],
18
+ reason: string,
19
+ ): Promise<Map<string, { readonly email: string | null; readonly displayName: string | null }>> {
20
+ const decrypted = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
21
+ const email =
22
+ typeof user.email === "string" ? await decryptStoredPii(user.email, "email", reason) : null;
23
+ const displayName =
24
+ typeof user.displayName === "string"
25
+ ? await decryptStoredPii(user.displayName, "displayName", reason)
26
+ : null;
27
+ return { userId: String(user.id), email, displayName };
28
+ });
29
+ return new Map(decrypted.map((d) => [d.userId, d]));
30
+ }
@@ -3,18 +3,9 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import * as z from "zod";
6
- import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
7
6
  import { userTable } from "../../user/index.ts";
8
7
  import { tenantMembershipsTable } from "../membership-table.ts";
9
-
10
- type UserRow = { readonly id: unknown; readonly email?: unknown; readonly displayName?: unknown };
11
-
12
- // Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
13
- // own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
14
- // one call per user unbounded and exhausts the pool once membership counts
15
- // exceed a handful, surfacing as "the connection was closed"; a strict
16
- // sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
17
- const KMS_POOL_CONCURRENCY = 4;
8
+ import { decryptMemberUsers, type MemberUserRow } from "./decrypt-member-users.ts";
18
9
 
19
10
  export const membersQuery = defineQueryHandler({
20
11
  name: "members",
@@ -43,21 +34,10 @@ export const membersQuery = defineQueryHandler({
43
34
 
44
35
  const userIds = [...new Set(rows.map((row) => row["userId"]))];
45
36
  const users =
46
- userIds.length > 0 ? await selectMany<UserRow>(db, userTable, { id: userIds }) : [];
37
+ userIds.length > 0 ? await selectMany<MemberUserRow>(db, userTable, { id: userIds }) : [];
47
38
  const userById = new Map(users.map((u) => [String(u.id), u]));
48
39
 
49
- const decrypted = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
50
- const email =
51
- typeof user.email === "string"
52
- ? await decryptStoredPii(user.email, "email", "tenant:members")
53
- : null;
54
- const displayName =
55
- typeof user.displayName === "string"
56
- ? await decryptStoredPii(user.displayName, "displayName", "tenant:members")
57
- : null;
58
- return { userId: String(user.id), email, displayName };
59
- });
60
- const decryptedByUserId = new Map(decrypted.map((d) => [d.userId, d]));
40
+ const decryptedByUserId = await decryptMemberUsers(users, "tenant:members");
61
41
 
62
42
  return rows.map((row) => {
63
43
  const user = userById.get(String(row["userId"]));