@cosmicdrift/kumiko-bundled-features 0.126.0 → 0.128.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 +6 -6
- package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
- package/src/audit/i18n.ts +9 -0
- package/src/audit/web/audit-log-screen.tsx +112 -2
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +195 -2
- package/src/billing-foundation/entities.ts +28 -2
- package/src/billing-foundation/events.ts +6 -2
- package/src/billing-foundation/feature.ts +2 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
- package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
- package/src/billing-foundation/handlers/process-event.write.ts +39 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +30 -7
- package/src/cap-counter/entity.ts +1 -1
- package/src/cap-counter/feature.ts +10 -2
- package/src/cap-counter/i18n.ts +12 -0
- package/src/compliance-profiles/feature.ts +3 -0
- package/src/compliance-profiles/i18n.ts +6 -0
- package/src/config/feature.ts +3 -0
- package/src/config/i18n.ts +9 -0
- package/src/delivery/feature.ts +3 -0
- package/src/delivery/i18n.ts +6 -0
- package/src/feature-toggles/feature.ts +3 -0
- package/src/feature-toggles/i18n.ts +6 -0
- package/src/jobs/i18n.ts +4 -0
- package/src/jobs/web/job-runs-screen.tsx +9 -9
- package/src/managed-pages/i18n.ts +5 -0
- package/src/managed-pages/screens/page-screens.ts +5 -1
- package/src/personal-access-tokens/feature.ts +2 -0
- package/src/personal-access-tokens/i18n.ts +5 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
- package/src/subscription-stripe/feature.ts +2 -0
- package/src/tags/feature.ts +2 -0
- package/src/tags/i18n.ts +5 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
- package/src/tenant/feature.ts +3 -0
- package/src/tenant/handlers/members.query.ts +19 -4
- package/src/tenant/i18n.ts +28 -0
- package/src/tenant/index.ts +1 -1
- package/src/tenant/schema/tenant.ts +2 -2
- package/src/tenant/screens.ts +3 -2
- package/src/tenant/web/members-screen.tsx +8 -3
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +161 -1
- package/src/tenant-lifecycle/feature.ts +1 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +2 -0
- package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +50 -39
- package/src/tenant-lifecycle/stages.ts +36 -6
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/i18n.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
- package/src/user/feature.ts +3 -0
- package/src/user/i18n.ts +20 -0
- package/src/user/schema/user.ts +1 -0
- package/src/user/screens.ts +3 -15
- package/src/user-data-rights/feature.ts +17 -0
- package/src/user-data-rights/i18n.ts +32 -33
- package/src/user-data-rights/schema/export-job.ts +2 -0
- package/src/user-data-rights/screens.ts +2 -1
- package/src/user-data-rights/web/i18n.ts +9 -0
package/src/tenant/screens.ts
CHANGED
|
@@ -16,7 +16,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
|
|
|
16
16
|
id: "tenant-list",
|
|
17
17
|
type: "entityList",
|
|
18
18
|
entity: "tenant",
|
|
19
|
-
columns: ["key", "name", "isEnabled"],
|
|
19
|
+
columns: ["key", "name", "status", "isEnabled"],
|
|
20
20
|
rowActions: [
|
|
21
21
|
{
|
|
22
22
|
kind: "navigate",
|
|
@@ -26,7 +26,8 @@ export const tenantListScreen: EntityListScreenDefinition = {
|
|
|
26
26
|
entityId: "id",
|
|
27
27
|
},
|
|
28
28
|
],
|
|
29
|
-
|
|
29
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
30
|
+
searchable: true,
|
|
30
31
|
access: { roles: ["SystemAdmin"] },
|
|
31
32
|
};
|
|
32
33
|
|
|
@@ -12,6 +12,8 @@ type MemberRow = {
|
|
|
12
12
|
readonly id: string;
|
|
13
13
|
readonly userId: string;
|
|
14
14
|
readonly tenantId: string;
|
|
15
|
+
readonly email: string | null;
|
|
16
|
+
readonly displayName: string | null;
|
|
15
17
|
readonly roles: readonly string[];
|
|
16
18
|
};
|
|
17
19
|
|
|
@@ -131,8 +133,8 @@ export function MembersScreen({
|
|
|
131
133
|
testId="members-active-table"
|
|
132
134
|
columns={[
|
|
133
135
|
{
|
|
134
|
-
field: "
|
|
135
|
-
label: t("tenant.members.col.
|
|
136
|
+
field: "email",
|
|
137
|
+
label: t("tenant.members.col.email"),
|
|
136
138
|
type: "string",
|
|
137
139
|
sortable: false,
|
|
138
140
|
},
|
|
@@ -145,7 +147,10 @@ export function MembersScreen({
|
|
|
145
147
|
]}
|
|
146
148
|
rows={state.members.map((m) => ({
|
|
147
149
|
id: m.id,
|
|
148
|
-
values: {
|
|
150
|
+
values: {
|
|
151
|
+
email: m.email ?? `${m.userId.slice(0, 8)}…`,
|
|
152
|
+
roles: m.roles.join(", "),
|
|
153
|
+
},
|
|
149
154
|
}))}
|
|
150
155
|
/>
|
|
151
156
|
</Card>
|
|
@@ -4,6 +4,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
4
4
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import {
|
|
6
6
|
defineFeature,
|
|
7
|
+
EXT_EXTERNAL_RESOURCE,
|
|
7
8
|
EXT_TENANT_DATA,
|
|
8
9
|
type TenantId,
|
|
9
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
setupTestStack,
|
|
19
20
|
type TestStack,
|
|
20
21
|
TestUsers,
|
|
22
|
+
testTenantId,
|
|
21
23
|
unsafeCreateEntityTable,
|
|
22
24
|
unsafePushTables,
|
|
23
25
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
@@ -26,6 +28,7 @@ import {
|
|
|
26
28
|
resetTestTables,
|
|
27
29
|
updateRows,
|
|
28
30
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
31
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
29
32
|
import {
|
|
30
33
|
createComplianceProfilesFeature,
|
|
31
34
|
tenantComplianceProfileEntity,
|
|
@@ -36,9 +39,11 @@ import { createConfigResolver } from "../../config/resolver";
|
|
|
36
39
|
import { configValuesTable } from "../../config/table";
|
|
37
40
|
import { createSessionsFeature } from "../../sessions";
|
|
38
41
|
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
42
|
+
import { tenantMembershipEntity, tenantMembershipsTable } from "../../tenant";
|
|
39
43
|
import { TenantHandlers, TenantQueries } from "../../tenant/constants";
|
|
40
44
|
import { createTenantFeature } from "../../tenant/feature";
|
|
41
45
|
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
46
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
42
47
|
import { createUserFeature } from "../../user/feature";
|
|
43
48
|
import {
|
|
44
49
|
TENANT_AGGREGATE_TYPE,
|
|
@@ -50,7 +55,8 @@ import {
|
|
|
50
55
|
resolveTenantLifecycleGate,
|
|
51
56
|
TenantLifecycleHandlers,
|
|
52
57
|
} from "../index";
|
|
53
|
-
import {
|
|
58
|
+
import { resetTenantLifecycleGateCacheForTests } from "../lifecycle-gate";
|
|
59
|
+
import { runNextDestructionStage, runTenantDestructionSweep } from "../run-tenant-destroy";
|
|
54
60
|
|
|
55
61
|
const REQUEST = TenantLifecycleHandlers.requestDestruction;
|
|
56
62
|
const CANCEL = TenantLifecycleHandlers.cancelDestruction;
|
|
@@ -86,6 +92,7 @@ beforeAll(async () => {
|
|
|
86
92
|
await unsafeCreateEntityTable(db, tenantEntity);
|
|
87
93
|
await unsafeCreateEntityTable(db, userSessionEntity);
|
|
88
94
|
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
95
|
+
await unsafeCreateEntityTable(db, tenantMembershipEntity);
|
|
89
96
|
await createEventsTable(db);
|
|
90
97
|
await unsafePushTables(db, { configValuesTable });
|
|
91
98
|
});
|
|
@@ -96,10 +103,15 @@ afterAll(async () => {
|
|
|
96
103
|
|
|
97
104
|
beforeEach(async () => {
|
|
98
105
|
stack.events.reset();
|
|
106
|
+
// resetTestTables wipes rows via raw SQL, not through the write handlers
|
|
107
|
+
// that invalidate the lifecycle-gate cache — clear it too, or a later test
|
|
108
|
+
// reusing tenantAdmin.tenantId reads an earlier test's cached status.
|
|
109
|
+
resetTenantLifecycleGateCacheForTests();
|
|
99
110
|
await resetTestTables(db, [
|
|
100
111
|
tenantTable,
|
|
101
112
|
tenantComplianceProfileTable,
|
|
102
113
|
userSessionTable,
|
|
114
|
+
tenantMembershipsTable,
|
|
103
115
|
eventsTable,
|
|
104
116
|
]);
|
|
105
117
|
});
|
|
@@ -306,4 +318,152 @@ describe("tenant-lifecycle :: batch cancel exemption", () => {
|
|
|
306
318
|
const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
|
|
307
319
|
expect(rows[0]?.["status"]).toBe("active");
|
|
308
320
|
});
|
|
321
|
+
|
|
322
|
+
test("batch mixing cancel with another command is NOT exempt (410)", async () => {
|
|
323
|
+
await seedTenant();
|
|
324
|
+
await stack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
325
|
+
const res = await stack.http.batch(
|
|
326
|
+
[
|
|
327
|
+
{ type: CANCEL, payload: {} },
|
|
328
|
+
{ type: SET_PROFILE, payload: { profileKey: "de-hr-dsgvo-hgb" } },
|
|
329
|
+
],
|
|
330
|
+
tenantAdmin,
|
|
331
|
+
);
|
|
332
|
+
expect(res.status).toBe(410);
|
|
333
|
+
|
|
334
|
+
// Neither command ran — the tenant is still destroyRequested, not
|
|
335
|
+
// cancelled back to active.
|
|
336
|
+
const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
|
|
337
|
+
expect(rows[0]?.["status"]).toBe("destroyRequested");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("tenant-lifecycle :: full sweep pipeline", () => {
|
|
342
|
+
test("runTenantDestructionSweep drives destroyRequested -> destroyed, memberships forgotten", async () => {
|
|
343
|
+
await seedTenant();
|
|
344
|
+
const membership = await seedTenantMembership(db, {
|
|
345
|
+
userId: TestUsers.user.id,
|
|
346
|
+
tenantId: tenantAdmin.tenantId,
|
|
347
|
+
roles: ["User"],
|
|
348
|
+
});
|
|
349
|
+
await stack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
350
|
+
|
|
351
|
+
// Comfortably past any compliance profile's grace period, so the sweep's
|
|
352
|
+
// first loop flips destroyRequested -> destroying on the first tick.
|
|
353
|
+
const farFuture = getTemporal()
|
|
354
|
+
.Now.instant()
|
|
355
|
+
.add({ hours: 24 * 3650 });
|
|
356
|
+
|
|
357
|
+
let status = "";
|
|
358
|
+
for (let i = 0; i < 20; i++) {
|
|
359
|
+
await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
|
|
360
|
+
const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
|
|
361
|
+
status = String(rows[0]?.["status"]);
|
|
362
|
+
if (status === "destroyed" || status === "destroyFailed") break;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
expect(status).toBe("destroyed");
|
|
366
|
+
|
|
367
|
+
const memberships = await selectMany(db, tenantMembershipsTable, {
|
|
368
|
+
tenantId: tenantAdmin.tenantId,
|
|
369
|
+
});
|
|
370
|
+
expect(memberships).toHaveLength(0);
|
|
371
|
+
|
|
372
|
+
// Discriminates the fix from a raw deleteMany bypass — both empty the
|
|
373
|
+
// projection table, but only forget() through the executor leaves an
|
|
374
|
+
// event on the membership's own aggregate stream (rebuild-safe erasure).
|
|
375
|
+
const membershipEvents = await loadAggregate(db, membership.id, tenantAdmin.tenantId);
|
|
376
|
+
expect(membershipEvents.some((e) => e.type === "tenant-membership.forgotten")).toBe(true);
|
|
377
|
+
|
|
378
|
+
// Gate resolves the fresh status too — proves the cache was invalidated
|
|
379
|
+
// through every status transition, not just the final one.
|
|
380
|
+
const gate = await resolveTenantLifecycleGate(stack.db, tenantAdmin.tenantId);
|
|
381
|
+
expect(gate?.status).toBe("destroyed");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// One tenantId is set here per-test right before the sweep call — the hook
|
|
386
|
+
// throws only for that tenant, letting a single stack exercise "one tenant's
|
|
387
|
+
// destroy-hook fails, does that stall every OTHER destroying tenant's sweep
|
|
388
|
+
// progress in the same tick?" without a second full stack.
|
|
389
|
+
let selectivePoisonTargetTenantId: TenantId | null = null;
|
|
390
|
+
|
|
391
|
+
function createSelectivePoisonTenantDataFeature() {
|
|
392
|
+
return defineFeature("test-selective-poison-tenant-data", (r) => {
|
|
393
|
+
r.requires("tenant-lifecycle");
|
|
394
|
+
// EXT_EXTERNAL_RESOURCE — the pipeline's FIRST stage — so the poison
|
|
395
|
+
// fires on the very first sweep tick instead of several stages in.
|
|
396
|
+
r.useExtension(EXT_EXTERNAL_RESOURCE, "selective-poison-pill", {
|
|
397
|
+
destroyTenant: async (tenantId: TenantId) => {
|
|
398
|
+
if (tenantId === selectivePoisonTargetTenantId) {
|
|
399
|
+
throw new Error("selective-poison");
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
describe("tenant-lifecycle :: sweep isolates one tenant's failure from another's progress", () => {
|
|
407
|
+
let isolationStack: TestStack;
|
|
408
|
+
const tenantA = tenantAdmin.tenantId; // poisoned
|
|
409
|
+
const tenantB = testTenantId(9002); // healthy
|
|
410
|
+
|
|
411
|
+
beforeAll(async () => {
|
|
412
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
413
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
414
|
+
isolationStack = await setupTestStack({
|
|
415
|
+
features: [
|
|
416
|
+
createConfigFeature(),
|
|
417
|
+
createTenantFeature(),
|
|
418
|
+
createComplianceProfilesFeature(),
|
|
419
|
+
createTenantLifecycleFeature(),
|
|
420
|
+
createSelectivePoisonTenantDataFeature(),
|
|
421
|
+
],
|
|
422
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
423
|
+
});
|
|
424
|
+
await unsafeCreateEntityTable(isolationStack.db, tenantEntity);
|
|
425
|
+
await unsafeCreateEntityTable(isolationStack.db, tenantComplianceProfileEntity);
|
|
426
|
+
await createEventsTable(isolationStack.db);
|
|
427
|
+
await unsafePushTables(isolationStack.db, { configValuesTable });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
afterAll(async () => {
|
|
431
|
+
await isolationStack.cleanup();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
async function seedDestroyingTenant(id: TenantId): Promise<void> {
|
|
435
|
+
await isolationStack.http.writeOk(
|
|
436
|
+
TenantHandlers.create,
|
|
437
|
+
{ id, key: `acme-${id.slice(-4)}`, name: "ACME Corp" },
|
|
438
|
+
TestUsers.systemAdmin,
|
|
439
|
+
);
|
|
440
|
+
await isolationStack.http.writeOk(
|
|
441
|
+
SET_PROFILE,
|
|
442
|
+
{ profileKey: "eu-dsgvo" },
|
|
443
|
+
{ ...tenantAdmin, tenantId: id },
|
|
444
|
+
);
|
|
445
|
+
await updateRows(isolationStack.db, tenantTable, { status: "destroying" }, { id });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
test("tenant A's poisoned destroy-hook does not block tenant B's sweep progress", async () => {
|
|
449
|
+
selectivePoisonTargetTenantId = tenantA;
|
|
450
|
+
await seedDestroyingTenant(tenantA);
|
|
451
|
+
await seedDestroyingTenant(tenantB);
|
|
452
|
+
|
|
453
|
+
const result = await runTenantDestructionSweep({
|
|
454
|
+
db: isolationStack.db,
|
|
455
|
+
registry: isolationStack.registry,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// A's first stage ("external-resources") throws every attempt — not
|
|
459
|
+
// counted as advanced. B has no poison, so it does advance. If A's
|
|
460
|
+
// failure aborted the sweep loop (the pre-fix behavior), B would never
|
|
461
|
+
// be reached and this would be 0.
|
|
462
|
+
expect(result.advanced).toBe(1);
|
|
463
|
+
|
|
464
|
+
const rowB = await selectMany(isolationStack.db, tenantTable, { id: tenantB });
|
|
465
|
+
expect(rowB[0]?.["status"]).toBe("destroying"); // still mid-pipeline, but progressed
|
|
466
|
+
const eventsB = await loadAggregate(isolationStack.db, tenantB, tenantB);
|
|
467
|
+
expect(eventsB.some((e) => e.type.endsWith("tenant-destruction-stage-succeeded"))).toBe(true);
|
|
468
|
+
});
|
|
309
469
|
});
|
|
@@ -6,6 +6,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
|
|
8
8
|
import { DESTRUCTION_CANCELLED_EVENT_QN } from "../constants";
|
|
9
|
+
import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
|
|
9
10
|
|
|
10
11
|
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
11
12
|
|
|
@@ -59,6 +60,7 @@ export const cancelDestructionWrite = defineWriteHandler({
|
|
|
59
60
|
{ skipOptimisticLock: true },
|
|
60
61
|
);
|
|
61
62
|
if (!update.isSuccess) return update;
|
|
63
|
+
invalidateTenantLifecycleGate(tenantId);
|
|
62
64
|
|
|
63
65
|
await ctx.unsafeAppendEvent({
|
|
64
66
|
aggregateId: tenantId,
|
|
@@ -9,6 +9,7 @@ import { resolveProfileForTenant } from "../../compliance-profiles";
|
|
|
9
9
|
import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
|
|
10
10
|
import { DESTRUCTION_REQUESTED_EVENT_QN } from "../constants";
|
|
11
11
|
import { revokeTenantSessions } from "../lib/revoke-tenant-sessions";
|
|
12
|
+
import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
|
|
12
13
|
|
|
13
14
|
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
14
15
|
|
|
@@ -54,6 +55,7 @@ export const requestDestructionWrite = defineWriteHandler({
|
|
|
54
55
|
{ skipOptimisticLock: true },
|
|
55
56
|
);
|
|
56
57
|
if (!update.isSuccess) return update;
|
|
58
|
+
invalidateTenantLifecycleGate(tenantId);
|
|
57
59
|
|
|
58
60
|
await ctx.unsafeAppendEvent({
|
|
59
61
|
aggregateId: tenantId,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { tenantTable } from "../tenant";
|
|
5
|
+
|
|
6
|
+
export type TenantLifecycleGate = {
|
|
7
|
+
readonly status: string;
|
|
8
|
+
readonly gracePeriodEnd: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Every authenticated + anonymous request consults this (via auth-middleware's
|
|
12
|
+
// resolveTenantLifecycleStatus) — a per-request DB round-trip otherwise. No
|
|
13
|
+
// TTL: status must be observable the instant it changes (a stale "active"
|
|
14
|
+
// read after tombstoning would let a request slip through the 410 gate), so
|
|
15
|
+
// this is invalidated explicitly by every status-changing write —
|
|
16
|
+
// request/cancel-destruction, the sweep, and tombstoneTenantRow all call
|
|
17
|
+
// invalidateTenantLifecycleGate(tenantId) right after their update succeeds.
|
|
18
|
+
// Split into its own module (not run-tenant-destroy.ts) so stages.ts can
|
|
19
|
+
// invalidate too without a stages.ts <-> run-tenant-destroy.ts import cycle.
|
|
20
|
+
// ponytail: unbounded Map — fine while tenant counts stay in the thousands (a
|
|
21
|
+
// few bytes/entry); swap for a bounded LRU if that ever changes.
|
|
22
|
+
const gateCache = new Map<TenantId, TenantLifecycleGate | null>();
|
|
23
|
+
|
|
24
|
+
export function invalidateTenantLifecycleGate(tenantId: TenantId): void {
|
|
25
|
+
gateCache.delete(tenantId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @internal test-only — resetTestTables/resetTestTable wipe rows directly
|
|
29
|
+
* via SQL, bypassing the write handlers that invalidate normally. Tests
|
|
30
|
+
* that reset tenantTable (and reuse a fixed tenantId across cases) must
|
|
31
|
+
* call this in beforeEach or a later test can read an earlier test's
|
|
32
|
+
* cached status for the same tenantId. */
|
|
33
|
+
export function resetTenantLifecycleGateCacheForTests(): void {
|
|
34
|
+
gateCache.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolveTenantLifecycleGate(
|
|
38
|
+
db: DbRunner,
|
|
39
|
+
tenantId: TenantId,
|
|
40
|
+
): Promise<TenantLifecycleGate | null> {
|
|
41
|
+
if (gateCache.has(tenantId)) return gateCache.get(tenantId) ?? null;
|
|
42
|
+
const rows = await selectMany<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
|
|
43
|
+
db,
|
|
44
|
+
tenantTable,
|
|
45
|
+
{ id: tenantId },
|
|
46
|
+
);
|
|
47
|
+
const row = rows[0];
|
|
48
|
+
if (!row) {
|
|
49
|
+
gateCache.set(tenantId, null);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const gate: TenantLifecycleGate = {
|
|
53
|
+
status: row.status,
|
|
54
|
+
gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
|
|
55
|
+
};
|
|
56
|
+
gateCache.set(tenantId, gate);
|
|
57
|
+
return gate;
|
|
58
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
|
|
24
24
|
TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
|
|
25
25
|
} from "./constants";
|
|
26
|
+
import { invalidateTenantLifecycleGate } from "./lifecycle-gate";
|
|
26
27
|
import { type DestructionStageCtx, isDestructionPipelineComplete, pickNextStage } from "./stages";
|
|
27
28
|
|
|
28
29
|
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
@@ -148,6 +149,7 @@ async function markTenantDestroyFailed(db: DbRunner, tenantId: TenantId): Promis
|
|
|
148
149
|
scopedDb,
|
|
149
150
|
{ skipOptimisticLock: true },
|
|
150
151
|
);
|
|
152
|
+
invalidateTenantLifecycleGate(tenantId);
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
async function haltPipelineOnAbandon(args: {
|
|
@@ -156,16 +158,20 @@ async function haltPipelineOnAbandon(args: {
|
|
|
156
158
|
readonly stage: string;
|
|
157
159
|
readonly attempts: number;
|
|
158
160
|
readonly error: string;
|
|
159
|
-
readonly expectedVersion: number;
|
|
160
161
|
}): Promise<void> {
|
|
161
162
|
const T = getTemporal();
|
|
162
163
|
const failedAt = T.Now.instant();
|
|
164
|
+
// Reload fresh instead of trusting a caller-supplied version: the failed
|
|
165
|
+
// stage's own run() may have appended to this same aggregate (e.g.
|
|
166
|
+
// tombstoneTenantRow's tenantCrud.update) before throwing, so any version
|
|
167
|
+
// captured earlier in the pipeline is stale and would VersionConflict here.
|
|
168
|
+
const eventsBeforeAbandon = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
163
169
|
await appendTenantStageEvent(
|
|
164
170
|
args.db,
|
|
165
171
|
args.tenantId,
|
|
166
172
|
qualified(TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT),
|
|
167
173
|
{ stage: args.stage, attempts: args.attempts, error: args.error },
|
|
168
|
-
|
|
174
|
+
lastEventVersion(eventsBeforeAbandon),
|
|
169
175
|
);
|
|
170
176
|
const eventsAfterAbandon = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
171
177
|
const versionAfterAbandon = lastEventVersion(eventsAfterAbandon);
|
|
@@ -243,14 +249,19 @@ export async function runNextDestructionStage(args: {
|
|
|
243
249
|
|
|
244
250
|
try {
|
|
245
251
|
await next.run(ctx);
|
|
252
|
+
// next.run() may itself have appended events to this aggregate (e.g.
|
|
253
|
+
// tombstoneTenantRow updates the tenant row on the same stream) — reload
|
|
254
|
+
// the version fresh instead of trusting the pre-run `version`, or this
|
|
255
|
+
// append throws VersionConflictError on every stage whose run() writes.
|
|
256
|
+
const eventsAfterRun = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
246
257
|
version = await appendTenantStageEventIdempotent(
|
|
247
258
|
args.db,
|
|
248
259
|
args.tenantId,
|
|
249
260
|
qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
|
|
250
261
|
{ stage: next.name, attempts: attempt },
|
|
251
|
-
|
|
262
|
+
lastEventVersion(eventsAfterRun),
|
|
252
263
|
stageOutcomeRecorded(
|
|
253
|
-
|
|
264
|
+
eventsAfterRun,
|
|
254
265
|
next.name,
|
|
255
266
|
TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
|
|
256
267
|
),
|
|
@@ -269,7 +280,6 @@ export async function runNextDestructionStage(args: {
|
|
|
269
280
|
stage: next.name,
|
|
270
281
|
attempts: attempt,
|
|
271
282
|
error: message,
|
|
272
|
-
expectedVersion: version,
|
|
273
283
|
});
|
|
274
284
|
return { done: false, error: message, halted: true };
|
|
275
285
|
}
|
|
@@ -289,6 +299,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
289
299
|
readonly db: DbRunner;
|
|
290
300
|
readonly registry: Registry;
|
|
291
301
|
readonly now?: Temporal.Instant;
|
|
302
|
+
readonly log?: (message: string) => void;
|
|
292
303
|
}): Promise<{ readonly triggered: number; readonly advanced: number }> {
|
|
293
304
|
const T = getTemporal();
|
|
294
305
|
const now = args.now ?? T.Now.instant();
|
|
@@ -302,23 +313,26 @@ export async function runTenantDestructionSweep(args: {
|
|
|
302
313
|
const tenantId = row.id as TenantId;
|
|
303
314
|
const events = await loadAggregate(args.db, tenantId, tenantId);
|
|
304
315
|
const version = lastEventVersion(events);
|
|
316
|
+
const user = createSystemUser(tenantId);
|
|
317
|
+
const scopedDb = createTenantDb(args.db, tenantId, "system");
|
|
318
|
+
// Version-guarded (no skipOptimisticLock): a concurrent cancel-destruction
|
|
319
|
+
// between the `due` select above and this write also appends to the same
|
|
320
|
+
// tenant stream, so an unguarded overwrite here would silently revert the
|
|
321
|
+
// cancellation. A version conflict means we lost that race — skip, the
|
|
322
|
+
// tenant is no longer due and the next tick re-evaluates it.
|
|
323
|
+
const result = await tenantCrud.update(
|
|
324
|
+
{ id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } },
|
|
325
|
+
user,
|
|
326
|
+
scopedDb,
|
|
327
|
+
);
|
|
328
|
+
if (!result.isSuccess) continue;
|
|
329
|
+
invalidateTenantLifecycleGate(tenantId);
|
|
305
330
|
await appendTenantStageEvent(
|
|
306
331
|
args.db,
|
|
307
332
|
tenantId,
|
|
308
333
|
qualified(TENANT_DESTRUCTION_STARTED_EVENT_SHORT),
|
|
309
334
|
{ startedAt: now.toString() },
|
|
310
|
-
version,
|
|
311
|
-
);
|
|
312
|
-
const user = createSystemUser(tenantId);
|
|
313
|
-
const scopedDb = createTenantDb(args.db, tenantId, "system");
|
|
314
|
-
await tenantCrud.update(
|
|
315
|
-
{
|
|
316
|
-
id: tenantId,
|
|
317
|
-
changes: { status: "destroying", destroyStartedAt: now },
|
|
318
|
-
},
|
|
319
|
-
user,
|
|
320
|
-
scopedDb,
|
|
321
|
-
{ skipOptimisticLock: true },
|
|
335
|
+
version + 1,
|
|
322
336
|
);
|
|
323
337
|
triggered++;
|
|
324
338
|
}
|
|
@@ -328,30 +342,27 @@ export async function runTenantDestructionSweep(args: {
|
|
|
328
342
|
status: "destroying",
|
|
329
343
|
});
|
|
330
344
|
for (const row of destroying) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
345
|
+
try {
|
|
346
|
+
const result = await runNextDestructionStage({
|
|
347
|
+
db: args.db,
|
|
348
|
+
registry: args.registry,
|
|
349
|
+
tenantId: row.id as TenantId,
|
|
350
|
+
log: args.log,
|
|
351
|
+
});
|
|
352
|
+
if (!result.error && !result.halted) advanced++;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
// Isolate the failure to this tenant — an uncaught throw here must not
|
|
355
|
+
// abort the whole sweep tick and starve every other "destroying" tenant
|
|
356
|
+
// of progress. The tenant stays "destroying" and the next tick retries.
|
|
357
|
+
args.log?.(
|
|
358
|
+
`[tenant-lifecycle] sweep: unexpected error advancing tenant ${row.id}: ${
|
|
359
|
+
err instanceof Error ? err.message : String(err)
|
|
360
|
+
}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
337
363
|
}
|
|
338
364
|
|
|
339
365
|
return { triggered, advanced };
|
|
340
366
|
}
|
|
341
367
|
|
|
342
|
-
export
|
|
343
|
-
db: DbRunner,
|
|
344
|
-
tenantId: TenantId,
|
|
345
|
-
): Promise<{ status: string; gracePeriodEnd: string | null } | null> {
|
|
346
|
-
const rows = await selectMany<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
|
|
347
|
-
db,
|
|
348
|
-
tenantTable,
|
|
349
|
-
{ id: tenantId },
|
|
350
|
-
);
|
|
351
|
-
const row = rows[0];
|
|
352
|
-
if (!row) return null;
|
|
353
|
-
return {
|
|
354
|
-
status: row.status,
|
|
355
|
-
gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
368
|
+
export { invalidateTenantLifecycleGate, resolveTenantLifecycleGate } from "./lifecycle-gate";
|
|
@@ -4,8 +4,6 @@ import {
|
|
|
4
4
|
createEventStoreExecutor,
|
|
5
5
|
createTenantDb,
|
|
6
6
|
type DbRunner,
|
|
7
|
-
deleteMany,
|
|
8
|
-
type EntityTableMeta,
|
|
9
7
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
10
8
|
import {
|
|
11
9
|
createSystemUser,
|
|
@@ -18,8 +16,14 @@ import {
|
|
|
18
16
|
type TenantId,
|
|
19
17
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
18
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
21
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
tenantEntity,
|
|
21
|
+
tenantMembershipEntity,
|
|
22
|
+
tenantMembershipsTable,
|
|
23
|
+
tenantTable,
|
|
24
|
+
} from "../tenant";
|
|
22
25
|
import type { TenantDestructionStageName } from "./constants";
|
|
26
|
+
import { invalidateTenantLifecycleGate } from "./lifecycle-gate";
|
|
23
27
|
|
|
24
28
|
export type DestructionStageCtx = {
|
|
25
29
|
readonly db: DbRunner;
|
|
@@ -35,6 +39,13 @@ export type DestructionStage = {
|
|
|
35
39
|
};
|
|
36
40
|
|
|
37
41
|
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
42
|
+
const tenantMembershipCrud = createEventStoreExecutor(
|
|
43
|
+
tenantMembershipsTable,
|
|
44
|
+
tenantMembershipEntity,
|
|
45
|
+
{
|
|
46
|
+
entityName: "tenant-membership",
|
|
47
|
+
},
|
|
48
|
+
);
|
|
38
49
|
|
|
39
50
|
async function runExtensionDestroyHooks(
|
|
40
51
|
registry: Registry,
|
|
@@ -96,6 +107,27 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
|
|
|
96
107
|
const now = getTemporal().Now.instant();
|
|
97
108
|
const user = createSystemUser(ctx.tenantId);
|
|
98
109
|
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
110
|
+
// Per-row forget() through the executor, not a bulk deleteMany: memberships
|
|
111
|
+
// are an ES-managed projection (add/remove/update-roles all go through
|
|
112
|
+
// tenantMembershipCrud), so a raw table write here is eventless — a future
|
|
113
|
+
// projection rebuild would replay the historical add-member events and
|
|
114
|
+
// resurrect rows this stage removed. forget() (Art.17 hard-purge) keeps the
|
|
115
|
+
// erasure rebuild-safe and gives each membership its own audit event.
|
|
116
|
+
const memberships = await selectMany<{ id: string }>(ctx.db, tenantMembershipsTable, {
|
|
117
|
+
tenantId: ctx.tenantId,
|
|
118
|
+
});
|
|
119
|
+
for (const membership of memberships) {
|
|
120
|
+
const result = await tenantMembershipCrud.forget({ id: membership.id }, user, db);
|
|
121
|
+
// executor writes return {isSuccess:false} on failure, they don't throw —
|
|
122
|
+
// a silently-discarded result here would report this stage "succeeded"
|
|
123
|
+
// while membership PII survives. Throw so the pipeline's retry/abandon
|
|
124
|
+
// handling (runNextDestructionStage) sees it instead.
|
|
125
|
+
if (!result.isSuccess) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`tenant-lifecycle: failed to forget membership ${membership.id} for tenant ${ctx.tenantId}: ${result.error.message}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
99
131
|
await tenantCrud.update(
|
|
100
132
|
{
|
|
101
133
|
id: ctx.tenantId,
|
|
@@ -109,9 +141,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
|
|
|
109
141
|
db,
|
|
110
142
|
{ skipOptimisticLock: true },
|
|
111
143
|
);
|
|
112
|
-
|
|
113
|
-
tenantId: ctx.tenantId,
|
|
114
|
-
});
|
|
144
|
+
invalidateTenantLifecycleGate(ctx.tenantId);
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
|
|
@@ -73,6 +73,7 @@ import { tierAssignmentEntity } from "./entity";
|
|
|
73
73
|
import { getActiveTierQuery } from "./handlers/active-tier.query";
|
|
74
74
|
import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
|
|
75
75
|
import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
|
|
76
|
+
import { TIER_ENGINE_I18N } from "./i18n";
|
|
76
77
|
import { isTrialActive, type TrialPolicy } from "./trial";
|
|
77
78
|
|
|
78
79
|
// Drizzle-table for the tier-assignment-entity. Built once at module-load
|
|
@@ -234,6 +235,7 @@ export function createTierEngineFeature<
|
|
|
234
235
|
// verdrahtet (Platform-Admin-Hoheit, nicht App-konfigurierbar). App
|
|
235
236
|
// platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
|
|
236
237
|
// Komponente liefert tierEngineClient() aus dem ./web-subpath.
|
|
238
|
+
r.translations({ keys: TIER_ENGINE_I18N });
|
|
237
239
|
r.screen({
|
|
238
240
|
id: TIER_ADMIN_SCREEN_ID,
|
|
239
241
|
type: "custom",
|
package/src/tier-engine/i18n.ts
CHANGED
|
@@ -5,8 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
|
|
8
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
9
|
+
|
|
10
|
+
export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
11
|
+
"screen:tier-admin.title": { de: "Tier manuell zuweisen", en: "Assign tier manually" },
|
|
12
|
+
};
|
|
13
|
+
|
|
8
14
|
export const defaultTranslations: TranslationsByLocale = {
|
|
9
15
|
de: {
|
|
16
|
+
"screen:tier-admin.title": "Tier manuell zuweisen",
|
|
10
17
|
"tier-admin.title": "Tier manuell zuweisen",
|
|
11
18
|
"tier-admin.explainer":
|
|
12
19
|
"Weise einem Tenant ein Tier ohne Kauf zu. Der Grant wird als „manuell“ markiert und von einem späteren Billing-Sync nicht überschrieben.",
|
|
@@ -22,6 +29,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
22
29
|
"Diese App hat keine TierMap konfiguriert — es gibt keine zuweisbaren Tiers.",
|
|
23
30
|
},
|
|
24
31
|
en: {
|
|
32
|
+
"screen:tier-admin.title": "Assign tier manually",
|
|
25
33
|
"tier-admin.title": "Assign tier manually",
|
|
26
34
|
"tier-admin.explainer":
|
|
27
35
|
"Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
|