@cosmicdrift/kumiko-bundled-features 0.304.0 → 0.306.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 +9 -9
- package/src/__tests__/extension-points-typed.test.ts +135 -0
- package/src/__tests__/use-extension-options.test.ts +278 -0
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/auth-email-password/i18n.ts +7 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
- package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
- package/src/auth-email-password/web/auth-client.ts +34 -2
- package/src/auth-email-password/web/auth-gate.tsx +5 -1
- package/src/auth-email-password/web/index.ts +4 -1
- package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
- package/src/auth-email-password/web/session.tsx +32 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
- package/src/auth-foundation/types.ts +14 -4
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
- package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +8 -1
- package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
- package/src/billing-foundation/types.ts +13 -1
- package/src/channel-email/feature.ts +2 -1
- package/src/channel-in-app/feature.ts +2 -1
- package/src/channel-push/feature.ts +2 -1
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
- package/src/config/__tests__/require-system-db.test.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +4 -8
- package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
- package/src/delivery/constants.ts +4 -0
- package/src/delivery/delivery-service.ts +45 -29
- package/src/delivery/feature.ts +2 -1
- package/src/delivery/index.ts +14 -11
- package/src/delivery/types.ts +24 -0
- package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
- package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
- package/src/document-ingest-foundation/changes.json +5 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
- package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
- package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/feature.ts +4 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
- package/src/inbound-mail-foundation/types.ts +10 -3
- package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
- package/src/mail-foundation/feature.ts +15 -4
- package/src/mail-foundation/index.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +5 -4
- package/src/mail-transport-smtp/feature.ts +5 -4
- package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
- package/src/renderer-foundation/changes.json +8 -1
- package/src/renderer-foundation/constants.ts +4 -0
- package/src/renderer-foundation/feature.ts +10 -13
- package/src/renderer-foundation/index.ts +3 -0
- package/src/renderer-foundation/types.ts +23 -1
- package/src/renderer-simple/feature.ts +2 -1
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
- package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
- package/src/tenant-lifecycle/stages.ts +20 -18
- package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
- package/src/user-data-rights/lib/default-mailers.ts +2 -1
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
- package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Drives the real tenant-lifecycle destruction sweep: a direct hook call with a hand-built
|
|
2
|
+
// TenantDb hides the escapeHatch grant the "app-data" stage actually resolves.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
+
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { append, isStreamArchived, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
|
+
import {
|
|
11
|
+
createTestUser,
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
TestUsers,
|
|
15
|
+
testTenantId,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import {
|
|
19
|
+
resetPiiSubjectKmsForTests,
|
|
20
|
+
resetTestTables,
|
|
21
|
+
updateRows,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
|
+
import {
|
|
25
|
+
createComplianceProfilesFeature,
|
|
26
|
+
tenantComplianceProfileEntity,
|
|
27
|
+
tenantComplianceProfileTable,
|
|
28
|
+
} from "../../compliance-profiles";
|
|
29
|
+
import { createConfigFeature } from "../../config";
|
|
30
|
+
import { tenantMembershipEntity } from "../../tenant";
|
|
31
|
+
import { TenantHandlers } from "../../tenant/constants";
|
|
32
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
33
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
34
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
35
|
+
import {
|
|
36
|
+
TENANT_AGGREGATE_TYPE,
|
|
37
|
+
TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
38
|
+
} from "../../tenant-lifecycle/constants";
|
|
39
|
+
import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
|
|
40
|
+
import { subscriptionAggregateId } from "../aggregate-id";
|
|
41
|
+
import { SubscriptionEventTypes, SubscriptionFoundationHandlers } from "../constants";
|
|
42
|
+
import { billingFoundationFeature } from "../feature";
|
|
43
|
+
import { subscriptionsProjectionTable } from "../projection";
|
|
44
|
+
|
|
45
|
+
const SET_PROFILE = "compliance-profiles:write:set-profile";
|
|
46
|
+
|
|
47
|
+
let stack: TestStack;
|
|
48
|
+
let db: DbConnection;
|
|
49
|
+
|
|
50
|
+
function adminFor(tenantNumber: number) {
|
|
51
|
+
return createTestUser({
|
|
52
|
+
id: tenantNumber,
|
|
53
|
+
tenantId: testTenantId(tenantNumber),
|
|
54
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tenantA = adminFor(9001);
|
|
59
|
+
const tenantB = adminFor(9002);
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
stack = await setupTestStack({
|
|
63
|
+
features: [
|
|
64
|
+
createConfigFeature(),
|
|
65
|
+
createTenantFeature(),
|
|
66
|
+
createComplianceProfilesFeature(),
|
|
67
|
+
createTenantLifecycleFeature(),
|
|
68
|
+
billingFoundationFeature,
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
db = stack.db;
|
|
72
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
73
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
74
|
+
await unsafeCreateEntityTable(db, tenantMembershipEntity);
|
|
75
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await stack.cleanup();
|
|
80
|
+
resetPiiSubjectKmsForTests();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
stack.events.reset();
|
|
85
|
+
await resetTestTables(db, [tenantTable, tenantComplianceProfileTable]);
|
|
86
|
+
await stack.db.unsafe?.(`TRUNCATE kumiko_events, read_subscriptions RESTART IDENTITY CASCADE`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
async function seedTenant(user: typeof tenantA, profileKey = "eu-dsgvo"): Promise<void> {
|
|
90
|
+
await stack.http.writeOk(
|
|
91
|
+
TenantHandlers.create,
|
|
92
|
+
{ id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
|
|
93
|
+
TestUsers.systemAdmin,
|
|
94
|
+
);
|
|
95
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey }, user);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function seedSubscription(user: typeof tenantA, eventIdSuffix: string): Promise<void> {
|
|
99
|
+
await stack.http.writeOk(
|
|
100
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
101
|
+
{
|
|
102
|
+
providerEventId: `evt_${eventIdSuffix}`,
|
|
103
|
+
providerName: "stripe",
|
|
104
|
+
type: SubscriptionEventTypes.created,
|
|
105
|
+
status: "active",
|
|
106
|
+
tier: "pro",
|
|
107
|
+
providerCustomerId: `cus_${eventIdSuffix}`,
|
|
108
|
+
providerSubscriptionId: `sub_${eventIdSuffix}`,
|
|
109
|
+
currentPeriodEndIso: "2030-01-01T00:00:00Z",
|
|
110
|
+
rawPayload: "{}",
|
|
111
|
+
},
|
|
112
|
+
user,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function seedDestroyingTenant(tenantId: TenantId): Promise<void> {
|
|
117
|
+
const now = getTemporal().Now.instant();
|
|
118
|
+
await updateRows(
|
|
119
|
+
db,
|
|
120
|
+
tenantTable,
|
|
121
|
+
{ status: "destroying", destroyStartedAt: now },
|
|
122
|
+
{ id: tenantId },
|
|
123
|
+
);
|
|
124
|
+
await append(db, {
|
|
125
|
+
aggregateId: tenantId,
|
|
126
|
+
aggregateType: TENANT_AGGREGATE_TYPE,
|
|
127
|
+
tenantId,
|
|
128
|
+
expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
|
|
129
|
+
type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
130
|
+
payload: { startedAt: now.toString() },
|
|
131
|
+
metadata: { userId: "system", requestId: "test:destruction-started" },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function driveDestructionToCompletion(tenantId: TenantId): Promise<string> {
|
|
136
|
+
const farFuture = getTemporal()
|
|
137
|
+
.Now.instant()
|
|
138
|
+
.add({ hours: 24 * 3650 });
|
|
139
|
+
let status = "";
|
|
140
|
+
for (let i = 0; i < 20; i++) {
|
|
141
|
+
await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
|
|
142
|
+
const rows = await selectMany(db, tenantTable, { id: tenantId });
|
|
143
|
+
status = String(rows[0]?.["status"]);
|
|
144
|
+
if (status === "destroyed" || status === "destroyFailed") break;
|
|
145
|
+
}
|
|
146
|
+
return status;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("billing-foundation :: tenant destroy (#3196)", () => {
|
|
150
|
+
test("deletes the subscription row for the destroyed tenant, archives its stream, leaves another tenant's row untouched", async () => {
|
|
151
|
+
await seedTenant(tenantA);
|
|
152
|
+
await seedTenant(tenantB);
|
|
153
|
+
|
|
154
|
+
await seedSubscription(tenantA, "a");
|
|
155
|
+
await seedSubscription(tenantB, "b");
|
|
156
|
+
|
|
157
|
+
await seedDestroyingTenant(tenantA.tenantId);
|
|
158
|
+
|
|
159
|
+
const finalStatus = await driveDestructionToCompletion(tenantA.tenantId);
|
|
160
|
+
expect(finalStatus).toBe("destroyed");
|
|
161
|
+
|
|
162
|
+
const rowsA = await selectMany(db, subscriptionsProjectionTable, {
|
|
163
|
+
id: subscriptionAggregateId(tenantA.tenantId),
|
|
164
|
+
});
|
|
165
|
+
expect(rowsA).toHaveLength(0);
|
|
166
|
+
|
|
167
|
+
const rowsB = await selectMany(db, subscriptionsProjectionTable, {
|
|
168
|
+
id: subscriptionAggregateId(tenantB.tenantId),
|
|
169
|
+
});
|
|
170
|
+
expect(rowsB).toHaveLength(1);
|
|
171
|
+
|
|
172
|
+
expect(
|
|
173
|
+
await isStreamArchived(db, tenantA.tenantId, subscriptionAggregateId(tenantA.tenantId)),
|
|
174
|
+
).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("HGB profile: redacts PII fields but keeps the row and archives its stream", async () => {
|
|
178
|
+
const tenantHgb = adminFor(9003);
|
|
179
|
+
await seedTenant(tenantHgb, "de-hr-dsgvo-hgb");
|
|
180
|
+
|
|
181
|
+
await seedSubscription(tenantHgb, "c");
|
|
182
|
+
|
|
183
|
+
await seedDestroyingTenant(tenantHgb.tenantId);
|
|
184
|
+
|
|
185
|
+
const finalStatus = await driveDestructionToCompletion(tenantHgb.tenantId);
|
|
186
|
+
expect(finalStatus).toBe("destroyed");
|
|
187
|
+
|
|
188
|
+
const rowsA = await selectMany(db, subscriptionsProjectionTable, {
|
|
189
|
+
id: subscriptionAggregateId(tenantHgb.tenantId),
|
|
190
|
+
});
|
|
191
|
+
expect(rowsA).toHaveLength(1);
|
|
192
|
+
expect(rowsA[0]?.["providerCustomerId"]).toBe("[erased]");
|
|
193
|
+
expect(rowsA[0]?.["providerSubscriptionId"]).toBe("[erased]");
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
await isStreamArchived(db, tenantHgb.tenantId, subscriptionAggregateId(tenantHgb.tenantId)),
|
|
197
|
+
).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.306.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenant destroy no longer fails on subscription and payment rows",
|
|
6
|
+
"detail": "The subscription and payment tenant-destroy hooks passed the tenant-scoped TenantDb they receive into raw DbRunner helpers, so the app-data stage failed and the tenant ended in destroyFailed. They now use ctx.db methods and declare an escapeHatch for the compliance-profile lookup and the event-stream archive."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.301.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -81,7 +81,12 @@ import {
|
|
|
81
81
|
paymentsProjectionTable,
|
|
82
82
|
subscriptionsProjectionTable,
|
|
83
83
|
} from "./projection";
|
|
84
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
PAYMENT_TENANT_DESTROY_ARCHIVE_REASON,
|
|
86
|
+
paymentTenantDestroyHook,
|
|
87
|
+
SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON,
|
|
88
|
+
subscriptionTenantDestroyHook,
|
|
89
|
+
} from "./tenant-destroy-hook";
|
|
85
90
|
|
|
86
91
|
export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
|
|
87
92
|
r.describe(
|
|
@@ -169,8 +174,10 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
|
|
|
169
174
|
|
|
170
175
|
r.useExtension(EXT_TENANT_DATA, "subscription", {
|
|
171
176
|
destroy: subscriptionTenantDestroyHook,
|
|
177
|
+
escapeHatch: { reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON },
|
|
172
178
|
});
|
|
173
179
|
r.useExtension(EXT_TENANT_DATA, "payment", {
|
|
174
180
|
destroy: paymentTenantDestroyHook,
|
|
181
|
+
escapeHatch: { reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON },
|
|
175
182
|
});
|
|
176
183
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
1
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
5
5
|
import { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
|
|
@@ -13,30 +13,33 @@ import { paymentsProjectionTable, subscriptionsProjectionTable } from "./project
|
|
|
13
13
|
// it redacts the projection row directly instead of relying on key erasure.
|
|
14
14
|
const REDACTED_PII_VALUE = "[erased]";
|
|
15
15
|
|
|
16
|
+
// Compliance-profile lookup and event-stream archive both sit outside TenantDb's own methods — need the escape hatch.
|
|
17
|
+
export const SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON =
|
|
18
|
+
"tenant-destroy looks up the compliance profile and archives the subscription event-stream before the projection row is redacted/deleted";
|
|
19
|
+
export const PAYMENT_TENANT_DESTROY_ARCHIVE_REASON =
|
|
20
|
+
"tenant-destroy looks up the compliance profile and archives the payment event-stream before the projection rows are redacted/deleted";
|
|
21
|
+
|
|
16
22
|
/** Tenant-destroy hook for billing PII (#800). HGB retains the accounting
|
|
17
23
|
* row but redacts the two provider-subject PII fields; other profiles
|
|
18
24
|
* hard-delete the row. Either way the subscription stream is archived so a
|
|
19
25
|
* future projection rebuild can't resurrect what was erased. */
|
|
20
|
-
export async function subscriptionTenantDestroyHook(ctx: {
|
|
21
|
-
|
|
22
|
-
readonly tenantId: TenantId;
|
|
23
|
-
}): Promise<void> {
|
|
26
|
+
export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
|
|
27
|
+
declareEscapeHatch({ reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON });
|
|
24
28
|
const { profile } = await resolveProfileForTenant({
|
|
25
|
-
db: ctx.db,
|
|
29
|
+
db: ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON),
|
|
26
30
|
tenantId: ctx.tenantId,
|
|
27
31
|
});
|
|
28
32
|
const aggregateId = subscriptionAggregateId(ctx.tenantId);
|
|
29
33
|
if (profile.key === "de-hr-dsgvo-hgb") {
|
|
30
|
-
await updateMany(
|
|
31
|
-
ctx.db,
|
|
34
|
+
await ctx.db.updateMany(
|
|
32
35
|
subscriptionsProjectionTable as EntityTableMeta,
|
|
33
36
|
{ providerCustomerId: REDACTED_PII_VALUE, providerSubscriptionId: REDACTED_PII_VALUE },
|
|
34
37
|
{ id: aggregateId },
|
|
35
38
|
);
|
|
36
39
|
} else {
|
|
37
|
-
await
|
|
40
|
+
await ctx.db.deleteMany(subscriptionsProjectionTable as EntityTableMeta, { id: aggregateId });
|
|
38
41
|
}
|
|
39
|
-
await archiveStream(ctx.db, {
|
|
42
|
+
await archiveStream(ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON), {
|
|
40
43
|
tenantId: ctx.tenantId,
|
|
41
44
|
aggregateId,
|
|
42
45
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
@@ -50,28 +53,25 @@ export async function subscriptionTenantDestroyHook(ctx: {
|
|
|
50
53
|
* rows — the predicate here is `tenantId`, not `id`. The payment-stream
|
|
51
54
|
* itself is still one stream per tenant (paymentAggregateId), so
|
|
52
55
|
* archiveStream targets that single aggregateId as usual. */
|
|
53
|
-
export async function paymentTenantDestroyHook(ctx: {
|
|
54
|
-
|
|
55
|
-
readonly tenantId: TenantId;
|
|
56
|
-
}): Promise<void> {
|
|
56
|
+
export async function paymentTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
|
|
57
|
+
declareEscapeHatch({ reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON });
|
|
57
58
|
const { profile } = await resolveProfileForTenant({
|
|
58
|
-
db: ctx.db,
|
|
59
|
+
db: ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON),
|
|
59
60
|
tenantId: ctx.tenantId,
|
|
60
61
|
});
|
|
61
62
|
const aggregateId = paymentAggregateId(ctx.tenantId);
|
|
62
63
|
if (profile.key === "de-hr-dsgvo-hgb") {
|
|
63
|
-
await updateMany(
|
|
64
|
-
ctx.db,
|
|
64
|
+
await ctx.db.updateMany(
|
|
65
65
|
paymentsProjectionTable as EntityTableMeta,
|
|
66
66
|
{ providerCustomerId: REDACTED_PII_VALUE },
|
|
67
67
|
{ tenantId: ctx.tenantId },
|
|
68
68
|
);
|
|
69
69
|
} else {
|
|
70
|
-
await
|
|
70
|
+
await ctx.db.deleteMany(paymentsProjectionTable as EntityTableMeta, {
|
|
71
71
|
tenantId: ctx.tenantId,
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
|
-
await archiveStream(ctx.db, {
|
|
74
|
+
await archiveStream(ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON), {
|
|
75
75
|
tenantId: ctx.tenantId,
|
|
76
76
|
aggregateId,
|
|
77
77
|
aggregateType: PAYMENT_AGGREGATE_TYPE,
|
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
|
|
27
27
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
28
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
-
import
|
|
29
|
+
import {
|
|
30
|
+
type BillingEventKinds,
|
|
31
|
+
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
32
|
+
type SubscriptionEventType,
|
|
33
|
+
type SubscriptionStatus,
|
|
34
|
+
} from "./constants";
|
|
30
35
|
|
|
31
36
|
// =============================================================================
|
|
32
37
|
// Normalisierter Webhook-Event
|
|
@@ -215,3 +220,10 @@ export type SubscriptionProviderPlugin = {
|
|
|
215
220
|
providerSubscriptionId: string,
|
|
216
221
|
) => Promise<void>;
|
|
217
222
|
};
|
|
223
|
+
|
|
224
|
+
// r.useExtension options-shape, co-located since the framework never imports upward.
|
|
225
|
+
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
226
|
+
interface KumikoExtensionOptionsMap {
|
|
227
|
+
[SUBSCRIPTION_PROVIDER_EXTENSION]: SubscriptionProviderPlugin;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { createEmailChannel, type EmailChannelOptions } from "./email-channel";
|
|
3
4
|
|
|
@@ -15,7 +16,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
|
|
|
15
16
|
});
|
|
16
17
|
r.requires("delivery");
|
|
17
18
|
|
|
18
|
-
r.useExtension(
|
|
19
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
|
|
19
20
|
mode: channel.mode,
|
|
20
21
|
resolve: channel.resolve,
|
|
21
22
|
render: channel.render,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { inboxQuery } from "./handlers/inbox.query";
|
|
3
4
|
import { markAllReadWrite } from "./handlers/mark-all-read.write";
|
|
@@ -23,7 +24,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
// Register as delivery channel via extension system
|
|
26
|
-
r.useExtension(
|
|
27
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
|
|
27
28
|
mode: inAppChannel.mode,
|
|
28
29
|
resolve: inAppChannel.resolve,
|
|
29
30
|
send: inAppChannel.send,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { createPushChannel, type PushChannelOptions } from "./push-channel";
|
|
3
4
|
|
|
@@ -15,7 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
|
|
|
15
16
|
});
|
|
16
17
|
r.requires("delivery");
|
|
17
18
|
|
|
18
|
-
r.useExtension(
|
|
19
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
|
|
19
20
|
mode: channel.mode,
|
|
20
21
|
resolve: channel.resolve,
|
|
21
22
|
send: channel.send,
|
|
@@ -19,8 +19,8 @@ import { randomBytes } from "node:crypto";
|
|
|
19
19
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
20
|
import {
|
|
21
21
|
createEventStoreExecutor,
|
|
22
|
+
createSystemDbView,
|
|
22
23
|
createTenantDb,
|
|
23
|
-
createUncheckedSystemDb,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
25
25
|
import {
|
|
26
26
|
access,
|
|
@@ -157,12 +157,23 @@ function racyJobCtx(
|
|
|
157
157
|
};
|
|
158
158
|
return {
|
|
159
159
|
db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
|
|
160
|
-
systemDb:
|
|
160
|
+
systemDb: createSystemDbView(
|
|
161
|
+
createTenantDb(stack.db, SYSTEM_TENANT_ID, "system", undefined, undefined, undefined, {
|
|
162
|
+
unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
161
165
|
registry: stack.registry,
|
|
162
166
|
masterKeyProvider: mutableProvider,
|
|
163
167
|
configEncryption: racyCipher,
|
|
164
168
|
log,
|
|
165
|
-
|
|
169
|
+
// Without a sink, the job's escape-hatch reporter warn-logs through `log` and its
|
|
170
|
+
// report is deduped by the framework's process-wide window (escape-hatch-report.ts),
|
|
171
|
+
// so whether the unconditional per-run scan ack reaches `log.warn` here depends on
|
|
172
|
+
// unrelated sibling tests' order in the same process. A sink (mirrors production,
|
|
173
|
+
// where the audit feature wires one) routes that report away from `log` entirely,
|
|
174
|
+
// isolating this test from that shared window.
|
|
175
|
+
_escapeHatchAuditSink: async () => {},
|
|
176
|
+
} as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log/_escapeHatchAuditSink
|
|
166
177
|
}
|
|
167
178
|
|
|
168
179
|
beforeAll(async () => {
|
|
@@ -12,8 +12,8 @@ import { randomBytes } from "node:crypto";
|
|
|
12
12
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import {
|
|
14
14
|
createEventStoreExecutor,
|
|
15
|
+
createSystemDbView,
|
|
15
16
|
createTenantDb,
|
|
16
|
-
createUncheckedSystemDb,
|
|
17
17
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
18
18
|
import {
|
|
19
19
|
access,
|
|
@@ -138,10 +138,18 @@ function capturingLog(captured: CapturedLog): TestJobLog {
|
|
|
138
138
|
|
|
139
139
|
function jobCtx(captured?: CapturedLog): Parameters<typeof reencryptJob>[1] {
|
|
140
140
|
const log = captured ? capturingLog(captured) : noopLog;
|
|
141
|
-
const systemModeDb = createTenantDb(
|
|
141
|
+
const systemModeDb = createTenantDb(
|
|
142
|
+
stack.db,
|
|
143
|
+
SYSTEM_TENANT_ID,
|
|
144
|
+
"system",
|
|
145
|
+
undefined,
|
|
146
|
+
undefined,
|
|
147
|
+
undefined,
|
|
148
|
+
{ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" } },
|
|
149
|
+
);
|
|
142
150
|
return {
|
|
143
151
|
db: systemModeDb,
|
|
144
|
-
systemDb:
|
|
152
|
+
systemDb: createSystemDbView(systemModeDb),
|
|
145
153
|
registry: stack.registry,
|
|
146
154
|
masterKeyProvider: mutableProvider,
|
|
147
155
|
configEncryption: cipher,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Regression guard for kumiko-framework#2072: the KEK-rotation job's
|
|
2
|
-
// systemScope() fail-closed checks —
|
|
2
|
+
// systemScope() fail-closed checks — createSystemDbView().
|
|
3
3
|
// acknowledgeCrossTenant() gating the initial cross-tenant scan,
|
|
4
4
|
// assertRowsTenant() re-verifying each per-tenant slice before it's served
|
|
5
5
|
// to the write loop — must not block legitimate multi-tenant rotation. A
|
|
@@ -14,8 +14,8 @@ import { randomBytes } from "node:crypto";
|
|
|
14
14
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
15
|
import {
|
|
16
16
|
createEventStoreExecutor,
|
|
17
|
+
createSystemDbView,
|
|
17
18
|
createTenantDb,
|
|
18
|
-
createUncheckedSystemDb,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
20
|
import {
|
|
21
21
|
access,
|
|
@@ -117,7 +117,11 @@ function capturingLog(captured: CapturedLog): TestJobLog {
|
|
|
117
117
|
function jobCtx(captured: CapturedLog): Parameters<typeof reencryptJob>[1] {
|
|
118
118
|
return {
|
|
119
119
|
db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
|
|
120
|
-
systemDb:
|
|
120
|
+
systemDb: createSystemDbView(
|
|
121
|
+
createTenantDb(stack.db, SYSTEM_TENANT_ID, "system", undefined, undefined, undefined, {
|
|
122
|
+
unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
121
125
|
registry: stack.registry,
|
|
122
126
|
masterKeyProvider: mutableProvider,
|
|
123
127
|
configEncryption: cipher,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
3
|
+
import { createSystemDbView } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import { requireSystemDb } from "../feature";
|
|
7
7
|
|
|
8
8
|
// Minimal TenantDb stub — only .tenantId is read by assertTenantMatch /
|
|
9
|
-
//
|
|
9
|
+
// createSystemDbView; the real db-query methods are never called by
|
|
10
10
|
// requireSystemDb itself.
|
|
11
11
|
function fakeTenantDb(tenantId: TenantId): TenantDb {
|
|
12
12
|
return { tenantId, mode: "system" } as unknown as TenantDb;
|
|
@@ -16,7 +16,7 @@ function fakeTenantDb(tenantId: TenantId): TenantDb {
|
|
|
16
16
|
function contextStub(db: TenantDb, withSystemDb: boolean): HandlerContext {
|
|
17
17
|
return {
|
|
18
18
|
db,
|
|
19
|
-
systemDb: withSystemDb ?
|
|
19
|
+
systemDb: withSystemDb ? createSystemDbView(db) : undefined,
|
|
20
20
|
// biome-ignore lint/suspicious/noExplicitAny: rest of HandlerContext isn't touched by requireSystemDb.
|
|
21
21
|
} as any;
|
|
22
22
|
}
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
// UncheckedSystemDb.acknowledgeCrossTenant(). Rows are bucketed by tenant,
|
|
22
22
|
// and assertRowsTenant() re-verifies each row against its bucket's tenant
|
|
23
23
|
// right before the write — both checks are built locally from the exported
|
|
24
|
-
//
|
|
24
|
+
// createSystemDbView().
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
27
|
createEventStoreExecutor,
|
|
28
|
+
createSystemDbView,
|
|
28
29
|
createTenantDb,
|
|
29
|
-
createUncheckedSystemDb,
|
|
30
30
|
type DbConnection,
|
|
31
31
|
type TenantDb,
|
|
32
32
|
type UncheckedSystemDb,
|
|
@@ -135,11 +135,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
135
135
|
function sdbFor(tenantId: TenantId): UncheckedSystemDb {
|
|
136
136
|
let existing = sdbCache.get(tenantId);
|
|
137
137
|
if (!existing) {
|
|
138
|
-
existing =
|
|
139
|
-
tdbFor(tenantId),
|
|
140
|
-
undefined,
|
|
141
|
-
reportEscapeHatchFor(tenantId),
|
|
142
|
-
);
|
|
138
|
+
existing = createSystemDbView(tdbFor(tenantId), undefined, reportEscapeHatchFor(tenantId));
|
|
143
139
|
sdbCache.set(tenantId, existing);
|
|
144
140
|
}
|
|
145
141
|
return existing;
|
|
@@ -149,7 +145,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
149
145
|
// to acknowledge the cross-tenant scan below, never bound to a real
|
|
150
146
|
// tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
|
|
151
147
|
// write-path cache entry double as the scan's ack gate.
|
|
152
|
-
const scanDb =
|
|
148
|
+
const scanDb = createSystemDbView(
|
|
153
149
|
createTenantDb(db, SYSTEM_TENANT_ID, "system"),
|
|
154
150
|
undefined,
|
|
155
151
|
reportEscapeHatchFor(SYSTEM_TENANT_ID),
|
|
@@ -1,32 +1,33 @@
|
|
|
1
|
-
//
|
|
2
|
-
// custom-field rows
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// unsafeReadRetrying instead — this test mirrors
|
|
6
|
-
// bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
|
|
7
|
-
// the retry now actually fires for each call site. applyRetentionRemovals
|
|
8
|
-
// (an UPDATE) is out of scope per #1358 — writes stay unretried.
|
|
1
|
+
// field-access.ts, quota.ts, user-data-rights.ts and retention.ts read
|
|
2
|
+
// custom-field rows through unsafeReadRetrying; this proves the retry fires
|
|
3
|
+
// per call site using a real captured driver error. applyRetentionRemovals
|
|
4
|
+
// (an UPDATE) stays out of scope — unretried by design.
|
|
9
5
|
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { captureClosedConnectionError } from "@cosmicdrift/kumiko-framework/testing";
|
|
11
8
|
import { selectSerializedFieldDefinition } from "../field-access";
|
|
12
9
|
import { countTenantFieldDefinitions } from "../quota";
|
|
13
10
|
import { selectHostRowsWithCustomFields } from "../retention";
|
|
14
11
|
import { selectCustomFieldsHostRows, selectFieldDefinitionsForEntity } from "../user-data-rights";
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
let closedConnectionError: unknown;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
closedConnectionError = await captureClosedConnectionError();
|
|
17
|
+
});
|
|
19
18
|
|
|
20
19
|
type FakeClient = {
|
|
21
20
|
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
22
21
|
begin: () => never;
|
|
22
|
+
options: { max: number };
|
|
23
23
|
calls: number;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
function fakeClient(failures:
|
|
26
|
+
function fakeClient(failures: unknown[], row: Record<string, unknown>): FakeClient {
|
|
27
27
|
const remaining = [...failures];
|
|
28
28
|
const client: FakeClient = {
|
|
29
29
|
calls: 0,
|
|
30
|
+
options: { max: 1 },
|
|
30
31
|
unsafe: async () => {
|
|
31
32
|
client.calls++;
|
|
32
33
|
const err = remaining.shift();
|
|
@@ -40,30 +41,30 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
|
|
|
40
41
|
return client;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
describe("custom-fields db/queries — closed-connection retry
|
|
44
|
+
describe("custom-fields db/queries — closed-connection retry", () => {
|
|
44
45
|
test("selectSerializedFieldDefinition retries once through db.unsafeRaw and returns the row", async () => {
|
|
45
|
-
const raw = fakeClient([closedConnectionError
|
|
46
|
+
const raw = fakeClient([closedConnectionError], { serialized_field: "sf1" });
|
|
46
47
|
const result = await selectSerializedFieldDefinition(raw, "t1", "entity", "field");
|
|
47
48
|
expect(result).toBe("sf1");
|
|
48
49
|
expect(raw.calls).toBe(2);
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
|
|
52
|
-
const raw = fakeClient([closedConnectionError
|
|
53
|
+
const raw = fakeClient([closedConnectionError], { n: 3 });
|
|
53
54
|
const result = await countTenantFieldDefinitions(raw, "t1");
|
|
54
55
|
expect(result).toBe(3);
|
|
55
56
|
expect(raw.calls).toBe(2);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
test("selectCustomFieldsHostRows retries once and returns rows", async () => {
|
|
59
|
-
const db = fakeClient([closedConnectionError
|
|
60
|
+
const db = fakeClient([closedConnectionError], { id: "1", custom_fields: {} });
|
|
60
61
|
const rows = await selectCustomFieldsHostRows(db as never, "host_table", "user_id", "u1", "t1");
|
|
61
62
|
expect(rows).toHaveLength(1);
|
|
62
63
|
expect(db.calls).toBe(2);
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
test("selectFieldDefinitionsForEntity retries once and returns rows", async () => {
|
|
66
|
-
const db = fakeClient([closedConnectionError
|
|
67
|
+
const db = fakeClient([closedConnectionError], { field_key: "k1", serialized_field: "sf" });
|
|
67
68
|
const rows = await selectFieldDefinitionsForEntity(db as never, "entity", "t1");
|
|
68
69
|
expect(rows).toHaveLength(1);
|
|
69
70
|
expect(rows[0]?.field_key).toBe("k1");
|
|
@@ -71,7 +72,7 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
test("selectHostRowsWithCustomFields retries once and returns rows", async () => {
|
|
74
|
-
const db = fakeClient([closedConnectionError
|
|
75
|
+
const db = fakeClient([closedConnectionError], {
|
|
75
76
|
id: "1",
|
|
76
77
|
modified_at: null,
|
|
77
78
|
custom_fields: {},
|
|
@@ -81,13 +82,13 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
|
81
82
|
expect(db.calls).toBe(2);
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
test("gives up after
|
|
85
|
-
const raw = fakeClient([closedConnectionError
|
|
85
|
+
test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
|
|
86
|
+
const raw = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError], {
|
|
86
87
|
serialized_field: "sf1",
|
|
87
88
|
});
|
|
88
|
-
await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.
|
|
89
|
-
|
|
89
|
+
await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toBe(
|
|
90
|
+
closedConnectionError,
|
|
90
91
|
);
|
|
91
|
-
expect(raw.calls).toBe(
|
|
92
|
+
expect(raw.calls).toBe(3);
|
|
92
93
|
});
|
|
93
94
|
});
|