@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.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.
Files changed (81) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
  3. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
  4. package/src/auth-email-password/feature.ts +9 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
  6. package/src/auth-email-password/handlers/login.write.ts +10 -5
  7. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
  8. package/src/auth-mfa/feature.ts +3 -0
  9. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  10. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
  11. package/src/compliance-profiles/feature.ts +18 -5
  12. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  13. package/src/compliance-profiles/index.ts +1 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  15. package/src/config/__tests__/require-system-db.test.ts +46 -0
  16. package/src/config/feature.ts +28 -0
  17. package/src/config/handlers/cascade.query.ts +2 -2
  18. package/src/config/handlers/readiness.query.ts +19 -6
  19. package/src/config/handlers/reencrypt.job.ts +79 -21
  20. package/src/config/handlers/values.query.ts +2 -2
  21. package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
  22. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  23. package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
  24. package/src/delivery/feature.ts +13 -3
  25. package/src/delivery/handlers/log.query.ts +10 -4
  26. package/src/delivery/handlers/preferences.query.ts +14 -1
  27. package/src/delivery/handlers/set-preference.write.ts +12 -1
  28. package/src/delivery/index.ts +1 -1
  29. package/src/delivery/jobs.ts +20 -10
  30. package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
  31. package/src/derivatives-sharp/render.ts +55 -15
  32. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  33. package/src/feature-toggles/constants.ts +6 -0
  34. package/src/feature-toggles/handlers/list.query.ts +12 -1
  35. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  36. package/src/feature-toggles/handlers/set.write.ts +18 -3
  37. package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
  38. package/src/file-derivatives/feature.ts +12 -13
  39. package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
  40. package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
  41. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
  42. package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
  43. package/src/form-draft/constants.ts +13 -0
  44. package/src/form-draft/db/queries/cleanup.ts +4 -1
  45. package/src/form-draft/db/queries/draft-count.ts +20 -0
  46. package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
  47. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
  48. package/src/form-draft/handlers/cleanup.job.ts +6 -0
  49. package/src/form-draft/handlers/discard.write.ts +6 -0
  50. package/src/form-draft/handlers/save.write.ts +15 -2
  51. package/src/form-draft/schemas.ts +16 -2
  52. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  53. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  54. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
  55. package/src/jobs/handlers/detail.query.ts +7 -1
  56. package/src/jobs/handlers/list.query.ts +8 -1
  57. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  58. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  59. package/src/jobs/handlers/retry.write.ts +7 -1
  60. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
  61. package/src/personal-access-tokens/feature.ts +4 -1
  62. package/src/personal-access-tokens/roles.ts +11 -3
  63. package/src/readiness/handlers/status.query.ts +2 -1
  64. package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
  65. package/src/sessions/feature.ts +18 -10
  66. package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
  67. package/src/sessions/index.ts +1 -1
  68. package/src/sessions/session-callbacks.ts +7 -1
  69. package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
  70. package/src/tenant/handlers/remove-member.write.ts +23 -1
  71. package/src/tenant/handlers/update-member-roles.write.ts +23 -1
  72. package/src/tenant/handlers/update.write.ts +35 -2
  73. package/src/tenant/invitation-table.ts +8 -1
  74. package/src/user/__tests__/user.integration.test.ts +21 -0
  75. package/src/user/handlers/create.write.ts +10 -3
  76. package/src/user/handlers/find-for-auth.query.ts +9 -1
  77. package/src/user/handlers/me.query.ts +10 -1
  78. package/src/user/handlers/update.write.ts +12 -2
  79. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
  80. package/src/user-data-rights/feature.ts +10 -7
  81. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.197.1",
3
+ "version": "0.199.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>",
@@ -125,12 +125,12 @@
125
125
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
126
126
  },
127
127
  "dependencies": {
128
- "@cosmicdrift/kumiko-dispatcher-live": "0.197.1",
129
- "@cosmicdrift/kumiko-framework": "0.197.1",
130
- "@cosmicdrift/kumiko-headless": "0.197.1",
131
- "@cosmicdrift/kumiko-renderer": "0.197.1",
132
- "@cosmicdrift/kumiko-renderer-web": "0.197.1",
133
- "@cosmicdrift/kumiko-types": "0.197.1",
128
+ "@cosmicdrift/kumiko-dispatcher-live": "0.199.0",
129
+ "@cosmicdrift/kumiko-framework": "0.199.0",
130
+ "@cosmicdrift/kumiko-headless": "0.199.0",
131
+ "@cosmicdrift/kumiko-renderer": "0.199.0",
132
+ "@cosmicdrift/kumiko-renderer-web": "0.199.0",
133
+ "@cosmicdrift/kumiko-types": "0.199.0",
134
134
  "@mollie/api-client": "^4.5.0",
135
135
  "@node-rs/argon2": "^2.0.2",
136
136
  "@types/mailparser": "^3.4.6",
@@ -0,0 +1,272 @@
1
+ // invite-accept-with-login used to mint a full session straight off a bare
2
+ // verifyPassword call, skipping every gate login.write.ts runs (lockout,
3
+ // account-status, MFA). This suite proves the shared gates are wired in:
4
+ // an MFA-enrolled invitee gets a challenge instead of a session, and a
5
+ // restricted/deleted invitee never gets a session at all.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
10
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
11
+ import {
12
+ createTestUser,
13
+ setupTestStack,
14
+ type TestStack,
15
+ unsafeCreateEntityTable,
16
+ unsafePushTables,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework/testing";
19
+ import {
20
+ AuthMfaHandlers,
21
+ base32Decode,
22
+ createAuthMfaFeature,
23
+ mfaStatusCheckerFromFeature,
24
+ userMfaEntity,
25
+ userMfaTable,
26
+ } from "../../auth-mfa";
27
+ import { currentTotpCode } from "../../auth-mfa/totp";
28
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
29
+ import { createConfigFeature } from "../../config";
30
+ import { createConfigResolver } from "../../config/resolver";
31
+ import { configValuesTable } from "../../config/table";
32
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
33
+ import { notificationPreferencesTable } from "../../delivery/tables";
34
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
35
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
36
+ import { hashPassword } from "../../shared";
37
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
38
+ import { createTenantFeature } from "../../tenant";
39
+ import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
40
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
41
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
42
+ import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
43
+ import { USER_STATUS } from "../../user";
44
+ import { createUserFeature } from "../../user/feature";
45
+ import { userEntity, userTable } from "../../user/schema/user";
46
+ import { AuthHandlers } from "../constants";
47
+ import { createAuthEmailPasswordFeature } from "../feature";
48
+
49
+ const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
50
+ const ALICE_EMAIL = "alice@example.com";
51
+
52
+ const emailTransport = createInMemoryTransport();
53
+
54
+ let stack: TestStack;
55
+ let aliceId: string;
56
+ let TENANT_A_ID: TenantId;
57
+
58
+ function newTenantId(): TenantId {
59
+ return crypto.randomUUID() as TenantId;
60
+ }
61
+
62
+ function extractTokenFromMail(html: string): string {
63
+ const match = html.match(/[?&]token=([^&"'<\s]+)/);
64
+ if (!match?.[1]) throw new Error(`No token in invite mail html: ${html.slice(0, 200)}`);
65
+ return decodeURIComponent(match[1]);
66
+ }
67
+
68
+ beforeAll(async () => {
69
+ const encryption = createTestEnvelopeCipher();
70
+ configureEntityFieldEncryption(encryption);
71
+ const resolver = createConfigResolver({ cipher: encryption });
72
+ const authMfaFeature = createAuthMfaFeature({
73
+ setupTokenSecret: "test-mfa-setup-token-secret-at-least-32-bytes!!",
74
+ issuer: "Kumiko Test",
75
+ challengeTokenSecret: "test-mfa-challenge-token-secret-at-least-32-bytes!!",
76
+ });
77
+
78
+ stack = await setupTestStack({
79
+ features: [
80
+ createConfigFeature(),
81
+ createUserFeature(),
82
+ createTenantFeature(),
83
+ createTemplateResolverFeature(),
84
+ createRendererFoundationFeature(),
85
+ createDeliveryFeature(),
86
+ createRendererSimpleFeature(),
87
+ createChannelEmailFeature({
88
+ transport: emailTransport,
89
+ renderer: simpleRenderer,
90
+ resolveEmail: async () => "unused@test.local",
91
+ }),
92
+ authMfaFeature,
93
+ createAuthEmailPasswordFeature({
94
+ invite: { tokenTtlMinutes: 60, appUrl: APP_ACCEPT_URL },
95
+ mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
96
+ }),
97
+ ],
98
+ extraContext: (deps) => ({
99
+ ...createDeliveryTestContext(deps),
100
+ configResolver: resolver,
101
+ configEncryption: encryption,
102
+ }),
103
+ authConfig: {
104
+ membershipQuery: "tenant:query:memberships",
105
+ loginHandler: AuthHandlers.login,
106
+ invite: {
107
+ acceptHandler: AuthHandlers.inviteAccept,
108
+ acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
109
+ signupCompleteHandler: AuthHandlers.inviteSignupComplete,
110
+ },
111
+ },
112
+ });
113
+
114
+ await unsafeCreateEntityTable(stack.db, userEntity);
115
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
116
+ await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
117
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
118
+ await unsafePushTables(stack.db, {
119
+ configValuesTable,
120
+ tenantMembershipsTable,
121
+ notificationPreferencesTable,
122
+ });
123
+ });
124
+
125
+ afterAll(async () => {
126
+ await stack.cleanup();
127
+ });
128
+
129
+ beforeEach(async () => {
130
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
131
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
132
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
133
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
134
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userMfaTable.tableName}"`);
135
+ emailTransport.sent.length = 0;
136
+ const allKeys = await stack.redis.redis.keys("invite:*");
137
+ if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
138
+
139
+ TENANT_A_ID = newTenantId();
140
+ await seedTenant(stack.db, {
141
+ id: TENANT_A_ID,
142
+ key: `tenant-a-${TENANT_A_ID.slice(0, 8)}`,
143
+ name: "Tenant A",
144
+ });
145
+
146
+ aliceId = crypto.randomUUID();
147
+ await seedRow(stack.db, userTable, {
148
+ id: aliceId,
149
+ tenantId: TENANT_A_ID,
150
+ email: ALICE_EMAIL,
151
+ displayName: "Alice",
152
+ passwordHash: await hashPassword("alice-pw-1234"),
153
+ locale: "de",
154
+ emailVerified: true,
155
+ roles: "[]",
156
+ status: USER_STATUS.Active,
157
+ });
158
+ await seedTenantMembership(stack.db, {
159
+ userId: aliceId,
160
+ tenantId: TENANT_A_ID,
161
+ roles: ["Admin"],
162
+ });
163
+ });
164
+
165
+ function aliceSession(): SessionUser {
166
+ return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
167
+ }
168
+
169
+ async function inviteEmail(email: string, role: string): Promise<string> {
170
+ await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, aliceSession());
171
+ const sent = emailTransport.sent.at(-1);
172
+ if (!sent) throw new Error("invite-create didn't send a mail");
173
+ return extractTokenFromMail(sent.html);
174
+ }
175
+
176
+ describe("invite-accept-with-login: MFA gate", () => {
177
+ test("an MFA-enrolled invitee gets an mfa-challenge, not a session", async () => {
178
+ const email = "mfa-invitee@example.com";
179
+ const password = "mfa-invitee-pw-1234";
180
+
181
+ // Enroll MFA for this user in TENANT_A BEFORE they're invited — userMfaEntity
182
+ // rows are keyed (userId, tenantId), so enrollment must target the tenant
183
+ // the invite will later grant membership in.
184
+ const mfaActor = createTestUser({ id: 701, tenantId: TENANT_A_ID, roles: ["User"] });
185
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
186
+ AuthMfaHandlers.enableStart,
187
+ { accountLabel: email },
188
+ mfaActor,
189
+ );
190
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
191
+ const secret = base32Decode(secretParam);
192
+ await stack.http.writeOk(
193
+ AuthMfaHandlers.enableConfirm,
194
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
195
+ mfaActor,
196
+ );
197
+
198
+ // Real user row matching the synthetic actor's id — invite-accept-with-login
199
+ // reads this row for password/status, gateEnforceMfa reads userMfaEntity
200
+ // by (mfaActor.id, TENANT_A_ID).
201
+ await seedRow(stack.db, userTable, {
202
+ id: mfaActor.id,
203
+ tenantId: TENANT_A_ID,
204
+ email,
205
+ passwordHash: await hashPassword(password),
206
+ displayName: "MFA Invitee",
207
+ locale: "de",
208
+ emailVerified: true,
209
+ roles: "[]",
210
+ status: USER_STATUS.Active,
211
+ });
212
+
213
+ const token = await inviteEmail(email, "Editor");
214
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
215
+ token,
216
+ email,
217
+ password,
218
+ });
219
+
220
+ expect(res.status).toBe(200);
221
+ const body = (await res.json()) as {
222
+ isSuccess: boolean;
223
+ mfaRequired?: boolean;
224
+ challengeToken?: string;
225
+ token?: string;
226
+ };
227
+ expect(body.isSuccess).toBe(true);
228
+ expect(body.mfaRequired).toBe(true);
229
+ expect(typeof body.challengeToken).toBe("string");
230
+ // No session — a challenge, not a JWT.
231
+ expect(body.token).toBeUndefined();
232
+ });
233
+ });
234
+
235
+ describe("invite-accept-with-login: account-status gate", () => {
236
+ test("a Restricted invitee is refused — no session minted", async () => {
237
+ const email = "restricted-invitee@example.com";
238
+ const password = "restricted-invitee-pw-1234";
239
+ const userId = crypto.randomUUID();
240
+
241
+ await seedRow(stack.db, userTable, {
242
+ id: userId,
243
+ tenantId: TENANT_A_ID,
244
+ email,
245
+ passwordHash: await hashPassword(password),
246
+ displayName: "Restricted Invitee",
247
+ locale: "de",
248
+ emailVerified: true,
249
+ roles: "[]",
250
+ status: USER_STATUS.Active,
251
+ });
252
+ await asRawClient(stack.db).unsafe(
253
+ `UPDATE "${userTable.tableName}" SET status = $1 WHERE id = $2`,
254
+ [USER_STATUS.Restricted, userId],
255
+ );
256
+
257
+ const token = await inviteEmail(email, "Editor");
258
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
259
+ token,
260
+ email,
261
+ password,
262
+ });
263
+
264
+ expect(res.status).not.toBe(200);
265
+ const body = (await res.json()) as { isSuccess: boolean; token?: string };
266
+ expect(body.isSuccess).toBe(false);
267
+ expect(body.token).toBeUndefined();
268
+
269
+ const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId });
270
+ expect(memberships).toHaveLength(0);
271
+ });
272
+ });
@@ -42,7 +42,10 @@ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
42
42
  import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
43
43
  import { createUserFeature } from "../../user/feature";
44
44
  import { userEntity, userTable } from "../../user/schema/user";
45
- import { tenantInvitationDeleteHook } from "../../user-data-rights-defaults";
45
+ import {
46
+ tenantInvitationDeleteHook,
47
+ tenantInvitationExportHook,
48
+ } from "../../user-data-rights-defaults";
46
49
  import { AuthHandlers } from "../constants";
47
50
  import { createAuthEmailPasswordFeature } from "../feature";
48
51
  import { seedUser } from "../seeding";
@@ -300,6 +303,33 @@ describe("auth flows with active KMS + blind index", () => {
300
303
  expect(list[0]?.invitedBy).toBe(aliceId);
301
304
  });
302
305
 
306
+ // fw-review #8: the export hook returned the raw stored `email` column —
307
+ // under active KMS that's base64 ciphertext, not the address, so a GDPR
308
+ // export contained an unreadable blob instead of the invited email. The
309
+ // hook matches on the EXPORTING user's own (decrypted) email, i.e. the
310
+ // invitee side — Bob is the invitee here, Alice the inviter.
311
+ test("Art.15 export decrypts the invitation email under active KMS", async () => {
312
+ await inviteEmail(BOB_EMAIL, "Editor");
313
+
314
+ const [rawInvitation] = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
315
+ `SELECT * FROM "${tenantInvitationsTable.tableName}" WHERE tenant_id = $1`,
316
+ [TENANT_A_ID],
317
+ );
318
+ if (!rawInvitation) throw new Error("no invitation row seeded");
319
+ expect(isPiiCiphertext(rawInvitation["email"])).toBe(true);
320
+
321
+ const result = await tenantInvitationExportHook({
322
+ db: stack.db,
323
+ registry: stack.registry,
324
+ tenantId: TENANT_A_ID,
325
+ userId: bobId,
326
+ });
327
+
328
+ expect(result?.entity).toBe("tenant-invitation");
329
+ expect(result?.rows).toHaveLength(1);
330
+ expect(result?.rows[0]?.["email"]).toBe(BOB_EMAIL);
331
+ });
332
+
303
333
  // fw-review #7: the inviter-forget arm compared a plaintext userId against
304
334
  // the encrypted `invitedBy` column via selectMany's equality filter — with
305
335
  // no lookupable/blind-index column on `invitedBy`, that filter matched
@@ -238,7 +238,15 @@ export function createAuthEmailPasswordFeature(
238
238
  if (opts.invite) {
239
239
  r.writeHandler(createInviteCreateHandler(opts.invite));
240
240
  r.writeHandler(createInviteAcceptHandler());
241
- r.writeHandler(createInviteAcceptWithLoginHandler());
241
+ // Same gates as the `login` handler above (lockout, password,
242
+ // email-verified, account-status, MFA) — see invite-accept-with-login.write.ts.
243
+ r.writeHandler(
244
+ createInviteAcceptWithLoginHandler({
245
+ mfaStatusChecker: opts.mfaStatusChecker,
246
+ accountLockout: opts.accountLockout,
247
+ strictEmailVerification: strictVerification,
248
+ }),
249
+ );
242
250
  r.writeHandler(createInviteSignupCompleteHandler());
243
251
  }
244
252
 
@@ -1,21 +1,25 @@
1
- // Tenant-Invite Step 2 — Branch 2 (anon User mit existing email).
1
+ // Tenant-Invite Step 2 — Branch 2 (anon user with an existing email).
2
2
  //
3
3
  // Flow:
4
- // 1. User (nicht eingeloggt) klickt Invite-Link → /invite/accept?token=...
5
- // 2. Frontend zeigt Login-Form mit pre-filled email (von der Invitation-
6
- // Page geliefert via separate Lookup-Query, oder vom User getippt)
7
- // 3. User submitted email + password + token an diesen Handler
8
- // 4. Server: login + accept in einem Schritt:
4
+ // 1. User (not logged in) clicks the invite link → /invite/accept?token=...
5
+ // 2. Frontend shows a login form with the pre-filled email (delivered by
6
+ // the invitation page via a separate lookup query, or typed by the user)
7
+ // 3. User submits email + password + token to this handler
8
+ // 4. Server: login + accept in one step:
9
9
  // a. Token → invitationId → invitation row
10
- // b. Login-Check: Password gegen userTable für invitation.email
11
- // c. Email-Match (vom User-Input) === invitation.email
12
- // d. Membership-Add im invited Tenant
13
- // e. Invitation status=accepted, Token gelöscht
14
- // 5. Response: SessionUser + tenantKey für Auto-Login (analog signup-confirm)
10
+ // b. Login check: password against userTable for invitation.email — via
11
+ // the same login.write.ts gates (lockout, password, email-verified,
12
+ // account-status, MFA), not a standalone verifyPassword call
13
+ // c. Email match (from user input) === invitation.email
14
+ // d. Membership added in the invited tenant
15
+ // e. Invitation → status=accepted, token deleted
16
+ // 5. Response: SessionUser + tenantId/role for auto-login (analog to
17
+ // signup-confirm), or an mfa-challenge/mfa-setup-required if an MFA
18
+ // gate fires — identical shape to login.write.ts (see LoginResult).
15
19
  //
16
- // Anders als signup-confirm: KEIN neuer Tenant entsteht, KEIN neuer
17
- // User entsteht beide existieren bereits. Magic ist die kombinierte
18
- // Login+Accept-Operation in einem Roundtrip.
20
+ // Unlike signup-confirm: no new tenant is created, no new user is created —
21
+ // both already exist. The magic is the combined login+accept operation in
22
+ // one round trip.
19
23
 
20
24
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
21
25
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
@@ -28,17 +32,21 @@ import {
28
32
  } from "@cosmicdrift/kumiko-framework/engine";
29
33
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
30
34
  import { z } from "zod";
31
- import { decryptStoredPii, sessionTimezoneField, verifyPassword } from "../../shared";
35
+ import { decryptStoredPii, sessionTimezoneField } from "../../shared";
32
36
  // kumiko-lint-ignore cross-feature-import invite-flow
33
37
  import {
34
38
  INVITATION_STATUS,
35
39
  tenantInvitationEntity,
36
40
  tenantInvitationsTable,
37
41
  } from "../../tenant/invitation-table";
38
- // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
42
+ // kumiko-lint-ignore cross-feature-import membership-seed-helper for a privileged cross-tenant add
39
43
  import { seedTenantMembership } from "../../tenant/seeding";
40
44
  // kumiko-lint-ignore cross-feature-import login-style password-check
41
45
  import { userTable } from "../../user/schema/user";
46
+ import {
47
+ AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
48
+ AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
49
+ } from "../constants";
42
50
  import { invalidInviteToken, inviteEmailMismatch } from "../errors";
43
51
  import {
44
52
  burnInviteToken,
@@ -47,6 +55,15 @@ import {
47
55
  unburnInviteToken,
48
56
  } from "../invite-token-store";
49
57
  import { passwordSchema } from "../password-policy";
58
+ import {
59
+ gateEnforceAccountStatus,
60
+ gateEnforceEmailVerified,
61
+ gateEnforceLockout,
62
+ gateEnforceMfa,
63
+ gateVerifyPassword,
64
+ type LoginHandlerOptions,
65
+ type LoginResult,
66
+ } from "./login.write";
50
67
 
51
68
  const InviteAcceptWithLoginSchema = z.object({
52
69
  token: z.string().min(1),
@@ -54,11 +71,23 @@ const InviteAcceptWithLoginSchema = z.object({
54
71
  password: passwordSchema,
55
72
  });
56
73
 
57
- export type InviteAcceptWithLoginData = {
58
- readonly kind: "auth-session";
59
- readonly session: SessionUser;
60
- readonly tenantId: TenantId;
61
- readonly role: string;
74
+ // Reuses login.write.ts's MFA branches unmodified (see LoginResult) so a
75
+ // client handles an invite-accept-with-login MFA challenge exactly like a
76
+ // regular login one. The auth-session branch stays invite-specific — it
77
+ // additionally carries tenantId/role for the invited tenant.
78
+ export type InviteAcceptWithLoginData =
79
+ | {
80
+ readonly kind: "auth-session";
81
+ readonly session: SessionUser;
82
+ readonly tenantId: TenantId;
83
+ readonly role: string;
84
+ }
85
+ | Exclude<LoginResult, { readonly kind: "auth-session" }>;
86
+
87
+ export type InviteAcceptWithLoginOptions = {
88
+ readonly mfaStatusChecker?: LoginHandlerOptions["mfaStatusChecker"];
89
+ readonly accountLockout?: LoginHandlerOptions["accountLockout"];
90
+ readonly strictEmailVerification?: boolean;
62
91
  };
63
92
 
64
93
  const invitationExecutor = createEventStoreExecutor(
@@ -67,7 +96,13 @@ const invitationExecutor = createEventStoreExecutor(
67
96
  { entityName: "tenant-invitation" },
68
97
  );
69
98
 
70
- export function createInviteAcceptWithLoginHandler() {
99
+ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOptions = {}) {
100
+ const strictVerification = opts.strictEmailVerification === true;
101
+ const maxFailedAttempts =
102
+ opts.accountLockout?.maxFailedAttempts ?? AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS;
103
+ const lockoutDurationMinutes =
104
+ opts.accountLockout?.lockoutDurationMinutes ?? AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES;
105
+
71
106
  return defineWriteHandler<
72
107
  "invite-accept-with-login",
73
108
  typeof InviteAcceptWithLoginSchema,
@@ -76,6 +111,7 @@ export function createInviteAcceptWithLoginHandler() {
76
111
  name: "invite-accept-with-login",
77
112
  schema: InviteAcceptWithLoginSchema,
78
113
  access: { roles: ["all"] },
114
+ // kumiko-lint-ignore complexity-budget reuses login.write.ts's gate chain (lockout/password/email/status/membership/mfa) plus invite-specific branches (email match, already-member check, invitation update, unburn-on-failure) — splitting would scatter gate order across functions without reducing risk
79
115
  handler: async (event, ctx) => {
80
116
  if (!ctx.redis) {
81
117
  return writeFailure(
@@ -100,6 +136,8 @@ export function createInviteAcceptWithLoginHandler() {
100
136
  readonly id: string;
101
137
  readonly passwordHash: string | null;
102
138
  readonly timezone?: string | null;
139
+ readonly emailVerified?: boolean | null;
140
+ readonly status?: string | null;
103
141
  };
104
142
 
105
143
  let committed = false;
@@ -119,24 +157,42 @@ export function createInviteAcceptWithLoginHandler() {
119
157
  const invitationRole = invitation.role;
120
158
  const invitationVersion = invitation.version;
121
159
 
122
- // Email-Match vom User-Input (nicht aus session — User ist anon)
160
+ // Email match from user input (not from session — user is anon)
123
161
  if (event.payload.email.toLowerCase() !== invitationEmail) {
124
162
  return inviteEmailMismatch();
125
163
  }
126
164
 
127
- // Password-Check gegen userTable. Anti-enumeration: bei
128
- // user-not-found ODER wrong-password collapsed beides auf
129
- // invalidInviteToken (gleicher anti-enum-Trade-off wie reset).
165
+ // Password check against userTable, through the same gates
166
+ // login.write.ts runs (see login-gates.test.ts for the gate contracts).
130
167
  const userRow = await fetchOne<UserAuthRow>(ctx.db.raw, userTable, {
131
168
  email: invitationEmail,
132
169
  });
133
170
  if (!userRow?.passwordHash) return invalidInviteToken();
134
- const passwordValid = await verifyPassword(userRow.passwordHash, event.payload.password);
135
- if (!passwordValid) return invalidInviteToken();
171
+
172
+ const lockoutGate = await gateEnforceLockout(ctx, userRow.id);
173
+ if (!lockoutGate.ok) return lockoutGate.result;
174
+
175
+ // Wrong password still collapses to invalidInviteToken (existing
176
+ // anti-enum contract for this endpoint) — gateVerifyPassword runs
177
+ // regardless, for its failed-attempt recording side effect.
178
+ const passwordGate = await gateVerifyPassword(
179
+ ctx,
180
+ { id: userRow.id, passwordHash: userRow.passwordHash },
181
+ event.payload.password,
182
+ maxFailedAttempts,
183
+ lockoutDurationMinutes,
184
+ );
185
+ if (!passwordGate.ok) return invalidInviteToken();
186
+
187
+ const emailGate = gateEnforceEmailVerified(userRow, strictVerification);
188
+ if (!emailGate.ok) return emailGate.result;
189
+
190
+ const statusGate = gateEnforceAccountStatus(userRow);
191
+ if (!statusGate.ok) return statusGate.result;
136
192
 
137
193
  const userId = userRow.id;
138
194
 
139
- // Already-Member-Check (idempotent)
195
+ // Already-member check (idempotent)
140
196
  const memberships = (await ctx.queryAs(
141
197
  createSystemUser(invitationTenantId),
142
198
  "tenant:query:memberships",
@@ -154,7 +210,7 @@ export function createInviteAcceptWithLoginHandler() {
154
210
  });
155
211
  }
156
212
 
157
- // Invitation → accepted: TenantDb für invitation-tenant.
213
+ // Invitation → accepted: TenantDb for the invitation's tenant.
158
214
  const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
159
215
  const updateResult = await invitationExecutor.update(
160
216
  {
@@ -169,14 +225,31 @@ export function createInviteAcceptWithLoginHandler() {
169
225
 
170
226
  await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
171
227
 
172
- // SessionUser für JWT-Mint im invited Tenant. Roles =
173
- // [invitationRole] (Admin/Editor/User je nach invite).
228
+ // buildSessionRoles calls stripForbiddenMembershipRoles internally
229
+ // a reserved role on the invitation itself must never reach the session.
230
+ const mergedRoles = buildSessionRoles([], [invitationRole]);
231
+
232
+ // MFA gate runs after membership is granted (mirrors login.write.ts's
233
+ // own order: membership resolution before MFA) — a challenge halts
234
+ // the session mint, not the invite acceptance itself.
235
+ const mfaGate = await gateEnforceMfa(
236
+ ctx,
237
+ { mfaStatusChecker: opts.mfaStatusChecker },
238
+ userId,
239
+ invitationTenantId,
240
+ mergedRoles,
241
+ );
242
+ if (mfaGate !== undefined) {
243
+ committed = true;
244
+ return { isSuccess: true, data: mfaGate };
245
+ }
246
+
247
+ // SessionUser for the JWT mint in the invited tenant. Roles =
248
+ // mergedRoles (Admin/Editor/User depending on the invite).
174
249
  const session: SessionUser = {
175
250
  id: userId,
176
251
  tenantId: invitationTenantId,
177
- // buildSessionRoles calls stripForbiddenMembershipRoles internally —
178
- // a reserved role on the invitation itself must never reach the session.
179
- roles: buildSessionRoles([], [invitationRole]),
252
+ roles: mergedRoles,
180
253
  ...sessionTimezoneField(userRow.timezone),
181
254
  };
182
255
 
@@ -7,6 +7,7 @@ import {
7
7
  type TenantId,
8
8
  type WriteResult,
9
9
  } from "@cosmicdrift/kumiko-framework/engine";
10
+ import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
10
11
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
11
12
  import { z } from "zod";
12
13
  import { verifyDummyPassword, verifyPassword } from "../../shared";
@@ -67,18 +68,21 @@ const SYSTEM_USER_ID = "00000000-0000-4000-8000-000000000000";
67
68
  // Two possible success shapes: a straight login mints a session; a login
68
69
  // gated by MFA hands back a challenge instead — auth-routes.ts branches on
69
70
  // `kind` to decide whether to mint a JWT now or wait for /auth/mfa/verify.
70
- type LoginResult =
71
+ export type LoginResult =
71
72
  | { readonly kind: "auth-session"; readonly session: SessionUser }
72
73
  | { readonly kind: "mfa-challenge"; readonly challengeToken: string }
73
74
  | { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string };
74
75
 
75
76
  type Membership = { readonly tenantId: TenantId; readonly roles: readonly string[] };
76
77
 
77
- type GateReject = { readonly ok: false; readonly result: WriteResult<LoginResult> };
78
+ // result is always a WriteFailure at construction (every reject() call site
79
+ // passes one) — typing it that way, not WriteResult<LoginResult>, lets other
80
+ // handlers with a different success-data type reuse these gates unchanged.
81
+ type GateReject = { readonly ok: false; readonly result: WriteFailure };
78
82
  type GateOk<T> = { readonly ok: true; readonly value: T };
79
83
  type GateOutcome<T> = GateReject | GateOk<T>;
80
84
 
81
- function reject(result: WriteResult<LoginResult>): GateReject {
85
+ function reject(result: WriteFailure): GateReject {
82
86
  return { ok: false, result };
83
87
  }
84
88
 
@@ -199,14 +203,15 @@ export async function gateResolveMembership(
199
203
  return ok({ chosen, mergedRoles });
200
204
  }
201
205
 
202
- /** MFA challenge / setup-required / proceed. */
206
+ // MFA challenge / setup-required / proceed. Excludes "auth-session" (this
207
+ // gate never builds one) so callers with their own session shape reuse it as-is.
203
208
  export async function gateEnforceMfa(
204
209
  ctx: HandlerContext,
205
210
  opts: LoginHandlerOptions,
206
211
  userId: string,
207
212
  tenantId: TenantId,
208
213
  mergedRoles: readonly string[],
209
- ): Promise<LoginResult | undefined> {
214
+ ): Promise<Exclude<LoginResult, { readonly kind: "auth-session" }> | undefined> {
210
215
  if (!opts.mfaStatusChecker) return undefined;
211
216
  const mfaStatus = await opts.mfaStatusChecker(ctx, userId, tenantId, mergedRoles);
212
217
  if ("challengeToken" in mfaStatus) {