@cosmicdrift/kumiko-dev-server 1.0.0 → 2.0.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 (66) hide show
  1. package/bin/kumiko-schema-check.ts +7 -0
  2. package/package.json +12 -7
  3. package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
  4. package/src/__tests__/build-server-bundle.test.ts +62 -0
  5. package/src/__tests__/compose-stacks.test.ts +271 -0
  6. package/src/__tests__/create-kumiko-server-errors.test.ts +54 -0
  7. package/src/__tests__/create-kumiko-server-options.test.ts +33 -0
  8. package/src/__tests__/create-kumiko-server.integration.test.ts +128 -1
  9. package/src/__tests__/discover-format.test.ts +1 -1
  10. package/src/__tests__/env-schema.integration.test.ts +1 -1
  11. package/src/__tests__/few-shot-corpus.test.ts +16 -11
  12. package/src/__tests__/merge-extra-context.test.ts +56 -0
  13. package/src/__tests__/resolve-stylesheet.test.ts +16 -0
  14. package/src/__tests__/saas-identity-wire.integration.test.ts +430 -0
  15. package/src/__tests__/scaffold-app-feature.test.ts +59 -6
  16. package/src/__tests__/scaffold-app.test.ts +34 -2
  17. package/src/__tests__/schema-apply.integration.test.ts +5 -2
  18. package/src/__tests__/setup-test-stack-from-features.integration.test.ts +30 -0
  19. package/src/__tests__/walkthrough.integration.test.ts +22 -6
  20. package/src/build-server-bundle.ts +33 -15
  21. package/src/build.ts +1 -2
  22. package/src/codegen/__tests__/render-codegen.test.ts +2 -1
  23. package/src/codegen/__tests__/run-codegen.test.ts +2 -2
  24. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +6 -1
  25. package/src/codegen/__tests__/watch.test.ts +1 -2
  26. package/src/codegen/render.ts +6 -1
  27. package/src/codegen/scan-events.ts +7 -5
  28. package/src/codegen/watch.ts +7 -4
  29. package/src/compose-stacks.ts +184 -0
  30. package/src/create-kumiko-server.ts +28 -5
  31. package/src/few-shot-corpus.ts +4 -3
  32. package/src/index.ts +30 -12
  33. package/src/run-dev-app.ts +195 -61
  34. package/src/scaffold-app-feature.ts +118 -15
  35. package/src/scaffold-app.ts +151 -79
  36. package/src/scaffold-demo-tasks.ts +233 -0
  37. package/src/schema-apply.ts +15 -2
  38. package/src/schema-check-core.ts +1 -1
  39. package/src/setup-test-stack-from-features.ts +61 -0
  40. package/src/welcome-banner.ts +1 -4
  41. package/src/__tests__/boot-extra-context.test.ts +0 -140
  42. package/src/__tests__/build-prod-bundle.test.ts +0 -311
  43. package/src/__tests__/cache-headers.test.ts +0 -83
  44. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
  45. package/src/__tests__/compose-features.test.ts +0 -129
  46. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  47. package/src/__tests__/inject-schema.test.ts +0 -62
  48. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  49. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  50. package/src/__tests__/require-env.test.ts +0 -29
  51. package/src/__tests__/resolve-auth-mail.test.ts +0 -69
  52. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  53. package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
  54. package/src/__tests__/run-prod-app-spec.test.ts +0 -57
  55. package/src/__tests__/run-prod-app.integration.test.ts +0 -840
  56. package/src/__tests__/session-wiring.test.ts +0 -51
  57. package/src/__tests__/try-hono-first.test.ts +0 -63
  58. package/src/boot/apply-boot-seeds.ts +0 -18
  59. package/src/build-prod-bundle.ts +0 -697
  60. package/src/compose-features.ts +0 -145
  61. package/src/extra-routes-deps.ts +0 -47
  62. package/src/inject-schema.ts +0 -24
  63. package/src/resolve-tailwind-cli.ts +0 -45
  64. package/src/run-prod-app.ts +0 -1492
  65. package/src/session-wiring.ts +0 -29
  66. package/src/try-hono-first.ts +0 -46
@@ -18,16 +18,43 @@ import {
18
18
  type SeedAdminOptions,
19
19
  seedAdmin,
20
20
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
21
+ import {
22
+ EXT_SESSION_STORE,
23
+ resolveSessionStore,
24
+ resolveTokenVerifier,
25
+ type SessionStore,
26
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
27
+ import {
28
+ AUTH_MFA_FEATURE,
29
+ AuthMfaHandlers,
30
+ bindMfaRevokeAllOtherSessionsFromFeature,
31
+ } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
21
32
  import {
22
33
  buildEnvConfigOverrides,
23
34
  createConfigResolver,
24
35
  } from "@cosmicdrift/kumiko-bundled-features/config";
25
36
  import {
26
- createSessionCallbacks,
27
- type SessionCallbacks,
37
+ PAT_FEATURE,
38
+ patRateLimitFromFeature,
39
+ } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
40
+ import {
41
+ bindAutoRevokeFromFeature,
42
+ SESSIONS_FEATURE,
28
43
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
29
44
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
30
- import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
45
+ import {
46
+ resolveTenantLifecycleGate,
47
+ TENANT_LIFECYCLE_FEATURE,
48
+ } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
49
+ import type { SessionMetadata, TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
50
+ import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
51
+ import {
52
+ configureBlindIndexKey,
53
+ configurePiiSubjectKms,
54
+ type KmsAdapter,
55
+ } from "@cosmicdrift/kumiko-framework/crypto";
56
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
57
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
31
58
  import {
32
59
  collectWriteHandlerQns,
33
60
  createRegistry,
@@ -41,13 +68,17 @@ import {
41
68
  validateAppCustomScreenWriteQns,
42
69
  validateBoot,
43
70
  } from "@cosmicdrift/kumiko-framework/engine";
44
- import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
71
+ import type { EnvelopeCipher, MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
45
72
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
46
73
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
47
- import { applyBootSeeds } from "./boot/apply-boot-seeds";
48
-
74
+ import { applyBootSeeds } from "@cosmicdrift/kumiko-server-runtime/boot/apply-boot-seeds";
75
+ import { resolveBootCrypto } from "@cosmicdrift/kumiko-server-runtime/boot/boot-crypto";
76
+ import {
77
+ buildComposeAuthOptions,
78
+ composeFeatures,
79
+ } from "@cosmicdrift/kumiko-server-runtime/compose-features";
80
+ import { assertPiiBootInvariants } from "@cosmicdrift/kumiko-server-runtime/pii-boot-gate";
49
81
  import { watchAndRegenerate } from "./codegen";
50
- import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
51
82
  import {
52
83
  type CreateKumikoServerOptions,
53
84
  createKumikoServer,
@@ -57,28 +88,31 @@ import { renderWelcomeBanner } from "./welcome-banner";
57
88
 
58
89
  // Re-export der shared Auth-Setup-Types damit Apps nur einen Import-Pfad
59
90
  // brauchen. PasswordResetSetup / EmailVerificationSetup leben in
60
- // run-prod-app.ts (single source of truth) — hier nur durchgereicht.
91
+ // @cosmicdrift/kumiko-server-runtime (single source of truth) — hier nur
92
+ // durchgereicht.
61
93
  export type {
94
+ AccountUnlockSetup,
62
95
  AuthMailOptions,
63
96
  EmailVerificationSetup,
64
97
  InviteSetup,
65
98
  PasswordResetSetup,
66
99
  SignupSetup,
67
- } from "./run-prod-app";
100
+ } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
68
101
 
69
102
  import type {
103
+ AccountUnlockSetup,
70
104
  AuthMailOptions,
71
105
  EmailVerificationSetup,
72
106
  InviteSetup,
73
107
  PasswordResetSetup,
74
108
  SignupSetup,
75
- } from "./run-prod-app";
109
+ } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
76
110
  import {
77
111
  addConfigAccessorFactory,
78
112
  buildBootExtraContext,
79
113
  requireEnv,
80
114
  resolveAuthMail,
81
- } from "./run-prod-app";
115
+ } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
82
116
 
83
117
  export type RunDevAppAuthOptions = {
84
118
  /** Admin user to seed at boot. Idempotent — re-runs in persistent-DB
@@ -87,17 +121,6 @@ export type RunDevAppAuthOptions = {
87
121
  /** Optional override of the login error → HTTP status map. Default
88
122
  * maps invalidCredentials → 401, noMembership → 403. */
89
123
  readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
90
- /** Opt-in: revocable server-side sessions. Caller MUSS
91
- * `createSessionsFeature()` zu `features` adden — runDevApp wired
92
- * hier nur die Auth-Callbacks (creator/revoker/checker) gegen
93
- * stack.db, plus sessionStrictMode=true.
94
- *
95
- * Standardverhalten ohne diese Option: stateless JWTs ohne sid,
96
- * Logout ist client-side cookie-clear, Karten­haus existing-Apps
97
- * bleibt unangefasst. */
98
- readonly sessions?: {
99
- readonly expiresInMs?: number;
100
- };
101
124
  /** Auth-Mail-Convenience — symmetrisch zu RunProdAppAuthOptions.mail.
102
125
  * Verdrahtet alle 4 Mail-Flows aus env-SMTP + Standard-Templates;
103
126
  * hmacSecret = `JWT_SECRET`-env (Dev-Fallback wenn ungesetzt). Ohne
@@ -109,21 +132,28 @@ export type RunDevAppAuthOptions = {
109
132
  * der request/confirm-Handler im auth-email-password-Feature wird
110
133
  * registriert. Symmetrisch zu RunProdAppAuthOptions.passwordReset. */
111
134
  readonly passwordReset?: PasswordResetSetup;
112
- /** Email-verification flow. Symmetric zu passwordReset. */
135
+ /** Email-verification flow. Symmetric to passwordReset. */
113
136
  readonly emailVerification?: EmailVerificationSetup;
114
- /** Self-Signup flow (Magic-Link). Symmetric zu RunProdAppAuthOptions. */
137
+ /** Self-signup flow (magic link). Symmetric to RunProdAppAuthOptions. */
115
138
  readonly signup?: SignupSetup;
116
- /** Tenant-Invite flow (Magic-Link). Symmetric. */
139
+ /** Tenant-invite flow (magic link). Symmetric. */
117
140
  readonly invite?: InviteSetup;
141
+ /** Account-unlock flow (#1266). Symmetric to RunProdAppAuthOptions. */
142
+ readonly accountUnlock?: AccountUnlockSetup;
118
143
  /** Domain attribute for both auth cookies (see
119
- * AuthRoutesConfig.cookieDomain). Symmetric zu RunProdAppAuthOptions. */
144
+ * AuthRoutesConfig.cookieDomain). Symmetric to RunProdAppAuthOptions. */
120
145
  readonly cookieDomain?: string;
121
146
  /** Server-side Origin allowlist for the CSRF guard (see
122
- * AuthRoutesConfig.allowedOrigins). Symmetric zu RunProdAppAuthOptions —
147
+ * AuthRoutesConfig.allowedOrigins). Symmetric to RunProdAppAuthOptions —
123
148
  * required once `cookieDomain` is set. */
124
149
  readonly allowedOrigins?: readonly string[];
125
- /** Opt out of the Origin guard. Symmetric zu RunProdAppAuthOptions. */
150
+ /** Opt out of the Origin guard. Symmetric to RunProdAppAuthOptions. */
126
151
  readonly unsafeSkipOriginCheck?: boolean;
152
+ /** Number of trusted reverse-proxy hops for client-IP derivation (see
153
+ * AuthRoutesConfig.trustedProxyHops, kumiko-framework#1539). Symmetric to
154
+ * RunProdAppAuthOptions — dev usually runs unproxied, so this is normally
155
+ * left unset (default 0). */
156
+ readonly trustedProxyHops?: number;
127
157
  };
128
158
 
129
159
  /** Hook for app-specific seeding (demo data, fixtures). Runs after the
@@ -176,6 +206,16 @@ export type RunDevAppOptions = {
176
206
  * `createEnvMasterKeyProvider`. Override für KMS-Backends. Nur relevant
177
207
  * wenn das secrets-Feature gemountet ist. Symmetrisch zu runProdApp. */
178
208
  readonly masterKey?: MasterKeyProvider;
209
+ /** Subject-Key-KMS für pii-annotierte Felder (Crypto-Shredding) — dev-
210
+ * Pendant zu runProdApp({ kms }). Ephemere DB: InMemoryKmsAdapter reicht.
211
+ * Persistente Dev-DB: createPgKmsAdapter gegen dieselbe DB, sonst sind
212
+ * alte Rows nach dem Restart unlesbar (DEKs weg). Ohne Adapter: Klartext
213
+ * + Boot-Warnung. */
214
+ readonly kms?: KmsAdapter;
215
+ /** 32-Byte-Key (base64) für Blind-Index-HMACs — Pflicht sobald `kms`
216
+ * gesetzt ist und lookupable-Felder gemountet sind (sonst Boot-Abbruch,
217
+ * symmetrisch zu runProdApp). */
218
+ readonly blindIndexKey?: string;
179
219
  /** Env-Quelle für die ENV→config-app-override-Brücke (Keys mit `env:`
180
220
  * bekommen ihren env-Wert als app-override-Default — symmetrisch zu
181
221
  * runProdApp). Default `process.env`. Injizierbar als Test-Seam, damit
@@ -226,10 +266,14 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
226
266
  ...(composeAuthOptions && { authOptions: composeAuthOptions }),
227
267
  });
228
268
 
229
- // Ein explizit gewireter File-Provider (options.files) erfüllt das
230
- // FILE_STORAGE_PROVIDER-Boot-Gateder Provider IST konfiguriert, nur
231
- // nicht über die env-Bridge. Setzen bevor validateBoot greift.
232
- if (options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined) {
269
+ // An explicitly wired file provider (options.files) satisfies the
270
+ // FILE_STORAGE_PROVIDER boot gate set it before validateBoot runs. Only
271
+ // if WE set it (532/2): deleting it after use instead of leaving a
272
+ // permanent process.env mutation, so a second runDevApp call in the same
273
+ // process (no files this time) doesn't fall through the gate falsely.
274
+ const setFileStorageProviderEnv =
275
+ options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined;
276
+ if (setFileStorageProviderEnv) {
233
277
  process.env["FILE_STORAGE_PROVIDER"] = "configured";
234
278
  }
235
279
 
@@ -238,7 +282,11 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
238
282
  // die früher nur runProdApp fing und sonst erst den Prod-Pod im
239
283
  // CrashLoopBackOff sterben ließ (#359). Wirft synchron, bevor ein
240
284
  // Socket oder Watcher (codegen-Write) aufgeht.
241
- validateBoot(features);
285
+ try {
286
+ validateBoot(features);
287
+ } finally {
288
+ if (setFileStorageProviderEnv) delete process.env["FILE_STORAGE_PROVIDER"];
289
+ }
242
290
  warnIfNonUtcServerTimeZone();
243
291
  validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
244
292
 
@@ -303,8 +351,27 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
303
351
  // throwaway-Registry hier extrahiert nur die config-Keys, weil der
304
352
  // configResolver vor dem Server-Boot konstruiert wird (stack.registry
305
353
  // gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
354
+ const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
355
+ // App-wide cipher for `encrypted: true` entity fields (symmetrisch zu
356
+ // runProdApp) — executors resolve it lazily.
357
+ configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
358
+ // Subject-KMS + Blind-Index (symmetrisch zu runProdApp). Dev warnt bei
359
+ // Klartext-PII statt zu failen; kms+lookupable ohne blindIndexKey bricht
360
+ // auch hier (Lookups wären in jedem Modus kaputt).
361
+ configurePiiSubjectKms(options.kms);
362
+ configureBlindIndexKey(options.blindIndexKey);
363
+ assertPiiBootInvariants(features, {
364
+ kms: options.kms,
365
+ blindIndexKey: options.blindIndexKey,
366
+ mode: "dev",
367
+ });
306
368
  const cfgExtra = effectiveAuth
307
- ? mergeConfigResolverDefault(options.extraContext, createRegistry(features), envSource)
369
+ ? mergeConfigResolverDefault(
370
+ options.extraContext,
371
+ createRegistry(features),
372
+ envSource,
373
+ bootCrypto.configCipher,
374
+ )
308
375
  : options.extraContext;
309
376
  // Auto-wire textContent (immer) + secrets (feature-gated), symmetrisch zu
310
377
  // runProdApp. Anders als prod existiert die db hier erst im Factory-deps
@@ -319,39 +386,70 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
319
386
  registry: deps.registry,
320
387
  hasAuth: false,
321
388
  sseBroker: deps.sseBroker,
322
- ...(options.masterKey && { masterKey: options.masterKey }),
389
+ crypto: bootCrypto,
323
390
  });
324
391
  const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
325
392
  return { ...boot, ...base };
326
393
  };
327
394
 
328
- // Sessions opt-in: Holder lebt im closure, `createSessionCallbacks`
329
- // kennt erst nach setupTestStack die echte db-connection. Inline
330
- // statt @cosmicdrift/kumiko-framework/testing's createLateBoundHolder zu reusen,
331
- // weil dev-server (dev-runtime) keine Tooling aus framework/testing
332
- // (test-runtime) importieren darf — Runtime-Isolation Guard.
333
- // Server-Start passiert NACH onAfterSetup (siehe create-kumiko-server.ts),
334
- // daher ist `sessionCallbacks` zur ersten Login-Request konkret.
335
- let sessionCallbacks: SessionCallbacks | undefined;
336
- const requireSessions = (): SessionCallbacks => {
337
- if (!sessionCallbacks) {
338
- throw new Error("[runDevApp] session-callbacks accessed before onAfterSetup");
395
+ // Sessions: late-bound holder resolveSessionStore needs concrete db+
396
+ // registry (only after setupTestStack). Wired when sessions feature is
397
+ // mounted (#1372); no auth.sessions opt-in anymore.
398
+ let sessionStore: SessionStore | undefined;
399
+ const requireSessionStore = (): SessionStore => {
400
+ if (!sessionStore) {
401
+ throw new Error("[runDevApp] sessionStore accessed before onAfterSetup");
339
402
  }
340
- return sessionCallbacks;
403
+ return sessionStore;
341
404
  };
405
+ // Token-verifier opt-in: same late-bound holder pattern — resolveTokenVerifier
406
+ // needs the real db+registry (only concrete after setupTestStack). Wired
407
+ // whenever a provider feature (personal-access-tokens today) is mounted.
408
+ let tokenVerifier: TokenVerifier | undefined;
409
+ let lifecycleDb: DbConnection | undefined;
410
+ const patFeature = features.find((f) => f.name === PAT_FEATURE);
411
+ const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
412
+ const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
413
+ const patAuthFragment = patFeature
414
+ ? {
415
+ tokenVerifier: (rawToken: string) => {
416
+ if (!tokenVerifier) {
417
+ throw new Error("[runDevApp] token-verifier accessed before onAfterSetup");
418
+ }
419
+ return tokenVerifier(rawToken);
420
+ },
421
+ patRateLimiter: (() => {
422
+ const rl = patRateLimitFromFeature(patFeature);
423
+ return createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs);
424
+ })(),
425
+ }
426
+ : {};
427
+
428
+ const tenantLifecycleAuthFragment = tenantLifecycleFeature
429
+ ? {
430
+ resolveTenantLifecycleStatus: async (tenantId: string) => {
431
+ if (!lifecycleDb) {
432
+ throw new Error("[runDevApp] tenant-lifecycle gate accessed before onAfterSetup");
433
+ }
434
+ const gate = await resolveTenantLifecycleGate(lifecycleDb, tenantId);
435
+ return gate ? { status: gate.status } : null;
436
+ },
437
+ }
438
+ : {};
439
+
440
+ // Parity with runProdApp (#1372 review): gate on sessionStore provider, not
441
+ // the sessions feature name — a custom provider must also wire auth callbacks.
442
+ const sessionStoreProviderMounted = features.some((f) =>
443
+ f.extensionUsages.some((u) => u.extensionName === EXT_SESSION_STORE),
444
+ );
342
445
  const sessionAuthFragment =
343
- effectiveAuth?.sessions !== undefined
446
+ effectiveAuth && sessionStoreProviderMounted
344
447
  ? {
345
448
  sessionCreator: (user: SessionUser, meta: SessionMetadata) =>
346
- requireSessions().sessionCreator(user, meta),
347
- sessionRevoker: (sid: string) => requireSessions().sessionRevoker(sid),
449
+ requireSessionStore().creator(user, meta),
450
+ sessionRevoker: (sid: string) => requireSessionStore().revoker(sid),
348
451
  sessionChecker: (sid: string, userId: string) =>
349
- requireSessions().sessionChecker(sid, userId),
350
- // strict-mode: jede neue Plattform-App startet ohne legacy-
351
- // JWTs ohne sid, daher safe als Default. Wer Sessions opt-in
352
- // wählt, will explizite Server-side Revocation — strict-mode
353
- // ist der einzige Modus der das tatsächlich erzwingt.
354
- sessionStrictMode: true,
452
+ requireSessionStore().checker(sid, userId),
355
453
  }
356
454
  : {};
357
455
 
@@ -391,7 +489,17 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
391
489
  ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
392
490
  unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
393
491
  }),
492
+ ...(effectiveAuth.trustedProxyHops !== undefined && {
493
+ trustedProxyHops: effectiveAuth.trustedProxyHops,
494
+ }),
394
495
  ...sessionAuthFragment,
496
+ ...patAuthFragment,
497
+ ...tenantLifecycleAuthFragment,
498
+ ...(mfaFeature && {
499
+ mfaVerifyHandler: AuthMfaHandlers.verify,
500
+ mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
501
+ mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
502
+ }),
395
503
  ...(effectiveAuth.passwordReset && {
396
504
  passwordReset: {
397
505
  requestHandler: AuthHandlers.requestPasswordReset,
@@ -404,6 +512,12 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
404
512
  confirmHandler: AuthHandlers.verifyEmail,
405
513
  },
406
514
  }),
515
+ ...(effectiveAuth.accountUnlock && {
516
+ accountUnlock: {
517
+ requestHandler: AuthHandlers.requestAccountUnlock,
518
+ confirmHandler: AuthHandlers.confirmAccountUnlock,
519
+ },
520
+ }),
407
521
  ...(effectiveAuth.signup && {
408
522
  signup: {
409
523
  requestHandler: AuthHandlers.signupRequest,
@@ -420,6 +534,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
420
534
  },
421
535
  }),
422
536
  onAfterSetup: async (stack) => {
537
+ lifecycleDb = stack.db;
423
538
  // Sprint-8a: build tier-resolver BEFORE any seeds so seeds can rely
424
539
  // on the resolver being live (e.g. seed that writes a SystemAdmin's
425
540
  // tier-assignment can immediately read tier-cuts).
@@ -430,12 +545,25 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
430
545
  registry: stack.registry,
431
546
  });
432
547
  }
433
- if (effectiveAuth?.sessions !== undefined) {
434
- const expiresInMs = effectiveAuth.sessions.expiresInMs;
435
- sessionCallbacks = createSessionCallbacks({
548
+ if (effectiveAuth && stack.registry.getExtensionUsages(EXT_SESSION_STORE).length > 0) {
549
+ // Secure-by-default (#1372): resolve sessionStore provider; bind
550
+ // password-change mass-revoke + MFA revokeAllOthers.
551
+ const store = await resolveSessionStore({
436
552
  db: stack.db,
437
- ...(expiresInMs !== undefined && { expiresInMs }),
553
+ registry: stack.registry,
438
554
  });
555
+ const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
556
+ if (sessionsFeature) {
557
+ bindAutoRevokeFromFeature(sessionsFeature)?.(store.massRevoker);
558
+ }
559
+ if (mfaFeature) {
560
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(store.revokeAllOthers);
561
+ }
562
+ sessionStore = store;
563
+ }
564
+ if (patFeature) {
565
+ tokenVerifier = (rawToken) =>
566
+ resolveTokenVerifier({ db: stack.db, registry: stack.registry }, rawToken);
439
567
  }
440
568
  if (effectiveAuth) {
441
569
  await seedAdmin(stack.db, effectiveAuth.admin);
@@ -444,7 +572,11 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
444
572
  // Runs before user-supplied seed callbacks so those can read /
445
573
  // override the deploy-defaults. The helper indirection is what
446
574
  // config-seed-boot.integration.ts pins — keep it as a single call.
447
- await applyBootSeeds({ registry: stack.registry, db: stack.db });
575
+ await applyBootSeeds({
576
+ registry: stack.registry,
577
+ db: stack.db,
578
+ ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
579
+ });
448
580
  for (const seed of options.seeds ?? []) {
449
581
  await seed(stack);
450
582
  }
@@ -480,10 +612,12 @@ export function mergeConfigResolverDefault(
480
612
  ctx: CreateKumikoServerOptions["extraContext"],
481
613
  registry: Registry,
482
614
  envSource: Record<string, string | undefined>,
615
+ cipher?: EnvelopeCipher,
483
616
  ): CreateKumikoServerOptions["extraContext"] {
484
617
  const defaults = {
485
618
  configResolver: createConfigResolver({
486
619
  appOverrides: buildEnvConfigOverrides(registry, envSource),
620
+ ...(cipher && { cipher }),
487
621
  }),
488
622
  };
489
623
  // ctx.config wird per-Request aus _configAccessorFactory geminted (siehe
@@ -47,14 +47,27 @@ export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): Scaffold
47
47
  }
48
48
  mkdirSync(featureDir, { recursive: true });
49
49
 
50
- const files: string[] = [];
51
- const featureFile = join(featureDir, "feature.ts");
52
- writeFileSync(featureFile, renderFeature(options.name));
53
- files.push(`src/features/${options.name}/feature.ts`);
50
+ // Volle App-Feature-Konvention (Referenz: bundled-features/tenant):
51
+ // feature.ts = nur Registrierung, schema/ für Entities, i18n.ts für
52
+ // Server-Keys, web/ für die Client-Seite, __tests__/ mit Boot-Test.
53
+ // handlers/ + lib/ entstehen beim ersten echten Handler/Logik-Modul.
54
+ mkdirSync(join(featureDir, "schema"), { recursive: true });
55
+ mkdirSync(join(featureDir, "web"), { recursive: true });
56
+ mkdirSync(join(featureDir, "__tests__"), { recursive: true });
54
57
 
55
- const indexFile = join(featureDir, "index.ts");
56
- writeFileSync(indexFile, renderIndex(options.name));
57
- files.push(`src/features/${options.name}/index.ts`);
58
+ const files: string[] = [];
59
+ const write = (relative: string, content: string): void => {
60
+ writeFileSync(join(featureDir, relative), content);
61
+ files.push(`src/features/${options.name}/${relative}`);
62
+ };
63
+ write("feature.ts", renderFeature(options.name));
64
+ write("index.ts", renderIndex(options.name));
65
+ write("constants.ts", renderConstants(options.name));
66
+ write("i18n.ts", renderI18n(options.name));
67
+ write(join("schema", `${options.name}-item.ts`), renderEntity(options.name));
68
+ write(join("schema", "index.ts"), renderSchemaIndex(options.name));
69
+ write(join("web", "index.ts"), renderWebIndex(options.name));
70
+ write(join("__tests__", "feature.boot.test.ts"), renderBootTest(options.name));
58
71
 
59
72
  const runConfigPath = join(appRoot, "src", "run-config.ts");
60
73
  let autoMounted = false;
@@ -83,18 +96,38 @@ function renderFeature(name: string): string {
83
96
  const camel = kebabToCamel(name);
84
97
  return `// ${name} feature — scaffolded by \`kumiko add feature\`. Edit freely.
85
98
  //
86
- // Doc-Pointer: https://docs.kumiko.rocks/en/patterns/ for the \`r.*\` API
87
- // (r.entity, r.writeHandler, r.queryHandler, hooks, …).
99
+ // Konvention (Referenz: bundled-features, z.B. tenant/):
100
+ // feature.ts — NUR Registrierung (r.entity / r.screen / r.queryHandler / …)
101
+ // schema/ — Entity-Definitionen
102
+ // handlers/ — eine Datei pro Handler (defineQueryHandler / defineWriteHandler)
103
+ // lib/ — reine Domain-Logik
104
+ // web/ — Client-Seite (index.ts, ein Screen pro Datei)
105
+ // i18n.ts — Server-Translation-Keys
106
+ //
107
+ // Doc-Pointer: https://docs.kumiko.rocks/en/patterns/ for the \`r.*\` API.
88
108
 
89
109
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
110
+ import { ${constName(name)} } from "./constants";
111
+ import { ${camel}TranslationKeys } from "./i18n";
112
+ import { ${camel}ItemEntity } from "./schema";
113
+
114
+ export const ${camel}Feature = defineFeature(${constName(name)}, (r) => {
115
+ r.entity("${name}-item", ${camel}ItemEntity);
90
116
 
91
- export const ${camel}Feature = defineFeature("${name}", (r) => {
92
- // Starter: declare an entity. Drop and replace with your domain.
93
- r.entity("${name}-item", {
94
- fields: {
95
- title: { type: "text", required: true },
96
- },
117
+ // Deklarativer List-Screen CRUD-Query kommt aus der Entity.
118
+ // Für query-getriebene Tabellen: type "projectionList"; für
119
+ // Kennzahlen-Grids: type "dashboard".
120
+ r.screen({
121
+ id: "items",
122
+ type: "entityList",
123
+ entity: "${name}-item",
124
+ columns: ["title"],
125
+ defaultSort: { field: "title", dir: "asc" },
97
126
  });
127
+
128
+ r.translations({ keys: ${camel}TranslationKeys });
129
+
130
+ r.nav({ id: "items", label: "screen:items.title", screen: "${name}:screen:items" });
98
131
  });
99
132
  `;
100
133
  }
@@ -104,10 +137,80 @@ function renderIndex(name: string): string {
104
137
  return `export { ${camel}Feature } from "./feature";\n`;
105
138
  }
106
139
 
140
+ function renderConstants(name: string): string {
141
+ return `export const ${constName(name)} = "${name}" as const;\n`;
142
+ }
143
+
144
+ function renderI18n(name: string): string {
145
+ const camel = kebabToCamel(name);
146
+ return `// Server-seitige Translation-Keys (r.translations). Screen-Titel und
147
+ // Field-Labels folgen den Framework-Konventionen — der Boot-Validator
148
+ // prüft die Abdeckung.
149
+
150
+ export const ${camel}TranslationKeys = {
151
+ "screen:items.title": { de: "Einträge", en: "Items" },
152
+ "${name}:entity:${name}-item:field:title": { de: "Titel", en: "Title" },
153
+ };
154
+ `;
155
+ }
156
+
157
+ function renderEntity(name: string): string {
158
+ const camel = kebabToCamel(name);
159
+ return `// Entity-Definition — Felder, Annotationen, Transitions.
160
+ // Starter: eine Text-Spalte. Ersetze sie durch deine Domain.
161
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
162
+
163
+ export const ${camel}ItemEntity = createEntity({
164
+ fields: {
165
+ title: createTextField({ required: true, sortable: true }),
166
+ },
167
+ });
168
+ `;
169
+ }
170
+
171
+ function renderSchemaIndex(name: string): string {
172
+ const camel = kebabToCamel(name);
173
+ return `export { ${camel}ItemEntity } from "./${name}-item";\n`;
174
+ }
175
+
176
+ function renderWebIndex(name: string): string {
177
+ const camel = kebabToCamel(name);
178
+ return `// @runtime client
179
+ // Client-Seite des Features: translations, components (custom-Screens),
180
+ // columnRenderers. In src/app/client.tsx bei
181
+ // createKumikoApp({ clientFeatures: [...] }) einhängen — das passiert
182
+ // NICHT automatisch. Screens: eine Datei pro Screen in diesem Ordner.
183
+
184
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
185
+
186
+ export const ${camel}Client: ClientFeatureDefinition = {
187
+ name: "${name}",
188
+ };
189
+ `;
190
+ }
191
+
192
+ function renderBootTest(name: string): string {
193
+ const camel = kebabToCamel(name);
194
+ return `import { describe, expect, test } from "bun:test";
195
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
196
+ import { ${camel}Feature } from "../feature";
197
+
198
+ describe("${name} feature", () => {
199
+ test("boots: Entity, Screens und i18n-Coverage validieren", () => {
200
+ expect(() => validateBoot([${camel}Feature])).not.toThrow();
201
+ });
202
+ });
203
+ `;
204
+ }
205
+
107
206
  function kebabToCamel(name: string): string {
108
207
  return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
109
208
  }
110
209
 
210
+ function constName(name: string): string {
211
+ return `${name.replace(/-/g, "_").toUpperCase()}_FEATURE`;
212
+ }
213
+
111
214
  // ts-morph: open run-config, prepend import, append APP_FEATURES entry.
112
215
  // Returns true on success, throws on shape-mismatch — the caller rolls back
113
216
  // the scaffolded feature dir and re-throws so a re-run isn't blocked.