@cosmicdrift/kumiko-bundled-features 0.248.0 → 0.250.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/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +69 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +3 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +37 -0
- package/src/billing-foundation/feature.ts +14 -5
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +5 -1
- package/src/billing-foundation/types.ts +5 -0
- package/src/cap-counter/feature.ts +1 -1
- package/src/cap-overview/__tests__/cap-overview-security.integration.test.ts +49 -0
- package/src/cap-overview/__tests__/cap-overview.boot.test.ts +26 -1
- package/src/cap-overview/access.ts +9 -0
- package/src/cap-overview/feature.ts +3 -3
- package/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/src/cap-overview/index.ts +1 -0
- package/src/cap-overview/screens.ts +3 -2
- package/src/crypto-shredding/feature.ts +4 -2
- package/src/custom-fields/feature.ts +9 -2
- package/src/document-ingest-foundation/feature.ts +8 -2
- package/src/feature-toggles/feature.ts +1 -1
- package/src/inbound-mail-foundation/feature.ts +19 -5
- package/src/secrets/feature.ts +1 -1
- package/src/sessions/feature.ts +1 -1
- package/src/subscription-mollie/__tests__/plugin-methods.test.ts +86 -0
- package/src/subscription-mollie/plugin-methods.ts +9 -1
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +49 -0
- package/src/subscription-stripe/plugin-methods.ts +11 -9
- package/src/tenant-lifecycle/feature.ts +23 -6
- package/src/user/__tests__/email-unique-plaintext-soft-delete.integration.test.ts +139 -0
- package/src/user/seeding.ts +1 -1
- package/src/user-data-rights/__tests__/boot-checks.test.ts +4 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +111 -1
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
- package/src/user-data-rights/run-forget-cleanup.ts +14 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.250.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.250.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.250.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.250.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.250.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.250.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.250.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.250.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.250.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts
CHANGED
|
@@ -353,3 +353,72 @@ describe("invite-accept-with-login: account-status gate", () => {
|
|
|
353
353
|
expect(memberships).toHaveLength(0);
|
|
354
354
|
});
|
|
355
355
|
});
|
|
356
|
+
|
|
357
|
+
describe("invite-accept-with-login: soft-deleted email predecessor (fw#2593)", () => {
|
|
358
|
+
// read_users_email_unique is now a partial index (WHERE is_deleted = false),
|
|
359
|
+
// so a soft-deleted row and a live row can share an email. The password
|
|
360
|
+
// lookup must resolve the live row, never the soft-deleted one.
|
|
361
|
+
test("logs in as the live user, not the soft-deleted predecessor with the same email", async () => {
|
|
362
|
+
const email = "reused-invitee@example.com";
|
|
363
|
+
const deletedUserId = crypto.randomUUID();
|
|
364
|
+
const liveUserId = crypto.randomUUID();
|
|
365
|
+
const deletedPassword = "deleted-predecessor-pw-1234";
|
|
366
|
+
const livePassword = "live-successor-pw-5678";
|
|
367
|
+
|
|
368
|
+
await seedRow(stack.db, userTable, {
|
|
369
|
+
id: deletedUserId,
|
|
370
|
+
tenantId: TENANT_A_ID,
|
|
371
|
+
email,
|
|
372
|
+
passwordHash: await hashPassword(deletedPassword),
|
|
373
|
+
displayName: "Deleted Predecessor",
|
|
374
|
+
locale: "de",
|
|
375
|
+
emailVerified: true,
|
|
376
|
+
roles: "[]",
|
|
377
|
+
status: USER_STATUS.Active,
|
|
378
|
+
});
|
|
379
|
+
await asRawClient(stack.db).unsafe(
|
|
380
|
+
`UPDATE "${userTable.tableName}" SET is_deleted = true WHERE id = $1`,
|
|
381
|
+
[deletedUserId],
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
await seedRow(stack.db, userTable, {
|
|
385
|
+
id: liveUserId,
|
|
386
|
+
tenantId: TENANT_A_ID,
|
|
387
|
+
email,
|
|
388
|
+
passwordHash: await hashPassword(livePassword),
|
|
389
|
+
displayName: "Live Successor",
|
|
390
|
+
locale: "de",
|
|
391
|
+
emailVerified: true,
|
|
392
|
+
roles: "[]",
|
|
393
|
+
status: USER_STATUS.Active,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const token = await inviteEmail(email, "Editor");
|
|
397
|
+
|
|
398
|
+
// The deleted predecessor's password must never authenticate this
|
|
399
|
+
// invite, even though it shares the email — proves the lookup didn't
|
|
400
|
+
// just happen to land on the live row by insertion order. A failed
|
|
401
|
+
// password check unburns the token (see the handler's `finally`), so
|
|
402
|
+
// the same token is still usable for the real attempt below.
|
|
403
|
+
const deletedAttempt = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
404
|
+
token,
|
|
405
|
+
email,
|
|
406
|
+
password: deletedPassword,
|
|
407
|
+
});
|
|
408
|
+
expect(deletedAttempt.status).not.toBe(200);
|
|
409
|
+
|
|
410
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
411
|
+
token,
|
|
412
|
+
email,
|
|
413
|
+
password: livePassword,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
expect(res.status).toBe(200);
|
|
417
|
+
const body = (await res.json()) as {
|
|
418
|
+
isSuccess: boolean;
|
|
419
|
+
user?: { id: string };
|
|
420
|
+
};
|
|
421
|
+
expect(body.isSuccess).toBe(true);
|
|
422
|
+
expect(body.user?.id).toBe(liveUserId);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
@@ -171,8 +171,11 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
171
171
|
|
|
172
172
|
// Password check against userTable, through the same gates
|
|
173
173
|
// login.write.ts runs (see login-gates.test.ts for the gate contracts).
|
|
174
|
+
// Email uniqueness is partial on live rows (framework#2593) — without
|
|
175
|
+
// this filter the lookup can resolve a soft-deleted row.
|
|
174
176
|
const userRow = await fetchOne<UserAuthRow>(ctx.db.raw, userTable, {
|
|
175
177
|
email: invitationEmail,
|
|
178
|
+
isDeleted: false,
|
|
176
179
|
});
|
|
177
180
|
if (!userRow?.passwordHash) return invalidInviteToken();
|
|
178
181
|
|
|
@@ -129,8 +129,11 @@ export function createInviteSignupCompleteHandler() {
|
|
|
129
129
|
// muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
|
|
130
130
|
// explizit "neue Email" — sonst hätten wir zwei Wege ein
|
|
131
131
|
// Password zu setzen für denselben User.
|
|
132
|
+
// Email uniqueness is partial on live rows (framework#2593) — without
|
|
133
|
+
// this filter the lookup can resolve a soft-deleted row.
|
|
132
134
|
const existingUser = await fetchOne(ctx.db.raw, userTable, {
|
|
133
135
|
email: invitationEmail,
|
|
136
|
+
isDeleted: false,
|
|
134
137
|
});
|
|
135
138
|
if (existingUser) return invalidInviteToken();
|
|
136
139
|
|
|
@@ -68,6 +68,7 @@ const mockCheckoutCalls: Array<{
|
|
|
68
68
|
successUrl: string;
|
|
69
69
|
cancelUrl: string;
|
|
70
70
|
providerCustomerId?: string;
|
|
71
|
+
mode?: "subscription" | "payment";
|
|
71
72
|
}> = [];
|
|
72
73
|
const mockPortalCalls: Array<{ providerCustomerId: string; returnUrl: string }> = [];
|
|
73
74
|
|
|
@@ -82,6 +83,7 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
|
|
|
82
83
|
successUrl: options.successUrl,
|
|
83
84
|
cancelUrl: options.cancelUrl,
|
|
84
85
|
...(options.providerCustomerId && { providerCustomerId: options.providerCustomerId }),
|
|
86
|
+
...(options.mode && { mode: options.mode }),
|
|
85
87
|
});
|
|
86
88
|
return { url: `https://mock.example/checkout/${options.priceId}` };
|
|
87
89
|
},
|
|
@@ -513,6 +515,41 @@ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
|
|
|
513
515
|
);
|
|
514
516
|
expect(mockCheckoutCalls[0]?.providerCustomerId).toBe("cus_existing_xyz");
|
|
515
517
|
});
|
|
518
|
+
|
|
519
|
+
test("optional mode wird durchgereicht (One-off-Payment-Flow, fw#2755)", async () => {
|
|
520
|
+
mockCheckoutCalls.length = 0;
|
|
521
|
+
const admin = adminFor(3014);
|
|
522
|
+
await stack.http.writeOk(
|
|
523
|
+
"billing-foundation:write:create-checkout-session",
|
|
524
|
+
{
|
|
525
|
+
providerName: "mock",
|
|
526
|
+
priceId: "price_topup_test",
|
|
527
|
+
successUrl: "https://example.com/s",
|
|
528
|
+
cancelUrl: "https://example.com/c",
|
|
529
|
+
mode: "payment",
|
|
530
|
+
},
|
|
531
|
+
admin,
|
|
532
|
+
);
|
|
533
|
+
expect(mockCheckoutCalls).toHaveLength(1);
|
|
534
|
+
expect(mockCheckoutCalls[0]?.mode).toBe("payment");
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("mode omitted → plugin receives no mode field (Regression-Pin für bestehende Aufrufer)", async () => {
|
|
538
|
+
mockCheckoutCalls.length = 0;
|
|
539
|
+
const admin = adminFor(3015);
|
|
540
|
+
await stack.http.writeOk(
|
|
541
|
+
"billing-foundation:write:create-checkout-session",
|
|
542
|
+
{
|
|
543
|
+
providerName: "mock",
|
|
544
|
+
priceId: "price_pro_test",
|
|
545
|
+
successUrl: "https://example.com/s",
|
|
546
|
+
cancelUrl: "https://example.com/c",
|
|
547
|
+
},
|
|
548
|
+
admin,
|
|
549
|
+
);
|
|
550
|
+
expect(mockCheckoutCalls).toHaveLength(1);
|
|
551
|
+
expect(mockCheckoutCalls[0]?.mode).toBeUndefined();
|
|
552
|
+
});
|
|
516
553
|
});
|
|
517
554
|
|
|
518
555
|
describe("scenario 7: create-portal-session — Plugin-routing", () => {
|
|
@@ -86,11 +86,20 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
|
|
|
86
86
|
// shape (= subscription-state-snapshot); der event-type taggt was
|
|
87
87
|
// passiert ist. Future-consumer (billing-history, accounting)
|
|
88
88
|
// listenen direkt auf den event-type ohne payload-discriminator.
|
|
89
|
-
|
|
90
|
-
r.defineEvent(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
r.defineEvent(
|
|
89
|
+
// piiFields: "none" — provider ids are tenantOwned ciphertext, not plaintext personal data.
|
|
90
|
+
r.defineEvent(SUBSCRIPTION_CREATED_EVENT_SHORT, subscriptionEventPayloadSchema, {
|
|
91
|
+
piiFields: "none",
|
|
92
|
+
});
|
|
93
|
+
r.defineEvent(SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema, {
|
|
94
|
+
piiFields: "none",
|
|
95
|
+
});
|
|
96
|
+
r.defineEvent(SUBSCRIPTION_CANCELED_EVENT_SHORT, subscriptionEventPayloadSchema, {
|
|
97
|
+
piiFields: "none",
|
|
98
|
+
});
|
|
99
|
+
r.defineEvent(INVOICE_PAID_EVENT_SHORT, subscriptionEventPayloadSchema, { piiFields: "none" });
|
|
100
|
+
r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema, {
|
|
101
|
+
piiFields: "none",
|
|
102
|
+
});
|
|
94
103
|
|
|
95
104
|
// Inline projection: materialized current state in `read_subscriptions`.
|
|
96
105
|
// Apply läuft in derselben TX wie ctx.unsafeAppendEvent — read-your-
|
|
@@ -32,13 +32,16 @@ const createCheckoutSessionSchema = z.object({
|
|
|
32
32
|
/** Optional: existierender provider-customer wenn der Tenant schon
|
|
33
33
|
* einen account beim Provider hat (= Plan-Wechsel statt Neuregistrierung). */
|
|
34
34
|
providerCustomerId: z.string().max(200).optional(),
|
|
35
|
+
/** Optional: `"payment"` for a one-off checkout (e.g. credit top-up)
|
|
36
|
+
* instead of a recurring subscription. Defaults to `"subscription"`. */
|
|
37
|
+
mode: z.enum(["subscription", "payment"]).optional(),
|
|
35
38
|
});
|
|
36
39
|
type CreateCheckoutSessionPayload = z.infer<typeof createCheckoutSessionSchema>;
|
|
37
40
|
|
|
38
41
|
export const createCheckoutSessionHandler: WriteHandlerDef = {
|
|
39
42
|
name: "create-checkout-session",
|
|
40
43
|
description:
|
|
41
|
-
|
|
44
|
+
'Opens a hosted checkout page at the named subscription provider for the caller\'s tenant and returns its URL; use it when a tenant admin wants to subscribe, switch plans, or make a one-off payment (mode: "payment").',
|
|
42
45
|
schema: createCheckoutSessionSchema,
|
|
43
46
|
// Tenant-Admin-only — der Tenant muss bewusst seine Subscription
|
|
44
47
|
// konfigurieren. SystemAdmin als Fallback für Operator-Initiated-Flows.
|
|
@@ -71,6 +74,7 @@ export const createCheckoutSessionHandler: WriteHandlerDef = {
|
|
|
71
74
|
successUrl: payload.successUrl,
|
|
72
75
|
cancelUrl: payload.cancelUrl,
|
|
73
76
|
...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
|
|
77
|
+
...(payload.mode && { mode: payload.mode }),
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
return {
|
|
@@ -129,6 +129,11 @@ export type SubscriptionProviderPlugin = {
|
|
|
129
129
|
* schon einen Account beim Provider hat. Sonst legt der Provider
|
|
130
130
|
* beim checkout einen neuen customer an. */
|
|
131
131
|
readonly providerCustomerId?: string;
|
|
132
|
+
/** Checkout mode. `"subscription"` (default) starts a recurring
|
|
133
|
+
* billing flow; `"payment"` a one-off checkout (e.g. pay-per-use
|
|
134
|
+
* credit top-ups). Optional with default `"subscription"` — existing
|
|
135
|
+
* callers are unaffected. */
|
|
136
|
+
readonly mode?: "subscription" | "payment";
|
|
132
137
|
},
|
|
133
138
|
) => Promise<{ readonly url: string }>;
|
|
134
139
|
|
|
@@ -84,7 +84,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
84
84
|
// dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
|
|
85
85
|
// "cap-counter:event:rolling-incremented" (siehe
|
|
86
86
|
// ROLLING_INCREMENTED_EVENT_QN).
|
|
87
|
-
r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema);
|
|
87
|
+
r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema, { piiFields: "none" });
|
|
88
88
|
|
|
89
89
|
// Custom write-handlers.
|
|
90
90
|
// - increment: Calendar-Period (CRUD via projection-row).
|
|
@@ -158,6 +158,55 @@ describe("cap-overview tenant isolation", () => {
|
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
describe("cap-overview my-caps for regular tenant members", () => {
|
|
162
|
+
test("a regular User(A) reads A's own usage, not a 403", async () => {
|
|
163
|
+
const memberA = createTestUser({ id: 90041, tenantId: TENANT_A, roles: ["User"] });
|
|
164
|
+
const result = await stack.http.queryOk<{
|
|
165
|
+
rows: readonly { id: string; used: number | null }[];
|
|
166
|
+
}>(CapOverviewQueries.capsUsage, {}, memberA);
|
|
167
|
+
const widgetsRow = result.rows.find((row) => row.id === testCap.id);
|
|
168
|
+
expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_A]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("an Editor(B) reads B's own usage, not A's", async () => {
|
|
172
|
+
const editorB = createTestUser({ id: 90042, tenantId: TENANT_B, roles: ["Editor"] });
|
|
173
|
+
const result = await stack.http.queryOk<{
|
|
174
|
+
rows: readonly { id: string; used: number | null }[];
|
|
175
|
+
}>(CapOverviewQueries.capsUsage, {}, editorB);
|
|
176
|
+
const widgetsRow = result.rows.find((row) => row.id === testCap.id);
|
|
177
|
+
expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_B]);
|
|
178
|
+
expect(widgetsRow?.used).not.toBe(USAGE_BY_TENANT[TENANT_A]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a regular User(A) cannot read tenant B's usage via override", async () => {
|
|
182
|
+
const memberA = createTestUser({ id: 90043, tenantId: TENANT_A, roles: ["User"] });
|
|
183
|
+
const res = await stack.http.query(
|
|
184
|
+
CapOverviewQueries.capsUsage,
|
|
185
|
+
{ tenantId: TENANT_B },
|
|
186
|
+
memberA,
|
|
187
|
+
);
|
|
188
|
+
expect(res.status).toBe(403);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a regular User(A) cannot list all tenants", async () => {
|
|
192
|
+
const memberA = createTestUser({ id: 90044, tenantId: TENANT_A, roles: ["User"] });
|
|
193
|
+
const res = await stack.http.query(CapOverviewQueries.tenantCapsList, {}, memberA);
|
|
194
|
+
expect(res.status).toBe(403);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a regular User(A) cannot enumerate tenant names via the platform picker", async () => {
|
|
198
|
+
const memberA = createTestUser({ id: 90045, tenantId: TENANT_A, roles: ["User"] });
|
|
199
|
+
const res = await stack.http.query(CapOverviewQueries.tenantOptions, {}, memberA);
|
|
200
|
+
expect(res.status).toBe(403);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an unranked app role still cannot reach my-caps", async () => {
|
|
204
|
+
const viewerA = createTestUser({ id: 90046, tenantId: TENANT_A, roles: ["Viewer"] });
|
|
205
|
+
const res = await stack.http.query(CapOverviewQueries.capsUsage, {}, viewerA);
|
|
206
|
+
expect(res.status).toBe(403);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
161
210
|
describe("cap-overview tier filter operators", () => {
|
|
162
211
|
test("op:ne excludes tenants matching the value, not just those matching it", async () => {
|
|
163
212
|
const sysAdmin = createTestUser({ id: 90017, tenantId: TENANT_A, roles: ["SystemAdmin"] });
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
3
4
|
import { billingFoundationFeature } from "../../billing-foundation";
|
|
4
5
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
5
6
|
import { createConfigFeature } from "../../config/feature";
|
|
6
7
|
import { createTenantFeature } from "../../tenant";
|
|
7
8
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
8
9
|
import { tierEngineFeature } from "../../tier-engine";
|
|
10
|
+
import { MY_CAPS_ACCESS_ROLES } from "../access";
|
|
9
11
|
import {
|
|
10
12
|
MY_CAPS_SCREEN_ID,
|
|
11
13
|
PLATFORM_TENANT_CAPS_SCREEN_ID,
|
|
@@ -58,6 +60,29 @@ describe("cap-overview boot", () => {
|
|
|
58
60
|
expect(feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID]?.type).toBe("dashboard");
|
|
59
61
|
});
|
|
60
62
|
|
|
63
|
+
test("my-caps screen and the query filling its cards carry the SAME rule", () => {
|
|
64
|
+
const feature = createCapOverviewFeature({ caps: [testCap] });
|
|
65
|
+
expect(rolesOf(feature.screens[MY_CAPS_SCREEN_ID]?.access)).toEqual([...MY_CAPS_ACCESS_ROLES]);
|
|
66
|
+
expect(rolesOf(feature.queryHandlers["caps:usage"]?.access)).toEqual([...MY_CAPS_ACCESS_ROLES]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("my-caps stays reachable for every role that reached it before", () => {
|
|
70
|
+
for (const role of access.admin) {
|
|
71
|
+
expect(MY_CAPS_ACCESS_ROLES).toContain(role);
|
|
72
|
+
}
|
|
73
|
+
expect(MY_CAPS_ACCESS_ROLES).toContain("User");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the platform-wide screens and their queries stay SystemAdmin-only", () => {
|
|
77
|
+
const feature = createCapOverviewFeature({ caps: [testCap] });
|
|
78
|
+
expect(rolesOf(feature.screens[TENANT_CAP_LIST_SCREEN_ID]?.access)).toEqual(["SystemAdmin"]);
|
|
79
|
+
expect(rolesOf(feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID]?.access)).toEqual([
|
|
80
|
+
"SystemAdmin",
|
|
81
|
+
]);
|
|
82
|
+
expect(rolesOf(feature.queryHandlers["tenant-caps:list"]?.access)).toEqual(["SystemAdmin"]);
|
|
83
|
+
expect(rolesOf(feature.queryHandlers["tenant-options"]?.access)).toEqual(["SystemAdmin"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
61
86
|
test("tenant-cap-list facet field is a declared column and the list handler schema accepts filters", () => {
|
|
62
87
|
const feature = createCapOverviewFeature({ caps: [testCap], tiers: ["free", "pro"] });
|
|
63
88
|
const screen = feature.screens[TENANT_CAP_LIST_SCREEN_ID];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { access } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// Every built-in membership rank (role-assignment.ts: User < Editor < Admin <
|
|
4
|
+
// TenantAdmin < SystemAdmin), so a regular team member can see their own
|
|
5
|
+
// tenant's usage. Deliberately not `access.authenticated` — that preset omits
|
|
6
|
+
// TenantAdmin and would revoke the access TenantAdmins already have. The
|
|
7
|
+
// screen and the query that fills its cards must carry the SAME rule, or the
|
|
8
|
+
// dashboard renders and every card 403s.
|
|
9
|
+
export const MY_CAPS_ACCESS_ROLES = access.roles("User", "Editor", ...access.admin);
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// platform-wide table of every tenant's tier, billing status, and
|
|
6
6
|
// usage against a configurable set of caps.
|
|
7
7
|
// 2. caps:usage query + my-caps / platform-tenant-caps dashboards —
|
|
8
|
-
// per-tenant usage cards.
|
|
9
|
-
// SystemAdmin can additionally view any tenant via `tenantId`.
|
|
8
|
+
// per-tenant usage cards. Every member of a tenant sees their own
|
|
9
|
+
// tenant; SystemAdmin can additionally view any tenant via `tenantId`.
|
|
10
10
|
//
|
|
11
11
|
// **What this feature does NOT do:**
|
|
12
12
|
// - No writes. Reads tier-engine's read_tier_assignments, billing-
|
|
@@ -52,7 +52,7 @@ export function createCapOverviewFeature(opts: CreateCapOverviewOptions): Featur
|
|
|
52
52
|
|
|
53
53
|
return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
|
|
54
54
|
r.describe(
|
|
55
|
-
"Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars;
|
|
55
|
+
"Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; every member of a tenant gets their own tenant's usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
|
|
56
56
|
);
|
|
57
57
|
r.uiHints({
|
|
58
58
|
displayLabel: "Cap Overview · Tier & Usage Visibility",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import {
|
|
4
|
-
access,
|
|
5
4
|
crossTenantOverrideDenied,
|
|
6
5
|
defineQueryHandler,
|
|
7
6
|
type QueryHandlerDefinition,
|
|
@@ -9,6 +8,7 @@ import {
|
|
|
9
8
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
9
|
import { z } from "zod";
|
|
11
10
|
import { tierAssignmentEntity } from "../../tier-engine";
|
|
11
|
+
import { MY_CAPS_ACCESS_ROLES } from "../access";
|
|
12
12
|
import type { CapSpec, CapUsageWithMeta } from "../types";
|
|
13
13
|
import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math";
|
|
14
14
|
|
|
@@ -20,9 +20,9 @@ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefi
|
|
|
20
20
|
return defineQueryHandler({
|
|
21
21
|
name: "caps:usage",
|
|
22
22
|
description:
|
|
23
|
-
"Returns
|
|
23
|
+
"Returns the calling user's own tenant's configured caps with used amount, tier limit and utilisation fraction; use it to answer how close a tenant is to its quota (only SystemAdmin may target another tenant via tenantId).",
|
|
24
24
|
schema: z.object({ tenantId: z.string().min(1).optional() }),
|
|
25
|
-
access: { roles:
|
|
25
|
+
access: { roles: MY_CAPS_ACCESS_ROLES },
|
|
26
26
|
handler: async (query, ctx) => {
|
|
27
27
|
if (!ctx.systemDb) {
|
|
28
28
|
throw new InternalError({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { MY_CAPS_ACCESS_ROLES } from "./access";
|
|
2
3
|
import {
|
|
3
4
|
CAP_CARDS_PANEL_COMPONENT,
|
|
4
5
|
CAP_USAGE_CELL_COMPONENT,
|
|
@@ -78,7 +79,7 @@ export const myCapsScreen: ScreenDefinition = {
|
|
|
78
79
|
component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
|
|
79
80
|
},
|
|
80
81
|
],
|
|
81
|
-
access: { roles:
|
|
82
|
+
access: { roles: MY_CAPS_ACCESS_ROLES },
|
|
82
83
|
};
|
|
83
84
|
|
|
84
85
|
export const platformTenantCapsScreen: ScreenDefinition = {
|
|
@@ -16,8 +16,10 @@ export function createCryptoShreddingFeature(): FeatureDefinition {
|
|
|
16
16
|
category: "compliance",
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// `reason` is operator free text and may name the subject; encrypting it under
|
|
20
|
+
// the subject key is pointless (that key is being erased) — see #2776.
|
|
21
|
+
r.defineEvent("subject-forgotten", subjectForgottenSchema, { piiFields: "none" });
|
|
22
|
+
r.defineEvent("forget-denied", subjectForgetDeniedSchema, { piiFields: "none" });
|
|
21
23
|
r.writeHandler(forgetSubjectWrite);
|
|
22
24
|
});
|
|
23
25
|
}
|
|
@@ -128,11 +128,18 @@ function registerCustomFields(
|
|
|
128
128
|
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
129
129
|
// Returned EventDefs liefern .name als compile-time literal-typed string,
|
|
130
130
|
// den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
// `value: z.unknown()` has no field-level schema to catalog under piiFields;
|
|
132
|
+
// "no PII" is a naming convention (#972), not an enforced guarantee — see #2776.
|
|
133
|
+
const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema, {
|
|
134
|
+
piiFields: "none",
|
|
135
|
+
});
|
|
136
|
+
const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema, {
|
|
137
|
+
piiFields: "none",
|
|
138
|
+
});
|
|
133
139
|
const fieldDefinitionDeletedEvent = r.defineEvent(
|
|
134
140
|
FIELD_DEFINITION_DELETED_EVENT,
|
|
135
141
|
fieldDefinitionDeletedSchema,
|
|
142
|
+
{ piiFields: "none" },
|
|
136
143
|
);
|
|
137
144
|
|
|
138
145
|
// Extension-Registrar — registriert dass diese Extension existiert.
|
|
@@ -102,8 +102,14 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
|
|
|
102
102
|
}),
|
|
103
103
|
);
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// "fileName" can carry a real person's name; the payload has no user-subject
|
|
106
|
+
// field to encrypt it under, so piiFields stays "none" pending #2776.
|
|
107
|
+
r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema, {
|
|
108
|
+
piiFields: "none",
|
|
109
|
+
});
|
|
110
|
+
r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema, {
|
|
111
|
+
piiFields: "none",
|
|
112
|
+
});
|
|
107
113
|
|
|
108
114
|
r.multiStreamProjection({
|
|
109
115
|
name: "request-ingest",
|
|
@@ -61,7 +61,7 @@ export function createFeatureTogglesFeature(
|
|
|
61
61
|
// alongside every other write — audit.list picks it up automatically,
|
|
62
62
|
// no dedicated projection needed. Qualified name after prefixing:
|
|
63
63
|
// "feature-toggles:event:toggle-set" (see constants.FEATURE_TOGGLE_SET_EVENT_NAME).
|
|
64
|
-
r.defineEvent("toggle-set", featureToggleSetSchema);
|
|
64
|
+
r.defineEvent("toggle-set", featureToggleSetSchema, { piiFields: "none" });
|
|
65
65
|
|
|
66
66
|
const handlers = {
|
|
67
67
|
set: r.writeHandler(createSetWriteHandler(options.getRuntime)),
|
|
@@ -109,11 +109,25 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
|
|
|
109
109
|
r.requires("tenant-lifecycle");
|
|
110
110
|
|
|
111
111
|
// 5 fine-grained domain-events (Payload-Schemas: events.ts).
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
r.defineEvent(
|
|
112
|
+
// piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
|
|
113
|
+
// "displayName" stays plaintext (often the mailbox owner's real name for
|
|
114
|
+
// personal accounts); encrypting it would break the entity-projection read
|
|
115
|
+
// path that carries it as `personal: false` (#2776).
|
|
116
|
+
r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
|
|
117
|
+
piiFields: "none",
|
|
118
|
+
});
|
|
119
|
+
r.defineEvent(MAIL_ACCOUNT_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema, {
|
|
120
|
+
piiFields: "none",
|
|
121
|
+
});
|
|
122
|
+
r.defineEvent(MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
|
|
123
|
+
piiFields: "none",
|
|
124
|
+
});
|
|
125
|
+
r.defineEvent(INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema, {
|
|
126
|
+
piiFields: "none",
|
|
127
|
+
});
|
|
128
|
+
r.defineEvent(MAIL_THREAD_UPDATED_EVENT_SHORT, mailThreadEventPayloadSchema, {
|
|
129
|
+
piiFields: "none",
|
|
130
|
+
});
|
|
117
131
|
|
|
118
132
|
// Inline projections — apply in derselben TX wie der Append.
|
|
119
133
|
r.projection({
|
package/src/secrets/feature.ts
CHANGED
|
@@ -153,7 +153,7 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
|
|
|
153
153
|
// discover the type; secrets-context.get parses payloads against
|
|
154
154
|
// `secretReadSchema` at write time because the low-level append() path
|
|
155
155
|
// skips ctx.appendEvent's schema-validation guard.
|
|
156
|
-
r.defineEvent("read", secretReadSchema);
|
|
156
|
+
r.defineEvent("read", secretReadSchema, { piiFields: "none" });
|
|
157
157
|
|
|
158
158
|
// Per-tenant handlers (set/delete/list) run in the default tenant-scope,
|
|
159
159
|
// giving them the automatic ctx.db tenant-filter as extra defense.
|
package/src/sessions/feature.ts
CHANGED
|
@@ -146,7 +146,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
146
146
|
// sessionRevokedSchema explicitly, same guarantee via a different path.
|
|
147
147
|
// No projection: the payload IS the read, consumed directly off the
|
|
148
148
|
// event-store NOTIFY (#1560).
|
|
149
|
-
r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema);
|
|
149
|
+
r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema, { piiFields: "none" });
|
|
150
150
|
|
|
151
151
|
const handlers = {
|
|
152
152
|
revoke: r.writeHandler(revokeWrite),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Unit tests for the Mollie plugin methods (createCheckoutSession only —
|
|
2
|
+
// createPortalSession/cancelSubscription are not implemented, see
|
|
3
|
+
// plugin-methods.ts). Mollie-SDK calls are mocked via spyOn — we test our
|
|
4
|
+
// sequenceType/metadata mapping, NOT Mollie itself.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
7
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import type { MollieClient } from "@mollie/api-client";
|
|
9
|
+
import { createMollieCheckoutSession, type MolliePriceConfig } from "../plugin-methods";
|
|
10
|
+
|
|
11
|
+
const PRICE_CONFIG: Readonly<Record<string, MolliePriceConfig>> = {
|
|
12
|
+
plan_pro: {
|
|
13
|
+
amountValue: "9.99",
|
|
14
|
+
amountCurrency: "EUR",
|
|
15
|
+
interval: "1 month",
|
|
16
|
+
description: "Pro Plan",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const WEBHOOK_URL = "https://app.example.com/api/subscription/webhook/mollie";
|
|
21
|
+
const stubCtx = {} as HandlerContext;
|
|
22
|
+
|
|
23
|
+
// No `: MollieClient` return-type annotation — keeping the inferred narrow
|
|
24
|
+
// shape lets spyOn resolve a non-overloaded signature for `payments.create`
|
|
25
|
+
// (MollieClient's real signature is Promise|void-overloaded, which makes
|
|
26
|
+
// spyOn(...).mockResolvedValue infer `never`, see plugin-methods.ts's own
|
|
27
|
+
// `@cast-boundary` comment on the same overload).
|
|
28
|
+
function buildClient() {
|
|
29
|
+
return {
|
|
30
|
+
customers: {
|
|
31
|
+
create: async () => ({ id: "cus_mock" }),
|
|
32
|
+
},
|
|
33
|
+
payments: {
|
|
34
|
+
create: async () => ({ getCheckoutUrl: () => "https://www.mollie.com/checkout/mock" }),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("createMollieCheckoutSession", () => {
|
|
40
|
+
test("mode omitted defaults to sequenceType 'first' (mandate-setup, Regression-Pin für bestehende Aufrufer)", async () => {
|
|
41
|
+
const client = buildClient();
|
|
42
|
+
const createMock = spyOn(client.payments, "create").mockResolvedValue({
|
|
43
|
+
getCheckoutUrl: () => "https://www.mollie.com/checkout/mock",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const checkout = createMollieCheckoutSession(
|
|
47
|
+
client as unknown as MollieClient,
|
|
48
|
+
PRICE_CONFIG,
|
|
49
|
+
WEBHOOK_URL,
|
|
50
|
+
);
|
|
51
|
+
const result = await checkout(stubCtx, {
|
|
52
|
+
priceId: "plan_pro",
|
|
53
|
+
tenantId: "tenant-001",
|
|
54
|
+
successUrl: "https://example.com/success",
|
|
55
|
+
cancelUrl: "https://example.com/cancel",
|
|
56
|
+
providerCustomerId: "cus_existing",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result).toEqual({ url: "https://www.mollie.com/checkout/mock" });
|
|
60
|
+
expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ sequenceType: "first" }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("mode='payment': sequenceType wird zu 'oneoff' (One-off-Top-up, kein Mandate-setup)", async () => {
|
|
64
|
+
const client = buildClient();
|
|
65
|
+
const createMock = spyOn(client.payments, "create").mockResolvedValue({
|
|
66
|
+
getCheckoutUrl: () => "https://www.mollie.com/checkout/topup",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const checkout = createMollieCheckoutSession(
|
|
70
|
+
client as unknown as MollieClient,
|
|
71
|
+
PRICE_CONFIG,
|
|
72
|
+
WEBHOOK_URL,
|
|
73
|
+
);
|
|
74
|
+
const result = await checkout(stubCtx, {
|
|
75
|
+
priceId: "plan_pro",
|
|
76
|
+
tenantId: "tenant-002",
|
|
77
|
+
successUrl: "https://example.com/success",
|
|
78
|
+
cancelUrl: "https://example.com/cancel",
|
|
79
|
+
providerCustomerId: "cus_existing",
|
|
80
|
+
mode: "payment",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result).toEqual({ url: "https://www.mollie.com/checkout/topup" });
|
|
84
|
+
expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ sequenceType: "oneoff" }));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -52,12 +52,20 @@ export function createMollieCheckoutSession(
|
|
|
52
52
|
customerId = customer.id;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// mode "payment" (one-off top-up) → sequenceType.oneoff, no mandate
|
|
56
|
+
// setup. verify-webhook.ts's ensureSubscriptionForMandate already
|
|
57
|
+
// gates on sequenceType === "first" — a oneoff payment falls into
|
|
58
|
+
// its "not our domain" branch and creates NO Mollie subscription.
|
|
59
|
+
// Webhook resolution for oneoff payments is separate follow-up
|
|
60
|
+
// work (offlot#109), not part of this contract.
|
|
61
|
+
const sequenceType = options.mode === "payment" ? SequenceType.oneoff : SequenceType.first;
|
|
62
|
+
|
|
55
63
|
// payments.create ist overloaded (Promise OR void mit callback);
|
|
56
64
|
// explicit cast auf Promise<Payment>-overload.
|
|
57
65
|
const payment = (await (client.payments.create({
|
|
58
66
|
amount: { currency: priceCfg.amountCurrency, value: priceCfg.amountValue },
|
|
59
67
|
description: priceCfg.description,
|
|
60
|
-
sequenceType
|
|
68
|
+
sequenceType,
|
|
61
69
|
customerId,
|
|
62
70
|
redirectUrl: options.successUrl,
|
|
63
71
|
cancelUrl: options.cancelUrl,
|
|
@@ -116,6 +116,55 @@ describe("createStripeCheckoutSession", () => {
|
|
|
116
116
|
);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
test("mode='payment': ruft stripe.checkout.sessions.create mit mode=payment + payment_intent_data.metadata (KEIN subscription_data)", async () => {
|
|
120
|
+
const stripe = buildStripe();
|
|
121
|
+
const createMock = spyOn(stripe.checkout.sessions, "create")
|
|
122
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
123
|
+
.mockResolvedValue({ url: "https://checkout.stripe.com/c/pay/topup" } as any);
|
|
124
|
+
|
|
125
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
|
|
126
|
+
const result = await checkout(stubCtx, {
|
|
127
|
+
priceId: "price_credits_topup",
|
|
128
|
+
tenantId: "tenant-003",
|
|
129
|
+
successUrl: "https://example.com/success",
|
|
130
|
+
cancelUrl: "https://example.com/cancel",
|
|
131
|
+
mode: "payment",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result).toEqual({ url: "https://checkout.stripe.com/c/pay/topup" });
|
|
135
|
+
expect(createMock).toHaveBeenCalledWith({
|
|
136
|
+
mode: "payment",
|
|
137
|
+
line_items: [{ price: "price_credits_topup", quantity: 1 }],
|
|
138
|
+
success_url: "https://example.com/success",
|
|
139
|
+
cancel_url: "https://example.com/cancel",
|
|
140
|
+
// Drift-Pin: payment-mode carries tenantId via payment_intent_data,
|
|
141
|
+
// NOT subscription_data — Stripe rejects subscription_data outside
|
|
142
|
+
// subscription-mode.
|
|
143
|
+
payment_intent_data: {
|
|
144
|
+
metadata: { tenantId: "tenant-003" },
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("mode omitted defaults to 'subscription' (Regression-Pin für bestehende Aufrufer)", async () => {
|
|
150
|
+
const stripe = buildStripe();
|
|
151
|
+
const createMock = spyOn(stripe.checkout.sessions, "create")
|
|
152
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
153
|
+
.mockResolvedValue({ url: "https://x" } as any);
|
|
154
|
+
|
|
155
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
|
|
156
|
+
await checkout(stubCtx, {
|
|
157
|
+
priceId: "price_x",
|
|
158
|
+
tenantId: "tenant-004",
|
|
159
|
+
successUrl: "https://x/s",
|
|
160
|
+
cancelUrl: "https://x/c",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(createMock).toHaveBeenCalledWith(
|
|
164
|
+
expect.objectContaining({ mode: "subscription", subscription_data: expect.anything() }),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
119
168
|
test("throws wenn Stripe keine url returnt (defensive — sollte nie passieren bei mode=subscription)", async () => {
|
|
120
169
|
const stripe = buildStripe();
|
|
121
170
|
spyOn(stripe.checkout.sessions, "create")
|
|
@@ -41,24 +41,26 @@ export function createStripeCheckoutSession(runtime: StripeCtxRuntime) {
|
|
|
41
41
|
// billing-live-config-key write-side.
|
|
42
42
|
await runtime.assertBillingLive(ctx);
|
|
43
43
|
const stripe = await runtime.clientForCtx(ctx);
|
|
44
|
+
const mode = options.mode ?? "subscription";
|
|
44
45
|
|
|
45
46
|
const session = await stripe.checkout.sessions.create({
|
|
46
|
-
mode
|
|
47
|
+
mode,
|
|
47
48
|
line_items: [{ price: options.priceId, quantity: 1 }],
|
|
48
49
|
success_url: options.successUrl,
|
|
49
50
|
cancel_url: options.cancelUrl,
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
// subscription_data is subscription-mode-only (Stripe rejects it in
|
|
52
|
+
// payment-mode) — the subsequent webhook reads metadata.tenantId off
|
|
53
|
+
// the subscription it creates. payment-mode has no subscription, so
|
|
54
|
+
// tenantId travels via payment_intent_data.metadata instead.
|
|
55
|
+
...(mode === "subscription"
|
|
56
|
+
? { subscription_data: { metadata: { tenantId: options.tenantId } } }
|
|
57
|
+
: { payment_intent_data: { metadata: { tenantId: options.tenantId } } }),
|
|
56
58
|
...(options.providerCustomerId && { customer: options.providerCustomerId }),
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
if (!session.url) {
|
|
60
|
-
// Stripe
|
|
61
|
-
//
|
|
62
|
+
// Defensive: Stripe returns a hosted url for both modes today —
|
|
63
|
+
// guards against future API drift.
|
|
62
64
|
throw new Error("subscription-stripe: checkout.sessions.create returned no url");
|
|
63
65
|
}
|
|
64
66
|
return { url: session.url };
|
|
@@ -60,24 +60,41 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
60
60
|
// guard's "tenant-lifecycle mounted" gate exactly.
|
|
61
61
|
r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
|
|
62
62
|
|
|
63
|
-
r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
|
|
64
|
+
piiFields: "none",
|
|
65
|
+
});
|
|
66
|
+
r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema, {
|
|
67
|
+
piiFields: "none",
|
|
68
|
+
});
|
|
69
|
+
r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema, {
|
|
70
|
+
piiFields: "none",
|
|
71
|
+
});
|
|
66
72
|
r.defineEvent(
|
|
67
73
|
TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
|
|
68
74
|
tenantDestructionStageStartedSchema,
|
|
75
|
+
{
|
|
76
|
+
piiFields: "none",
|
|
77
|
+
},
|
|
69
78
|
);
|
|
70
79
|
r.defineEvent(
|
|
71
80
|
TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
|
|
72
81
|
tenantDestructionStageSucceededSchema,
|
|
82
|
+
{ piiFields: "none" },
|
|
73
83
|
);
|
|
74
|
-
r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema
|
|
84
|
+
r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema, {
|
|
85
|
+
piiFields: "none",
|
|
86
|
+
});
|
|
75
87
|
r.defineEvent(
|
|
76
88
|
TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
|
|
77
89
|
tenantDestructionStageAbandonedSchema,
|
|
90
|
+
{ piiFields: "none" },
|
|
78
91
|
);
|
|
79
|
-
r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema
|
|
80
|
-
|
|
92
|
+
r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema, {
|
|
93
|
+
piiFields: "none",
|
|
94
|
+
});
|
|
95
|
+
r.defineEvent(TENANT_DESTRUCTION_FAILED_EVENT_SHORT, tenantDestructionFailedSchema, {
|
|
96
|
+
piiFields: "none",
|
|
97
|
+
});
|
|
81
98
|
|
|
82
99
|
r.writeHandler(requestDestructionWrite);
|
|
83
100
|
r.writeHandler(cancelDestructionWrite);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// fw#2593 — the plaintext `read_users_email_unique` index used to be a full
|
|
2
|
+
// unique index regardless of softDelete: a soft-deleted user permanently
|
|
3
|
+
// blocked reuse of its email whenever PII/blind-index wasn't configured
|
|
4
|
+
// (the *_bidx partial index from fw#2464 only helps once a blind-index key
|
|
5
|
+
// is actually configured). buildEntityTable / deriveEntityTableMeta now
|
|
6
|
+
// attach `WHERE "is_deleted" = false` to any explicitly declared unique
|
|
7
|
+
// index on a softDelete entity that doesn't carry an author-provided
|
|
8
|
+
// `where` of its own — this proves the plaintext path directly, with no
|
|
9
|
+
// blind-index key configured at all.
|
|
10
|
+
|
|
11
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
|
+
import {
|
|
17
|
+
createTestDb,
|
|
18
|
+
type TestDb,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import {
|
|
22
|
+
resetBlindIndexKeyForTests,
|
|
23
|
+
resetPiiSubjectKmsForTests,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
25
|
+
import { userEntity, userTable } from "../schema/user";
|
|
26
|
+
|
|
27
|
+
let testDb: TestDb;
|
|
28
|
+
const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
testDb = await createTestDb();
|
|
32
|
+
await unsafeCreateEntityTable(testDb.db, userEntity, "user");
|
|
33
|
+
await createEventsTable(testDb.db);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await testDb.cleanup();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await asRawClient(testDb.db).unsafe(
|
|
42
|
+
`TRUNCATE kumiko_events, read_users RESTART IDENTITY CASCADE`,
|
|
43
|
+
);
|
|
44
|
+
// The premise of this file is "nothing configured" — global crypto state
|
|
45
|
+
// from another test file running first in the same process must not leak
|
|
46
|
+
// in and accidentally exercise the bidx path instead of the plaintext one.
|
|
47
|
+
resetPiiSubjectKmsForTests();
|
|
48
|
+
resetBlindIndexKeyForTests();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
resetPiiSubjectKmsForTests();
|
|
53
|
+
resetBlindIndexKeyForTests();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("fw#2593 — email unique constraint on the plaintext column (no blind index configured)", () => {
|
|
57
|
+
test("a soft-deleted user's email is reusable when no blind index is configured", async () => {
|
|
58
|
+
const email = "plaintext-soft-deleted@example.com";
|
|
59
|
+
const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
|
|
60
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
61
|
+
|
|
62
|
+
const created = await executor.create(
|
|
63
|
+
{ email, displayName: "Departing Plaintext User" },
|
|
64
|
+
systemUser,
|
|
65
|
+
tdb,
|
|
66
|
+
);
|
|
67
|
+
if (!created.isSuccess) throw new Error("expected create to succeed");
|
|
68
|
+
|
|
69
|
+
const deleted = await executor.delete({ id: created.data.id }, systemUser, tdb);
|
|
70
|
+
if (!deleted.isSuccess) throw new Error("expected soft-delete to succeed");
|
|
71
|
+
|
|
72
|
+
const reCreated = await executor.create(
|
|
73
|
+
{ email, displayName: "Plaintext Squatter" },
|
|
74
|
+
systemUser,
|
|
75
|
+
tdb,
|
|
76
|
+
);
|
|
77
|
+
if (!reCreated.isSuccess) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"expected re-create with the same email to succeed once the original row is soft-deleted",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
expect(reCreated.data.id).not.toBe(created.data.id);
|
|
83
|
+
|
|
84
|
+
// DB-proof: both rows survive under the plaintext email — the
|
|
85
|
+
// soft-deleted original stays untouched, the new live row now also
|
|
86
|
+
// carries the email, since the partial index only enforces uniqueness
|
|
87
|
+
// among non-deleted rows.
|
|
88
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
89
|
+
`SELECT "id", "is_deleted" FROM "read_users" WHERE "email" = $1 ORDER BY "is_deleted"`,
|
|
90
|
+
[email],
|
|
91
|
+
)) as ReadonlyArray<{ id: string; is_deleted: boolean }>;
|
|
92
|
+
expect(rows).toHaveLength(2);
|
|
93
|
+
expect(rows[0]).toEqual({ id: String(reCreated.data.id), is_deleted: false });
|
|
94
|
+
expect(rows[1]).toEqual({ id: String(created.data.id), is_deleted: true });
|
|
95
|
+
|
|
96
|
+
// Proves the plaintext path was actually exercised, not the bidx one:
|
|
97
|
+
// no blind-index key is configured in this file, so email_bidx stays NULL.
|
|
98
|
+
const plaintextRows = (await asRawClient(testDb.db).unsafe(
|
|
99
|
+
`SELECT "email", "email_bidx" FROM "read_users" WHERE "id" = $1::uuid`,
|
|
100
|
+
[reCreated.data.id],
|
|
101
|
+
)) as ReadonlyArray<{ email: string; email_bidx: string | null }>;
|
|
102
|
+
expect(plaintextRows[0]?.email).toBe(email);
|
|
103
|
+
expect(plaintextRows[0]?.email_bidx).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a second live user with the same email is still rejected", async () => {
|
|
107
|
+
const email = "plaintext-live-collision@example.com";
|
|
108
|
+
const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
|
|
109
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
110
|
+
|
|
111
|
+
const first = await executor.create(
|
|
112
|
+
{ email, displayName: "First Plaintext User" },
|
|
113
|
+
systemUser,
|
|
114
|
+
tdb,
|
|
115
|
+
);
|
|
116
|
+
if (!first.isSuccess) throw new Error("expected first create to succeed");
|
|
117
|
+
|
|
118
|
+
const second = await executor.create(
|
|
119
|
+
{ email, displayName: "Second Plaintext User" },
|
|
120
|
+
systemUser,
|
|
121
|
+
tdb,
|
|
122
|
+
);
|
|
123
|
+
if (second.isSuccess) {
|
|
124
|
+
throw new Error("expected second create with the same live email to fail");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expect(second.error.code).toBe("unique_violation");
|
|
128
|
+
expect(second.error.httpStatus).toBe(409);
|
|
129
|
+
const details = second.error.details as { constraintName?: string };
|
|
130
|
+
expect(details.constraintName).toBe("read_users_email_unique");
|
|
131
|
+
|
|
132
|
+
// DB-proof: only the first row survives under that email.
|
|
133
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
134
|
+
`SELECT count(*)::int AS n FROM "read_users" WHERE "email" = $1`,
|
|
135
|
+
[email],
|
|
136
|
+
)) as ReadonlyArray<{ n: number }>;
|
|
137
|
+
expect(rows[0]?.n).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/user/seeding.ts
CHANGED
|
@@ -66,7 +66,7 @@ export async function seedUser(
|
|
|
66
66
|
// Interface braucht den Wrap.
|
|
67
67
|
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
68
68
|
|
|
69
|
-
const existing = await fetchOne(db, userTable, { email: options.email });
|
|
69
|
+
const existing = await fetchOne(db, userTable, { email: options.email, isDeleted: false });
|
|
70
70
|
// @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
|
|
71
71
|
// liefert die Projection-Row als Record<string, unknown>.
|
|
72
72
|
if (existing) {
|
|
@@ -114,7 +114,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
114
114
|
test("V3: pii field on a projection-only entity (no r.entity) is still caught", () => {
|
|
115
115
|
const projectionTable = projectionProbeTable("crm_contact_summary");
|
|
116
116
|
const bad = defineFeature("crm", (r) => {
|
|
117
|
-
const contactCreated = r.defineEvent("contact-created", z.unknown());
|
|
117
|
+
const contactCreated = r.defineEvent("contact-created", z.unknown(), { piiFields: "none" });
|
|
118
118
|
r.projection({
|
|
119
119
|
name: "contact-summary",
|
|
120
120
|
source: "contact",
|
|
@@ -171,7 +171,9 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
171
171
|
const projectionTable = projectionProbeTable("crm_contact_summary_hooked");
|
|
172
172
|
const hooked = defineFeature("crm-hooked", (r) => {
|
|
173
173
|
r.requires("user-data-rights");
|
|
174
|
-
const contactCreated = r.defineEvent("contact-hooked-created", z.unknown()
|
|
174
|
+
const contactCreated = r.defineEvent("contact-hooked-created", z.unknown(), {
|
|
175
|
+
piiFields: "none",
|
|
176
|
+
});
|
|
175
177
|
r.projection({
|
|
176
178
|
name: "contact-summary",
|
|
177
179
|
source: "contact",
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
16
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
17
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
createEventStoreExecutor,
|
|
20
|
+
createTenantDb,
|
|
21
|
+
entityEventName,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
23
|
import {
|
|
20
24
|
createEntity,
|
|
21
25
|
createSystemUser,
|
|
@@ -337,6 +341,112 @@ describe("forget pipeline honours the effective tenant model", () => {
|
|
|
337
341
|
// Payload-tenant latch must still see 2 historical members.
|
|
338
342
|
expect(await rowCount()).toBe(1);
|
|
339
343
|
});
|
|
344
|
+
|
|
345
|
+
test("single-user, sole member removed and re-added → rows still erased (#2608)", async () => {
|
|
346
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c7");
|
|
347
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
348
|
+
|
|
349
|
+
// Remove + re-add of the SAME person: removeMemberWrite only drops the
|
|
350
|
+
// projection row, so two `tenant-membership.created` events survive for a
|
|
351
|
+
// tenant that never had a co-member. Counting rows instead of identities
|
|
352
|
+
// latched such a tenant to multi-user forever, and every tenantScopedOnly
|
|
353
|
+
// erase hook skipped on the "co-members keep shared data" grounds.
|
|
354
|
+
const first = await seedTenantMembership(stack.db, {
|
|
355
|
+
userId: FORGET_USER,
|
|
356
|
+
tenantId: TENANT,
|
|
357
|
+
roles: ["Member"],
|
|
358
|
+
});
|
|
359
|
+
const deleteResult = await membershipExecutor.delete(
|
|
360
|
+
{ id: first.id },
|
|
361
|
+
createSystemUser(TENANT),
|
|
362
|
+
createTenantDb(stack.db, TENANT, "system"),
|
|
363
|
+
);
|
|
364
|
+
if (!deleteResult.isSuccess) {
|
|
365
|
+
throw new Error(`test setup: member removal failed: ${deleteResult.error.code}`);
|
|
366
|
+
}
|
|
367
|
+
await seedTenantMembership(stack.db, {
|
|
368
|
+
userId: FORGET_USER,
|
|
369
|
+
tenantId: TENANT,
|
|
370
|
+
roles: ["Member"],
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Pins the premise: 2 created-events, 1 distinct userId, 1 live member.
|
|
374
|
+
const createdEvents = await asRawClient(stack.db).unsafe(
|
|
375
|
+
`SELECT payload->>'userId' AS user_id FROM kumiko_events
|
|
376
|
+
WHERE aggregate_type = 'tenant-membership' AND type = $1 AND payload->>'tenantId' = $2`,
|
|
377
|
+
[entityEventName("tenant-membership", "created"), TENANT],
|
|
378
|
+
);
|
|
379
|
+
expect(createdEvents.length).toBe(2);
|
|
380
|
+
const liveMemberships = await asRawClient(stack.db).unsafe(
|
|
381
|
+
"SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
|
|
382
|
+
[TENANT],
|
|
383
|
+
);
|
|
384
|
+
expect(liveMemberships.length).toBe(1);
|
|
385
|
+
|
|
386
|
+
const result = await runForgetCleanup({
|
|
387
|
+
db: stack.db,
|
|
388
|
+
registry: stack.registry,
|
|
389
|
+
now: nowInstant(),
|
|
390
|
+
tenantModel: "single-user",
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
expect(result.errors).toHaveLength(0);
|
|
394
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
395
|
+
expect(await rowCount()).toBe(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("single-user, sole member, but a created-event with unreadable userId → rows preserved", async () => {
|
|
399
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c8");
|
|
400
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
401
|
+
await seedTenantMembership(stack.db, {
|
|
402
|
+
userId: FORGET_USER,
|
|
403
|
+
tenantId: TENANT,
|
|
404
|
+
roles: ["Member"],
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// A created-event whose userId payload cannot be read may name a second
|
|
408
|
+
// member — the fail-safe branch must survive the #2608 fix.
|
|
409
|
+
await asRawClient(stack.db).unsafe(
|
|
410
|
+
`INSERT INTO kumiko_events
|
|
411
|
+
(aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
|
|
412
|
+
VALUES ($1, 'tenant-membership', $2, 1, $3, $4, '{}'::jsonb, 'system')`,
|
|
413
|
+
[
|
|
414
|
+
"eeeeeeee-eeee-4eee-8eee-0000000000c8",
|
|
415
|
+
TENANT,
|
|
416
|
+
entityEventName("tenant-membership", "created"),
|
|
417
|
+
// Object, not JSON.stringify: a string param into a jsonb column is
|
|
418
|
+
// encoded a second time and lands as a JSON scalar, which no
|
|
419
|
+
// payload->>'...' lookup would ever match.
|
|
420
|
+
{ tenantId: TENANT },
|
|
421
|
+
],
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const createdEvents = await asRawClient(stack.db).unsafe(
|
|
425
|
+
`SELECT payload->>'userId' AS user_id FROM kumiko_events
|
|
426
|
+
WHERE aggregate_type = 'tenant-membership' AND type = $1 AND payload->>'tenantId' = $2`,
|
|
427
|
+
[entityEventName("tenant-membership", "created"), TENANT],
|
|
428
|
+
);
|
|
429
|
+
expect(createdEvents.length).toBe(2);
|
|
430
|
+
|
|
431
|
+
// Without exactly one live member the live gate would answer multi-user
|
|
432
|
+
// before the history latch runs, and this test would prove nothing.
|
|
433
|
+
const liveMemberships = await asRawClient(stack.db).unsafe(
|
|
434
|
+
"SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
|
|
435
|
+
[TENANT],
|
|
436
|
+
);
|
|
437
|
+
expect(liveMemberships.length).toBe(1);
|
|
438
|
+
|
|
439
|
+
const result = await runForgetCleanup({
|
|
440
|
+
db: stack.db,
|
|
441
|
+
registry: stack.registry,
|
|
442
|
+
now: nowInstant(),
|
|
443
|
+
tenantModel: "single-user",
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(result.errors).toHaveLength(0);
|
|
447
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
448
|
+
expect(await rowCount()).toBe(1);
|
|
449
|
+
});
|
|
340
450
|
});
|
|
341
451
|
|
|
342
452
|
describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
|
|
@@ -76,7 +76,7 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
|
|
|
76
76
|
const userRow = await fetchOne<{ id: string; status: string; email: string }>(
|
|
77
77
|
ctx.db.raw,
|
|
78
78
|
userTable,
|
|
79
|
-
{ email: event.payload.email },
|
|
79
|
+
{ email: event.payload.email, isDeleted: false },
|
|
80
80
|
);
|
|
81
81
|
if (!userRow || userRow["status"] !== USER_STATUS.Active || !userRow["email"]) {
|
|
82
82
|
return success;
|
|
@@ -573,19 +573,26 @@ async function resolveEffectiveTenantModel(
|
|
|
573
573
|
// rows.
|
|
574
574
|
//
|
|
575
575
|
// Filter by payload->>'tenantId' (not the event column tenant_id): cross-
|
|
576
|
-
// tenant SystemAdmin adds land under the actor's tenant_id (#2347).
|
|
577
|
-
//
|
|
578
|
-
// created-
|
|
576
|
+
// tenant SystemAdmin adds land under the actor's tenant_id (#2347).
|
|
577
|
+
//
|
|
578
|
+
// Counting created-ROWS against distinct userIds latched a tenant to
|
|
579
|
+
// multi-user as soon as its sole member was removed and re-added (#2608) —
|
|
580
|
+
// two created-events, one identity, no co-member ever. The fail-safe only
|
|
581
|
+
// needs to cover payloads whose userId cannot be READ, so the unreadable
|
|
582
|
+
// remainder is measured directly instead of inferred from the row count.
|
|
583
|
+
// NULLIF folds an empty-string userId into that remainder (it names nobody).
|
|
579
584
|
async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
|
|
580
585
|
const eventType = entityEventName("tenant-membership", "created");
|
|
581
586
|
const rows = await executeRawQuery<{
|
|
582
587
|
event_count: number;
|
|
588
|
+
readable_users: number;
|
|
583
589
|
distinct_users: number;
|
|
584
590
|
}>(
|
|
585
591
|
db,
|
|
586
592
|
`SELECT
|
|
587
593
|
COUNT(*)::int AS event_count,
|
|
588
|
-
COUNT(
|
|
594
|
+
COUNT(NULLIF(payload->>'userId', ''))::int AS readable_users,
|
|
595
|
+
COUNT(DISTINCT NULLIF(payload->>'userId', ''))::int AS distinct_users
|
|
589
596
|
FROM kumiko_events
|
|
590
597
|
WHERE aggregate_type = 'tenant-membership'
|
|
591
598
|
AND type = $1
|
|
@@ -595,11 +602,9 @@ async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise
|
|
|
595
602
|
const row = rows[0];
|
|
596
603
|
if (!row) return false;
|
|
597
604
|
if (row.distinct_users > 1) return true;
|
|
598
|
-
// Unreadable / missing userId payloads: fail-safe to multi-user
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
if (row.event_count > 1 && row.distinct_users <= 1) return true;
|
|
602
|
-
return false;
|
|
605
|
+
// Unreadable / missing userId payloads: fail-safe to multi-user, each one
|
|
606
|
+
// could name a second member.
|
|
607
|
+
return row.event_count > row.readable_users;
|
|
603
608
|
}
|
|
604
609
|
|
|
605
610
|
// Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
|