@cosmicdrift/kumiko-dev-server 0.155.1 → 0.156.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1",
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.1",
58
+ "@cosmicdrift/kumiko-framework": "0.156.1",
59
+ "@cosmicdrift/kumiko-server-runtime": "0.156.1",
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
+ });
@@ -121,6 +121,7 @@ describe("scaffoldApp", () => {
121
121
  expect(client).toContain(">my-shop</span>");
122
122
  expect(client).toContain('from "@cosmicdrift/kumiko-renderer-web"');
123
123
  expect(client).toContain('from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web"');
124
+ expect(client).toContain("tasksClient");
124
125
  });
125
126
 
126
127
  test(".env.example carries KUMIKO_DEV_DB_NAME default so reboots are persistent", async () => {
@@ -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,
@@ -26,7 +26,9 @@ import {
26
26
  createDemoTasksFeature,
27
27
  renderDemoSeedFile,
28
28
  renderDemoTasksFeatureFile,
29
+ renderDemoTasksI18n,
29
30
  renderDemoTasksIndex,
31
+ renderDemoTasksWebIndex,
30
32
  } from "./scaffold-demo-tasks";
31
33
  import { scaffoldDeploy } from "./scaffold-deploy";
32
34
 
@@ -101,11 +103,18 @@ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<Scaffold
101
103
  write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
102
104
  files.push("src/run-config.ts");
103
105
 
104
- mkdirSync(join(destination, "src", "features", "tasks"), { recursive: true });
106
+ mkdirSync(join(destination, "src", "features", "tasks", "web"), { recursive: true });
105
107
  write(join(destination, "src", "features", "tasks", "feature.ts"), renderDemoTasksFeatureFile());
106
108
  files.push("src/features/tasks/feature.ts");
109
+ write(join(destination, "src", "features", "tasks", "i18n.ts"), renderDemoTasksI18n());
110
+ files.push("src/features/tasks/i18n.ts");
107
111
  write(join(destination, "src", "features", "tasks", "index.ts"), renderDemoTasksIndex());
108
112
  files.push("src/features/tasks/index.ts");
113
+ write(
114
+ join(destination, "src", "features", "tasks", "web", "index.ts"),
115
+ renderDemoTasksWebIndex(),
116
+ );
117
+ files.push("src/features/tasks/web/index.ts");
109
118
  write(join(destination, "src", "seed.ts"), renderDemoSeedFile());
110
119
  files.push("src/seed.ts");
111
120
 
@@ -500,7 +509,7 @@ function renderMain(appName: string): string {
500
509
  [
501
510
  "// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
502
511
  "// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
503
- "// (see @cosmicdrift/kumiko-dev-server runProdApp). The real dev boot",
512
+ "// (see @cosmicdrift/kumiko-server-runtime runProdApp). The real dev boot",
504
513
  "// runs via `bunx kumiko dev` (in-repo dev-tool) with a Docker stack — DX-1.0",
505
514
  "// only covers the boot-mode path; `kumiko dev` lands in a later DX phase.",
506
515
  "",
@@ -608,6 +617,7 @@ function renderClient(appName: string): string {
608
617
  "// here — symmetric to APP_FEATURES on the server side.",
609
618
  "",
610
619
  'import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";',
620
+ 'import { tasksClient } from "./features/tasks/web";',
611
621
  'import { type AppSchema, createKumikoApp, DefaultAppShell } from "@cosmicdrift/kumiko-renderer-web";',
612
622
  'import type { ReactNode } from "react";',
613
623
  "",
@@ -622,7 +632,7 @@ function renderClient(appName: string): string {
622
632
  "",
623
633
  "createKumikoApp({",
624
634
  " shell: AppShell,",
625
- " clientFeatures: [emailPasswordClient()],",
635
+ " clientFeatures: [emailPasswordClient(), tasksClient],",
626
636
  "});",
627
637
  "",
628
638
  ].join("\n");
@@ -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));
@@ -90,6 +101,7 @@ import type {
90
101
  EntityEditScreenDefinition,
91
102
  EntityListScreenDefinition,
92
103
  } from "@cosmicdrift/kumiko-framework/ui-types";
104
+ import { tasksTranslationKeys } from "./i18n";
93
105
 
94
106
  const taskEntity = createEntity({
95
107
  fields: {
@@ -106,6 +118,7 @@ const listScreen: EntityListScreenDefinition = {
106
118
  entity: "task",
107
119
  columns: ["title", "status", "isUrgent", "priority"],
108
120
  defaultSort: { field: "title", dir: "asc" },
121
+ rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
109
122
  };
110
123
 
111
124
  const editScreen: EntityEditScreenDefinition = {
@@ -120,6 +133,7 @@ const editScreen: EntityEditScreenDefinition = {
120
133
  const open = { access: { openToAll: true } } as const;
121
134
 
122
135
  export const tasksFeature = defineFeature("tasks", (r) => {
136
+ r.translations({ keys: tasksTranslationKeys });
123
137
  r.entity("task", taskEntity);
124
138
  r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
125
139
  r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
@@ -140,6 +154,52 @@ export const tasksFeature = defineFeature("tasks", (r) => {
140
154
  `;
141
155
  }
142
156
 
157
+ export function renderDemoTasksI18n(): string {
158
+ return `// Server-side translation keys for the demo tasks feature.
159
+
160
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
161
+
162
+ export const tasksTranslationKeys = {
163
+ "screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
164
+ "screen:task-edit.title": { de: "Aufgabe", en: "Task" },
165
+ "tasks:entity:task:field:title": { de: "Titel", en: "Title" },
166
+ "tasks:entity:task:field:status": { de: "Status", en: "Status" },
167
+ "tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
168
+ "tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
169
+ } as const;
170
+
171
+ export const tasksTranslations: TranslationsByLocale = {
172
+ de: {
173
+ "screen:task-list.title": "Aufgaben",
174
+ "screen:task-edit.title": "Aufgabe",
175
+ "tasks:entity:task:field:title": "Titel",
176
+ "tasks:entity:task:field:status": "Status",
177
+ "tasks:entity:task:field:priority": "Priorität",
178
+ "tasks:entity:task:field:isUrgent": "Dringend",
179
+ },
180
+ en: {
181
+ "screen:task-list.title": "Tasks",
182
+ "screen:task-edit.title": "Task",
183
+ "tasks:entity:task:field:title": "Title",
184
+ "tasks:entity:task:field:status": "Status",
185
+ "tasks:entity:task:field:priority": "Priority",
186
+ "tasks:entity:task:field:isUrgent": "Urgent",
187
+ },
188
+ };
189
+ `;
190
+ }
191
+
192
+ export function renderDemoTasksWebIndex(): string {
193
+ return `import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
194
+ import { tasksTranslations } from "../i18n";
195
+
196
+ export const tasksClient: ClientFeatureDefinition = {
197
+ name: "tasks",
198
+ translations: tasksTranslations,
199
+ };
200
+ `;
201
+ }
202
+
143
203
  export function renderDemoTasksIndex(): string {
144
204
  return `export { tasksFeature } from "./feature";
145
205
  `;