@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -0,0 +1,38 @@
1
+ // Bounded-concurrency map — runs `fn` over `items` with at most `limit`
2
+ // calls in flight. `Promise.all(items.map(fn))` is unbounded (as many
3
+ // calls as rows); a plain sequential loop caps concurrency at 1, which
4
+ // under-uses a resource pool that allows more (e.g. PgKmsAdapter's
5
+ // default `max: 4` — sequential decrypt-per-row leaves 3 pool slots idle
6
+ // and turns each query into 2N serial round-trips instead of ~2N/4).
7
+ // Order of `results` matches `items`, independent of completion order.
8
+ // On rejection: the returned promise rejects with the first error, and
9
+ // remaining workers stop claiming new items (`failed` flag). In-flight
10
+ // calls already past the claim still run to completion (their promises
11
+ // stay in the `Promise.all` array, so a later rejection can't surface as
12
+ // an unhandled rejection — verified in `mapWithConcurrency.test.ts`).
13
+ export async function mapWithConcurrency<T, R>(
14
+ items: readonly T[],
15
+ limit: number,
16
+ fn: (item: T, index: number) => Promise<R>,
17
+ ): Promise<R[]> {
18
+ const results: R[] = new Array(items.length);
19
+ let nextIndex = 0;
20
+ let failed = false;
21
+ async function worker(): Promise<void> {
22
+ for (;;) {
23
+ // skip: another worker already failed — stop claiming new items.
24
+ if (failed) return;
25
+ const index = nextIndex++;
26
+ // skip: cursor exhausted, normal worker-loop exit
27
+ if (index >= items.length) return;
28
+ try {
29
+ results[index] = await fn(items[index] as T, index);
30
+ } catch (err) {
31
+ failed = true;
32
+ throw err;
33
+ }
34
+ }
35
+ }
36
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
37
+ return results;
38
+ }
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
4
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
5
- import type { MollieClient } from "@mollie/api-client";
5
+ import type {
6
+ MollieClient,
7
+ Payment as MolliePayment,
8
+ Subscription as MollieSubscription,
9
+ } from "@mollie/api-client";
6
10
  import { describeSubscriptionProviderContract } from "../../billing-foundation/__tests__/subscription-provider-contract";
7
11
  import { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "../constants";
8
12
  import { createSubscriptionMollieFeature } from "../feature";
@@ -132,16 +136,18 @@ function buildMollieContractFixture() {
132
136
  nextPaymentDate: "2026-06-15",
133
137
  startDate: "2026-05-15",
134
138
  metadata: { tenantId: CONTRACT_TENANT_ID, priceId: CONTRACT_PRICE_ID },
135
- // biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
136
- } as any;
139
+ // Minimal Mollie-SDK-shape mock — full Subscription has more fields
140
+ // than the plugin reads; double-cast at the test/SDK boundary instead
141
+ // of `as any` per coding-standards.
142
+ } as unknown as MollieSubscription;
137
143
  const payment = {
138
144
  id: "tr_contract_001",
139
145
  customerId: "cst_contract_001",
140
146
  subscriptionId: "sub_contract_001",
141
147
  sequenceType: "first",
142
148
  status: "paid",
143
- // biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
144
- } as any;
149
+ // Minimal Mollie-SDK-shape mock, see subscription above.
150
+ } as unknown as MolliePayment;
145
151
 
146
152
  const webhookClient: MollieClientShape = {
147
153
  payments: { get: async () => payment },
@@ -190,4 +196,8 @@ function buildMollieContractFixture() {
190
196
  };
191
197
  }
192
198
 
193
- describeSubscriptionProviderContract("subscription-mollie", buildMollieContractFixture);
199
+ describeSubscriptionProviderContract("subscription-mollie", buildMollieContractFixture, {
200
+ // Mollie has no customer-portal or programmatic cancel API.
201
+ hasPortal: false,
202
+ hasCancel: false,
203
+ });
@@ -347,6 +347,27 @@ describe("verifyAndParseMollieWebhook — error + ignore paths", () => {
347
347
  });
348
348
  });
349
349
 
350
+ describe("verifyAndParseMollieWebhook — kumiko-framework#1525: no ambient Temporal global", () => {
351
+ const verify = (client: MollieClientShape) =>
352
+ verifyAndParseMollieWebhook(client, {
353
+ priceToTier: { plan_pro: "pro" },
354
+ priceToConfig: TEST_PRICE_CONFIG,
355
+ });
356
+
357
+ test("computes currentPeriodEnd without relying on globalThis.Temporal", async () => {
358
+ const client = buildClient();
359
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
360
+ delete (globalThis as { Temporal?: unknown }).Temporal;
361
+ try {
362
+ const event = await verify(client)("id=tr_test_001", {});
363
+ expect(event?.currentPeriodEnd).toBe("2026-06-15T00:00:00Z");
364
+ } finally {
365
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
366
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
367
+ }
368
+ });
369
+ });
370
+
350
371
  // =============================================================================
351
372
  // Mapping-helpers
352
373
  // =============================================================================
@@ -25,6 +25,7 @@ import type {
25
25
  Payment as MolliePayment,
26
26
  Subscription as MollieSubscription,
27
27
  } from "@mollie/api-client";
28
+ import { Temporal } from "temporal-polyfill";
28
29
  import { MOLLIE_PROVIDER_NAME } from "./constants";
29
30
  import type { MolliePriceConfig } from "./plugin-methods";
30
31
 
@@ -134,17 +134,21 @@ async function buildStripeContractFixture() {
134
134
  const stripe = new Stripe(CONTRACT_API_KEY);
135
135
  spyOn(stripe.checkout.sessions, "create").mockResolvedValue({
136
136
  url: "https://checkout.stripe.com/c/pay/contract-test",
137
- // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
138
- } as any);
137
+ // Only `.url` is read by createStripeCheckoutSession. mockResolvedValue
138
+ // wants Stripe.Response<Session> (adds lastResponse metadata) — the SDK
139
+ // wrapper type makes a structural mock impractical, so double-cast at
140
+ // the test/SDK boundary instead of `as any`.
141
+ } as unknown as Stripe.Response<Stripe.Checkout.Session>);
139
142
  spyOn(stripe.billingPortal.sessions, "create").mockResolvedValue({
140
143
  url: "https://billing.stripe.com/p/session/contract-test",
141
- // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
142
- } as any);
144
+ // Only `.url` is read by createStripePortalSession, see above.
145
+ } as unknown as Stripe.Response<Stripe.BillingPortal.Session>);
143
146
  spyOn(stripe.subscriptions, "cancel").mockResolvedValue({
144
147
  id: "sub_contract",
145
148
  status: "canceled",
146
- // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
147
- } as any);
149
+ // Return value isn't read by createStripeCancelSubscription (fire-and-
150
+ // forget) — double-cast at the test/SDK boundary instead of `as any`.
151
+ } as unknown as Stripe.Response<Stripe.Subscription>);
148
152
  const runtime = contractCtxRuntime(stripe);
149
153
 
150
154
  const stripeForWebhookFixture = new Stripe(CONTRACT_API_KEY);
@@ -218,4 +222,7 @@ async function buildStripeContractFixture() {
218
222
  };
219
223
  }
220
224
 
221
- describeSubscriptionProviderContract("subscription-stripe", buildStripeContractFixture);
225
+ describeSubscriptionProviderContract("subscription-stripe", buildStripeContractFixture, {
226
+ hasPortal: true,
227
+ hasCancel: true,
228
+ });
@@ -255,6 +255,26 @@ describe("verifyAndParseStripeWebhook — tenant-resolution + price-to-tier", ()
255
255
  });
256
256
  });
257
257
 
258
+ describe("verifyAndParseStripeWebhook — kumiko-framework#1525: no ambient Temporal global", () => {
259
+ test("computes currentPeriodEnd without relying on globalThis.Temporal", async () => {
260
+ const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
261
+ priceToTier: { price_pro_monthly: "pro" },
262
+ });
263
+ const payload = JSON.stringify(buildSubscriptionEvent({ currentPeriodEndUnix: 1_780_000_000 }));
264
+ const sig = await signEvent(payload);
265
+
266
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
267
+ delete (globalThis as { Temporal?: unknown }).Temporal;
268
+ try {
269
+ const event = await verify(payload, { "stripe-signature": sig });
270
+ expect(event?.currentPeriodEnd).toBe("2026-05-28T20:26:40Z");
271
+ } finally {
272
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
273
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
274
+ }
275
+ });
276
+ });
277
+
258
278
  // =============================================================================
259
279
  // Mapping-helpers (pure functions, kein Stripe-mock nötig)
260
280
  // =============================================================================
@@ -40,6 +40,7 @@ import {
40
40
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
41
41
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
42
42
  import type Stripe from "stripe";
43
+ import { Temporal } from "temporal-polyfill";
43
44
  import { STRIPE_PROVIDER_NAME, StripeEventTypes } from "./constants";
44
45
  import type { StripeWebhookRuntime } from "./runtime";
45
46
 
@@ -138,8 +139,8 @@ export function verifyAndParseStripeWebhook(
138
139
  const status = mapStripeStatus(sub.status);
139
140
  const periodEndUnixSec = sub.items.data[0]?.current_period_end ?? 0;
140
141
  // Stripe returns Unix-seconds; Temporal.Instant.fromEpochMilliseconds
141
- // expects ms. Multiply, then ISO. (No-Date-API-Guard verbietet
142
- // `new Date()` — Temporal ist global verfügbar via polyfill.)
142
+ // expects ms. Multiply, then ISO. (No-Date-API-Guard forbids
143
+ // `new Date()` — static import above, not the ambient global, see #1490.)
143
144
  const currentPeriodEnd = Temporal.Instant.fromEpochMilliseconds(
144
145
  periodEndUnixSec * 1000,
145
146
  ).toString();
@@ -13,7 +13,7 @@ import {
13
13
  isPiiCiphertext,
14
14
  resetPiiSubjectKmsForTests,
15
15
  } from "@cosmicdrift/kumiko-framework/crypto";
16
- import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
16
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
17
17
  import {
18
18
  setupTestStack,
19
19
  type TestStack,
@@ -33,7 +33,7 @@ import { tenantMembershipsTable } from "../membership-table";
33
33
  import { tenantEntity } from "../schema/tenant";
34
34
  import { seedTenant, seedTenantMembership } from "../seeding";
35
35
 
36
- const tenantId = testTenantId(1) as TenantId;
36
+ const tenantId = testTenantId(1);
37
37
 
38
38
  let stack: TestStack;
39
39
 
@@ -96,7 +96,7 @@ beforeAll(async () => {
96
96
  ...bridgeStub(),
97
97
  },
98
98
  );
99
- return result as number[];
99
+ return result as TenantId[];
100
100
  },
101
101
  });
102
102
 
@@ -1,5 +1,9 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ defineQueryHandler,
4
+ SYSTEM_ROLE,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
3
7
  import { z } from "zod";
4
8
  import { tenantTable } from "../schema/tenant";
5
9
 
@@ -8,7 +12,9 @@ export const activeTenantIdsQuery = defineQueryHandler({
8
12
  schema: z.object({}),
9
13
  access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
10
14
  handler: async (_query, ctx) => {
11
- const rows = await selectMany<{ id: number }>(ctx.db, tenantTable, { isEnabled: true });
15
+ // tenants.id is a uuid string (TenantId), not a numeric surrogate.
16
+ // Brand at the DB parse boundary — getActiveTenantIds consumers expect TenantId[].
17
+ const rows = await selectMany<{ id: TenantId }>(ctx.db, tenantTable, { isEnabled: true });
12
18
  return rows.map((r) => r.id);
13
19
  },
14
20
  });
@@ -1,9 +1,16 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
- import { decryptStoredPii } from "../../shared";
4
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared";
5
5
  import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
6
6
 
7
+ // Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
8
+ // own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
9
+ // one call per row unbounded and exhausts the pool once invitation counts
10
+ // exceed a handful, surfacing as "the connection was closed"; a strict
11
+ // sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
12
+ const KMS_POOL_CONCURRENCY = 4;
13
+
7
14
  // Pending-Invitations-Liste für den aktuellen Tenant. Admin-only.
8
15
  // Filter: status="pending" — accepted/cancelled/expired sind für die
9
16
  // UI uninteressant (UI zeigt nur "ausstehende Einladungen"; Audit-Log
@@ -22,24 +29,15 @@ export const invitationsQuery = defineQueryHandler({
22
29
  tenantId: query.user.tenantId,
23
30
  status: INVITATION_STATUS.pending,
24
31
  });
25
- // Sequential, not Promise.all: each decrypt hits the KMS adapter's own
26
- // small dedicated pool (PgKmsAdapter default max: 4) — firing 2 calls
27
- // per row concurrently for every row exhausts it once invitation counts
28
- // exceed a handful, surfacing as "the connection was closed".
29
- const out: Record<string, unknown>[] = [];
30
- for (const row of rows ?? []) {
32
+ return mapWithConcurrency(rows ?? [], KMS_POOL_CONCURRENCY, async (row) => {
31
33
  const email = row["email"];
32
- const invitedBy = row["invitedBy"];
33
34
  const decryptedEmail =
34
35
  typeof email === "string"
35
36
  ? await decryptStoredPii(email, "email", "tenant:invitations")
36
37
  : email;
37
- const decryptedInvitedBy =
38
- typeof invitedBy === "string"
39
- ? await decryptStoredPii(invitedBy, "invitedBy", "tenant:invitations")
40
- : invitedBy;
41
- out.push({ ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy });
42
- }
43
- return out;
38
+ // invitedBy is a plain userId (invitation-table.ts: no `pii: true`) —
39
+ // never encrypted at write time, so it needs no decrypt (#1252).
40
+ return { ...row, email: decryptedEmail };
41
+ });
44
42
  },
45
43
  });
@@ -2,12 +2,19 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
4
4
  import { z } from "zod";
5
- import { decryptStoredPii } from "../../shared";
5
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared";
6
6
  import { userTable } from "../../user";
7
7
  import { tenantMembershipsTable } from "../membership-table";
8
8
 
9
9
  type UserRow = { readonly id: unknown; readonly email?: unknown; readonly displayName?: unknown };
10
10
 
11
+ // Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
12
+ // own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
13
+ // one call per user unbounded and exhausts the pool once membership counts
14
+ // exceed a handful, surfacing as "the connection was closed"; a strict
15
+ // sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
16
+ const KMS_POOL_CONCURRENCY = 4;
17
+
11
18
  export const membersQuery = defineQueryHandler({
12
19
  name: "members",
13
20
  schema: z.object({}),
@@ -22,15 +29,7 @@ export const membersQuery = defineQueryHandler({
22
29
  userIds.length > 0 ? await selectMany<UserRow>(ctx.db, userTable, { id: userIds }) : [];
23
30
  const userById = new Map(users.map((u) => [String(u.id), u]));
24
31
 
25
- // Sequential, not Promise.all: each decrypt hits the KMS adapter's own
26
- // small dedicated pool (PgKmsAdapter default max: 4) — firing 2 calls
27
- // per row concurrently for every row exhausts it once membership counts
28
- // exceed a handful, surfacing as "the connection was closed".
29
- const decryptedByUserId = new Map<
30
- string,
31
- { email: string | null; displayName: string | null }
32
- >();
33
- for (const user of users) {
32
+ const decrypted = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
34
33
  const email =
35
34
  typeof user.email === "string"
36
35
  ? await decryptStoredPii(user.email, "email", "tenant:members")
@@ -39,8 +38,9 @@ export const membersQuery = defineQueryHandler({
39
38
  typeof user.displayName === "string"
40
39
  ? await decryptStoredPii(user.displayName, "displayName", "tenant:members")
41
40
  : null;
42
- decryptedByUserId.set(String(user.id), { email, displayName });
43
- }
41
+ return { userId: String(user.id), email, displayName };
42
+ });
43
+ const decryptedByUserId = new Map(decrypted.map((d) => [d.userId, d]));
44
44
 
45
45
  return rows.map((row) => {
46
46
  const user = userById.get(String(row["userId"]));
@@ -38,6 +38,7 @@ import {
38
38
  type AppContext,
39
39
  HookPhases,
40
40
  type Registry,
41
+ type SaveContext,
41
42
  type SessionUser,
42
43
  type TenantId,
43
44
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -94,6 +95,104 @@ export type SeedTenantHooks = {
94
95
  readonly context: AppContext;
95
96
  };
96
97
 
98
+ // Shared by seedTenant/seedTenantMembership/auth-email-password's
99
+ // seedUserWithPassword: fires the same entity-scoped postSave hooks the
100
+ // real dispatcher would (registry.getEntityPostSaveHooks by entityName),
101
+ // just without the full HTTP/JWT roundtrip a fixture doesn't need. Fresh
102
+ // lifecycle-hooks instance per call, no systemHooks — only feature-
103
+ // registered entity/handler hooks fire (search-index/SSE system hooks are
104
+ // wired at server-boot and out of reach here).
105
+ //
106
+ // Tenant re-scope (#1566): when `targetTenantId` is set we rebuild the
107
+ // tenant-bound pieces of the context (`_tenantId`, `db`, `notify`,
108
+ // `hasFeature`, `user.tenantId`) — a shallow `{ ...ctx, _tenantId }` left
109
+ // closures over the caller's tenant (self-signup is anonymous/system).
110
+ // `query`/`write` bridges still share the caller's identity; hooks that
111
+ // need a full identity switch use `writeAs`/`queryAs`.
112
+ //
113
+ // afterCommit (#1566): when `hooks.context.scheduleAfterCommit` is present
114
+ // (HandlerContext from a live write), afterCommit hooks are queued onto the
115
+ // dispatcher's sink and flush post-commit. Without a sink (fixture / plain
116
+ // Connection) they still fire immediately — there is no outer TX to wait for.
117
+ function resolveRawDb(db: AppContext["db"]): DbRunner | undefined {
118
+ if (!db) return undefined;
119
+ if (typeof db === "object" && "raw" in db) {
120
+ return (db as { raw: DbRunner }).raw;
121
+ }
122
+ return db as DbRunner;
123
+ }
124
+
125
+ function scopeSeedHookContext(context: AppContext, targetTenantId: TenantId): AppContext {
126
+ const raw = resolveRawDb(context.db);
127
+ const db = raw
128
+ ? createTenantDb(raw, targetTenantId, "system", context.tracer, context.meter, context.signal)
129
+ : context.db;
130
+
131
+ // HandlerContext carries `user`; AppContext only `systemUser`. Prefer the
132
+ // live handler user when present so notify/hasFeature keep a real SessionUser.
133
+ const handlerUser = (context as { user?: SessionUser }).user;
134
+ const baseUser: SessionUser | undefined = handlerUser ?? context.systemUser;
135
+ const scopedUser: SessionUser | undefined = baseUser
136
+ ? { ...baseUser, tenantId: targetTenantId }
137
+ : undefined;
138
+
139
+ const notify =
140
+ context._notifyFactory && scopedUser
141
+ ? context._notifyFactory(scopedUser, targetTenantId)
142
+ : context.notify;
143
+
144
+ const features = context.effectiveFeatures as
145
+ | (((tenantId: TenantId) => ReadonlySet<string>) & {
146
+ trialGate?: (tenantId: TenantId, featureName: string) => Promise<boolean>;
147
+ })
148
+ | undefined;
149
+ const hasFeature = features
150
+ ? async (featureName: string): Promise<boolean> => {
151
+ if (features(targetTenantId).has(featureName)) return true;
152
+ if (!features.trialGate) return false;
153
+ return features.trialGate(targetTenantId, featureName);
154
+ }
155
+ : "hasFeature" in context
156
+ ? // Keep caller's hasFeature when no effectiveFeatures (always-on apps).
157
+ (context as { hasFeature?: (n: string) => Promise<boolean> }).hasFeature
158
+ : undefined;
159
+
160
+ return {
161
+ ...context,
162
+ _tenantId: targetTenantId,
163
+ ...(db !== undefined ? { db } : {}),
164
+ ...(notify !== undefined ? { notify } : {}),
165
+ ...(scopedUser && "user" in context ? { user: scopedUser } : {}),
166
+ ...(hasFeature ? { hasFeature } : {}),
167
+ };
168
+ }
169
+
170
+ export async function fireEntityPostSave(
171
+ hooks: SeedTenantHooks | undefined,
172
+ pseudoType: string,
173
+ entityData: SaveContext,
174
+ // Undefined for tenant-agnostic entities (user has no tenant_id column at
175
+ // seed time) — keeps hooks.context._tenantId as the caller passed it.
176
+ targetTenantId?: TenantId,
177
+ ): Promise<void> {
178
+ // skip: caller opted out of hooks (existing fixture/test call-sites that
179
+ // don't pass them keep today's hook-less behavior, see seedTenant's doc).
180
+ if (!hooks) return;
181
+ const lifecycle = createLifecycleHooks(hooks.registry);
182
+ const scopedContext =
183
+ targetTenantId === undefined
184
+ ? hooks.context
185
+ : scopeSeedHookContext(hooks.context, targetTenantId);
186
+ await lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.inTransaction);
187
+ const runAfterCommit = () =>
188
+ lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.afterCommit);
189
+ if (scopedContext.scheduleAfterCommit) {
190
+ scopedContext.scheduleAfterCommit(runAfterCommit);
191
+ } else {
192
+ await runAfterCommit();
193
+ }
194
+ }
195
+
97
196
  /**
98
197
  * Seed a tenant through the event-store executor. Idempotent add-only:
99
198
  * a second call for the same `id` is a no-op (no update path). Same
@@ -134,22 +233,10 @@ export async function seedTenant(
134
233
  );
135
234
  }
136
235
 
137
- if (hooks) {
138
- // Fresh instance per call, no systemHooks — only feature-registered
139
- // entity/handler hooks fire (search-index/SSE system hooks are wired
140
- // at server-boot and out of reach here, same as before this fix).
141
- // "tenant:seed" matches no handler-scoped hook, only entity-scoped ones
142
- // (keyed by result.entityName === "tenant") — that's exactly what
143
- // tier-engine's `r.hook("postSave", { allOf: "tenant" }, ...)` needs.
144
- const lifecycle = createLifecycleHooks(hooks.registry);
145
- await lifecycle.runPostSave(
146
- "tenant:seed",
147
- result.data,
148
- hooks.context,
149
- HookPhases.inTransaction,
150
- );
151
- await lifecycle.runPostSave("tenant:seed", result.data, hooks.context, HookPhases.afterCommit);
152
- }
236
+ // "tenant:seed" matches no handler-scoped hook, only entity-scoped ones
237
+ // (keyed by result.entityName === "tenant") — that's exactly what
238
+ // tier-engine's `r.hook("postSave", { allOf: "tenant" }, ...)` needs.
239
+ await fireEntityPostSave(hooks, "tenant:seed", result.data, options.id);
153
240
 
154
241
  return { id: options.id };
155
242
  }
@@ -166,6 +253,12 @@ export async function seedTenant(
166
253
  export async function seedTenantMembership(
167
254
  db: DbRunner,
168
255
  options: SeedTenantMembershipOptions,
256
+ // Optional, append-only — same rationale as seedTenant's hooks param
257
+ // (#1478): fires tenantMembership's postSave hooks (e.g. a Welcome-
258
+ // notification or default-notification-preferences hook keyed off
259
+ // `allOf: "tenantMembership"`) for fixtures/self-signup that opt in.
260
+ // Omit to keep today's hook-less behavior.
261
+ hooks?: SeedTenantHooks,
169
262
  ): Promise<{ id: string }> {
170
263
  // Chokepoint: invite-accept (×3) + seedAdmin/provisionSignup all flow
171
264
  // through here — reject reserved/global roles before they ever persist.
@@ -203,6 +296,7 @@ export async function seedTenantMembership(
203
296
  `seedTenantMembership failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
204
297
  );
205
298
  }
299
+ await fireEntityPostSave(hooks, "tenant-membership:seed", result.data, options.tenantId);
206
300
  return { id: extractMembershipId(result.data) };
207
301
  }
208
302
 
@@ -12,6 +12,16 @@ import { entitiesOf } from "../shared";
12
12
  // (r.extendsRegistrar), so its own mount is the trigger, matching the
13
13
  // original guard's "tenant-lifecycle mounted" gate exactly (unlike gating on
14
14
  // a sibling feature, which would silently narrow coverage).
15
+
16
+ function hasTenantOwned(field: unknown): field is PiiAnnotations {
17
+ return (
18
+ typeof field === "object" &&
19
+ field !== null &&
20
+ "tenantOwned" in field &&
21
+ (field as { tenantOwned?: unknown }).tenantOwned === true
22
+ );
23
+ }
24
+
15
25
  export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
16
26
  const hookedEntities = new Set<string>();
17
27
  for (const f of features) {
@@ -24,10 +34,7 @@ export function validateTenantDataHookCoverage(features: readonly FeatureDefinit
24
34
  for (const [entityName, entity] of entitiesOf(feature)) {
25
35
  if (hookedEntities.has(entityName)) continue;
26
36
  const tenantSubjectFields = Object.entries(entity.fields)
27
- .filter(([, field]) => {
28
- const annot = field as PiiAnnotations;
29
- return Boolean(annot.tenantOwned);
30
- })
37
+ .filter(([, field]) => hasTenantOwned(field))
31
38
  .map(([name]) => name);
32
39
  if (tenantSubjectFields.length === 0) continue;
33
40
  throw new Error(
@@ -2,12 +2,14 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
- import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
5
  import { z } from "zod";
6
+ import { isWithinGracePeriod } from "../../shared";
7
7
  import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
8
8
  import { DESTRUCTION_CANCELLED_EVENT_QN } from "../constants";
9
9
  import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
10
10
 
11
+ export { isWithinGracePeriod };
12
+
11
13
  const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
12
14
 
13
15
  type TenantLifecycleRow = {
@@ -33,11 +35,7 @@ export const cancelDestructionWrite = defineWriteHandler({
33
35
  );
34
36
  }
35
37
 
36
- const gracePeriodEnd = row.gracePeriodEnd;
37
- const inGrace =
38
- gracePeriodEnd != null &&
39
- Temporal.Instant.compare(gracePeriodEnd, getTemporal().Now.instant()) > 0;
40
- if (!inGrace) {
38
+ if (!isWithinGracePeriod(row.gracePeriodEnd)) {
41
39
  return writeFailure(
42
40
  new UnprocessableError("grace_period_expired", {
43
41
  details: { reason: "grace_period_expired" },