@cosmicdrift/kumiko-dev-server 0.113.1 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.113.1",
3
+ "version": "1.0.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -50,8 +50,8 @@
50
50
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@cosmicdrift/kumiko-bundled-features": "0.113.1",
54
- "@cosmicdrift/kumiko-framework": "0.113.1",
53
+ "@cosmicdrift/kumiko-bundled-features": "1.0.0",
54
+ "@cosmicdrift/kumiko-framework": "1.0.0",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -98,11 +98,12 @@ describe("composeFeatures", () => {
98
98
  });
99
99
 
100
100
  test("app feature duplicating a bundled name is dropped (no createRegistry crash)", () => {
101
- // create-kumiko-app's picker hands back createAuthEmailPasswordFeature()
102
- // because the user ticked it in the recommended set; runDevApp then adds
103
- // its OWN bundled copy via includeBundled:true, and createRegistry throws
104
- // "Duplicate feature: auth-email-password". The dedupe path keeps the
105
- // bundled instance (it carries authOptions wiring) and drops the app stub.
101
+ // Bug discovered during Phase 3 recording: create-kumiko-app's picker
102
+ // hands back createAuthEmailPasswordFeature() because the user ticked
103
+ // it in the recommended set; runDevApp then adds its OWN bundled
104
+ // copy via includeBundled:true, and createRegistry throws "Duplicate
105
+ // feature: auth-email-password". The dedupe path keeps the bundled
106
+ // instance (it carries authOptions wiring) and drops the app stub.
106
107
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
107
108
  const features = composeFeatures([pickerAuthDupe, noopFeature], {
108
109
  includeBundled: true,
@@ -6,7 +6,7 @@
6
6
  // `relation "kumiko_events" does not exist`. Dieser Test fährt den echten
7
7
  // Pfad gegen eine leere DB + den idempotenten Re-Run.
8
8
 
9
- import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
10
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
@@ -76,7 +76,7 @@ describe("runSchemaApply", () => {
76
76
  expect(await tableExists(conn.db, "public.read_thing")).toBe(true);
77
77
  });
78
78
 
79
- test("rebuild-Marker für nicht-registrierte Tabelle → kein Crash, 0, aber laut warnen (522/3)", async () => {
79
+ test("rebuild-Marker für nicht-registrierte Tabelle → kein Crash, 0", async () => {
80
80
  writeFileSync(
81
81
  join(migDir, "0002_more.sql"),
82
82
  `CREATE TABLE "read_more" ("id" text PRIMARY KEY);`,
@@ -86,10 +86,7 @@ describe("runSchemaApply", () => {
86
86
  JSON.stringify({ version: 1, tables: ["read_more"] }),
87
87
  );
88
88
 
89
- const warn = spyOn(console, "warn").mockImplementation(() => {});
90
89
  expect(await runSchemaApply({ ...APPLY, appCwd })).toBe(0);
91
90
  expect(await tableExists(conn.db, "public.read_more")).toBe(true);
92
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('Table "read_more"'));
93
- warn.mockRestore();
94
91
  });
95
92
  });
@@ -1,7 +1,6 @@
1
1
  import { seedAllConfigValues } from "@cosmicdrift/kumiko-bundled-features/config";
2
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbConnection, EncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
- import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
5
4
 
6
5
  // Single boot-seed entry-point. runDevApp + runProdApp both call this
7
6
  // from their post-stack hook, so the wiring lives in exactly one place
@@ -13,7 +12,7 @@ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
13
12
  export async function applyBootSeeds(deps: {
14
13
  registry: Registry;
15
14
  db: DbConnection;
16
- cipher?: EnvelopeCipher;
15
+ encryption?: EncryptionProvider;
17
16
  }): Promise<void> {
18
- await seedAllConfigValues(deps.registry, deps.db, deps.cipher);
17
+ await seedAllConfigValues(deps.registry, deps.db, deps.encryption);
19
18
  }
@@ -23,21 +23,11 @@ import {
23
23
  createConfigResolver,
24
24
  } from "@cosmicdrift/kumiko-bundled-features/config";
25
25
  import {
26
- createPatResolver,
27
- PAT_FEATURE,
28
- patRateLimitFromFeature,
29
- patScopesFromFeature,
30
- } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
31
- import {
32
- bindAutoRevokeFromFeature,
33
26
  createSessionCallbacks,
34
- SESSIONS_FEATURE,
35
27
  type SessionCallbacks,
36
28
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
37
29
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
38
- import type { PatResolver, SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
39
- import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
40
- import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
30
+ import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
41
31
  import {
42
32
  collectWriteHandlerQns,
43
33
  createRegistry,
@@ -51,11 +41,10 @@ import {
51
41
  validateAppCustomScreenWriteQns,
52
42
  validateBoot,
53
43
  } from "@cosmicdrift/kumiko-framework/engine";
54
- import type { EnvelopeCipher, MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
44
+ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
55
45
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
56
46
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
57
47
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
58
- import { resolveBootCrypto } from "./boot/boot-crypto";
59
48
 
60
49
  import { watchAndRegenerate } from "./codegen";
61
50
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
@@ -237,14 +226,10 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
237
226
  ...(composeAuthOptions && { authOptions: composeAuthOptions }),
238
227
  });
239
228
 
240
- // An explicitly wired file provider (options.files) satisfies the
241
- // FILE_STORAGE_PROVIDER boot gate set it before validateBoot runs. Only
242
- // if WE set it (532/2): deleting it after use instead of leaving a
243
- // permanent process.env mutation, so a second runDevApp call in the same
244
- // process (no files this time) doesn't fall through the gate falsely.
245
- const setFileStorageProviderEnv =
246
- options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined;
247
- if (setFileStorageProviderEnv) {
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) {
248
233
  process.env["FILE_STORAGE_PROVIDER"] = "configured";
249
234
  }
250
235
 
@@ -253,11 +238,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
253
238
  // die früher nur runProdApp fing und sonst erst den Prod-Pod im
254
239
  // CrashLoopBackOff sterben ließ (#359). Wirft synchron, bevor ein
255
240
  // Socket oder Watcher (codegen-Write) aufgeht.
256
- try {
257
- validateBoot(features);
258
- } finally {
259
- if (setFileStorageProviderEnv) delete process.env["FILE_STORAGE_PROVIDER"];
260
- }
241
+ validateBoot(features);
261
242
  warnIfNonUtcServerTimeZone();
262
243
  validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
263
244
 
@@ -322,17 +303,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
322
303
  // throwaway-Registry hier extrahiert nur die config-Keys, weil der
323
304
  // configResolver vor dem Server-Boot konstruiert wird (stack.registry
324
305
  // gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
325
- const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
326
- // App-wide cipher for `encrypted: true` entity fields (symmetrisch zu
327
- // runProdApp) — executors resolve it lazily.
328
- configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
329
306
  const cfgExtra = effectiveAuth
330
- ? mergeConfigResolverDefault(
331
- options.extraContext,
332
- createRegistry(features),
333
- envSource,
334
- bootCrypto.configCipher,
335
- )
307
+ ? mergeConfigResolverDefault(options.extraContext, createRegistry(features), envSource)
336
308
  : options.extraContext;
337
309
  // Auto-wire textContent (immer) + secrets (feature-gated), symmetrisch zu
338
310
  // runProdApp. Anders als prod existiert die db hier erst im Factory-deps
@@ -347,7 +319,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
347
319
  registry: deps.registry,
348
320
  hasAuth: false,
349
321
  sseBroker: deps.sseBroker,
350
- crypto: bootCrypto,
322
+ ...(options.masterKey && { masterKey: options.masterKey }),
351
323
  });
352
324
  const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
353
325
  return { ...boot, ...base };
@@ -367,26 +339,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
367
339
  }
368
340
  return sessionCallbacks;
369
341
  };
370
- // PAT opt-in: same late-bound holder pattern — the resolver needs the real
371
- // db (only concrete after setupTestStack). Wired when the feature is mounted;
372
- // scopes come from the feature's exports (single source with its handlers).
373
- let patResolver: PatResolver | undefined;
374
- const patFeature = features.find((f) => f.name === PAT_FEATURE);
375
- const patAuthFragment = patFeature
376
- ? {
377
- patResolver: (rawToken: string) => {
378
- if (!patResolver) {
379
- throw new Error("[runDevApp] pat-resolver accessed before onAfterSetup");
380
- }
381
- return patResolver(rawToken);
382
- },
383
- patRateLimiter: (() => {
384
- const rl = patRateLimitFromFeature(patFeature);
385
- return createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs);
386
- })(),
387
- }
388
- : {};
389
-
390
342
  const sessionAuthFragment =
391
343
  effectiveAuth?.sessions !== undefined
392
344
  ? {
@@ -440,7 +392,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
440
392
  unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
441
393
  }),
442
394
  ...sessionAuthFragment,
443
- ...patAuthFragment,
444
395
  ...(effectiveAuth.passwordReset && {
445
396
  passwordReset: {
446
397
  requestHandler: AuthHandlers.requestPasswordReset,
@@ -485,15 +436,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
485
436
  db: stack.db,
486
437
  ...(expiresInMs !== undefined && { expiresInMs }),
487
438
  });
488
- // Secure-by-default (symmetrisch zu runProdApp): Password-Change/
489
- // -Reset mass-revoked die Sessions des Users ohne App-Opt-in.
490
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
491
- if (sessionsFeature) {
492
- bindAutoRevokeFromFeature(sessionsFeature)?.(sessionCallbacks.sessionMassRevoker);
493
- }
494
- }
495
- if (patFeature) {
496
- patResolver = createPatResolver({ db: stack.db, scopes: patScopesFromFeature(patFeature) });
497
439
  }
498
440
  if (effectiveAuth) {
499
441
  await seedAdmin(stack.db, effectiveAuth.admin);
@@ -502,11 +444,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
502
444
  // Runs before user-supplied seed callbacks so those can read /
503
445
  // override the deploy-defaults. The helper indirection is what
504
446
  // config-seed-boot.integration.ts pins — keep it as a single call.
505
- await applyBootSeeds({
506
- registry: stack.registry,
507
- db: stack.db,
508
- ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
509
- });
447
+ await applyBootSeeds({ registry: stack.registry, db: stack.db });
510
448
  for (const seed of options.seeds ?? []) {
511
449
  await seed(stack);
512
450
  }
@@ -542,12 +480,10 @@ export function mergeConfigResolverDefault(
542
480
  ctx: CreateKumikoServerOptions["extraContext"],
543
481
  registry: Registry,
544
482
  envSource: Record<string, string | undefined>,
545
- cipher?: EnvelopeCipher,
546
483
  ): CreateKumikoServerOptions["extraContext"] {
547
484
  const defaults = {
548
485
  configResolver: createConfigResolver({
549
486
  appOverrides: buildEnvConfigOverrides(registry, envSource),
550
- ...(cipher && { cipher }),
551
487
  }),
552
488
  };
553
489
  // ctx.config wird per-Request aus _configAccessorFactory geminted (siehe
@@ -50,18 +50,11 @@ import {
50
50
  createDeliveryService,
51
51
  DELIVERY_FEATURE,
52
52
  } from "@cosmicdrift/kumiko-bundled-features/delivery";
53
- import {
54
- createPatResolver,
55
- PAT_FEATURE,
56
- patRateLimitFromFeature,
57
- patScopesFromFeature,
58
- } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
59
53
  import {
60
54
  createSecretsContext,
61
55
  SECRETS_FEATURE_NAME,
62
56
  } from "@cosmicdrift/kumiko-bundled-features/secrets";
63
57
  import {
64
- bindAutoRevokeFromFeature,
65
58
  createSessionCallbacks,
66
59
  SESSIONS_FEATURE,
67
60
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
@@ -73,12 +66,10 @@ import {
73
66
  cachedResponse,
74
67
  computeStrongEtag,
75
68
  computeWeakEtag,
76
- createInMemoryLoginRateLimiter,
77
69
  createSseBroker,
78
70
  type SseBroker,
79
71
  } from "@cosmicdrift/kumiko-framework/api";
80
72
  import {
81
- configureEntityFieldEncryption,
82
73
  createDbConnection,
83
74
  type DbConnection,
84
75
  type DbRunner,
@@ -124,11 +115,13 @@ import {
124
115
  createEventDedup,
125
116
  createIdempotencyGuard,
126
117
  } from "@cosmicdrift/kumiko-framework/pipeline";
127
- import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
118
+ import {
119
+ createEnvMasterKeyProvider,
120
+ type MasterKeyProvider,
121
+ } from "@cosmicdrift/kumiko-framework/secrets";
128
122
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
129
123
  import Redis from "ioredis";
130
124
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
131
- import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
132
125
  import { ASSETS_DIR } from "./build-prod-bundle";
133
126
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
134
127
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
@@ -577,7 +570,14 @@ export type ProdAppHandle = {
577
570
  readonly stop: () => Promise<void>;
578
571
  };
579
572
 
580
- // Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
573
+ // Mint `ctx.config` per request: the dispatcher only builds a per-user
574
+ // ConfigAccessor when `_configAccessorFactory` is on the AppContext
575
+ // (pipeline/dispatcher.ts). Without it `ctx.config` stays undefined and any
576
+ // handler reading it — e.g. createFileProviderForTenant for the GDPR export
577
+ // download — throws "ctx.config is missing". Built from the EFFECTIVE resolver
578
+ // so an app-supplied configResolver override (its appOverrides) is the one
579
+ // ctx.config reads. Shared with runDevApp (mergeConfigResolverDefault) for
580
+ // dev/prod parity.
581
581
  export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
582
582
  resolved: T,
583
583
  registry: Registry,
@@ -596,13 +596,18 @@ export function addConfigAccessorFactory<T extends { readonly configResolver?: C
596
596
  // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
597
597
  // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
598
598
  // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
599
- // KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
600
- // (envHasMasterKek, resolveBootCrypto) gemeinsam mit runDevApp.
599
+ // createEnvMasterKeyProvider nicht wirft. Das deckt zwei Fälle: (a) App ohne
600
+ // secrets → kein KEK-Zwang; (b) dev mit App-eigenem DEV-KEK in extraContext
601
+ // (kein env-KEK) → kein Boot-Crash, die App-explizite secrets-Wiring gewinnt.
601
602
  // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
602
603
  // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
603
604
  // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
604
605
  // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
605
606
  // passiert beim Caller (App gewinnt).
607
+ const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
608
+ function envHasMasterKek(env: Record<string, string | undefined>): boolean {
609
+ return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
610
+ }
606
611
 
607
612
  // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
608
613
  // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
@@ -629,16 +634,12 @@ export function buildBootExtraContext(opts: {
629
634
  readonly envSource: Record<string, string | undefined>;
630
635
  readonly registry: Registry;
631
636
  readonly hasAuth: boolean;
632
- // Resolved once per boot via resolveBootCrypto — shared by secrets,
633
- // config-resolver, config-set-handler and boot-seeds. Absent (tests
634
- // that don't care about encryption) ⇒ resolved from envSource here.
635
- readonly crypto?: BootCrypto;
636
637
  readonly masterKey?: MasterKeyProvider;
637
638
  readonly sseBroker?: SseBroker;
638
639
  }): Record<string, unknown> {
639
- const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
640
640
  const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
641
- const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
641
+ const wireSecrets =
642
+ hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
642
643
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
643
644
  return {
644
645
  textContent: createTextContentApi(opts.db),
@@ -649,25 +650,25 @@ export function buildBootExtraContext(opts: {
649
650
  ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
650
651
  }),
651
652
  }),
652
- // Top-level provider so feature jobs (secrets rotate, config reencrypt)
653
- // reach it via ctx — previously only test-stack wired it.
654
- ...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
655
- // Encrypt/decrypt partner for `encrypted: true` config keys. Wired
656
- // whenever a master key exists — NOT gated on the secrets feature,
657
- // config encryption must work without mounting ctx.secrets.
658
- ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
659
- ...(wireSecrets &&
660
- crypto.masterKeyProvider && {
661
- secrets: createSecretsContext({
662
- db: opts.db,
663
- masterKeyProvider: crypto.masterKeyProvider,
664
- dekCache: crypto.dekCache,
665
- }),
653
+ ...(wireSecrets && {
654
+ secrets: createSecretsContext({
655
+ db: opts.db,
656
+ masterKeyProvider:
657
+ opts.masterKey ??
658
+ createEnvMasterKeyProvider({
659
+ // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
660
+ // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
661
+ env: {
662
+ ...opts.envSource,
663
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
664
+ opts.envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
665
+ },
666
+ }),
666
667
  }),
668
+ }),
667
669
  ...(opts.hasAuth && {
668
670
  configResolver: createConfigResolver({
669
671
  appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
670
- ...(crypto.configCipher && { cipher: crypto.configCipher }),
671
672
  }),
672
673
  }),
673
674
  };
@@ -918,10 +919,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
918
919
 
919
920
  // Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
920
921
  // gewinnen immer (z.B. money-horse's eigener configResolver).
921
- const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
922
- // App-wide cipher for `encrypted: true` entity fields — executors resolve
923
- // it lazily, entities without encrypted fields never touch it.
924
- configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
925
922
  const autoExtraContext = buildBootExtraContext({
926
923
  db,
927
924
  features,
@@ -929,7 +926,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
929
926
  registry,
930
927
  hasAuth: !!effectiveAuth,
931
928
  sseBroker,
932
- crypto: bootCrypto,
929
+ ...(options.masterKey && { masterKey: options.masterKey }),
933
930
  });
934
931
  const extraContext = addConfigAccessorFactory(
935
932
  { ...autoExtraContext, ...resolvedExtraContext },
@@ -945,40 +942,21 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
945
942
  // sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
946
943
  // von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
947
944
  // (4. callback aus createSessionCallbacks) ist nicht Teil der
948
- // AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
949
- // sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
950
- // über die auth-routes.
945
+ // AuthRoutesConfig-Surface — der wird vom sessions-Feature selbst über
946
+ // die `autoRevokeOnPasswordChange`-Option konsumiert, nicht über die
947
+ // auth-routes.
951
948
  // Secure-by-default: if the sessions feature is mounted, server-side revocation +
952
- // sessionStrictMode + auto-revoke-on-password-change are wired automatically;
953
- // `auth.sessions` only overrides the config, and `auth.sessions: false` is the
954
- // explicit opt-out (back to stateless JWTs).
955
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
949
+ // sessionStrictMode are wired automatically; `auth.sessions` only overrides the config,
950
+ // and `auth.sessions: false` is the explicit opt-out (back to stateless JWTs).
951
+ const sessionsFeatureMounted = features.some((f) => f.name === SESSIONS_FEATURE);
956
952
  const sessionAuthFragment = shouldWireProdSessions(
957
953
  Boolean(effectiveAuth),
958
- sessionsFeature !== undefined,
954
+ sessionsFeatureMounted,
959
955
  effectiveAuth?.sessions,
960
956
  )
961
- ? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions), sessionsFeature)
957
+ ? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions))
962
958
  : undefined;
963
959
 
964
- // PAT opt-in: if the personal-access-tokens feature is mounted, wire its
965
- // resolver (bearer PATs → SessionUser, before jwt.verify). Scopes come from
966
- // the feature's exports — the same declaration its handlers use.
967
- const patFeature = features.find((f) => f.name === PAT_FEATURE);
968
- let patAuthFragment:
969
- | {
970
- patResolver: ReturnType<typeof createPatResolver>;
971
- patRateLimiter: ReturnType<typeof createInMemoryLoginRateLimiter>;
972
- }
973
- | undefined;
974
- if (effectiveAuth && patFeature) {
975
- const rl = patRateLimitFromFeature(patFeature);
976
- patAuthFragment = {
977
- patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
978
- patRateLimiter: createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs),
979
- };
980
- }
981
-
982
960
  const baseEntrypointOptions = {
983
961
  registry,
984
962
  context: {
@@ -1016,7 +994,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1016
994
  unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
1017
995
  }),
1018
996
  ...sessionAuthFragment,
1019
- ...patAuthFragment,
1020
997
  ...(effectiveAuth.passwordReset && {
1021
998
  passwordReset: {
1022
999
  requestHandler: AuthHandlers.requestPasswordReset,
@@ -1108,11 +1085,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1108
1085
  if (effectiveAuth) {
1109
1086
  await seedAdmin(db, effectiveAuth.admin);
1110
1087
  }
1111
- await applyBootSeeds({
1112
- registry,
1113
- db,
1114
- ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
1115
- });
1088
+ await applyBootSeeds({ registry, db });
1116
1089
  for (const seed of options.seeds ?? []) {
1117
1090
  await seed({ db });
1118
1091
  }
@@ -1500,7 +1473,6 @@ export function staticCachePolicy(pathname: string): CachePolicy {
1500
1473
  function buildProdSessionAuth(
1501
1474
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
1502
1475
  opts: ProdSessionsConfig,
1503
- sessionsFeature: import("@cosmicdrift/kumiko-framework/engine").FeatureDefinition | undefined,
1504
1476
  ): {
1505
1477
  readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
1506
1478
  readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
@@ -1511,11 +1483,6 @@ function buildProdSessionAuth(
1511
1483
  db,
1512
1484
  ...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
1513
1485
  });
1514
- // Secure-by-default: password-change/-reset mass-revokes the user's live
1515
- // sessions without the app opting in via autoRevokeOnPasswordChange.
1516
- if (sessionsFeature) {
1517
- bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
1518
- }
1519
1486
  return {
1520
1487
  sessionCreator: cbs.sessionCreator,
1521
1488
  sessionRevoker: cbs.sessionRevoker,
@@ -611,20 +611,19 @@ KUMIKO_DEV_DB_NAME=${devDb}
611
611
  `;
612
612
  }
613
613
 
614
- // Ports + credentials match the *_URL defaults in renderEnvExample, so
615
- // `docker compose up -d` just works with the generated .env. Named pg volume
616
- // so dev data survives `docker compose down` (pairs with KUMIKO_DEV_DB_NAME
617
- // persistence) the loopback-binding rationale is in the generated file's
618
- // own comment (657/1), no need to duplicate it here.
614
+ // Local Postgres + Redis for `bun dev`. Ports + credentials match the *_URL
615
+ // defaults in renderEnvExample, so `docker compose up -d` (referenced by the
616
+ // README) just works with the generated .env. Named pg volume so dev data
617
+ // survives `docker compose down` (pairs with KUMIKO_DEV_DB_NAME persistence).
618
+ // Ports bind to 127.0.0.1 only: the dev DB (postgres/postgres) and auth-less
619
+ // Redis must not be reachable from the LAN on a machine without a firewall.
619
620
  function renderDockerCompose(): string {
620
621
  return `# Local Postgres + Redis for \`bun dev\`. Matches the *_URL defaults in .env.example.
621
622
  # Start: docker compose up -d · Stop: docker compose down · Reset: docker compose down -v
622
623
  # Ports bind to 127.0.0.1 only — weak dev credentials must not be exposed on the LAN.
623
624
  services:
624
625
  postgres:
625
- # Pinned to the project's own compose-file tag (663/1) — Alpine variant
626
- # (~90MB vs ~400MB) and a reproducible patch version, bump on PG18 minors.
627
- image: postgres:18.3-alpine
626
+ image: postgres:18
628
627
  environment:
629
628
  POSTGRES_USER: postgres
630
629
  POSTGRES_PASSWORD: postgres
@@ -100,16 +100,7 @@ async function rebuildAffectedProjections(
100
100
  const projections = new Set<string>();
101
101
  for (const table of changedTables) {
102
102
  const name = tableToProjection.get(table);
103
- if (name) {
104
- projections.add(name);
105
- } else {
106
- // 522/3: a table in a .rebuild.json marker that no longer matches any
107
- // registered projection would otherwise rebuild nothing and exit 0 —
108
- // indistinguishable from "nothing needed a rebuild".
109
- console.warn(
110
- ` ⚠ Table "${table}" is in a rebuild marker but matches no registered projection — skipped.`,
111
- );
112
- }
103
+ if (name) projections.add(name);
113
104
  }
114
105
  if (projections.size === 0) return;
115
106
 
@@ -37,7 +37,10 @@ export function renderWelcomeBanner(input: WelcomeBannerInput): string {
37
37
  return [top, ...padded, bottom].join("\n");
38
38
  }
39
39
 
40
- // ponytail: codepoint width; ok for ASCII + arrows, add a width-lib if CJK ever lands here.
40
+ // Plain monospace-cell width counts each codepoint as one cell. Good
41
+ // enough for ASCII + the small set of arrows/checkmarks used above; if a
42
+ // real wide-char ever lands in the banner the row alignment will drift,
43
+ // caught visually by the snapshot test.
41
44
  function stringWidth(s: string): number {
42
45
  return [...s].length;
43
46
  }
@@ -1,82 +0,0 @@
1
- // One place that turns env/options into the app-wide crypto objects:
2
- // the MasterKeyProvider (KUMIKO_SECRETS_MASTER_KEY_V<n> keyring) and the
3
- // EnvelopeCipher for `encrypted: true` config keys. runProdApp and
4
- // runDevApp both resolve this once and thread it into
5
- // buildBootExtraContext + applyBootSeeds so resolver, set-handler and
6
- // seeds share the same cipher instance (and DEK cache).
7
-
8
- import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
9
- import {
10
- createDekCache,
11
- createEnvelopeCipher,
12
- createEnvMasterKeyProvider,
13
- type DekCache,
14
- type EnvelopeCipher,
15
- type MasterKeyProvider,
16
- } from "@cosmicdrift/kumiko-framework/secrets";
17
-
18
- // Keyring detection without throwing: only build the env provider when at
19
- // least one versioned KEK is actually set. Covers (a) apps without any
20
- // encryption — no KEK requirement, and (b) dev with an app-supplied
21
- // provider in options.masterKey — env stays irrelevant.
22
- const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
23
- export function envHasMasterKek(env: Record<string, string | undefined>): boolean {
24
- return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
25
- }
26
-
27
- export type BootCrypto = {
28
- readonly masterKeyProvider?: MasterKeyProvider;
29
- // Cipher for encrypted config keys. Present exactly when a master key is
30
- // available. Decrypts legacy CONFIG_ENCRYPTION_KEY values as fallback
31
- // until the config re-encrypt job migrated them.
32
- readonly configCipher?: EnvelopeCipher;
33
- // Cipher for `encrypted: true` entity fields — same master key, but the
34
- // legacy fallback reads the pre-envelope ENCRYPTION_KEY format. Identical
35
- // to configCipher when no ENCRYPTION_KEY is set.
36
- readonly entityFieldCipher?: EnvelopeCipher;
37
- readonly dekCache: DekCache;
38
- };
39
-
40
- export function resolveBootCrypto(
41
- envSource: Record<string, string | undefined>,
42
- masterKeyOverride?: MasterKeyProvider,
43
- ): BootCrypto {
44
- const masterKeyProvider =
45
- masterKeyOverride ??
46
- (envHasMasterKek(envSource)
47
- ? createEnvMasterKeyProvider({
48
- // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
49
- // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
50
- env: {
51
- ...envSource,
52
- KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
53
- envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
54
- },
55
- })
56
- : undefined);
57
-
58
- const dekCache = createDekCache();
59
- const legacyConfigKey = envSource["CONFIG_ENCRYPTION_KEY"];
60
- const configCipher = masterKeyProvider
61
- ? createEnvelopeCipher(masterKeyProvider, {
62
- dekCache,
63
- ...(legacyConfigKey ? { legacy: createEncryptionProvider(legacyConfigKey) } : {}),
64
- })
65
- : undefined;
66
-
67
- const legacyEntityKey = envSource["ENCRYPTION_KEY"];
68
- const entityFieldCipher =
69
- masterKeyProvider && legacyEntityKey
70
- ? createEnvelopeCipher(masterKeyProvider, {
71
- dekCache,
72
- legacy: createEncryptionProvider(legacyEntityKey),
73
- })
74
- : configCipher;
75
-
76
- return {
77
- ...(masterKeyProvider && { masterKeyProvider }),
78
- ...(configCipher && { configCipher }),
79
- ...(entityFieldCipher && { entityFieldCipher }),
80
- dekCache,
81
- };
82
- }