@cosmicdrift/kumiko-dev-server 0.328.1 → 0.330.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +17 -0
  2. package/bin/kumiko-build.ts +3 -2
  3. package/bin/kumiko-dev.ts +1 -1
  4. package/bin/kumiko-init-deploy.ts +1 -1
  5. package/bin/kumiko-schema-check.ts +4 -1
  6. package/dist/build-server-bundle.d.ts +31 -0
  7. package/dist/build-server-bundle.js +282 -0
  8. package/dist/build.d.ts +2 -0
  9. package/dist/build.js +5 -0
  10. package/dist/cli.d.ts +5 -0
  11. package/dist/cli.js +9 -0
  12. package/dist/codegen/index.d.ts +4 -0
  13. package/dist/codegen/index.js +4 -0
  14. package/dist/codegen/render.d.ts +55 -0
  15. package/dist/codegen/render.js +273 -0
  16. package/dist/codegen/run-codegen.d.ts +20 -0
  17. package/dist/codegen/run-codegen.js +219 -0
  18. package/dist/codegen/scan-events.d.ts +76 -0
  19. package/dist/codegen/scan-events.js +447 -0
  20. package/dist/codegen/watch.d.ts +33 -0
  21. package/dist/codegen/watch.js +112 -0
  22. package/dist/compose-stacks.d.ts +60 -0
  23. package/dist/compose-stacks.js +127 -0
  24. package/dist/crash-tracker.d.ts +17 -0
  25. package/dist/crash-tracker.js +37 -0
  26. package/dist/create-kumiko-server.d.ts +195 -0
  27. package/dist/create-kumiko-server.js +1040 -0
  28. package/dist/env-schema.d.ts +15 -0
  29. package/dist/env-schema.js +60 -0
  30. package/dist/few-shot-corpus.d.ts +72 -0
  31. package/dist/few-shot-corpus.js +262 -0
  32. package/dist/framework-version.d.ts +1 -0
  33. package/dist/framework-version.js +31 -0
  34. package/dist/index.d.ts +22 -0
  35. package/dist/index.js +33 -0
  36. package/dist/init-deploy-cli.d.ts +10 -0
  37. package/dist/init-deploy-cli.js +111 -0
  38. package/dist/kebab.d.ts +1 -0
  39. package/{src/kebab.ts → dist/kebab.js} +2 -3
  40. package/dist/run-dev-app.d.ts +174 -0
  41. package/dist/run-dev-app.js +403 -0
  42. package/dist/scaffold-app-feature.d.ts +16 -0
  43. package/dist/scaffold-app-feature.js +240 -0
  44. package/dist/scaffold-app.d.ts +45 -0
  45. package/dist/scaffold-app.js +852 -0
  46. package/dist/scaffold-demo-tasks.d.ts +8 -0
  47. package/{src/scaffold-demo-tasks.ts → dist/scaffold-demo-tasks.js} +69 -98
  48. package/dist/scaffold-deploy.d.ts +92 -0
  49. package/dist/scaffold-deploy.js +254 -0
  50. package/dist/scaffold-feature.d.ts +26 -0
  51. package/dist/scaffold-feature.js +163 -0
  52. package/dist/schema-apply.d.ts +11 -0
  53. package/dist/schema-apply.js +95 -0
  54. package/dist/schema-check-core.d.ts +2 -0
  55. package/{src/schema-check-core.ts → dist/schema-check-core.js} +8 -10
  56. package/dist/schema-tables-auth-mode.d.ts +2 -0
  57. package/{src/schema-tables-auth-mode.ts → dist/schema-tables-auth-mode.js} +2 -10
  58. package/dist/schema-tables-minimal.d.ts +2 -0
  59. package/{src/schema-tables-minimal.ts → dist/schema-tables-minimal.js} +2 -11
  60. package/dist/setup-test-stack-from-features.d.ts +11 -0
  61. package/dist/setup-test-stack-from-features.js +42 -0
  62. package/dist/welcome-banner.d.ts +15 -0
  63. package/dist/welcome-banner.js +27 -0
  64. package/package.json +71 -24
  65. package/src/changes.json +6 -0
  66. package/src/__tests__/build-prod-bundle.integration.test.ts +0 -282
  67. package/src/__tests__/build-server-bundle.test.ts +0 -228
  68. package/src/__tests__/classify-change.test.ts +0 -87
  69. package/src/__tests__/compose-stacks.test.ts +0 -273
  70. package/src/__tests__/config-resolver-default.integration.test.ts +0 -165
  71. package/src/__tests__/crash-tracker.test.ts +0 -89
  72. package/src/__tests__/create-kumiko-server-errors.test.ts +0 -54
  73. package/src/__tests__/create-kumiko-server-options.test.ts +0 -33
  74. package/src/__tests__/create-kumiko-server.integration.test.ts +0 -1007
  75. package/src/__tests__/discover-format.test.ts +0 -32
  76. package/src/__tests__/env-schema.integration.test.ts +0 -248
  77. package/src/__tests__/env-schema.test.ts +0 -132
  78. package/src/__tests__/few-shot-corpus.test.ts +0 -316
  79. package/src/__tests__/framework-version.test.ts +0 -45
  80. package/src/__tests__/init-deploy-cli.test.ts +0 -113
  81. package/src/__tests__/kumiko-schema-check.test.ts +0 -56
  82. package/src/__tests__/merge-extra-context.test.ts +0 -67
  83. package/src/__tests__/resolve-stylesheet.test.ts +0 -132
  84. package/src/__tests__/run-dev-app-boot-validation.test.ts +0 -38
  85. package/src/__tests__/run-dev-app.integration.test.ts +0 -290
  86. package/src/__tests__/runtime-classification.test.ts +0 -26
  87. package/src/__tests__/saas-identity-wire.integration.test.ts +0 -427
  88. package/src/__tests__/scaffold-app-feature.test.ts +0 -216
  89. package/src/__tests__/scaffold-app.test.ts +0 -630
  90. package/src/__tests__/scaffold-demo-tasks.test.ts +0 -47
  91. package/src/__tests__/scaffold-deploy.test.ts +0 -464
  92. package/src/__tests__/scaffold-dev-cycle.integration.test.ts +0 -109
  93. package/src/__tests__/scaffold-feature.test.ts +0 -143
  94. package/src/__tests__/schema-apply.integration.test.ts +0 -191
  95. package/src/__tests__/setup-test-stack-from-features.integration.test.ts +0 -30
  96. package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +0 -72
  97. package/src/__tests__/walkthrough.integration.test.ts +0 -140
  98. package/src/__tests__/welcome-banner.test.ts +0 -42
  99. package/src/build-server-bundle.ts +0 -346
  100. package/src/build.ts +0 -28
  101. package/src/codegen/__tests__/render-codegen.test.ts +0 -100
  102. package/src/codegen/__tests__/run-codegen.test.ts +0 -665
  103. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +0 -347
  104. package/src/codegen/__tests__/watch.test.ts +0 -222
  105. package/src/codegen/index.ts +0 -19
  106. package/src/codegen/render.ts +0 -305
  107. package/src/codegen/run-codegen.ts +0 -278
  108. package/src/codegen/scan-events.ts +0 -565
  109. package/src/codegen/watch.ts +0 -140
  110. package/src/compose-stacks.ts +0 -193
  111. package/src/crash-tracker.ts +0 -56
  112. package/src/create-kumiko-server.ts +0 -1397
  113. package/src/env-schema.ts +0 -72
  114. package/src/few-shot-corpus.ts +0 -370
  115. package/src/framework-version.ts +0 -39
  116. package/src/index.ts +0 -105
  117. package/src/init-deploy-cli.ts +0 -148
  118. package/src/run-dev-app.ts +0 -694
  119. package/src/scaffold-app-feature.ts +0 -283
  120. package/src/scaffold-app.ts +0 -1033
  121. package/src/scaffold-deploy.ts +0 -393
  122. package/src/scaffold-feature.ts +0 -226
  123. package/src/schema-apply.ts +0 -123
  124. package/src/setup-test-stack-from-features.ts +0 -80
  125. package/src/welcome-banner.ts +0 -43
@@ -1,427 +0,0 @@
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
- // writeOk drives real HTTP through the auth middleware, so a hand-built
98
- // actor needs a live sid — sessionChecker rejects sidless JWTs.
99
- async function withSession(user: SessionUser): Promise<SessionUser> {
100
- const sid = await callbacks.get().sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
101
- return { ...user, sid };
102
- }
103
-
104
- beforeAll(async () => {
105
- configureEntityFieldEncryption(createTestEnvelopeCipher());
106
- const bound = sessionCallbacksFromLateBound(callbacks);
107
-
108
- const identity = composeIdentityStack({
109
- mfa: {
110
- setupTokenSecret: SETUP_TOKEN_SECRET,
111
- challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
112
- issuer: "Kumiko Wire",
113
- },
114
- });
115
- const mfaFeature = identity.find((f) => f.name === "auth-mfa");
116
- if (!mfaFeature) throw new Error("composeIdentityStack did not mount auth-mfa");
117
-
118
- const features = composeFeatures(
119
- [
120
- ...identity,
121
- ...composeOpsStack({ delivery: true, audit: false, jobs: false }),
122
- ...composeRendererStack(),
123
- createChannelEmailFeature({
124
- transport: emailTransport,
125
- renderer: simpleRenderer,
126
- resolveEmail: async () => "unused@test.local",
127
- }),
128
- ],
129
- {
130
- includeBundled: true,
131
- authOptions: {
132
- signup: { tokenTtlMinutes: 60, appUrl: APP_SIGNUP_URL },
133
- invite: { tokenTtlMinutes: 60, appUrl: APP_INVITE_URL },
134
- passwordReset: {
135
- hmacSecret: RESET_SECRET,
136
- tokenTtlMinutes: 15,
137
- appUrl: APP_RESET_URL,
138
- },
139
- emailVerification: {
140
- hmacSecret: VERIFY_SECRET,
141
- tokenTtlMinutes: 60,
142
- mode: "strict",
143
- appUrl: APP_VERIFY_URL,
144
- },
145
- },
146
- },
147
- );
148
-
149
- stack = await setupTestStack({
150
- features,
151
- extraContext: (deps) => ({
152
- ...createDeliveryTestContext(deps),
153
- configResolver: createConfigResolver(),
154
- }),
155
- authConfig: {
156
- ...bound.asAuthConfig(),
157
- // sessionChecker rejects sidless JWTs — hand-built actors used with
158
- // writeOk go through withSession() to get a live sid first.
159
- membershipQuery: "tenant:query:memberships",
160
- loginHandler: AuthHandlers.login,
161
- mfaVerifyHandler: AuthMfaHandlers.verify,
162
- loginErrorStatusMap: {
163
- [AuthErrors.invalidCredentials]: 401,
164
- [AuthErrors.noMembership]: 403,
165
- [AuthErrors.emailNotVerified]: 403,
166
- },
167
- signup: {
168
- requestHandler: AuthHandlers.signupRequest,
169
- confirmHandler: AuthHandlers.signupConfirm,
170
- },
171
- passwordReset: {
172
- requestHandler: AuthHandlers.requestPasswordReset,
173
- confirmHandler: AuthHandlers.resetPassword,
174
- },
175
- emailVerification: {
176
- requestHandler: AuthHandlers.requestEmailVerification,
177
- confirmHandler: AuthHandlers.verifyEmail,
178
- },
179
- invite: {
180
- acceptHandler: AuthHandlers.inviteAccept,
181
- acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
182
- signupCompleteHandler: AuthHandlers.inviteSignupComplete,
183
- },
184
- },
185
- });
186
-
187
- const sessionCallbacks = createSessionCallbacks({ db: stack.db });
188
- callbacks.set(sessionCallbacks);
189
- bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
190
-
191
- await unsafeCreateEntityTable(stack.db, userEntity);
192
- await unsafeCreateEntityTable(stack.db, tenantEntity);
193
- await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
194
- await unsafeCreateEntityTable(stack.db, userMfaEntity);
195
- await unsafeCreateEntityTable(stack.db, userSessionEntity);
196
- await unsafePushTables(stack.db, {
197
- configValuesTable,
198
- tenantMembershipsTable,
199
- notificationPreferencesTable,
200
- userSessionTable,
201
- });
202
- });
203
-
204
- afterAll(async () => {
205
- await stack?.cleanup();
206
- configureEntityFieldEncryption(undefined);
207
- });
208
-
209
- beforeEach(async () => {
210
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
211
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
212
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
213
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
214
- await deleteRows(stack.db, userSessionTable, {});
215
- await deleteRows(stack.db, userMfaTable, {});
216
- emailTransport.sent.length = 0;
217
- for (const pattern of ["signup:*", "invite:*"] as const) {
218
- const keys = await stack.redis.redis.keys(pattern);
219
- if (keys.length > 0) await stack.redis.redis.del(...keys);
220
- }
221
- });
222
-
223
- describe("saas-identity-wire", () => {
224
- test("signup → delivery mail → confirm → login JWT with sid", async () => {
225
- const email = "signup-wire@example.com";
226
- const password = "fresh-secure-pw-1234";
227
-
228
- const req = await stack.http.raw("POST", "/api/auth/signup-request", { email });
229
- expect(req.status).toBe(200);
230
- expect(emailTransport.sent).toHaveLength(1);
231
- const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
232
-
233
- const confirm = await stack.http.raw("POST", "/api/auth/signup-confirm", { token, password });
234
- expect(confirm.status).toBe(200);
235
-
236
- const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
237
- expect(login.status).toBe(200);
238
- const body = (await login.json()) as { token?: string };
239
- expect(body.token).toBeTypeOf("string");
240
- expect(typeof jose.decodeJwt(body.token!).jti).toBe("string");
241
- });
242
-
243
- test("password reset → delivery mail → reset → login with new password", async () => {
244
- const email = "reset-wire@example.com";
245
- const oldPassword = "old-password-1234";
246
- const newPassword = "new-password-5678";
247
- const tenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
248
- const { id } = await seedAdmin(stack.db, {
249
- email,
250
- password: oldPassword,
251
- displayName: "Reset",
252
- memberships: [
253
- {
254
- tenantId,
255
- tenantKey: "reset-wire",
256
- tenantName: "Reset Wire",
257
- roles: ["User"],
258
- },
259
- ],
260
- });
261
- await markVerified(id);
262
-
263
- const req = await stack.http.raw("POST", "/api/auth/request-password-reset", { email });
264
- expect(req.status).toBe(200);
265
- expect(emailTransport.sent).toHaveLength(1);
266
- const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
267
-
268
- const reset = await stack.http.raw("POST", "/api/auth/reset-password", {
269
- token,
270
- newPassword,
271
- });
272
- expect(reset.status).toBe(200);
273
-
274
- const oldLogin = await stack.http.raw("POST", "/api/auth/login", {
275
- email,
276
- password: oldPassword,
277
- });
278
- expect(oldLogin.status).toBe(401);
279
-
280
- const login = await stack.http.raw("POST", "/api/auth/login", { email, password: newPassword });
281
- expect(login.status).toBe(200);
282
- const body = (await login.json()) as { token?: string };
283
- expect(body.token).toBeTypeOf("string");
284
- });
285
-
286
- test("invite Branch3 → signup-complete → login", async () => {
287
- const tenantId = crypto.randomUUID() as TenantId;
288
- const { id: adminId } = await seedAdmin(stack.db, {
289
- email: "admin-wire@example.com",
290
- password: "admin-password-1234",
291
- displayName: "Admin",
292
- memberships: [
293
- {
294
- tenantId,
295
- tenantKey: `invite-${tenantId.slice(0, 8)}`,
296
- tenantName: "Invite Wire",
297
- roles: ["Admin"],
298
- },
299
- ],
300
- });
301
- await markVerified(adminId);
302
-
303
- const admin = await withSession({ id: adminId, tenantId, roles: ["Admin"] });
304
- const invitee = "carol-wire@example.com";
305
- await stack.http.writeOk(AuthHandlers.inviteCreate, { email: invitee, role: "User" }, admin);
306
- expect(emailTransport.sent.length).toBeGreaterThanOrEqual(1);
307
- const token = extractTokenFromMail(emailTransport.sent.at(-1)?.html ?? "");
308
-
309
- const complete = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
310
- token,
311
- password: "carol-new-pw-1234",
312
- });
313
- expect(complete.status).toBe(200);
314
-
315
- const login = await stack.http.raw("POST", "/api/auth/login", {
316
- email: invitee,
317
- password: "carol-new-pw-1234",
318
- });
319
- expect(login.status).toBe(200);
320
- });
321
-
322
- test("email verify gate: unverified blocked → verify → login ok", async () => {
323
- const email = "verify-wire@example.com";
324
- const password = "verify-password-1234";
325
- const tenantId = "00000000-0000-4000-8000-000000000002" as TenantId;
326
- await seedTenant(stack.db, {
327
- id: tenantId,
328
- key: "verify-wire",
329
- name: "Verify Wire",
330
- });
331
- const { id } = await seedUserWithPassword(stack.db, {
332
- email,
333
- password,
334
- displayName: "Verify",
335
- emailVerified: false,
336
- });
337
- await seedTenantMembership(stack.db, {
338
- userId: id,
339
- tenantId,
340
- roles: ["User"],
341
- });
342
-
343
- const blocked = await stack.http.raw("POST", "/api/auth/login", { email, password });
344
- expect(blocked.status).toBe(403);
345
- const blockedBody = (await blocked.json()) as { error?: { details?: { reason?: string } } };
346
- expect(blockedBody.error?.details?.reason).toBe(AuthErrors.emailNotVerified);
347
-
348
- const req = await stack.http.raw("POST", "/api/auth/request-email-verification", { email });
349
- expect(req.status).toBe(200);
350
- expect(emailTransport.sent).toHaveLength(1);
351
- const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
352
-
353
- const verify = await stack.http.raw("POST", "/api/auth/verify-email", { token });
354
- expect(verify.status).toBe(200);
355
-
356
- const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
357
- expect(login.status).toBe(200);
358
- });
359
-
360
- test("MFA enable → login challenges → verify JWT; sessions:mine lists sid", async () => {
361
- const email = "mfa-wire@example.com";
362
- const password = "mfa-password-1234";
363
- const tenantId = "00000000-0000-4000-8000-000000000003" as TenantId;
364
- const { id: userId } = await seedAdmin(stack.db, {
365
- email,
366
- password,
367
- displayName: "Mfa",
368
- memberships: [
369
- {
370
- tenantId,
371
- tenantKey: "mfa-wire",
372
- tenantName: "MFA Wire",
373
- roles: ["User"],
374
- },
375
- ],
376
- });
377
- await markVerified(userId);
378
- const user = await withSession({ id: userId, tenantId, roles: ["User"] });
379
-
380
- const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
381
- AuthMfaHandlers.enableStart,
382
- { accountLabel: email },
383
- user,
384
- );
385
- const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
386
- const secret = base32Decode(secretParam);
387
- await stack.http.writeOk(
388
- AuthMfaHandlers.enableConfirm,
389
- { setupToken: start.setupToken, code: currentTotpCode(secret) },
390
- user,
391
- );
392
-
393
- const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
394
- expect(login.status).toBe(200);
395
- const challenge = (await login.json()) as {
396
- mfaRequired?: boolean;
397
- challengeToken?: string;
398
- token?: string;
399
- };
400
- expect(challenge.mfaRequired).toBe(true);
401
- expect(challenge.token).toBeUndefined();
402
- expect(challenge.challengeToken).toBeTypeOf("string");
403
-
404
- const verify = await stack.http.raw("POST", "/api/auth/mfa/verify", {
405
- challengeToken: challenge.challengeToken,
406
- code: currentTotpCode(secret),
407
- });
408
- expect(verify.status).toBe(200);
409
- const { token } = (await verify.json()) as { token: string };
410
- expect(token).toBeTypeOf("string");
411
- const sid = jose.decodeJwt(token).jti;
412
- expect(typeof sid).toBe("string");
413
-
414
- const mineRes = await stack.http.raw(
415
- "POST",
416
- "/api/query",
417
- { type: SessionQueries.mine, payload: {} },
418
- { Authorization: `Bearer ${token}` },
419
- );
420
- expect(mineRes.status).toBe(200);
421
- const mineBody = (await mineRes.json()) as { data: { rows: Array<{ id: string }> } };
422
- expect(mineBody.data.rows.some((s) => s.id === sid)).toBe(true);
423
-
424
- const rows = await selectMany(stack.db, userSessionTable, { userId });
425
- expect(rows.length).toBeGreaterThanOrEqual(1);
426
- });
427
- });
@@ -1,216 +0,0 @@
1
- // scaffoldAppFeature unit-tests (DX-2).
2
-
3
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
8
- import { scaffoldApp } from "../scaffold-app";
9
- import { scaffoldAppFeature } from "../scaffold-app-feature";
10
-
11
- describe("scaffoldAppFeature", () => {
12
- let tmp: string;
13
- let appRoot: string;
14
- beforeEach(async () => {
15
- tmp = mkdtempSync(join(tmpdir(), "scaffold-app-feature-"));
16
- appRoot = join(tmp, "my-shop");
17
- await scaffoldApp({ name: "my-shop", destination: appRoot });
18
- });
19
- afterEach(() => {
20
- rmSync(tmp, { recursive: true, force: true });
21
- });
22
-
23
- test("scaffolds die volle App-Feature-Konvention", () => {
24
- const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
25
- expect(result.featureName).toBe("product-catalog");
26
- expect(result.files).toEqual([
27
- "src/features/product-catalog/feature.ts",
28
- "src/features/product-catalog/index.ts",
29
- "src/features/product-catalog/constants.ts",
30
- "src/features/product-catalog/i18n.ts",
31
- "src/features/product-catalog/schema/product-catalog-item.ts",
32
- "src/features/product-catalog/schema/index.ts",
33
- "src/features/product-catalog/web/index.ts",
34
- "src/features/product-catalog/__tests__/feature.boot.test.ts",
35
- ]);
36
- for (const file of result.files) {
37
- expect(existsSync(join(appRoot, file))).toBe(true);
38
- }
39
- });
40
-
41
- test("feature.ts registriert nur — Entity aus schema/, Keys aus i18n.ts", () => {
42
- scaffoldAppFeature({ name: "product-catalog", appRoot });
43
- const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
44
- expect(feature).toContain("defineFeature(PRODUCT_CATALOG_FEATURE");
45
- expect(feature).toContain("export const productCatalogFeature");
46
- expect(feature).toContain('r.entity("product-catalog-item", productCatalogItemEntity)');
47
- expect(feature).toContain('type: "entityList"');
48
- expect(feature).toContain("r.translations({ keys: productCatalogTranslationKeys })");
49
- // Konvention: Entity-Felder leben in schema/, nicht inline in feature.ts.
50
- expect(feature).not.toContain("fields:");
51
- });
52
-
53
- test("Starter erfüllt Boot-Constraints: sortable-Feld, defaultSort, i18n-Pflichtkeys", () => {
54
- scaffoldAppFeature({ name: "product-catalog", appRoot });
55
- const entity = readFileSync(
56
- join(appRoot, "src/features/product-catalog/schema/product-catalog-item.ts"),
57
- "utf-8",
58
- );
59
- expect(entity).toContain("sortable: true");
60
- const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
61
- expect(feature).toContain('defaultSort: { field: "title", dir: "asc" }');
62
- const i18n = readFileSync(join(appRoot, "src/features/product-catalog/i18n.ts"), "utf-8");
63
- expect(i18n).toContain('"screen:items.title"');
64
- expect(i18n).toContain('"product-catalog:entity:product-catalog-item:field:title"');
65
- });
66
-
67
- test("web/index.ts ist Client-Stub, __tests__ enthält validateBoot-Test", () => {
68
- scaffoldAppFeature({ name: "product-catalog", appRoot });
69
- const web = readFileSync(join(appRoot, "src/features/product-catalog/web/index.ts"), "utf-8");
70
- expect(web).toContain("@runtime client");
71
- expect(web).toContain("export const productCatalogClient: ClientFeatureDefinition");
72
- const bootTest = readFileSync(
73
- join(appRoot, "src/features/product-catalog/__tests__/feature.boot.test.ts"),
74
- "utf-8",
75
- );
76
- expect(bootTest).toContain("validateBoot([productCatalogFeature])");
77
- });
78
-
79
- test("auto-mounts in src/run-config.ts (import + APP_FEATURES entry)", () => {
80
- const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
81
- expect(result.autoMounted).toBe(true);
82
- const runConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
83
- expect(runConfig).toContain(
84
- `import { productCatalogFeature } from "./features/product-catalog";`,
85
- );
86
- expect(runConfig).toContain("productCatalogFeature");
87
- // APP_FEATURES still ends with `as const`
88
- expect(runConfig).toMatch(/\]\s*as const;/);
89
- });
90
-
91
- test("mounting a second feature keeps the first feature's import exactly once", () => {
92
- // Cross-feature non-duplication: adding billing must not touch the
93
- // product-catalog import/entry. Same-feature re-mount idempotency is the
94
- // next test — the dir-exists guard blocks a direct second scaffold of the
95
- // same name, so this one cannot reach the short-circuit branches.
96
- scaffoldAppFeature({ name: "product-catalog", appRoot });
97
- const firstRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
98
- scaffoldAppFeature({ name: "billing", appRoot });
99
- const secondRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
100
- expect(secondRunConfig).toContain("productCatalogFeature");
101
- expect(secondRunConfig).toContain("billingFeature");
102
- // Count: each feature-import appears exactly once.
103
- const occurrences = secondRunConfig.match(/productCatalogFeature/g) ?? [];
104
- expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
105
- expect(firstRunConfig.length).toBeLessThan(secondRunConfig.length);
106
- });
107
-
108
- test("idempotent: re-scaffolding the same feature is a full no-op on run-config", () => {
109
- scaffoldAppFeature({ name: "product-catalog", appRoot });
110
- const runConfigPath = join(appRoot, "src/run-config.ts");
111
- const afterFirst = readFileSync(runConfigPath, "utf-8");
112
-
113
- // Drop only the feature dir so the dir-exists guard doesn't trip; the
114
- // run-config import + APP_FEATURES entry both survive. The re-scaffold must
115
- // hit the full short-circuit in mountInRunConfig (import present AND
116
- // alreadyListed → changed=false → no save) and duplicate neither half —
117
- // the branch the cross-feature test above never reaches.
118
- rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
119
- const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
120
- expect(result.autoMounted).toBe(true);
121
-
122
- const afterSecond = readFileSync(runConfigPath, "utf-8");
123
- expect(afterSecond).toBe(afterFirst); // byte-identical: no save, no duplication
124
- expect((afterSecond.match(/productCatalogFeature/g) ?? []).length).toBe(2);
125
- });
126
-
127
- test("self-heals a half-mounted run-config: import present but APP_FEATURES entry removed", () => {
128
- scaffoldAppFeature({ name: "product-catalog", appRoot });
129
- const runConfigPath = join(appRoot, "src/run-config.ts");
130
-
131
- // Simulate a half-applied state: the feature dir is gone and the
132
- // APP_FEATURES entry was hand-removed, but the import line still lingers.
133
- rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
134
- const stripped = readFileSync(runConfigPath, "utf-8")
135
- // Drop the APP_FEATURES array element (appended last, so `, productCatalogFeature`
136
- // right before the closing `]`). The import `{ productCatalogFeature }` is
137
- // followed by ` }`, not `]`/`,`, so the lookahead leaves it intact.
138
- .replace(/,\s*productCatalogFeature(?=\s*\])/, "");
139
- writeFileSync(runConfigPath, stripped, "utf-8");
140
- // Guard the simulation itself: the import must remain, the array entry must
141
- // be gone — exactly one mention left. Without this, a regex that failed to
142
- // strip would leave the entry in place and the test would false-pass.
143
- expect(stripped).toContain(
144
- 'import { productCatalogFeature } from "./features/product-catalog";',
145
- );
146
- expect((stripped.match(/productCatalogFeature/g) ?? []).length).toBe(1);
147
-
148
- // Re-scaffold: dir is gone so it proceeds; mountInRunConfig sees the import
149
- // already present and must still re-add the missing APP_FEATURES entry.
150
- const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
151
- expect(result.autoMounted).toBe(true);
152
-
153
- const healed = readFileSync(runConfigPath, "utf-8");
154
- // Import stays exactly once; the array entry is back.
155
- const occurrences = healed.match(/productCatalogFeature/g) ?? [];
156
- expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
157
- expect(healed).toMatch(/\]\s*as const;/);
158
- });
159
-
160
- test("gescaffoldetes Feature bootet wirklich (validateBoot über dynamic import)", async () => {
161
- // Ins Repo scaffolden (nicht os.tmpdir): nur hier löst der dynamic
162
- // import "@cosmicdrift/kumiko-framework/engine" über node_modules auf.
163
- const repoTmp = mkdtempSync(join(import.meta.dir, ".tmp-scaffold-"));
164
- try {
165
- scaffoldAppFeature({ name: "boot-probe", appRoot: repoTmp });
166
- const mod = (await import(join(repoTmp, "src/features/boot-probe/feature.ts"))) as {
167
- readonly bootProbeFeature: Parameters<typeof validateBoot>[0][number];
168
- };
169
- expect(() => validateBoot([mod.bootProbeFeature])).not.toThrow();
170
- } finally {
171
- rmSync(repoTmp, { recursive: true, force: true });
172
- }
173
- });
174
-
175
- test("rejects non-kebab-case", () => {
176
- expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
177
- expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
178
- });
179
-
180
- test("rejects trailing- and double-hyphen names (kebabToCamel would break)", () => {
181
- // `product-` → kebabToCamel leaves a trailing hyphen → `product-Feature`,
182
- // an invalid identifier. The segment-strict regex must reject these.
183
- expect(() => scaffoldAppFeature({ name: "product-", appRoot })).toThrow(/kebab-case/);
184
- expect(() => scaffoldAppFeature({ name: "foo--bar", appRoot })).toThrow(/kebab-case/);
185
- });
186
-
187
- test("rolls back the scaffolded dir when run-config has no APP_FEATURES (re-run not blocked)", () => {
188
- // run-config exists but is the wrong shape → mountInRunConfig throws. The
189
- // feature files were already written; without rollback a re-run would hit
190
- // the "already exists" guard and the user would be stuck.
191
- const runConfigPath = join(appRoot, "src/run-config.ts");
192
- writeFileSync(runConfigPath, "export const NOT_APP_FEATURES = [];\n", "utf-8");
193
-
194
- expect(() => scaffoldAppFeature({ name: "orders", appRoot })).toThrow(/APP_FEATURES/);
195
- // Rolled back: the half-written feature dir is gone.
196
- expect(existsSync(join(appRoot, "src/features/orders"))).toBe(false);
197
-
198
- // Re-run with a valid run-config now succeeds instead of "already exists".
199
- writeFileSync(runConfigPath, "export const APP_FEATURES = [] as const;\n", "utf-8");
200
- const result = scaffoldAppFeature({ name: "orders", appRoot });
201
- expect(result.autoMounted).toBe(true);
202
- expect(existsSync(join(appRoot, "src/features/orders/feature.ts"))).toBe(true);
203
- });
204
-
205
- test("refuses to overwrite existing feature dir", () => {
206
- scaffoldAppFeature({ name: "billing", appRoot });
207
- expect(() => scaffoldAppFeature({ name: "billing", appRoot })).toThrow(/already exists/);
208
- });
209
-
210
- test("autoMounted=false when run-config.ts is missing", () => {
211
- const emptyRoot = join(tmp, "no-app");
212
- expect(() => scaffoldAppFeature({ name: "foo", appRoot: emptyRoot })).not.toThrow();
213
- const result = scaffoldAppFeature({ name: "bar", appRoot: emptyRoot });
214
- expect(result.autoMounted).toBe(false);
215
- });
216
- });