@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.
Files changed (149) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  29. package/src/auth-email-password/web/auth-client.ts +33 -0
  30. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  31. package/src/auth-email-password/web/index.ts +6 -0
  32. package/src/auth-email-password/web/login-screen.tsx +15 -0
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  34. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  35. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  37. package/src/auth-foundation/boot-checks.ts +91 -0
  38. package/src/auth-foundation/feature.ts +103 -0
  39. package/src/auth-foundation/index.ts +23 -0
  40. package/src/auth-foundation/types.ts +107 -0
  41. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  42. package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
  43. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  44. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  45. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  46. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  47. package/src/channel-email/pii-guard.ts +6 -3
  48. package/src/config/__tests__/config.integration.test.ts +14 -0
  49. package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
  50. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  51. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  52. package/src/config/feature.ts +2 -4
  53. package/src/config/handlers/set.write.ts +7 -1
  54. package/src/config/resolver.ts +1 -1
  55. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  56. package/src/delivery/handlers/log.query.ts +1 -1
  57. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  58. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  59. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  60. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  61. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  62. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  63. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  64. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  65. package/src/inbound-provider-inmemory/feature.ts +2 -2
  66. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  67. package/src/jobs/handlers/detail.query.ts +1 -1
  68. package/src/jobs/handlers/list.query.ts +1 -1
  69. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  70. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  71. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  72. package/src/managed-pages/handlers/branding.query.ts +5 -1
  73. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  74. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  75. package/src/personal-access-tokens/feature.ts +22 -7
  76. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  77. package/src/personal-access-tokens/index.ts +0 -14
  78. package/src/personal-access-tokens/resolver.ts +2 -2
  79. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  80. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  81. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  82. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  83. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  84. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  85. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  86. package/src/sessions/__tests__/test-helpers.ts +15 -2
  87. package/src/sessions/feature.ts +31 -3
  88. package/src/sessions/handlers/detail.query.ts +2 -2
  89. package/src/sessions/handlers/list.query.ts +4 -2
  90. package/src/sessions/handlers/mine.query.ts +4 -2
  91. package/src/sessions/testing.ts +14 -1
  92. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  93. package/src/shared/decrypt-stored-pii.ts +12 -5
  94. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  95. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  96. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  97. package/src/tenant/handlers/invitations.query.ts +4 -2
  98. package/src/tenant/handlers/members.query.ts +2 -2
  99. package/src/tenant/seeding.ts +2 -2
  100. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  101. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  102. package/src/tenant-lifecycle/feature.ts +7 -0
  103. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  104. package/src/tier-engine/feature.ts +2 -2
  105. package/src/user-data-rights/COMPLIANCE.md +20 -0
  106. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  107. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  108. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  109. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  110. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  111. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  112. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  113. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  114. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  115. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  116. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  117. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  118. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  120. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  122. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  123. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  129. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
  131. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  135. package/src/user-data-rights/boot-checks.ts +86 -0
  136. package/src/user-data-rights/feature.ts +116 -6
  137. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  138. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  139. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  140. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  141. package/src/user-data-rights/run-export-jobs.ts +3 -3
  142. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  143. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  144. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  145. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  146. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  147. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  148. package/src/user-profile/handlers/change-email.write.ts +1 -1
  149. 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). An erased subject yields the
10
- // sentinel, which matches no real value.
11
- export async function decryptStoredPii(value: string, fallbackRequestId: string): Promise<string> {
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 }, ["value"], kms, {
21
+ const out = await decryptPiiFieldValues({ [field]: value }, [field], kms, {
15
22
  requestId: requestContext.get()?.requestId ?? fallbackRequestId,
16
23
  });
17
- const decrypted = out["value"];
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" ? await decryptStoredPii(email, "tenant:invitations") : email;
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
  }
@@ -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 { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
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 getAggregateStreamMaxVersion(db, options.id);
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);
@@ -67,7 +67,7 @@ export const requestDestructionWrite = defineWriteHandler({
67
67
  },
68
68
  });
69
69
 
70
- if (ctx.hasFeature("sessions")) {
70
+ if (await ctx.hasFeature("sessions")) {
71
71
  await revokeTenantSessions(ctx.db.raw, tenantId);
72
72
  }
73
73