@cosmicdrift/kumiko-dev-server 0.155.1 → 0.156.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.155.1",
3
+ "version": "0.156.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules — see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -54,9 +54,9 @@
54
54
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
55
55
  },
56
56
  "dependencies": {
57
- "@cosmicdrift/kumiko-bundled-features": "0.155.1",
58
- "@cosmicdrift/kumiko-framework": "0.155.1",
59
- "@cosmicdrift/kumiko-server-runtime": "0.155.1",
57
+ "@cosmicdrift/kumiko-bundled-features": "0.156.0",
58
+ "@cosmicdrift/kumiko-framework": "0.156.0",
59
+ "@cosmicdrift/kumiko-server-runtime": "0.156.0",
60
60
  "ts-morph": "^28.0.0"
61
61
  },
62
62
  "publishConfig": {
@@ -4,6 +4,7 @@ import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-feat
4
4
  import {
5
5
  composeFileStack,
6
6
  composeGdprStack,
7
+ composeIdentityStack,
7
8
  composeMailStack,
8
9
  composeOpsStack,
9
10
  composePagesStack,
@@ -12,6 +13,12 @@ import {
12
13
  stackFeatureNames,
13
14
  } from "../compose-stacks";
14
15
 
16
+ const TEST_MFA = {
17
+ setupTokenSecret: "compose-stacks-mfa-setup-secret-at-least-32b!!",
18
+ challengeTokenSecret: "compose-stacks-mfa-challenge-secret-32b!!",
19
+ issuer: "Kumiko ComposeStacks",
20
+ } as const;
21
+
15
22
  describe("composeStacks", () => {
16
23
  test("composeRendererStack → template-resolver, renderer-foundation, renderer-simple", () => {
17
24
  expect(stackFeatureNames(composeRendererStack())).toEqual([
@@ -77,6 +84,38 @@ describe("composeStacks", () => {
77
84
  "rate-limiting",
78
85
  ]);
79
86
  });
87
+
88
+ test("composeIdentityStack defaults sessions only", () => {
89
+ expect(stackFeatureNames(composeIdentityStack())).toEqual(["sessions"]);
90
+ });
91
+
92
+ test("composeIdentityStack mounts auth-mfa when mfa options given", () => {
93
+ expect(stackFeatureNames(composeIdentityStack({ mfa: TEST_MFA }))).toEqual([
94
+ "sessions",
95
+ "auth-mfa",
96
+ ]);
97
+ });
98
+
99
+ test("composeIdentityStack sessions:false + mfa → auth-mfa only", () => {
100
+ expect(stackFeatureNames(composeIdentityStack({ sessions: false, mfa: TEST_MFA }))).toEqual([
101
+ "auth-mfa",
102
+ ]);
103
+ });
104
+
105
+ test("composeIdentityStack + composeGdprStack(sessions) → duplicate feature rejected at boot", () => {
106
+ const names = stackFeatureNames([
107
+ ...composeIdentityStack(),
108
+ ...composeGdprStack({ sessions: true }),
109
+ ]);
110
+ expect(names.filter((n) => n === "sessions")).toHaveLength(2);
111
+ expect(() =>
112
+ validateBoot(
113
+ composeFeatures([...composeIdentityStack(), ...composeGdprStack({ sessions: true })], {
114
+ includeBundled: true,
115
+ }),
116
+ ),
117
+ ).toThrow(/duplicate feature/i);
118
+ });
80
119
  });
81
120
 
82
121
  /** Snapshot, not a live parity check: intended block combinations, mirrored by hand from studio/money-horse/publicstatus run-configs. A drifted run-config stays green here. */
@@ -95,6 +134,25 @@ describe("composeStacks boots for real", () => {
95
134
  );
96
135
  expect(() => validateBoot(features)).not.toThrow();
97
136
  });
137
+
138
+ // auth-mfa's encrypted fields need a runtime KEK (env or configureEntityFieldEncryption
139
+ // + secrets). Name-list + saas-identity-wire.integration.test.ts cover identity boot.
140
+ test("identity stack names compose with ops + renderer under includeBundled", () => {
141
+ const features = composeFeatures(
142
+ [
143
+ ...composeIdentityStack({ mfa: TEST_MFA }),
144
+ ...composeOpsStack(),
145
+ ...composeRendererStack(),
146
+ ...composeMailStack({ transports: ["inmemory"] }),
147
+ ],
148
+ { includeBundled: true },
149
+ );
150
+ const names = stackFeatureNames(features);
151
+ expect(names).toContain("auth-email-password");
152
+ expect(names).toContain("sessions");
153
+ expect(names).toContain("auth-mfa");
154
+ expect(names).toContain("delivery");
155
+ });
98
156
  });
99
157
 
100
158
  describe("composeStacks intended block names (snapshot, not live parity)", () => {
@@ -172,4 +230,10 @@ describe("composeStacks intended block names (snapshot, not live parity)", () =>
172
230
  expect(names).toContain(expected);
173
231
  }
174
232
  });
233
+
234
+ test("identity stack covers sessions + auth-mfa", () => {
235
+ const names = stackFeatureNames(composeIdentityStack({ mfa: TEST_MFA }));
236
+ expect(names).toContain("sessions");
237
+ expect(names).toContain("auth-mfa");
238
+ });
175
239
  });
@@ -169,7 +169,7 @@ describe("buildFewShotCorpus — warnings", () => {
169
169
 
170
170
  expect(corpus.entries).toHaveLength(1);
171
171
  expect(corpus.warnings).toHaveLength(1);
172
- expect(corpus.warnings[0]?.reason).toMatch(/^duplicate-id:/);
172
+ expect(corpus.warnings[0]?.message).toMatch(/^duplicate-id:/);
173
173
  });
174
174
 
175
175
  test("parser-throw surfaces as a warning instead of silent skip", () => {
@@ -0,0 +1,423 @@
1
+ // Cross-feature SaaS identity wire suite — one mount that mirrors a real app
2
+ // stack (composeIdentityStack + delivery + renderer + channel-email) and drives
3
+ // the happy paths apps always need. Proves auth ↔ delivery mail ↔ sessions
4
+ // (login jti + mine) ↔ MFA challenge/verify. Renderer stack is mounted for
5
+ // delivery deps; channel-email uses an injected in-memory transport (not a
6
+ // template-resolver round-trip). Edge cases stay in per-feature suites.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { randomBytes } from "node:crypto";
10
+ import { AuthErrors, AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
11
+ import {
12
+ seedAdmin,
13
+ seedUserWithPassword,
14
+ } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
15
+ import {
16
+ AuthMfaHandlers,
17
+ base32Decode,
18
+ bindMfaRevokeAllOtherSessionsFromFeature,
19
+ userMfaEntity,
20
+ userMfaTable,
21
+ } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
22
+ import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
23
+ import {
24
+ createChannelEmailFeature,
25
+ createInMemoryTransport,
26
+ } from "@cosmicdrift/kumiko-bundled-features/channel-email";
27
+ import {
28
+ configValuesTable,
29
+ createConfigResolver,
30
+ } from "@cosmicdrift/kumiko-bundled-features/config";
31
+ import { createDeliveryTestContext } from "@cosmicdrift/kumiko-bundled-features/delivery";
32
+ import { notificationPreferencesTable } from "@cosmicdrift/kumiko-bundled-features/delivery/tables";
33
+ import { simpleRenderer } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
34
+ import {
35
+ createSessionCallbacks,
36
+ type SessionCallbacks,
37
+ SessionQueries,
38
+ userSessionEntity,
39
+ userSessionTable,
40
+ } from "@cosmicdrift/kumiko-bundled-features/sessions";
41
+ import { sessionCallbacksFromLateBound } from "@cosmicdrift/kumiko-bundled-features/sessions/testing";
42
+ import {
43
+ tenantEntity,
44
+ tenantInvitationEntity,
45
+ tenantInvitationsTable,
46
+ tenantMembershipsTable,
47
+ tenantTable,
48
+ } from "@cosmicdrift/kumiko-bundled-features/tenant";
49
+ import {
50
+ seedTenant,
51
+ seedTenantMembership,
52
+ } from "@cosmicdrift/kumiko-bundled-features/tenant/seeding";
53
+ import { userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
54
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
55
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
56
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
57
+ import {
58
+ setupTestStack,
59
+ type TestStack,
60
+ unsafeCreateEntityTable,
61
+ unsafePushTables,
62
+ } from "@cosmicdrift/kumiko-framework/stack";
63
+ import {
64
+ createLateBoundHolder,
65
+ createTestEnvelopeCipher,
66
+ deleteRows,
67
+ updateRows,
68
+ } from "@cosmicdrift/kumiko-framework/testing";
69
+ import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
70
+ import * as jose from "jose";
71
+ import { composeIdentityStack, composeOpsStack, composeRendererStack } from "../compose-stacks";
72
+
73
+ const SETUP_TOKEN_SECRET = "wire-mfa-setup-secret-at-least-32-bytes-long!!";
74
+ const CHALLENGE_TOKEN_SECRET = "wire-mfa-challenge-secret-at-least-32-bytes!!";
75
+ const RESET_SECRET = randomBytes(32).toString("base64");
76
+ const VERIFY_SECRET = randomBytes(32).toString("base64");
77
+ const APP_SIGNUP_URL = "https://app.example.com/signup/complete";
78
+ const APP_RESET_URL = "https://app.example.com/reset";
79
+ const APP_VERIFY_URL = "https://app.example.com/verify";
80
+ const APP_INVITE_URL = "https://app.example.com/invite/accept";
81
+
82
+ const emailTransport = createInMemoryTransport();
83
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
84
+
85
+ let stack: TestStack;
86
+
87
+ function extractTokenFromMail(html: string): string {
88
+ const match = html.match(/[?&]token=([^&"'<\s]+)/);
89
+ if (!match?.[1]) throw new Error(`No token in mail html: ${html.slice(0, 200)}`);
90
+ return decodeURIComponent(match[1]);
91
+ }
92
+
93
+ async function markVerified(userId: string): Promise<void> {
94
+ await updateRows(stack.db, userTable, { emailVerified: true }, { id: userId });
95
+ }
96
+
97
+ beforeAll(async () => {
98
+ configureEntityFieldEncryption(createTestEnvelopeCipher());
99
+ const bound = sessionCallbacksFromLateBound(callbacks);
100
+
101
+ const identity = composeIdentityStack({
102
+ mfa: {
103
+ setupTokenSecret: SETUP_TOKEN_SECRET,
104
+ challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
105
+ issuer: "Kumiko Wire",
106
+ },
107
+ });
108
+ const mfaFeature = identity.find((f) => f.name === "auth-mfa");
109
+ if (!mfaFeature) throw new Error("composeIdentityStack did not mount auth-mfa");
110
+
111
+ const features = composeFeatures(
112
+ [
113
+ ...identity,
114
+ ...composeOpsStack({ delivery: true, audit: false, jobs: false }),
115
+ ...composeRendererStack(),
116
+ createChannelEmailFeature({
117
+ transport: emailTransport,
118
+ renderer: simpleRenderer,
119
+ resolveEmail: async () => "unused@test.local",
120
+ }),
121
+ ],
122
+ {
123
+ includeBundled: true,
124
+ authOptions: {
125
+ signup: { tokenTtlMinutes: 60, appUrl: APP_SIGNUP_URL },
126
+ invite: { tokenTtlMinutes: 60, appUrl: APP_INVITE_URL },
127
+ passwordReset: {
128
+ hmacSecret: RESET_SECRET,
129
+ tokenTtlMinutes: 15,
130
+ appUrl: APP_RESET_URL,
131
+ },
132
+ emailVerification: {
133
+ hmacSecret: VERIFY_SECRET,
134
+ tokenTtlMinutes: 60,
135
+ mode: "strict",
136
+ appUrl: APP_VERIFY_URL,
137
+ },
138
+ },
139
+ },
140
+ );
141
+
142
+ stack = await setupTestStack({
143
+ features,
144
+ extraContext: (deps) => ({
145
+ ...createDeliveryTestContext(deps),
146
+ configResolver: createConfigResolver(),
147
+ }),
148
+ authConfig: {
149
+ ...bound.asAuthConfig(),
150
+ // No sessionStrictMode: seed/writeOk uses TestUsers JWTs without sid.
151
+ // Login still gets jti via sessionCreator (asserted below).
152
+ membershipQuery: "tenant:query:memberships",
153
+ loginHandler: AuthHandlers.login,
154
+ mfaVerifyHandler: AuthMfaHandlers.verify,
155
+ loginErrorStatusMap: {
156
+ [AuthErrors.invalidCredentials]: 401,
157
+ [AuthErrors.noMembership]: 403,
158
+ [AuthErrors.emailNotVerified]: 403,
159
+ },
160
+ signup: {
161
+ requestHandler: AuthHandlers.signupRequest,
162
+ confirmHandler: AuthHandlers.signupConfirm,
163
+ },
164
+ passwordReset: {
165
+ requestHandler: AuthHandlers.requestPasswordReset,
166
+ confirmHandler: AuthHandlers.resetPassword,
167
+ },
168
+ emailVerification: {
169
+ requestHandler: AuthHandlers.requestEmailVerification,
170
+ confirmHandler: AuthHandlers.verifyEmail,
171
+ },
172
+ invite: {
173
+ acceptHandler: AuthHandlers.inviteAccept,
174
+ acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
175
+ signupCompleteHandler: AuthHandlers.inviteSignupComplete,
176
+ },
177
+ },
178
+ });
179
+
180
+ const sessionCallbacks = createSessionCallbacks({ db: stack.db });
181
+ callbacks.set(sessionCallbacks);
182
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
183
+
184
+ await unsafeCreateEntityTable(stack.db, userEntity);
185
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
186
+ await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
187
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
188
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
189
+ await unsafePushTables(stack.db, {
190
+ configValuesTable,
191
+ tenantMembershipsTable,
192
+ notificationPreferencesTable,
193
+ userSessionTable,
194
+ });
195
+ });
196
+
197
+ afterAll(async () => {
198
+ await stack?.cleanup();
199
+ configureEntityFieldEncryption(undefined);
200
+ });
201
+
202
+ beforeEach(async () => {
203
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
204
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
205
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
206
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
207
+ await deleteRows(stack.db, userSessionTable, {});
208
+ await deleteRows(stack.db, userMfaTable, {});
209
+ emailTransport.sent.length = 0;
210
+ for (const pattern of ["signup:*", "invite:*"] as const) {
211
+ const keys = await stack.redis.redis.keys(pattern);
212
+ if (keys.length > 0) await stack.redis.redis.del(...keys);
213
+ }
214
+ });
215
+
216
+ describe("saas-identity-wire", () => {
217
+ test("signup → delivery mail → confirm → login JWT with sid", async () => {
218
+ const email = "signup-wire@example.com";
219
+ const password = "fresh-secure-pw-1234";
220
+
221
+ const req = await stack.http.raw("POST", "/api/auth/signup-request", { email });
222
+ expect(req.status).toBe(200);
223
+ expect(emailTransport.sent).toHaveLength(1);
224
+ const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
225
+
226
+ const confirm = await stack.http.raw("POST", "/api/auth/signup-confirm", { token, password });
227
+ expect(confirm.status).toBe(200);
228
+
229
+ const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
230
+ expect(login.status).toBe(200);
231
+ const body = (await login.json()) as { token?: string };
232
+ expect(body.token).toBeTypeOf("string");
233
+ expect(typeof jose.decodeJwt(body.token!).jti).toBe("string");
234
+ });
235
+
236
+ test("password reset → delivery mail → reset → login with new password", async () => {
237
+ const email = "reset-wire@example.com";
238
+ const oldPassword = "old-password-1234";
239
+ const newPassword = "new-password-5678";
240
+ const tenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
241
+ const { id } = await seedAdmin(stack.db, {
242
+ email,
243
+ password: oldPassword,
244
+ displayName: "Reset",
245
+ memberships: [
246
+ {
247
+ tenantId,
248
+ tenantKey: "reset-wire",
249
+ tenantName: "Reset Wire",
250
+ roles: ["User"],
251
+ },
252
+ ],
253
+ });
254
+ await markVerified(id);
255
+
256
+ const req = await stack.http.raw("POST", "/api/auth/request-password-reset", { email });
257
+ expect(req.status).toBe(200);
258
+ expect(emailTransport.sent).toHaveLength(1);
259
+ const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
260
+
261
+ const reset = await stack.http.raw("POST", "/api/auth/reset-password", {
262
+ token,
263
+ newPassword,
264
+ });
265
+ expect(reset.status).toBe(200);
266
+
267
+ const oldLogin = await stack.http.raw("POST", "/api/auth/login", {
268
+ email,
269
+ password: oldPassword,
270
+ });
271
+ expect(oldLogin.status).toBe(401);
272
+
273
+ const login = await stack.http.raw("POST", "/api/auth/login", { email, password: newPassword });
274
+ expect(login.status).toBe(200);
275
+ const body = (await login.json()) as { token?: string };
276
+ expect(body.token).toBeTypeOf("string");
277
+ });
278
+
279
+ test("invite Branch3 → signup-complete → login", async () => {
280
+ const tenantId = crypto.randomUUID() as TenantId;
281
+ const { id: adminId } = await seedAdmin(stack.db, {
282
+ email: "admin-wire@example.com",
283
+ password: "admin-password-1234",
284
+ displayName: "Admin",
285
+ memberships: [
286
+ {
287
+ tenantId,
288
+ tenantKey: `invite-${tenantId.slice(0, 8)}`,
289
+ tenantName: "Invite Wire",
290
+ roles: ["Admin"],
291
+ },
292
+ ],
293
+ });
294
+ await markVerified(adminId);
295
+
296
+ const admin: SessionUser = { id: adminId, tenantId, roles: ["Admin"] };
297
+ const invitee = "carol-wire@example.com";
298
+ await stack.http.writeOk(AuthHandlers.inviteCreate, { email: invitee, role: "User" }, admin);
299
+ expect(emailTransport.sent.length).toBeGreaterThanOrEqual(1);
300
+ const token = extractTokenFromMail(emailTransport.sent.at(-1)?.html ?? "");
301
+
302
+ const complete = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
303
+ token,
304
+ password: "carol-new-pw-1234",
305
+ });
306
+ expect(complete.status).toBe(200);
307
+
308
+ const login = await stack.http.raw("POST", "/api/auth/login", {
309
+ email: invitee,
310
+ password: "carol-new-pw-1234",
311
+ });
312
+ expect(login.status).toBe(200);
313
+ });
314
+
315
+ test("email verify gate: unverified blocked → verify → login ok", async () => {
316
+ const email = "verify-wire@example.com";
317
+ const password = "verify-password-1234";
318
+ const tenantId = "00000000-0000-4000-8000-000000000002" as TenantId;
319
+ await seedTenant(stack.db, {
320
+ id: tenantId,
321
+ key: "verify-wire",
322
+ name: "Verify Wire",
323
+ });
324
+ const { id } = await seedUserWithPassword(stack.db, {
325
+ email,
326
+ password,
327
+ displayName: "Verify",
328
+ emailVerified: false,
329
+ });
330
+ await seedTenantMembership(stack.db, {
331
+ userId: id,
332
+ tenantId,
333
+ roles: ["User"],
334
+ });
335
+
336
+ const blocked = await stack.http.raw("POST", "/api/auth/login", { email, password });
337
+ expect(blocked.status).toBe(403);
338
+ const blockedBody = (await blocked.json()) as { error?: { details?: { reason?: string } } };
339
+ expect(blockedBody.error?.details?.reason).toBe(AuthErrors.emailNotVerified);
340
+
341
+ const req = await stack.http.raw("POST", "/api/auth/request-email-verification", { email });
342
+ expect(req.status).toBe(200);
343
+ expect(emailTransport.sent).toHaveLength(1);
344
+ const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
345
+
346
+ const verify = await stack.http.raw("POST", "/api/auth/verify-email", { token });
347
+ expect(verify.status).toBe(200);
348
+
349
+ const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
350
+ expect(login.status).toBe(200);
351
+ });
352
+
353
+ test("MFA enable → login challenges → verify JWT; sessions:mine lists sid", async () => {
354
+ const email = "mfa-wire@example.com";
355
+ const password = "mfa-password-1234";
356
+ const tenantId = "00000000-0000-4000-8000-000000000003" as TenantId;
357
+ const { id: userId } = await seedAdmin(stack.db, {
358
+ email,
359
+ password,
360
+ displayName: "Mfa",
361
+ memberships: [
362
+ {
363
+ tenantId,
364
+ tenantKey: "mfa-wire",
365
+ tenantName: "MFA Wire",
366
+ roles: ["User"],
367
+ },
368
+ ],
369
+ });
370
+ await markVerified(userId);
371
+ const user: SessionUser = { id: userId, tenantId, roles: ["User"] };
372
+
373
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
374
+ AuthMfaHandlers.enableStart,
375
+ { accountLabel: email },
376
+ user,
377
+ );
378
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
379
+ const secret = base32Decode(secretParam);
380
+ await stack.http.writeOk(
381
+ AuthMfaHandlers.enableConfirm,
382
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
383
+ user,
384
+ );
385
+
386
+ const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
387
+ expect(login.status).toBe(200);
388
+ const challenge = (await login.json()) as {
389
+ mfaRequired?: boolean;
390
+ challengeToken?: string;
391
+ token?: string;
392
+ };
393
+ expect(challenge.mfaRequired).toBe(true);
394
+ expect(challenge.token).toBeUndefined();
395
+ expect(challenge.challengeToken).toBeTypeOf("string");
396
+
397
+ const verify = await stack.http.raw("POST", "/api/auth/mfa/verify", {
398
+ challengeToken: challenge.challengeToken,
399
+ code: currentTotpCode(secret),
400
+ });
401
+ expect(verify.status).toBe(200);
402
+ const { token } = (await verify.json()) as { token: string };
403
+ expect(token).toBeTypeOf("string");
404
+ const sid = jose.decodeJwt(token).jti;
405
+ expect(typeof sid).toBe("string");
406
+
407
+ const mineRes = await stack.http.raw(
408
+ "POST",
409
+ "/api/query",
410
+ { type: SessionQueries.mine, payload: {} },
411
+ { Authorization: `Bearer ${token}` },
412
+ );
413
+ expect(mineRes.status).toBe(200);
414
+ const mineBody = (await mineRes.json()) as {
415
+ data?: Array<{ id: string }> | { items?: Array<{ id: string }> };
416
+ };
417
+ const items = Array.isArray(mineBody.data) ? mineBody.data : (mineBody.data?.items ?? []);
418
+ expect(items.some((s) => s.id === sid)).toBe(true);
419
+
420
+ const rows = await selectMany(stack.db, userSessionTable, { userId });
421
+ expect(rows.length).toBeGreaterThanOrEqual(1);
422
+ });
423
+ });
@@ -38,6 +38,7 @@
38
38
  import { existsSync, readFileSync, statSync } from "node:fs";
39
39
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
40
40
  import { dirname, join, resolve } from "node:path";
41
+ import { parseJsonOrThrow, parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
41
42
 
42
43
  const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
43
44
 
@@ -239,7 +240,7 @@ async function resolveRuntimeDepsVersions(
239
240
  for (const path of pinSources) {
240
241
  if (!existsSync(path)) continue;
241
242
  const raw = await readFile(path, "utf-8");
242
- const parsed = JSON.parse(raw) as { dependencies?: Record<string, string> };
243
+ const parsed = parseJsonOrThrow<{ dependencies?: Record<string, string> }>(raw, path);
243
244
  Object.assign(allDeps, parsed.dependencies ?? {});
244
245
  }
245
246
  for (const pkg of packages) {
@@ -251,12 +252,17 @@ async function resolveRuntimeDepsVersions(
251
252
  function derivePkgName(cwd: string): string {
252
253
  const pkgJson = join(cwd, "package.json");
253
254
  if (!existsSync(pkgJson)) return "kumiko-app-runtime";
255
+ // existsSync/readFileSync race (deleted between the two calls) or a
256
+ // permission error must fall back like a missing file, not crash the
257
+ // build — read here too, not just the JSON.parse below.
258
+ let raw: string;
254
259
  try {
255
- const parsed = JSON.parse(readFileSync(pkgJson, "utf-8")) as { name?: string };
256
- return parsed.name ? `${parsed.name}-runtime` : "kumiko-app-runtime";
260
+ raw = readFileSync(pkgJson, "utf-8");
257
261
  } catch {
258
262
  return "kumiko-app-runtime";
259
263
  }
264
+ const parsed = parseJsonSafe<{ name?: string }>(raw, {});
265
+ return parsed.name ? `${parsed.name}-runtime` : "kumiko-app-runtime";
260
266
  }
261
267
 
262
268
  export function formatServerBuildResult(
@@ -138,8 +138,7 @@ export default defineFeature("orders", (r) => {
138
138
  writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("nope", "evt"));
139
139
  const handle = watchAndRegenerate({ appRoot, onResult: () => {} });
140
140
  handle.close();
141
- handle.close(); // must not throw
142
- expect(true).toBe(true);
141
+ expect(() => handle.close()).not.toThrow();
143
142
  });
144
143
 
145
144
  test("non-ts file changes do not trigger codegen", async () => {
@@ -97,7 +97,7 @@ export function watchAndRegenerate(opts: WatchOptions): WatchHandle {
97
97
  watcher = watch(srcDir, { recursive: true }, (_eventType, filename) => {
98
98
  // skip: watcher closed or no filename reported, nothing to act on
99
99
  if (closed || !filename) return;
100
- // skip: node liefert filename relativ zu srcDir, kann aber posix oder
100
+ // node liefert filename relativ zu srcDir, kann aber posix oder
101
101
  // windows-style separators haben. Wir prüfen substring-tolerant.
102
102
  const normalised = `/${filename.toString().replace(/\\/g, "/")}`;
103
103
  // skip: path matches an excluded segment (node_modules/dist/etc.)
@@ -3,6 +3,10 @@
3
3
  // or tier maps (those stay in bin/server.ts / app run-config).
4
4
 
5
5
  import { createAuditFeature } from "@cosmicdrift/kumiko-bundled-features/audit";
6
+ import {
7
+ type AuthMfaFeatureOptions,
8
+ createAuthMfaFeature,
9
+ } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
6
10
  import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
7
11
  import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features/data-retention";
8
12
  import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";
@@ -61,10 +65,9 @@ export type UserDataRightsStackOptions = {
61
65
  readonly includeDefaults?: boolean;
62
66
  };
63
67
 
64
- // `sessions` lives ONLY on GdprStackOptions composeOpsStack used to accept
65
- // it too, and combining both presets with sessions:true double-pushed the
66
- // "sessions" feature name into the registry, which createRegistry rejects
67
- // with "duplicate feature" (a boot-time crash, not a warning).
68
+ // `sessions` may live on composeIdentityStack OR composeGdprStack never both
69
+ // with sessions:true. composeOpsStack used to accept it too; combining presets
70
+ // that each push "sessions" crashes createRegistry with "duplicate feature".
68
71
  export type OpsStackOptions = {
69
72
  readonly delivery?: boolean;
70
73
  readonly audit?: boolean;
@@ -72,6 +75,13 @@ export type OpsStackOptions = {
72
75
  readonly rateLimiting?: boolean;
73
76
  };
74
77
 
78
+ /** sessions (+ optional auth-mfa). config/user/tenant/auth-email-password stay
79
+ * on composeFeatures(includeBundled). Pass `mfa` options to mount TOTP. */
80
+ export type IdentityStackOptions = {
81
+ readonly sessions?: boolean;
82
+ readonly mfa?: AuthMfaFeatureOptions;
83
+ };
84
+
75
85
  export function stackFeatureNames(features: readonly FeatureDefinition[]): string[] {
76
86
  return features.map((f) => f.name);
77
87
  }
@@ -146,3 +156,13 @@ export function composeOpsStack(options: OpsStackOptions = {}): FeatureDefinitio
146
156
  if (rateLimiting) out.push(createRateLimitingFeature());
147
157
  return out;
148
158
  }
159
+
160
+ /** Identity opt-ins: sessions by default; auth-mfa when `mfa` options given.
161
+ * Do not also pass `sessions: true` to composeGdprStack — duplicate feature. */
162
+ export function composeIdentityStack(options: IdentityStackOptions = {}): FeatureDefinition[] {
163
+ const sessions = options.sessions ?? true;
164
+ const out: FeatureDefinition[] = [];
165
+ if (sessions) out.push(createSessionsFeature());
166
+ if (options.mfa !== undefined) out.push(createAuthMfaFeature(options.mfa));
167
+ return out;
168
+ }
@@ -52,7 +52,7 @@ export type CorpusWarning = {
52
52
  /** Human-readable explanation. Currently only "parser-throw" but
53
53
  * kept open-ended so future builders can add more (e.g.
54
54
  * "duplicate-id", "no-feature-name"). */
55
- readonly reason: string;
55
+ readonly message: string;
56
56
  };
57
57
 
58
58
  export type FewShotEntry = {
@@ -161,7 +161,7 @@ export function buildFewShotCorpus(options: BuildFewShotCorpusOptions): FewShotC
161
161
  // warning, drop the second occurrence.
162
162
  warnings.push({
163
163
  sourcePath: entry.sourcePath,
164
- reason: `duplicate-id: collides with ${previousPath}`,
164
+ message: `duplicate-id: collides with ${previousPath}`,
165
165
  });
166
166
  continue;
167
167
  }
@@ -198,7 +198,7 @@ function buildEntry(filePath: string, repoRoot: string): BuildEntryResult {
198
198
  // hide newly broken feature-files until L2 hit them.
199
199
  const detail = err instanceof Error ? err.message : String(err);
200
200
  return {
201
- warning: { sourcePath, reason: `parser-throw: ${detail}` },
201
+ warning: { sourcePath, message: `parser-throw: ${detail}` },
202
202
  };
203
203
  }
204
204
 
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  export {
33
33
  composeFileStack,
34
34
  composeGdprStack,
35
+ composeIdentityStack,
35
36
  composeMailStack,
36
37
  composeOpsStack,
37
38
  composePagesStack,
@@ -41,6 +42,7 @@ export {
41
42
  type FileStackOptions,
42
43
  type GdprStackOptions,
43
44
  type GdprStackOrder,
45
+ type IdentityStackOptions,
44
46
  type MailStackOptions,
45
47
  type MailTransportKind,
46
48
  type OpsStackOptions,
@@ -500,7 +500,7 @@ function renderMain(appName: string): string {
500
500
  [
501
501
  "// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
502
502
  "// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
503
- "// (see @cosmicdrift/kumiko-dev-server runProdApp). The real dev boot",
503
+ "// (see @cosmicdrift/kumiko-server-runtime runProdApp). The real dev boot",
504
504
  "// runs via `bunx kumiko dev` (in-repo dev-tool) with a Docker stack — DX-1.0",
505
505
  "// only covers the boot-mode path; `kumiko dev` lands in a later DX phase.",
506
506
  "",
@@ -35,6 +35,7 @@ const listScreen: EntityListScreenDefinition = {
35
35
  entity: "task",
36
36
  columns: ["title", "status", "isUrgent", "priority"],
37
37
  defaultSort: { field: "title", dir: "asc" },
38
+ rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
38
39
  };
39
40
 
40
41
  const editScreen: EntityEditScreenDefinition = {
@@ -48,9 +49,19 @@ const editScreen: EntityEditScreenDefinition = {
48
49
 
49
50
  const open = { access: { openToAll: true } } as const;
50
51
 
52
+ const TASKS_I18N = {
53
+ "screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
54
+ "screen:task-edit.title": { de: "Aufgabe", en: "Task" },
55
+ "tasks:entity:task:field:title": { de: "Titel", en: "Title" },
56
+ "tasks:entity:task:field:status": { de: "Status", en: "Status" },
57
+ "tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
58
+ "tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
59
+ } as const;
60
+
51
61
  /** Canonical demo feature — keep in sync with `renderDemoTasksFeatureFile()`. */
52
62
  export function createDemoTasksFeature(): FeatureDefinition {
53
63
  return defineFeature("tasks", (r) => {
64
+ r.translations({ keys: TASKS_I18N });
54
65
  r.entity("task", taskEntity);
55
66
  r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
56
67
  r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
@@ -106,6 +117,7 @@ const listScreen: EntityListScreenDefinition = {
106
117
  entity: "task",
107
118
  columns: ["title", "status", "isUrgent", "priority"],
108
119
  defaultSort: { field: "title", dir: "asc" },
120
+ rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
109
121
  };
110
122
 
111
123
  const editScreen: EntityEditScreenDefinition = {
@@ -119,7 +131,17 @@ const editScreen: EntityEditScreenDefinition = {
119
131
 
120
132
  const open = { access: { openToAll: true } } as const;
121
133
 
134
+ const TASKS_I18N = {
135
+ "screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
136
+ "screen:task-edit.title": { de: "Aufgabe", en: "Task" },
137
+ "tasks:entity:task:field:title": { de: "Titel", en: "Title" },
138
+ "tasks:entity:task:field:status": { de: "Status", en: "Status" },
139
+ "tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
140
+ "tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
141
+ } as const;
142
+
122
143
  export const tasksFeature = defineFeature("tasks", (r) => {
144
+ r.translations({ keys: TASKS_I18N });
123
145
  r.entity("task", taskEntity);
124
146
  r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
125
147
  r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));