@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.1
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 +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +22 -7
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runChunkedMigration } from "../chunked-entity-migration";
|
|
3
|
+
|
|
4
|
+
describe("runChunkedMigration", () => {
|
|
5
|
+
test("processes all batches and stops with 'done' when nextBatch returns empty", async () => {
|
|
6
|
+
const batches: readonly number[][] = [[1, 2], [3], []];
|
|
7
|
+
let call = 0;
|
|
8
|
+
const migrated: number[] = [];
|
|
9
|
+
|
|
10
|
+
const result = await runChunkedMigration<number>({
|
|
11
|
+
nextBatch: async () => batches[call++] ?? [],
|
|
12
|
+
migrateRow: async (row) => {
|
|
13
|
+
migrated.push(row);
|
|
14
|
+
return "migrated";
|
|
15
|
+
},
|
|
16
|
+
maxFailures: 10,
|
|
17
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(migrated).toEqual([1, 2, 3]);
|
|
21
|
+
expect(result).toEqual({
|
|
22
|
+
migrated: 3,
|
|
23
|
+
skipped: 0,
|
|
24
|
+
failed: 0,
|
|
25
|
+
batchesProcessed: 2,
|
|
26
|
+
stoppedReason: "done",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("counts skipped and failed outcomes separately from migrated", async () => {
|
|
31
|
+
let call = 0;
|
|
32
|
+
const rows = [
|
|
33
|
+
{ id: 1, outcome: "migrated" as const },
|
|
34
|
+
{ id: 2, outcome: "skipped" as const },
|
|
35
|
+
{ id: 3, outcome: "failed" as const },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const result = await runChunkedMigration({
|
|
39
|
+
nextBatch: async () => (call++ === 0 ? rows : []),
|
|
40
|
+
migrateRow: async (row) => row.outcome,
|
|
41
|
+
maxFailures: 10,
|
|
42
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.migrated).toBe(1);
|
|
46
|
+
expect(result.skipped).toBe(1);
|
|
47
|
+
expect(result.failed).toBe(1);
|
|
48
|
+
expect(result.stoppedReason).toBe("done");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a thrown migrateRow counts as failed and invokes onRowError", async () => {
|
|
52
|
+
let call = 0;
|
|
53
|
+
const errors: unknown[] = [];
|
|
54
|
+
const boom = new Error("boom");
|
|
55
|
+
|
|
56
|
+
const result = await runChunkedMigration<number>({
|
|
57
|
+
nextBatch: async () => (call++ === 0 ? [1] : []),
|
|
58
|
+
migrateRow: async () => {
|
|
59
|
+
throw boom;
|
|
60
|
+
},
|
|
61
|
+
maxFailures: 10,
|
|
62
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
63
|
+
onRowError: (row, err) => errors.push([row, err]),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.failed).toBe(1);
|
|
67
|
+
expect(errors).toEqual([[1, boom]]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("stops with 'too_many_failures' once failed >= maxFailures, mid-batch", async () => {
|
|
71
|
+
let call = 0;
|
|
72
|
+
const seen: number[] = [];
|
|
73
|
+
|
|
74
|
+
const result = await runChunkedMigration<number>({
|
|
75
|
+
nextBatch: async () => (call++ === 0 ? [1, 2, 3] : []),
|
|
76
|
+
migrateRow: async (row) => {
|
|
77
|
+
seen.push(row);
|
|
78
|
+
return "failed";
|
|
79
|
+
},
|
|
80
|
+
maxFailures: 2,
|
|
81
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The circuit-breaker checks before each row — row 3 is never attempted.
|
|
85
|
+
expect(seen).toEqual([1, 2]);
|
|
86
|
+
expect(result.failed).toBe(2);
|
|
87
|
+
expect(result.stoppedReason).toBe("too_many_failures");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("stops with 'too_many_failures' checked between batches too", async () => {
|
|
91
|
+
let call = 0;
|
|
92
|
+
const batches = [["a"], ["b"], ["c"]];
|
|
93
|
+
|
|
94
|
+
const result = await runChunkedMigration<string>({
|
|
95
|
+
nextBatch: async () => batches[call++] ?? [],
|
|
96
|
+
migrateRow: async () => "failed",
|
|
97
|
+
maxFailures: 1,
|
|
98
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Batch 1 processes "a" and pushes failed to 1; the breaker is only
|
|
102
|
+
// checked at the TOP of the loop, so batch 2 is fetched (batchesProcessed
|
|
103
|
+
// becomes 2) before the breaker trips and its row is never touched.
|
|
104
|
+
expect(result.batchesProcessed).toBe(2);
|
|
105
|
+
expect(result.stoppedReason).toBe("too_many_failures");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("stops with 'timeout' once the deadline has passed, without calling nextBatch again", async () => {
|
|
109
|
+
let nextBatchCalls = 0;
|
|
110
|
+
|
|
111
|
+
const result = await runChunkedMigration<number>({
|
|
112
|
+
nextBatch: async () => {
|
|
113
|
+
nextBatchCalls++;
|
|
114
|
+
return [];
|
|
115
|
+
},
|
|
116
|
+
migrateRow: async () => "migrated",
|
|
117
|
+
maxFailures: 10,
|
|
118
|
+
deadlineAt: Date.now() - 1,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(nextBatchCalls).toBe(0);
|
|
122
|
+
expect(result.batchesProcessed).toBe(0);
|
|
123
|
+
expect(result.stoppedReason).toBe("timeout");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("stops with 'signal' when the AbortSignal is already aborted", async () => {
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
controller.abort();
|
|
129
|
+
let nextBatchCalls = 0;
|
|
130
|
+
|
|
131
|
+
const result = await runChunkedMigration<number>({
|
|
132
|
+
nextBatch: async () => {
|
|
133
|
+
nextBatchCalls++;
|
|
134
|
+
return [];
|
|
135
|
+
},
|
|
136
|
+
migrateRow: async () => "migrated",
|
|
137
|
+
maxFailures: 10,
|
|
138
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(nextBatchCalls).toBe(0);
|
|
143
|
+
expect(result.stoppedReason).toBe("signal");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -6,14 +6,21 @@ import {
|
|
|
6
6
|
|
|
7
7
|
// Raw reads (fetchOne/selectMany) return the stored column value — with an
|
|
8
8
|
// active KMS that is ciphertext (self-describing: it names its own subject,
|
|
9
|
-
// so no entity/field context is needed
|
|
10
|
-
//
|
|
11
|
-
|
|
9
|
+
// so no entity/field context is needed for key selection). `field` must
|
|
10
|
+
// still match the field name used at encrypt time — it is part of the
|
|
11
|
+
// AAD (#1263), so a mismatch fails decrypt loud rather than silently
|
|
12
|
+
// letting ciphertext cut-and-pasted from another field decrypt. An erased
|
|
13
|
+
// subject yields the sentinel, which matches no real value.
|
|
14
|
+
export async function decryptStoredPii(
|
|
15
|
+
value: string,
|
|
16
|
+
field: string,
|
|
17
|
+
fallbackRequestId: string,
|
|
18
|
+
): Promise<string> {
|
|
12
19
|
const kms = configuredPiiSubjectKms();
|
|
13
20
|
if (!kms) return value;
|
|
14
|
-
const out = await decryptPiiFieldValues({ value }, [
|
|
21
|
+
const out = await decryptPiiFieldValues({ [field]: value }, [field], kms, {
|
|
15
22
|
requestId: requestContext.get()?.requestId ?? fallbackRequestId,
|
|
16
23
|
});
|
|
17
|
-
const decrypted = out[
|
|
24
|
+
const decrypted = out[field];
|
|
18
25
|
return typeof decrypted === "string" ? decrypted : value;
|
|
19
26
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// feature.ts contract tests for subscription-mollie.
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import type { MollieClient } from "@mollie/api-client";
|
|
6
|
+
import { describeSubscriptionProviderContract } from "../../billing-foundation/__tests__/subscription-provider-contract";
|
|
4
7
|
import { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "../constants";
|
|
5
8
|
import { createSubscriptionMollieFeature } from "../feature";
|
|
9
|
+
import { createMollieCheckoutSession } from "../plugin-methods";
|
|
10
|
+
import { type MollieClientShape, verifyAndParseMollieWebhook } from "../verify-webhook";
|
|
6
11
|
|
|
7
12
|
const VALID_OPTIONS = {
|
|
8
13
|
apiKey: "test_dummy_apikey",
|
|
@@ -98,9 +103,91 @@ describe("subscription-mollie — plugin-registration", () => {
|
|
|
98
103
|
createPortalSession?: unknown;
|
|
99
104
|
cancelSubscription?: unknown;
|
|
100
105
|
};
|
|
106
|
+
// Checks the mounted feature (the contract below builds its plugin
|
|
107
|
+
// by hand-wiring and would not catch a wiring gap here).
|
|
101
108
|
expect(typeof plugin?.verifyAndParseWebhook).toBe("function");
|
|
102
109
|
expect(typeof plugin?.createCheckoutSession).toBe("function");
|
|
103
110
|
expect(plugin?.createPortalSession).toBeUndefined();
|
|
104
111
|
expect(plugin?.cancelSubscription).toBeUndefined();
|
|
105
112
|
});
|
|
106
113
|
});
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// SubscriptionProviderPlugin contract
|
|
117
|
+
// =============================================================================
|
|
118
|
+
//
|
|
119
|
+
// Built directly from the plugin-method constructors (not from
|
|
120
|
+
// createSubscriptionMollieFeature's mounted plugin) — same reasoning as
|
|
121
|
+
// subscription-stripe's contract fixture: keeps SDK-call mocking local to
|
|
122
|
+
// the fixture instead of routing through the full feature-mount.
|
|
123
|
+
|
|
124
|
+
const CONTRACT_TENANT_ID = "tenant-contract";
|
|
125
|
+
const CONTRACT_PRICE_ID = "plan_pro";
|
|
126
|
+
|
|
127
|
+
function buildMollieContractFixture() {
|
|
128
|
+
const subscription = {
|
|
129
|
+
id: "sub_contract_001",
|
|
130
|
+
customerId: "cst_contract_001",
|
|
131
|
+
status: "active",
|
|
132
|
+
nextPaymentDate: "2026-06-15",
|
|
133
|
+
startDate: "2026-05-15",
|
|
134
|
+
metadata: { tenantId: CONTRACT_TENANT_ID, priceId: CONTRACT_PRICE_ID },
|
|
135
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
|
|
136
|
+
} as any;
|
|
137
|
+
const payment = {
|
|
138
|
+
id: "tr_contract_001",
|
|
139
|
+
customerId: "cst_contract_001",
|
|
140
|
+
subscriptionId: "sub_contract_001",
|
|
141
|
+
sequenceType: "first",
|
|
142
|
+
status: "paid",
|
|
143
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
|
|
144
|
+
} as any;
|
|
145
|
+
|
|
146
|
+
const webhookClient: MollieClientShape = {
|
|
147
|
+
payments: { get: async () => payment },
|
|
148
|
+
customerSubscriptions: {
|
|
149
|
+
get: async () => subscription,
|
|
150
|
+
list: async () => [],
|
|
151
|
+
create: async () => subscription,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const checkoutClient = {
|
|
156
|
+
customers: { create: async () => ({ id: "cus_contract" }) },
|
|
157
|
+
payments: {
|
|
158
|
+
create: async () => ({
|
|
159
|
+
getCheckoutUrl: () => "https://www.mollie.com/checkout/contract-test",
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
} as unknown as MollieClient;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
plugin: {
|
|
166
|
+
verifyAndParseWebhook: verifyAndParseMollieWebhook(webhookClient, {
|
|
167
|
+
priceToTier: VALID_OPTIONS.priceToTier,
|
|
168
|
+
priceToConfig: VALID_OPTIONS.priceToConfig,
|
|
169
|
+
}),
|
|
170
|
+
createCheckoutSession: createMollieCheckoutSession(
|
|
171
|
+
checkoutClient,
|
|
172
|
+
VALID_OPTIONS.priceToConfig,
|
|
173
|
+
VALID_OPTIONS.webhookUrl,
|
|
174
|
+
),
|
|
175
|
+
// createPortalSession + cancelSubscription deliberately omitted — Mollie limitation.
|
|
176
|
+
},
|
|
177
|
+
ctx: {} as HandlerContext,
|
|
178
|
+
checkout: {
|
|
179
|
+
priceId: CONTRACT_PRICE_ID,
|
|
180
|
+
tenantId: CONTRACT_TENANT_ID,
|
|
181
|
+
successUrl: "https://example.com/success",
|
|
182
|
+
cancelUrl: "https://example.com/cancel",
|
|
183
|
+
},
|
|
184
|
+
webhook: {
|
|
185
|
+
rawBody: JSON.stringify({ id: "tr_contract_001" }),
|
|
186
|
+
headers: { "content-type": "application/json" },
|
|
187
|
+
expectedTenantId: CONTRACT_TENANT_ID,
|
|
188
|
+
expectedTier: "pro",
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describeSubscriptionProviderContract("subscription-mollie", buildMollieContractFixture);
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
// feature.ts contract tests for subscription-stripe.
|
|
2
2
|
|
|
3
|
-
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
4
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import Stripe from "stripe";
|
|
6
|
+
import { describeSubscriptionProviderContract } from "../../billing-foundation/__tests__/subscription-provider-contract";
|
|
4
7
|
import { STRIPE_PROVIDER_NAME, StripeEventTypes, SUBSCRIPTION_STRIPE_FEATURE } from "../constants";
|
|
5
8
|
import { createSubscriptionStripeFeature } from "../feature";
|
|
9
|
+
import {
|
|
10
|
+
createStripeCancelSubscription,
|
|
11
|
+
createStripeCheckoutSession,
|
|
12
|
+
createStripePortalSession,
|
|
13
|
+
} from "../plugin-methods";
|
|
14
|
+
import type { StripeCtxRuntime } from "../runtime";
|
|
15
|
+
import { verifyAndParseStripeWebhook } from "../verify-webhook";
|
|
6
16
|
|
|
7
17
|
const OPTIONS = {
|
|
8
18
|
priceToTier: { price_test: "pro" },
|
|
@@ -69,6 +79,8 @@ describe("subscription-stripe — plugin-registration", () => {
|
|
|
69
79
|
// "method not supported"-error brechen — type-check würde es nicht
|
|
70
80
|
// fangen weil die useExtension-options als `unknown` durchgereicht
|
|
71
81
|
// werden.
|
|
82
|
+
// Checks the mounted feature — the contract below builds its plugin
|
|
83
|
+
// by hand-wiring, so it would not catch a wiring gap here.
|
|
72
84
|
const feature = createSubscriptionStripeFeature(OPTIONS);
|
|
73
85
|
const usage = feature.extensionUsages.find((u) => u.entityName === STRIPE_PROVIDER_NAME);
|
|
74
86
|
expect(usage).toBeDefined();
|
|
@@ -100,3 +112,110 @@ describe("constants — Stripe-event-types die wir mappen", () => {
|
|
|
100
112
|
]);
|
|
101
113
|
});
|
|
102
114
|
});
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// SubscriptionProviderPlugin contract
|
|
118
|
+
// =============================================================================
|
|
119
|
+
//
|
|
120
|
+
// Built directly from the plugin-method constructors (not from
|
|
121
|
+
// createSubscriptionStripeFeature's mounted plugin) — the mounted plugin
|
|
122
|
+
// resolves its Stripe client per-call from ctx.config/ctx.secrets, which
|
|
123
|
+
// can't be spied on from outside. plugin-methods.test.ts already exercises
|
|
124
|
+
// this same runtime-injection pattern for the individual methods.
|
|
125
|
+
|
|
126
|
+
const CONTRACT_API_KEY = "sk_test_contract_dummy";
|
|
127
|
+
const CONTRACT_WEBHOOK_SECRET = "whsec_contract_test_secret";
|
|
128
|
+
|
|
129
|
+
function contractCtxRuntime(stripe: Stripe): StripeCtxRuntime {
|
|
130
|
+
return { clientForCtx: async () => stripe, assertBillingLive: async () => {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function buildStripeContractFixture() {
|
|
134
|
+
const stripe = new Stripe(CONTRACT_API_KEY);
|
|
135
|
+
spyOn(stripe.checkout.sessions, "create").mockResolvedValue({
|
|
136
|
+
url: "https://checkout.stripe.com/c/pay/contract-test",
|
|
137
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
138
|
+
} as any);
|
|
139
|
+
spyOn(stripe.billingPortal.sessions, "create").mockResolvedValue({
|
|
140
|
+
url: "https://billing.stripe.com/p/session/contract-test",
|
|
141
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
142
|
+
} as any);
|
|
143
|
+
spyOn(stripe.subscriptions, "cancel").mockResolvedValue({
|
|
144
|
+
id: "sub_contract",
|
|
145
|
+
status: "canceled",
|
|
146
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
147
|
+
} as any);
|
|
148
|
+
const runtime = contractCtxRuntime(stripe);
|
|
149
|
+
|
|
150
|
+
const stripeForWebhookFixture = new Stripe(CONTRACT_API_KEY);
|
|
151
|
+
const webhookEvent = {
|
|
152
|
+
id: "evt_contract_001",
|
|
153
|
+
object: "event",
|
|
154
|
+
api_version: "2026-04-22.dahlia",
|
|
155
|
+
created: 1_770_000_000,
|
|
156
|
+
type: StripeEventTypes.customerSubscriptionCreated,
|
|
157
|
+
livemode: false,
|
|
158
|
+
pending_webhooks: 1,
|
|
159
|
+
request: { id: null, idempotency_key: null },
|
|
160
|
+
data: {
|
|
161
|
+
object: {
|
|
162
|
+
id: "sub_contract_001",
|
|
163
|
+
object: "subscription",
|
|
164
|
+
customer: "cus_contract_001",
|
|
165
|
+
status: "active",
|
|
166
|
+
metadata: { tenantId: "tenant-contract" },
|
|
167
|
+
items: {
|
|
168
|
+
object: "list",
|
|
169
|
+
data: [
|
|
170
|
+
{
|
|
171
|
+
id: "si_contract",
|
|
172
|
+
object: "subscription_item",
|
|
173
|
+
current_period_end: 1_780_000_000,
|
|
174
|
+
price: { id: "price_test", object: "price" },
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
has_more: false,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
const rawBody = JSON.stringify(webhookEvent);
|
|
183
|
+
const signature = await stripeForWebhookFixture.webhooks.generateTestHeaderStringAsync({
|
|
184
|
+
payload: rawBody,
|
|
185
|
+
secret: CONTRACT_WEBHOOK_SECRET,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
plugin: {
|
|
190
|
+
verifyAndParseWebhook: verifyAndParseStripeWebhook(
|
|
191
|
+
{
|
|
192
|
+
resolve: async () => ({
|
|
193
|
+
stripe: stripeForWebhookFixture,
|
|
194
|
+
webhookSecret: CONTRACT_WEBHOOK_SECRET,
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
{ priceToTier: OPTIONS.priceToTier },
|
|
198
|
+
),
|
|
199
|
+
createCheckoutSession: createStripeCheckoutSession(runtime),
|
|
200
|
+
createPortalSession: createStripePortalSession(runtime),
|
|
201
|
+
cancelSubscription: createStripeCancelSubscription(runtime),
|
|
202
|
+
},
|
|
203
|
+
ctx: {} as HandlerContext,
|
|
204
|
+
checkout: {
|
|
205
|
+
priceId: "price_test",
|
|
206
|
+
tenantId: "tenant-contract",
|
|
207
|
+
successUrl: "https://example.com/success",
|
|
208
|
+
cancelUrl: "https://example.com/cancel",
|
|
209
|
+
},
|
|
210
|
+
portal: { providerCustomerId: "cus_contract", returnUrl: "https://example.com/return" },
|
|
211
|
+
cancelSubscriptionId: "sub_contract",
|
|
212
|
+
webhook: {
|
|
213
|
+
rawBody,
|
|
214
|
+
headers: { "stripe-signature": signature },
|
|
215
|
+
expectedTenantId: "tenant-contract",
|
|
216
|
+
expectedTier: "pro",
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
describeSubscriptionProviderContract("subscription-stripe", buildStripeContractFixture);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// members.query.ts's decrypt-on-read fix (#1252): before the fix, an
|
|
2
|
+
// active PII-subject KMS meant the query returned raw ciphertext for
|
|
3
|
+
// email/displayName instead of decrypting them like every other PII read
|
|
4
|
+
// path. This pins the decrypt-on-read roundtrip with a real KMS adapter
|
|
5
|
+
// configured, so a later refactor can't silently re-introduce the
|
|
6
|
+
// ciphertext leak.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configurePiiSubjectKms,
|
|
12
|
+
InMemoryKmsAdapter,
|
|
13
|
+
isPiiCiphertext,
|
|
14
|
+
resetPiiSubjectKmsForTests,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
testTenantId,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
unsafePushTables,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
25
|
+
import { createConfigFeature } from "../../config";
|
|
26
|
+
import { configValuesTable } from "../../config/table";
|
|
27
|
+
import { createUserFeature } from "../../user/feature";
|
|
28
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
29
|
+
import { seedUser } from "../../user/seeding";
|
|
30
|
+
import { TenantQueries } from "../constants";
|
|
31
|
+
import { createTenantFeature } from "../feature";
|
|
32
|
+
import { tenantMembershipsTable } from "../membership-table";
|
|
33
|
+
import { tenantEntity } from "../schema/tenant";
|
|
34
|
+
import { seedTenant, seedTenantMembership } from "../seeding";
|
|
35
|
+
|
|
36
|
+
const tenantId = testTenantId(1) as TenantId;
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
function tenantAdmin(userId: string): SessionUser {
|
|
41
|
+
return { id: userId, tenantId, roles: ["TenantAdmin"] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
stack = await setupTestStack({
|
|
46
|
+
features: [createConfigFeature(), createUserFeature(), createTenantFeature()],
|
|
47
|
+
});
|
|
48
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
49
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
50
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
await resetTestTables(stack.db, [userTable, tenantMembershipsTable]);
|
|
55
|
+
await seedTenant(stack.db, { id: tenantId, key: "tenant-kms-members", name: "KMS Tenant" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
resetPiiSubjectKmsForTests();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
await stack.cleanup();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("members query — decrypt-on-read with an active KMS (#1252)", () => {
|
|
67
|
+
test("email and displayName come back as plaintext, not ciphertext", async () => {
|
|
68
|
+
// KMS active BEFORE the seed — seedUser runs through the executor, so
|
|
69
|
+
// the row reflects the encrypted prod state.
|
|
70
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
71
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
72
|
+
email: "kms-member@example.com",
|
|
73
|
+
displayName: "KMS Member",
|
|
74
|
+
emailVerified: true,
|
|
75
|
+
});
|
|
76
|
+
await seedTenantMembership(stack.db, { userId, tenantId, roles: ["User"] });
|
|
77
|
+
|
|
78
|
+
// Prove the row is actually ciphertext at rest — otherwise a no-op
|
|
79
|
+
// encryption path would make this test pass while proving nothing.
|
|
80
|
+
const rawRows = await selectMany<{ email: unknown; displayName: unknown }>(
|
|
81
|
+
stack.db,
|
|
82
|
+
userTable,
|
|
83
|
+
{
|
|
84
|
+
id: userId,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
expect(isPiiCiphertext(rawRows[0]?.email)).toBe(true);
|
|
88
|
+
expect(isPiiCiphertext(rawRows[0]?.displayName)).toBe(true);
|
|
89
|
+
|
|
90
|
+
const members = await stack.http.queryOk<
|
|
91
|
+
readonly { userId: string; email: string | null; displayName: string | null }[]
|
|
92
|
+
>(TenantQueries.members, {}, tenantAdmin(userId));
|
|
93
|
+
|
|
94
|
+
const self = members.find((m) => m.userId === userId);
|
|
95
|
+
expect(self?.email).toBe("kms-member@example.com");
|
|
96
|
+
expect(self?.displayName).toBe("KMS Member");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -31,10 +31,12 @@ export const invitationsQuery = defineQueryHandler({
|
|
|
31
31
|
const email = row["email"];
|
|
32
32
|
const invitedBy = row["invitedBy"];
|
|
33
33
|
const decryptedEmail =
|
|
34
|
-
typeof email === "string"
|
|
34
|
+
typeof email === "string"
|
|
35
|
+
? await decryptStoredPii(email, "email", "tenant:invitations")
|
|
36
|
+
: email;
|
|
35
37
|
const decryptedInvitedBy =
|
|
36
38
|
typeof invitedBy === "string"
|
|
37
|
-
? await decryptStoredPii(invitedBy, "tenant:invitations")
|
|
39
|
+
? await decryptStoredPii(invitedBy, "invitedBy", "tenant:invitations")
|
|
38
40
|
: invitedBy;
|
|
39
41
|
out.push({ ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy });
|
|
40
42
|
}
|
|
@@ -33,11 +33,11 @@ export const membersQuery = defineQueryHandler({
|
|
|
33
33
|
for (const user of users) {
|
|
34
34
|
const email =
|
|
35
35
|
typeof user.email === "string"
|
|
36
|
-
? await decryptStoredPii(user.email, "tenant:members")
|
|
36
|
+
? await decryptStoredPii(user.email, "email", "tenant:members")
|
|
37
37
|
: null;
|
|
38
38
|
const displayName =
|
|
39
39
|
typeof user.displayName === "string"
|
|
40
|
-
? await decryptStoredPii(user.displayName, "tenant:members")
|
|
40
|
+
? await decryptStoredPii(user.displayName, "displayName", "tenant:members")
|
|
41
41
|
: null;
|
|
42
42
|
decryptedByUserId.set(String(user.id), { email, displayName });
|
|
43
43
|
}
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
type DbRunner,
|
|
36
36
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
37
37
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
-
import {
|
|
38
|
+
import { getUnscopedAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
39
39
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
40
40
|
import { assertAssignableMembershipRoles } from "./membership-roles";
|
|
41
41
|
import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
|
|
@@ -99,7 +99,7 @@ export async function seedTenant(
|
|
|
99
99
|
// (Projection-Drift nach rebuild, manuellem DELETE, oder async-lag). Wenn
|
|
100
100
|
// Stream-Version > 0 → kein create() — wäre version_conflict. Caller
|
|
101
101
|
// bekommt die ID, Projection wird beim nächsten Dispatcher-Cycle aufgebaut.
|
|
102
|
-
const streamVersion = await
|
|
102
|
+
const streamVersion = await getUnscopedAggregateStreamMaxVersion(db, options.id);
|
|
103
103
|
if (streamVersion > 0) return { id: options.id };
|
|
104
104
|
|
|
105
105
|
const result = await tenantExecutor.create(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
3
4
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
5
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
6
|
import {
|
|
@@ -76,6 +77,7 @@ beforeAll(async () => {
|
|
|
76
77
|
createUserFeature(),
|
|
77
78
|
createTenantFeature(),
|
|
78
79
|
createComplianceProfilesFeature(),
|
|
80
|
+
authFoundationFeature,
|
|
79
81
|
createSessionsFeature(),
|
|
80
82
|
createTenantLifecycleFeature(),
|
|
81
83
|
],
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EntityDefinition,
|
|
3
|
+
EXT_TENANT_DATA,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
type PiiAnnotations,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// r.entity(...) is not the only way a feature exposes an entity shape:
|
|
9
|
+
// r.projection(...) can carry an optional `entity` too (raw read-models with
|
|
10
|
+
// no executor, e.g. billing-foundation's subscription table). Need every
|
|
11
|
+
// entity a feature declares, not just the r.entity ones, or a tenantOwned
|
|
12
|
+
// field on a projection-only entity is invisible to the guard below.
|
|
13
|
+
function entitiesOf(
|
|
14
|
+
feature: FeatureDefinition,
|
|
15
|
+
): ReadonlyArray<readonly [string, EntityDefinition]> {
|
|
16
|
+
const fromEntities = Object.entries(feature.entities ?? {});
|
|
17
|
+
const fromProjections = Object.values(feature.projections ?? {})
|
|
18
|
+
.filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
|
|
19
|
+
.map((p) => [p.name, p.entity] as const);
|
|
20
|
+
return [...fromEntities, ...fromProjections];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// V4: tenantOwned-entity-without-hook gate. Mirrors user-data-rights' V3
|
|
24
|
+
// (validateGdprPiiHookCoverage) but for EXT_TENANT_DATA. Registered as this
|
|
25
|
+
// feature's own `r.bootCheck()` (#1314, moved off the framework-internal
|
|
26
|
+
// boot-validator) — tenant-lifecycle owns EXT_TENANT_DATA
|
|
27
|
+
// (r.extendsRegistrar), so its own mount is the trigger, matching the
|
|
28
|
+
// original guard's "tenant-lifecycle mounted" gate exactly (unlike gating on
|
|
29
|
+
// a sibling feature, which would silently narrow coverage).
|
|
30
|
+
export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
|
|
31
|
+
const hookedEntities = new Set<string>();
|
|
32
|
+
for (const f of features) {
|
|
33
|
+
for (const usage of f.extensionUsages) {
|
|
34
|
+
if (usage.extensionName === EXT_TENANT_DATA) hookedEntities.add(usage.entityName);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const feature of features) {
|
|
39
|
+
for (const [entityName, entity] of entitiesOf(feature)) {
|
|
40
|
+
if (hookedEntities.has(entityName)) continue;
|
|
41
|
+
const tenantSubjectFields = Object.entries(entity.fields)
|
|
42
|
+
.filter(([, field]) => {
|
|
43
|
+
const annot = field as PiiAnnotations;
|
|
44
|
+
return Boolean(annot.tenantOwned);
|
|
45
|
+
})
|
|
46
|
+
.map(([name]) => name);
|
|
47
|
+
if (tenantSubjectFields.length === 0) continue;
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has tenant-subject fields (${tenantSubjectFields.join(", ")}) but no feature registers an EXT_TENANT_DATA destroy hook for it — tenant destroy never erases this data. Register r.useExtension(EXT_TENANT_DATA, "${entityName}", { destroy }) or a documented no-op if crypto-shredding covers it.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
EXT_TENANT_DATA,
|
|
8
8
|
type FeatureDefinition,
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { validateTenantDataHookCoverage } from "./boot-checks";
|
|
10
11
|
import {
|
|
11
12
|
DESTRUCTION_CANCELLED_EVENT_SHORT,
|
|
12
13
|
DESTRUCTION_REQUESTED_EVENT_SHORT,
|
|
@@ -53,6 +54,12 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
53
54
|
r.extendsRegistrar(EXT_STORAGE_PROVIDER, {});
|
|
54
55
|
r.extendsRegistrar(EXT_INFRA_RESOURCE, {});
|
|
55
56
|
|
|
57
|
+
// GDPR-storage guard V4 (#1314) — moved off the framework-internal
|
|
58
|
+
// boot-validator onto this feature's own r.bootCheck(), since it owns
|
|
59
|
+
// EXT_TENANT_DATA: its own mount is the trigger, matching the original
|
|
60
|
+
// guard's "tenant-lifecycle mounted" gate exactly.
|
|
61
|
+
r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
|
|
62
|
+
|
|
56
63
|
r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema);
|
|
57
64
|
r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema);
|
|
58
65
|
r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema);
|