@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.
- package/package.json +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- 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 {
|
|
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
|
-
//
|
|
136
|
-
|
|
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
|
-
//
|
|
144
|
-
} as
|
|
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
|
-
//
|
|
138
|
-
|
|
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
|
-
//
|
|
142
|
-
} as
|
|
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
|
-
//
|
|
147
|
-
|
|
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
|
|
142
|
-
// `new Date()` —
|
|
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
|
|
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)
|
|
36
|
+
const tenantId = testTenantId(1);
|
|
37
37
|
|
|
38
38
|
let stack: TestStack;
|
|
39
39
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]));
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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" },
|