@cosmicdrift/kumiko-samples 0.404.1 → 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 (93) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +10 -10
  3. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  4. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  5. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  6. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  7. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  8. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  9. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  11. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  12. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  13. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  14. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  15. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  16. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  17. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  18. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  19. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  21. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  22. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  23. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  24. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  25. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  26. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  27. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  28. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  29. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  30. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  31. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  32. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  33. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  34. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  35. package/packages/bundled-features/src/folders/feature.ts +2 -8
  36. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  37. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  38. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  39. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  40. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  41. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  42. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  43. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  44. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  45. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  46. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  47. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  48. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  49. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  50. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  51. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  52. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  53. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  54. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  55. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  56. package/packages/bundled-features/src/shared/changes.json +6 -0
  57. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  58. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  59. package/packages/bundled-features/src/shared/index.ts +6 -0
  60. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  61. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  62. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  63. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  64. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  65. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  66. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  67. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  68. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  69. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  70. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  71. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  72. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  73. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  74. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  75. package/packages/bundled-features/src/tags/feature.ts +2 -8
  76. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  77. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  78. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  79. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  80. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  81. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  82. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  83. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  84. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  85. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  86. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  87. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  88. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  89. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  90. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  91. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  92. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  93. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
@@ -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;
@@ -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;
@@ -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"]));
@@ -14,8 +14,11 @@ import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
14
14
  import { userTable } from "../../user/index.ts";
15
15
  import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table.ts";
16
16
  import { tenantMembershipsTable } from "../membership-table.ts";
17
-
18
- const KMS_POOL_CONCURRENCY = 4;
17
+ import {
18
+ decryptMemberUsers,
19
+ KMS_POOL_CONCURRENCY,
20
+ type MemberUserRow,
21
+ } from "./decrypt-member-users.ts";
19
22
 
20
23
  type TeamStatus = "active" | "pending";
21
24
 
@@ -115,11 +118,7 @@ export const teamListQuery = definePagedQueryHandler({
115
118
  const membershipRows = await selectMany(db, tenantMembershipsTable, { tenantId });
116
119
  const userIds = [...new Set(membershipRows.map((row) => String(row["userId"])))];
117
120
  const users =
118
- userIds.length > 0
119
- ? await selectMany<{ id: unknown; email?: unknown; displayName?: unknown }>(db, userTable, {
120
- id: userIds,
121
- })
122
- : [];
121
+ userIds.length > 0 ? await selectMany<MemberUserRow>(db, userTable, { id: userIds }) : [];
123
122
 
124
123
  const lastSeenByUserId = new Map<string, Temporal.Instant>();
125
124
  if (userIds.length > 0) {
@@ -147,21 +146,10 @@ export const teamListQuery = definePagedQueryHandler({
147
146
  }
148
147
 
149
148
  // Bounded, not Promise.all/sequential-per-item: shares the KMS
150
- // adapter's small dedicated pool (max 4, see members.query.ts) with the
149
+ // adapter's small dedicated pool (max 4, see decrypt-member-users.ts) with the
151
150
  // invitations decrypt below — the two passes run one after the other
152
151
  // (await, not Promise.all), so combined concurrency never exceeds 4.
153
- const decryptedUsers = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
154
- const email =
155
- typeof user.email === "string"
156
- ? await decryptStoredPii(user.email, "email", "tenant:team-list")
157
- : null;
158
- const displayName =
159
- typeof user.displayName === "string"
160
- ? await decryptStoredPii(user.displayName, "displayName", "tenant:team-list")
161
- : null;
162
- return { userId: String(user.id), email, displayName };
163
- });
164
- const decryptedByUserId = new Map(decryptedUsers.map((d) => [d.userId, d]));
152
+ const decryptedByUserId = await decryptMemberUsers(users, "tenant:team-list");
165
153
 
166
154
  const memberRows: TeamRow[] = membershipRows.map((row) => {
167
155
  const userId = String(row["userId"]);
@@ -0,0 +1,28 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
4
+
5
+ export const TENANT_DESTROY_ARCHIVED_BY = "tenant-lifecycle:destroy";
6
+ export const TENANT_DESTROY_ARCHIVE_REASON = "tenant_destroy";
7
+
8
+ // Archiving first means a later projection rebuild cannot resurrect the deleted rows.
9
+ export async function archiveAndDeleteTenantRows(
10
+ ctx: TenantDataHookCtx,
11
+ table: EntityTableMeta,
12
+ aggregateType: string,
13
+ escapeHatchReason: string,
14
+ ): Promise<readonly string[]> {
15
+ declareEscapeHatch({ reason: escapeHatchReason });
16
+ const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
17
+ for (const row of rows) {
18
+ await archiveStream(ctx.db.unsafeRaw(), {
19
+ tenantId: ctx.tenantId,
20
+ aggregateId: row.id,
21
+ aggregateType,
22
+ archivedBy: TENANT_DESTROY_ARCHIVED_BY,
23
+ reason: TENANT_DESTROY_ARCHIVE_REASON,
24
+ });
25
+ }
26
+ await ctx.db.deleteMany(table, { tenantId: ctx.tenantId });
27
+ return rows.map((row) => row.id);
28
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.405.0",
4
+ "type": "improvement",
5
+ "title": "archiveAndDeleteTenantRows for tenant-destroy hooks",
6
+ "detail": "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle exports archiveAndDeleteTenantRows(ctx, table, aggregateType, escapeHatchReason), which archives each row's event stream and then deletes the tenant's rows, plus the TENANT_DESTROY_ARCHIVED_BY and TENANT_DESTROY_ARCHIVE_REASON constants. inbound-mail-foundation uses the helper, billing-foundation the constants."
7
+ },
2
8
  {
3
9
  "version": "0.389.0",
4
10
  "type": "fix",
@@ -1,3 +1,8 @@
1
+ export {
2
+ archiveAndDeleteTenantRows,
3
+ TENANT_DESTROY_ARCHIVE_REASON,
4
+ TENANT_DESTROY_ARCHIVED_BY,
5
+ } from "./archive-and-delete-tenant-rows.ts";
1
6
  export {
2
7
  TENANT_DESTRUCTION_STAGES,
3
8
  TENANT_LIFECYCLE_FEATURE,
@@ -495,7 +495,7 @@ export function createTierEngineFeature<
495
495
  // point AND registers itself as the default plugin. Other features could
496
496
  // register there later (e.g. a future toggle-only resolver), but only one
497
497
  // plugin wins — see the "single-plugin" comment on findTierResolverUsage.
498
- r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
498
+ r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, {});
499
499
 
500
500
  // Plugin-registration: framework's runDevApp/runProdApp picks this up
501
501
  // post-stack-setup, calls build(deps), wires effectiveFeatures.
@@ -0,0 +1,40 @@
1
+ import { createTenantDb, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ type EscapeHatchAuditSink,
4
+ EXT_USER_DATA,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
8
+ import {
9
+ createEscapeHatchReporter,
10
+ UNATTRIBUTED_ACTOR,
11
+ } from "@cosmicdrift/kumiko-framework/pipeline";
12
+
13
+ export function buildHookDb(
14
+ db: DbRunner,
15
+ tenantId: TenantId,
16
+ entry: { readonly entityName: string; readonly escapeHatchReason: string | undefined },
17
+ args: {
18
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
19
+ readonly escapeHatchAuditLog?: Logger;
20
+ readonly actor?: string;
21
+ },
22
+ ) {
23
+ const report = createEscapeHatchReporter({
24
+ handler: `${EXT_USER_DATA}:${entry.entityName}`,
25
+ tenantId,
26
+ actor: args.actor ?? UNATTRIBUTED_ACTOR,
27
+ sink: args.escapeHatchAuditSink,
28
+ log: args.escapeHatchAuditLog ?? createFallbackLogger("user-data-rights"),
29
+ });
30
+ return createTenantDb({
31
+ db,
32
+ tenantId,
33
+ mode: "tenant",
34
+ grants: {
35
+ unsafeRaw:
36
+ entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
37
+ report,
38
+ },
39
+ });
40
+ }
@@ -23,9 +23,9 @@
23
23
 
24
24
  import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
25
25
  import { defineWriteHandler, type SaveContext } from "@cosmicdrift/kumiko-framework/engine";
26
- import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
27
26
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
28
27
  import * as z from "zod";
28
+ import { isUniqueConstraintFailure } from "../../shared/index.ts";
29
29
  import {
30
30
  ACTIVE_JOB_CONSTRAINT,
31
31
  EXPORT_JOB_STATUS,
@@ -40,23 +40,6 @@ const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
40
40
  const REQUEST_EXPORT_REASON =
41
41
  "export jobs are keyed by userId across all of the user's tenant memberships, not the caller's current tenant";
42
42
 
43
- /**
44
- * `createEventStoreExecutor.create` catches 23505 internally and returns
45
- * `WriteFailure(UniqueViolationError)` rather than throwing. Check the
46
- * failure code and constraintName to distinguish the app-side and race
47
- * paths; propagate other failures (validation, version conflicts, ...)
48
- * unchanged.
49
- */
50
- function isActiveJobConflict(failure: WriteFailure): boolean {
51
- const error = failure.error as {
52
- code?: string;
53
- details?: { constraintName?: string };
54
- }; // @cast-boundary error-details
55
- return (
56
- error.code === "unique_violation" && error.details?.constraintName === ACTIVE_JOB_CONSTRAINT
57
- );
58
- }
59
-
60
43
  export const requestExportWrite = defineWriteHandler({
61
44
  name: "request-export",
62
45
  schema: z.object({}),
@@ -115,7 +98,7 @@ export const requestExportWrite = defineWriteHandler({
115
98
  // crud.create, causing UniqueViolationError for our constraint. Re-fetch
116
99
  // and return the existing job with isExisting=true. Propagate other
117
100
  // failures (validation, version conflicts, ...) unchanged.
118
- if (isActiveJobConflict(result)) {
101
+ if (isUniqueConstraintFailure(result, ACTIVE_JOB_CONSTRAINT)) {
119
102
  const winner = await findActiveJob(exportJobRunner, userId);
120
103
  if (!winner) {
121
104
  // This should be impossible: a constraint violation without an existing
@@ -1,19 +1,13 @@
1
1
  import {
2
2
  type FeatureRegistrar,
3
- type NotificationLocalizedTemplate,
4
3
  QnTypes,
5
4
  qn,
6
5
  type TemplateVariables,
7
6
  templateVariable,
8
7
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import {
10
- hasMailTranslationKey,
11
- listMailLocales,
12
- mailT,
13
- resolveMailLocale,
14
- } from "@cosmicdrift/kumiko-framework/i18n";
8
+ import { mailT } from "@cosmicdrift/kumiko-framework/i18n";
15
9
  import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
16
- import { mailParagraph } from "../shared/index.ts";
10
+ import { createMailLocaleTemplates, mailParagraph } from "../shared/index.ts";
17
11
  import { USER_DATA_RIGHTS_FEATURE } from "./web/constants.ts";
18
12
 
19
13
  const ALL_GDPR_MAIL_KINDS = [
@@ -49,18 +43,14 @@ function subjectKey(kind: GdprMailKind): string {
49
43
  return `gdpr.mail.${GDPR_MAIL_KINDS[kind].translationKey}.subject`;
50
44
  }
51
45
 
52
- const SUBJECT_KEY_BY_TYPE: ReadonlyMap<string, string> = new Map(
53
- ALL_GDPR_MAIL_KINDS.map((kind) => [GdprMailNotificationTypes[kind], subjectKey(kind)]),
54
- );
55
-
56
- // A registered locale whose bundle lacks the subject key has no template (see
57
- // gdprMailLocalizedTemplate); the renderer has no locale fallback, so the
58
- // sender must pick a locale that has one or the mail would not go out at all.
59
- export function gdprMailTemplateLocale(notificationType: string, requestedLocale: string): string {
60
- const locale = resolveMailLocale(requestedLocale);
61
- const key = SUBJECT_KEY_BY_TYPE.get(notificationType);
62
- return key !== undefined && hasMailTranslationKey(locale, key) ? locale : "en";
63
- }
46
+ const mailLocaleTemplates = createMailLocaleTemplates({
47
+ kinds: ALL_GDPR_MAIL_KINDS,
48
+ notificationTypes: GdprMailNotificationTypes,
49
+ subjectKey,
50
+ bodyFor: (kind, locale) => mailBody(kind, locale),
51
+ });
52
+
53
+ export const gdprMailTemplateLocale = mailLocaleTemplates.templateLocale;
64
54
 
65
55
  const appName = () => templateVariable.text({ required: true });
66
56
 
@@ -119,33 +109,7 @@ function mailBody(kind: GdprMailKind, locale: string): string {
119
109
  }
120
110
  }
121
111
 
122
- // Evaluated when the registry is built, after every locale feature has
123
- // registered its mail translations. English always ships; another locale only
124
- // gets a template when it has its own subject, so a missing translation never
125
- // silently becomes an English mail in that locale's slot.
126
- export function gdprMailLocalizedTemplate(kind: GdprMailKind): () => NotificationLocalizedTemplate {
127
- const kindSubjectKey = subjectKey(kind);
128
- return () => {
129
- const locales = [
130
- "en",
131
- ...listMailLocales().filter(
132
- (locale) => locale !== "en" && hasMailTranslationKey(locale, kindSubjectKey),
133
- ),
134
- ];
135
- return Object.fromEntries(
136
- locales.map((locale) => [
137
- locale,
138
- {
139
- body: mailBody(kind, locale),
140
- meta: {
141
- subject: mailT(locale, kindSubjectKey, { app: "{{appName}}" }),
142
- category: "transactional" as const,
143
- },
144
- },
145
- ]),
146
- );
147
- };
148
- }
112
+ export const gdprMailLocalizedTemplate = mailLocaleTemplates.localizedTemplate;
149
113
 
150
114
  export function registerGdprMails(r: FeatureRegistrar): void {
151
115
  for (const kind of ALL_GDPR_MAIL_KINDS) {