@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.
- package/package.json +7 -7
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
- package/src/auth-email-password/feature.ts +9 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
- package/src/auth-email-password/handlers/login.write.ts +10 -5
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
- package/src/auth-mfa/feature.ts +3 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
- package/src/compliance-profiles/feature.ts +18 -5
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/compliance-profiles/index.ts +1 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
- package/src/delivery/feature.ts +13 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/index.ts +1 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
- package/src/derivatives-sharp/render.ts +55 -15
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
- package/src/file-derivatives/feature.ts +12 -13
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
- package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
- package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
- package/src/form-draft/constants.ts +13 -0
- package/src/form-draft/db/queries/cleanup.ts +4 -1
- package/src/form-draft/db/queries/draft-count.ts +20 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
- package/src/form-draft/handlers/cleanup.job.ts +6 -0
- package/src/form-draft/handlers/discard.write.ts +6 -0
- package/src/form-draft/handlers/save.write.ts +15 -2
- package/src/form-draft/schemas.ts +16 -2
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
- package/src/personal-access-tokens/feature.ts +4 -1
- package/src/personal-access-tokens/roles.ts +11 -3
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
- package/src/sessions/feature.ts +18 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
- package/src/sessions/index.ts +1 -1
- package/src/sessions/session-callbacks.ts +7 -1
- package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
- package/src/tenant/handlers/remove-member.write.ts +23 -1
- package/src/tenant/handlers/update-member-roles.write.ts +23 -1
- package/src/tenant/handlers/update.write.ts +35 -2
- package/src/tenant/invitation-table.ts +8 -1
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
- package/src/user-data-rights/feature.ts +10 -7
- 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.
|
|
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.
|
|
129
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1
|
+
// Tenant-Invite Step 2 — Branch 2 (anon user with an existing email).
|
|
2
2
|
//
|
|
3
3
|
// Flow:
|
|
4
|
-
// 1. User (
|
|
5
|
-
// 2. Frontend
|
|
6
|
-
//
|
|
7
|
-
// 3. User
|
|
8
|
-
// 4. Server: login + accept in
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
128
|
-
//
|
|
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
|
-
|
|
135
|
-
|
|
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-
|
|
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
|
|
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
|
-
//
|
|
173
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) {
|