@cosmicdrift/kumiko-dev-server 0.116.1 → 0.119.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.116.1",
3
+ "version": "0.119.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.116.1",
54
- "@cosmicdrift/kumiko-framework": "0.116.1",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.119.0",
54
+ "@cosmicdrift/kumiko-framework": "0.119.0",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { assertPiiBootInvariants } from "../pii-boot-gate";
5
+
6
+ const piiFeature = defineFeature("gate-pii", (r) => {
7
+ r.entity(
8
+ "person",
9
+ createEntity({
10
+ table: "read_gate_persons",
11
+ fields: { email: createTextField({ required: true, pii: true, lookupable: true }) },
12
+ }),
13
+ );
14
+ });
15
+ const plainFeature = defineFeature("gate-plain", (r) => {
16
+ r.entity(
17
+ "thing",
18
+ createEntity({ table: "read_gate_things", fields: { name: createTextField() } }),
19
+ );
20
+ });
21
+
22
+ const kms = new InMemoryKmsAdapter();
23
+ const KEY = Buffer.alloc(32, 7).toString("base64");
24
+
25
+ describe("assertPiiBootInvariants — prod", () => {
26
+ test("PII without a KMS aborts boot", () => {
27
+ expect(() => assertPiiBootInvariants([piiFeature], { mode: "prod" })).toThrow(
28
+ /BOOT ABORTED.*PLAINTEXT.*allowPlaintextPii/s,
29
+ );
30
+ });
31
+
32
+ test("explicit allowPlaintextPii downgrades to a warning", () => {
33
+ expect(() =>
34
+ assertPiiBootInvariants([piiFeature], {
35
+ mode: "prod",
36
+ allowPlaintextPii: "kms rollout pending, infra#188",
37
+ }),
38
+ ).not.toThrow();
39
+ });
40
+
41
+ test("KMS + blindIndexKey boots", () => {
42
+ expect(() =>
43
+ assertPiiBootInvariants([piiFeature], { mode: "prod", kms, blindIndexKey: KEY }),
44
+ ).not.toThrow();
45
+ });
46
+
47
+ test("KMS without blindIndexKey aborts when lookupable fields exist", () => {
48
+ expect(() => assertPiiBootInvariants([piiFeature], { mode: "prod", kms })).toThrow(
49
+ /blindIndexKey/,
50
+ );
51
+ });
52
+
53
+ test("no PII entities → nothing to gate", () => {
54
+ expect(() => assertPiiBootInvariants([plainFeature], { mode: "prod" })).not.toThrow();
55
+ });
56
+ });
57
+
58
+ describe("assertPiiBootInvariants — dev", () => {
59
+ test("PII without a KMS only warns in dev", () => {
60
+ expect(() => assertPiiBootInvariants([piiFeature], { mode: "dev" })).not.toThrow();
61
+ });
62
+
63
+ test("KMS without blindIndexKey aborts in dev too (lookups broken in any mode)", () => {
64
+ expect(() => assertPiiBootInvariants([piiFeature], { mode: "dev", kms })).toThrow(
65
+ /blindIndexKey/,
66
+ );
67
+ });
68
+ });
@@ -14,6 +14,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
+ import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
17
18
  import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
18
19
  import {
19
20
  createBooleanField,
@@ -69,6 +70,12 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
69
70
  access: { roles: ["anonymous"] },
70
71
  handler: async () => ({ pong: true }),
71
72
  });
73
+ r.queryHandler({
74
+ name: "kms-probe",
75
+ schema: z.object({}),
76
+ access: { roles: ["anonymous"] },
77
+ handler: async (_event, ctx) => ({ hasKms: ctx.kms !== undefined }),
78
+ });
72
79
  // SystemAdmin-gated write — Ziel des extraRoutes.dispatchSystemWrite-
73
80
  // Tests: Echo von user.tenantId + roles beweist, dass der Dispatch
74
81
  // durch den echten Dispatcher (Zod + Access-Check) läuft und der
@@ -782,7 +789,10 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
782
789
 
783
790
  test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
784
791
  await expect(
785
- boot(undefined, { auth: { admin: ADMIN, cookieDomain: "example.eu" } }),
792
+ boot(undefined, {
793
+ auth: { admin: ADMIN, cookieDomain: "example.eu" },
794
+ allowPlaintextPii: "test: origin-guard focus, not crypto",
795
+ }),
786
796
  ).rejects.toThrow(/allowedOrigins is empty/);
787
797
  });
788
798
 
@@ -837,4 +847,69 @@ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
837
847
  false,
838
848
  );
839
849
  });
850
+
851
+ test("kms option: adapter reaches handler ctx; without the option ctx.kms is absent", async () => {
852
+ const probe = async (handle: ProdAppHandle): Promise<boolean | undefined> => {
853
+ const res = await handle.entrypoint.app.fetch(
854
+ new Request("http://test/api/query", {
855
+ method: "POST",
856
+ headers: { "content-type": "application/json" },
857
+ body: JSON.stringify({ type: "prod-probe:query:kms-probe", payload: {} }),
858
+ }),
859
+ );
860
+ expect(res.status).toBe(200);
861
+ const body = (await res.json()) as { data?: { hasKms?: boolean } };
862
+ return body.data?.hasKms;
863
+ };
864
+
865
+ const withKms = await boot(undefined, {
866
+ kms: new InMemoryKmsAdapter(),
867
+ anonymousAccess: { defaultTenantId: TENANT_ID },
868
+ });
869
+ expect(await probe(withKms)).toBe(true);
870
+
871
+ const withoutKms = await boot(undefined, {
872
+ anonymousAccess: { defaultTenantId: TENANT_ID },
873
+ });
874
+ expect(await probe(withoutKms)).toBe(false);
875
+ });
876
+
877
+ test("unhealthy kms aborts boot before any connection is opened", async () => {
878
+ const unhealthyKms: KmsAdapter = {
879
+ capabilities: { mode: "local-key" },
880
+ createKey: async () => {},
881
+ getKey: async () => {
882
+ throw new Error("unreachable");
883
+ },
884
+ eraseKey: async () => {},
885
+ health: async () => ({ ok: false, latencyMs: 3 }),
886
+ };
887
+ await expect(boot(undefined, { kms: unhealthyKms })).rejects.toThrow(/KMS health check failed/);
888
+ });
889
+ });
890
+
891
+ describe("hard PII boot gate (#818 step 2)", () => {
892
+ const gatePiiFeature = defineFeature("gate-pii", (r) => {
893
+ r.entity(
894
+ "gate-person",
895
+ createEntity({
896
+ table: "read_gate_persons",
897
+ fields: { email: createTextField({ required: true, pii: true }) },
898
+ }),
899
+ );
900
+ });
901
+
902
+ test("PII entities without a kms abort the boot", async () => {
903
+ await expect(boot(undefined, { features: [gatePiiFeature] })).rejects.toThrow(
904
+ /BOOT ABORTED.*PLAINTEXT.*allowPlaintextPii/s,
905
+ );
906
+ });
907
+
908
+ test("allowPlaintextPii boots with a warning instead", async () => {
909
+ const handle = await boot(undefined, {
910
+ features: [gatePiiFeature],
911
+ allowPlaintextPii: "test: kms rollout pending",
912
+ });
913
+ expect(handle).toBeDefined();
914
+ });
840
915
  });
@@ -13,6 +13,9 @@ const SCAFFOLD_FILES = [
13
13
  "bunfig.toml",
14
14
  "bunfig.ci.toml",
15
15
  "src/run-config.ts",
16
+ "src/features/tasks/feature.ts",
17
+ "src/features/tasks/index.ts",
18
+ "src/seed.ts",
16
19
  "kumiko/schema.ts",
17
20
  "bin/main.ts",
18
21
  "bin/dev.ts",
@@ -202,13 +205,15 @@ describe("scaffoldApp", () => {
202
205
  );
203
206
  });
204
207
 
205
- test("src/run-config.ts mounts secrets + sessions + HAS_AUTH", async () => {
208
+ test("src/run-config.ts mounts secrets + sessions + tasks + HAS_AUTH", async () => {
206
209
  const dest = join(tmp, "my-shop");
207
210
  await scaffoldApp({ name: "my-shop", destination: dest });
208
211
 
209
212
  const runConfig = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
210
213
  expect(runConfig).toContain("createSecretsFeature()");
211
214
  expect(runConfig).toContain("createSessionsFeature()");
215
+ expect(runConfig).toContain("tasksFeature");
216
+ expect(runConfig).toContain('from "./features/tasks"');
212
217
  expect(runConfig).toContain("export const APP_FEATURES");
213
218
  expect(runConfig).toContain("export const HAS_AUTH");
214
219
  });
@@ -362,6 +367,7 @@ describe("scaffoldApp", () => {
362
367
  const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
363
368
  expect(cfg).not.toContain("createSecretsFeature");
364
369
  expect(cfg).not.toContain("createSessionsFeature");
370
+ expect(cfg).toContain("tasksFeature");
365
371
  });
366
372
 
367
373
  test("deterministic tenantId for same name (reproducible boots)", async () => {
@@ -0,0 +1,62 @@
1
+ import {
2
+ collectLookupableFields,
3
+ collectPiiSubjectFields,
4
+ type KmsAdapter,
5
+ } from "@cosmicdrift/kumiko-framework/crypto";
6
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ type PiiGateOptions = {
9
+ readonly kms?: KmsAdapter | undefined;
10
+ readonly blindIndexKey?: string | undefined;
11
+ readonly allowPlaintextPii?: string | undefined;
12
+ /** prod fails hard on plaintext PII (opt-out via allowPlaintextPii);
13
+ * dev only warns — local data, and an InMemory KMS against a persistent
14
+ * dev DB would strand every row after a restart. */
15
+ readonly mode: "prod" | "dev";
16
+ };
17
+
18
+ export function assertPiiBootInvariants(
19
+ features: readonly FeatureDefinition[],
20
+ opts: PiiGateOptions,
21
+ ): void {
22
+ const tag = opts.mode === "prod" ? "runProdApp" : "runDevApp";
23
+
24
+ const lookupableEntities = features.flatMap((feature) =>
25
+ Object.entries(feature.entities ?? {})
26
+ .filter(([, entity]) => collectLookupableFields(entity).length > 0)
27
+ .map(([name]) => name),
28
+ );
29
+ if (opts.kms && !opts.blindIndexKey && lookupableEntities.length > 0) {
30
+ throw new Error(
31
+ `[${tag}] BOOT ABORTED — entities [${lookupableEntities.join(", ")}] declare lookupable fields and a KMS is configured, but no blindIndexKey was passed. Equality lookups on encrypted fields would silently stop matching. Pass { blindIndexKey } (env: KUMIKO_BLIND_INDEX_KEY, generate: openssl rand -base64 32).`,
32
+ );
33
+ }
34
+
35
+ // skip: KMS configured — PII fields are encrypted, nothing left to gate.
36
+ if (opts.kms) return;
37
+ const piiEntities = features.flatMap((feature) =>
38
+ Object.entries(feature.entities ?? {})
39
+ .filter(([, entity]) => collectPiiSubjectFields(entity).length > 0)
40
+ .map(([name]) => name),
41
+ );
42
+ // skip: no PII-annotated entities mounted — plaintext gate is moot.
43
+ if (piiEntities.length === 0) return;
44
+
45
+ if (opts.mode === "dev") {
46
+ // biome-ignore lint/suspicious/noConsole: boot-time security warning
47
+ console.warn(
48
+ `[${tag}] ${piiEntities.length} entities carry pii/userOwned/tenantOwned annotations but no \`kms\` adapter is configured — fields are stored in PLAINTEXT locally. Pass { kms: new InMemoryKmsAdapter() } (ephemeral DB) or createPgKmsAdapter(...) to exercise crypto-shredding in dev.`,
49
+ );
50
+ return;
51
+ }
52
+ if (opts.allowPlaintextPii) {
53
+ // biome-ignore lint/suspicious/noConsole: boot-time security warning
54
+ console.warn(
55
+ `[${tag}] ${piiEntities.length} entities carry PII annotations but no \`kms\` adapter is configured — fields are stored in PLAINTEXT (allowPlaintextPii: "${opts.allowPlaintextPii}"). GDPR erasure via crypto-shredding is NOT possible until a KMS is provisioned.`,
56
+ );
57
+ return;
58
+ }
59
+ throw new Error(
60
+ `[${tag}] BOOT ABORTED — entities [${piiEntities.join(", ")}] carry pii/userOwned/tenantOwned annotations but no \`kms\` adapter is configured. The fields would be stored in PLAINTEXT and GDPR erasure (crypto-shredding) could not work. Pass runProdApp({ kms: createPgKmsAdapter({ databaseUrl, platformKek }) }) — or acknowledge explicitly with { allowPlaintextPii: "<reason>" } until your KMS is provisioned.`,
61
+ );
62
+ }
@@ -37,6 +37,11 @@ import {
37
37
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
38
38
  import type { PatResolver, SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
39
39
  import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
40
+ import {
41
+ configureBlindIndexKey,
42
+ configurePiiSubjectKms,
43
+ type KmsAdapter,
44
+ } from "@cosmicdrift/kumiko-framework/crypto";
40
45
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
41
46
  import {
42
47
  collectWriteHandlerQns,
@@ -56,7 +61,6 @@ import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
56
61
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
57
62
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
58
63
  import { resolveBootCrypto } from "./boot/boot-crypto";
59
-
60
64
  import { watchAndRegenerate } from "./codegen";
61
65
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
62
66
  import {
@@ -64,6 +68,7 @@ import {
64
68
  createKumikoServer,
65
69
  type KumikoServerHandle,
66
70
  } from "./create-kumiko-server";
71
+ import { assertPiiBootInvariants } from "./pii-boot-gate";
67
72
  import { renderWelcomeBanner } from "./welcome-banner";
68
73
 
69
74
  // Re-export der shared Auth-Setup-Types damit Apps nur einen Import-Pfad
@@ -187,6 +192,16 @@ export type RunDevAppOptions = {
187
192
  * `createEnvMasterKeyProvider`. Override für KMS-Backends. Nur relevant
188
193
  * wenn das secrets-Feature gemountet ist. Symmetrisch zu runProdApp. */
189
194
  readonly masterKey?: MasterKeyProvider;
195
+ /** Subject-Key-KMS für pii-annotierte Felder (Crypto-Shredding) — dev-
196
+ * Pendant zu runProdApp({ kms }). Ephemere DB: InMemoryKmsAdapter reicht.
197
+ * Persistente Dev-DB: createPgKmsAdapter gegen dieselbe DB, sonst sind
198
+ * alte Rows nach dem Restart unlesbar (DEKs weg). Ohne Adapter: Klartext
199
+ * + Boot-Warnung. */
200
+ readonly kms?: KmsAdapter;
201
+ /** 32-Byte-Key (base64) für Blind-Index-HMACs — Pflicht sobald `kms`
202
+ * gesetzt ist und lookupable-Felder gemountet sind (sonst Boot-Abbruch,
203
+ * symmetrisch zu runProdApp). */
204
+ readonly blindIndexKey?: string;
190
205
  /** Env-Quelle für die ENV→config-app-override-Brücke (Keys mit `env:`
191
206
  * bekommen ihren env-Wert als app-override-Default — symmetrisch zu
192
207
  * runProdApp). Default `process.env`. Injizierbar als Test-Seam, damit
@@ -326,6 +341,16 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
326
341
  // App-wide cipher for `encrypted: true` entity fields (symmetrisch zu
327
342
  // runProdApp) — executors resolve it lazily.
328
343
  configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
344
+ // Subject-KMS + Blind-Index (symmetrisch zu runProdApp). Dev warnt bei
345
+ // Klartext-PII statt zu failen; kms+lookupable ohne blindIndexKey bricht
346
+ // auch hier (Lookups wären in jedem Modus kaputt).
347
+ configurePiiSubjectKms(options.kms);
348
+ configureBlindIndexKey(options.blindIndexKey);
349
+ assertPiiBootInvariants(features, {
350
+ kms: options.kms,
351
+ blindIndexKey: options.blindIndexKey,
352
+ mode: "dev",
353
+ });
329
354
  const cfgExtra = effectiveAuth
330
355
  ? mergeConfigResolverDefault(
331
356
  options.extraContext,
@@ -77,6 +77,11 @@ import {
77
77
  createSseBroker,
78
78
  type SseBroker,
79
79
  } from "@cosmicdrift/kumiko-framework/api";
80
+ import {
81
+ configureBlindIndexKey,
82
+ configurePiiSubjectKms,
83
+ type KmsAdapter,
84
+ } from "@cosmicdrift/kumiko-framework/crypto";
80
85
  import {
81
86
  configureEntityFieldEncryption,
82
87
  createDbConnection,
@@ -133,6 +138,7 @@ import { ASSETS_DIR } from "./build-prod-bundle";
133
138
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
134
139
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
135
140
  import { injectSchema } from "./inject-schema";
141
+ import { assertPiiBootInvariants } from "./pii-boot-gate";
136
142
  import {
137
143
  type ProdSessionsConfig,
138
144
  type ProdSessionsOption,
@@ -477,6 +483,26 @@ export type RunProdAppOptions = {
477
483
  * Override für KMS-Backends (AWS/GCP/Azure) statt env-KEK. Nur relevant
478
484
  * wenn das `secrets`-Feature gemountet ist. */
479
485
  readonly masterKey?: MasterKeyProvider;
486
+ /** Subject-Key-Adapter für Crypto-Shredding (DSGVO Art. 17). Wenn gesetzt,
487
+ * steht er Feature-Code als `ctx.kms` zur Verfügung und der Boot prüft
488
+ * seine health() — die App startet nicht gegen ein unerreichbares KMS
489
+ * (Silent-Start würde jeden PII-Read mit 503 beantworten). Kein Default:
490
+ * ohne Adapter bleibt Crypto-Shredding aus. */
491
+ readonly kms?: KmsAdapter;
492
+ /** 32-Byte-Key (base64) für Blind-Index-HMACs auf `lookupable`-Feldern
493
+ * (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). Bewusst
494
+ * getrennt vom PLATFORM_KEK und nicht Teil des KmsAdapter-Contracts.
495
+ * Pflicht sobald `kms` gesetzt ist UND ein Feature lookupable-Felder
496
+ * deklariert — sonst bricht jeder Equality-Lookup auf Ciphertext
497
+ * (Login by email!) und der Boot failt hart. */
498
+ readonly blindIndexKey?: string;
499
+ /** Explizites Opt-out aus dem harten PII-Boot-Gate: Features deklarieren
500
+ * pii/userOwned/tenantOwned-Felder, aber es ist (noch) kein `kms`
501
+ * provisioniert — die Felder liegen dann als KLARTEXT in Rows und
502
+ * Events und DSGVO-Erasure via Crypto-Shredding ist unmöglich. Der Wert
503
+ * ist eine Begründung für den Audit-Trail (z.B. "kms rollout pending,
504
+ * infra#188"). Ohne Flag und ohne `kms` bricht der Boot ab. */
505
+ readonly allowPlaintextPii?: string;
480
506
  /** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
481
507
  * fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
482
508
  * (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
@@ -635,6 +661,7 @@ export function buildBootExtraContext(opts: {
635
661
  readonly crypto?: BootCrypto;
636
662
  readonly masterKey?: MasterKeyProvider;
637
663
  readonly sseBroker?: SseBroker;
664
+ readonly kms?: KmsAdapter;
638
665
  }): Record<string, unknown> {
639
666
  const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
640
667
  const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
@@ -642,6 +669,7 @@ export function buildBootExtraContext(opts: {
642
669
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
643
670
  return {
644
671
  textContent: createTextContentApi(opts.db),
672
+ ...(opts.kms && { kms: opts.kms }),
645
673
  ...(hasDeliveryFeature && {
646
674
  _notifyFactory: buildDeliveryNotifyFactory({
647
675
  db: opts.db,
@@ -828,6 +856,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
828
856
  validateBoot(features);
829
857
  warnIfNonUtcServerTimeZone();
830
858
  validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
859
+ assertPiiBootInvariants(features, {
860
+ kms: options.kms,
861
+ blindIndexKey: options.blindIndexKey,
862
+ allowPlaintextPii: options.allowPlaintextPii,
863
+ mode: "prod",
864
+ });
831
865
  const registry = createRegistry(features);
832
866
 
833
867
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
@@ -848,6 +882,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
848
882
  // idempotency, event-dedup, entity-cache, rate-limit; failing to
849
883
  // construct here surfaces the misconfig immediately. `new Redis(...)`
850
884
  // connects eagerly, so it must stay AFTER the boot-mode exit above.
885
+ // KMS health gate: an app configured for crypto-shredding must not boot
886
+ // against an unreachable key store — a silent start would answer every
887
+ // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
888
+ if (options.kms) {
889
+ const kmsHealth = await options.kms.health();
890
+ if (!kmsHealth.ok) {
891
+ throw new Error(
892
+ `[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`,
893
+ );
894
+ }
895
+ }
896
+
851
897
  const { db, close: closeDb } = createDbConnection(databaseUrl);
852
898
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
853
899
 
@@ -922,6 +968,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
922
968
  // App-wide cipher for `encrypted: true` entity fields — executors resolve
923
969
  // it lazily, entities without encrypted fields never touch it.
924
970
  configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
971
+ // Subject-key KMS for pii-annotated fields (crypto-shredding, #724).
972
+ // No adapter = engine off, plaintext as before — the warning keeps the gap
973
+ // visible until the prod-grade PgKmsAdapter (phase E) makes a hard boot
974
+ // gate viable (InMemory in prod would lose every DEK on restart).
975
+ configurePiiSubjectKms(options.kms);
976
+ // Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
977
+ // Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
978
+ // jeder Equality-Lookup (Login by email!) liefe ins Leere. Fail-fast statt
979
+ // silent-broken-auth.
980
+ configureBlindIndexKey(options.blindIndexKey);
925
981
  const autoExtraContext = buildBootExtraContext({
926
982
  db,
927
983
  features,
@@ -930,6 +986,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
930
986
  hasAuth: !!effectiveAuth,
931
987
  sseBroker,
932
988
  crypto: bootCrypto,
989
+ ...(options.kms && { kms: options.kms }),
933
990
  });
934
991
  const extraContext = addConfigAccessorFactory(
935
992
  { ...autoExtraContext, ...resolvedExtraContext },
@@ -22,6 +22,12 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { IndentationText, Project, VariableDeclarationKind } from "ts-morph";
23
23
  import { composeFeatures } from "./compose-features";
24
24
  import { isKebabSegment } from "./kebab";
25
+ import {
26
+ createDemoTasksFeature,
27
+ renderDemoSeedFile,
28
+ renderDemoTasksFeatureFile,
29
+ renderDemoTasksIndex,
30
+ } from "./scaffold-demo-tasks";
25
31
  import { scaffoldDeploy } from "./scaffold-deploy";
26
32
 
27
33
  // Single bundled-feature entry the scaffolder mounts into run-config.ts.
@@ -95,6 +101,14 @@ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<Scaffold
95
101
  write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
96
102
  files.push("src/run-config.ts");
97
103
 
104
+ mkdirSync(join(destination, "src", "features", "tasks"), { recursive: true });
105
+ write(join(destination, "src", "features", "tasks", "feature.ts"), renderDemoTasksFeatureFile());
106
+ files.push("src/features/tasks/feature.ts");
107
+ write(join(destination, "src", "features", "tasks", "index.ts"), renderDemoTasksIndex());
108
+ files.push("src/features/tasks/index.ts");
109
+ write(join(destination, "src", "seed.ts"), renderDemoSeedFile());
110
+ files.push("src/seed.ts");
111
+
98
112
  write(join(destination, "kumiko", "schema.ts"), renderKumikoSchema());
99
113
  files.push("kumiko/schema.ts");
100
114
 
@@ -345,15 +359,19 @@ function renderRunConfig(features?: ReadonlyArray<ScaffoldFeatureEntry>): string
345
359
  for (const [importPath, namedImports] of grouped) {
346
360
  sf.addImportDeclaration({ moduleSpecifier: importPath, namedImports });
347
361
  }
362
+ sf.addImportDeclaration({
363
+ moduleSpecifier: "./features/tasks",
364
+ namedImports: ["tasksFeature"],
365
+ });
348
366
 
349
- const callList = effective.map((f) => f.callExpression).join(", ");
367
+ const callExprs = [...effective.map((f) => f.callExpression), "tasksFeature"];
350
368
  sf.addVariableStatement({
351
369
  declarationKind: VariableDeclarationKind.Const,
352
370
  isExported: true,
353
371
  declarations: [
354
372
  {
355
373
  name: "APP_FEATURES",
356
- initializer: `[${callList}] as const`,
374
+ initializer: `[${callExprs.join(", ")}] as const`,
357
375
  },
358
376
  ],
359
377
  });
@@ -504,6 +522,10 @@ function renderDev(appName: string): string {
504
522
  moduleSpecifier: "../src/run-config",
505
523
  namedImports: ["APP_FEATURES"],
506
524
  });
525
+ sf.addImportDeclaration({
526
+ moduleSpecifier: "../src/seed",
527
+ namedImports: ["seedDemoTasks"],
528
+ });
507
529
 
508
530
  sf.addVariableStatement({
509
531
  declarationKind: VariableDeclarationKind.Const,
@@ -522,6 +544,7 @@ function renderDev(appName: string): string {
522
544
  writer.writeLine("features: APP_FEATURES,");
523
545
  writer.writeLine("welcomeBanner: true,");
524
546
  writer.writeLine(`clientEntry: "./src/client.tsx",`);
547
+ writer.writeLine("seeds: [seedDemoTasks],");
525
548
  writer.write("auth: ").inlineBlock(() => {
526
549
  writer.write("admin: ").inlineBlock(() => {
527
550
  writer.writeLine(`email: "admin@${appName}.local",`);
@@ -647,33 +670,36 @@ function renderReadme(
647
670
  ): string {
648
671
  const featureList =
649
672
  features && features.length > 0
650
- ? features.map((f) => `- \`${f.name}\``).join("\n")
651
- : "- `secrets` (foundation)\n- `sessions` (foundation)";
673
+ ? [...features.map((f) => `- \`${f.name}\``), "- `tasks` (demo — list + edit screens)"].join(
674
+ "\n",
675
+ )
676
+ : "- `secrets` (foundation)\n- `sessions` (foundation)\n- `tasks` (demo — list + edit screens)";
652
677
  return `# ${appName}
653
678
 
654
- Scaffolded by \`bun create kumiko-app\`. Boots out-of-the-box with the picked
655
- feature stack mounted. Add features by editing \`src/run-config.ts\` or via
656
- \`bunx @cosmicdrift/kumiko-cli add feature <name>\`.
679
+ Scaffolded by \`kumiko new app\`. Includes a demo **tasks** feature with list +
680
+ edit screens, sidebar nav, and seeded rows \`bun dev\` shows a working admin UI
681
+ after login. Add more features via \`bunx @cosmicdrift/kumiko-cli add feature <name>\`.
657
682
 
658
683
  ## Mounted features
659
684
 
660
685
  ${featureList}
661
686
 
662
- Edit \`src/run-config.ts\` to add or remove.
687
+ Edit \`src/run-config.ts\` to add bundled features. The demo lives in
688
+ \`src/features/tasks/\`.
663
689
 
664
- ## First run
690
+ ## First run (browser)
665
691
 
666
692
  \`\`\`sh
667
693
  bun install
668
694
  cp .env.example .env
669
- # edit .env — set JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1, point DATABASE_URL/REDIS_URL at a real PG+Redis
670
- docker compose up -d # if you don't have PG+Redis running already
695
+ # set JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1 in .env
696
+ docker compose up -d # local Postgres + Redis (skip if you already have them)
671
697
  bun dev
672
698
  \`\`\`
673
699
 
674
- The dev-server prints a welcome banner with the URL + admin login when ready.
675
- Edits to \`src/features/**\` trigger a process restart (\`bun --watch\`); new
676
- \`r.entity(...)\` calls auto-create tables on reboot no manual migration.
700
+ The welcome banner prints the URL (default \`http://localhost:4173\`) and admin
701
+ login. Sign in as \`admin@${appName}.local\` / \`changeme\`, then open **Tasks**
702
+ in the sidebar demo rows are pre-seeded.
677
703
 
678
704
  ## Boot-only smoke (no DB needed)
679
705
 
@@ -706,6 +732,8 @@ deploys. Build context = app repo root; migrations ship in \`kumiko/migrations/\
706
732
  ## Architecture
707
733
 
708
734
  - \`src/run-config.ts\` — single source of truth: which features your app mounts (\`APP_FEATURES\`, \`HAS_AUTH\`).
735
+ - \`src/features/tasks/\` — demo feature (entity + handlers + screens + nav).
736
+ - \`src/seed.ts\` — dev seed for demo tasks (\`bun dev\` only).
709
737
  - \`kumiko/schema.ts\` — same feature set → \`ENTITY_METAS\` for \`kumiko schema\`.
710
738
  - \`bin/dev.ts\` — dev-server entry (\`bun dev\`).
711
739
  - \`bin/main.ts\` — production-bootstrap (\`bun run start\`).
@@ -796,6 +824,7 @@ async function instantiateScaffoldFeatures(
796
824
  instances.push(exp as FeatureDefinition);
797
825
  }
798
826
  }
827
+ instances.push(createDemoTasksFeature());
799
828
  return instances;
800
829
  }
801
830
 
@@ -0,0 +1,173 @@
1
+ // Demo `tasks` feature + seed for scaffolded apps — wasp-like starter UX.
2
+ // `createDemoTasksFeature()` feeds init-migration generation; the render*
3
+ // functions emit the same shape into the user's repo.
4
+
5
+ import {
6
+ createBooleanField,
7
+ createEntity,
8
+ createNumberField,
9
+ createTextField,
10
+ defineEntityCreateHandler,
11
+ defineEntityDeleteHandler,
12
+ defineEntityDetailHandler,
13
+ defineEntityListHandler,
14
+ defineEntityUpdateHandler,
15
+ defineFeature,
16
+ type FeatureDefinition,
17
+ } from "@cosmicdrift/kumiko-framework/engine";
18
+ import type {
19
+ EntityEditScreenDefinition,
20
+ EntityListScreenDefinition,
21
+ } from "@cosmicdrift/kumiko-framework/ui-types";
22
+
23
+ const taskEntity = createEntity({
24
+ fields: {
25
+ title: createTextField({ required: true, sortable: true }),
26
+ status: createTextField({ sortable: true }),
27
+ priority: createNumberField(),
28
+ isUrgent: createBooleanField({ default: false }),
29
+ },
30
+ });
31
+
32
+ const listScreen: EntityListScreenDefinition = {
33
+ id: "task-list",
34
+ type: "entityList",
35
+ entity: "task",
36
+ columns: ["title", "status", "isUrgent", "priority"],
37
+ defaultSort: { field: "title", dir: "asc" },
38
+ };
39
+
40
+ const editScreen: EntityEditScreenDefinition = {
41
+ id: "task-edit",
42
+ type: "entityEdit",
43
+ entity: "task",
44
+ layout: {
45
+ sections: [{ title: "Task", fields: ["title", "status", "priority", "isUrgent"] }],
46
+ },
47
+ };
48
+
49
+ const open = { access: { openToAll: true } } as const;
50
+
51
+ /** Canonical demo feature — keep in sync with `renderDemoTasksFeatureFile()`. */
52
+ export function createDemoTasksFeature(): FeatureDefinition {
53
+ return defineFeature("tasks", (r) => {
54
+ r.entity("task", taskEntity);
55
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
56
+ r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
57
+ r.writeHandler(defineEntityDeleteHandler("task", taskEntity, open));
58
+ r.queryHandler(defineEntityListHandler("task", taskEntity, open));
59
+ r.queryHandler(defineEntityDetailHandler("task", taskEntity, open));
60
+ r.screen(listScreen);
61
+ r.screen(editScreen);
62
+ r.nav({ id: "tasks", label: "Tasks", order: 10, screen: "tasks:screen:task-list" });
63
+ r.nav({
64
+ id: "task-new",
65
+ label: "New task",
66
+ parent: "tasks:nav:tasks",
67
+ screen: "tasks:screen:task-edit",
68
+ order: 10,
69
+ });
70
+ });
71
+ }
72
+
73
+ export function renderDemoTasksFeatureFile(): string {
74
+ return `// Demo tasks feature — scaffolded by \`kumiko new app\`. Edit or replace.
75
+ // Entity + CRUD handlers + list/edit screens + sidebar nav.
76
+
77
+ import {
78
+ createBooleanField,
79
+ createEntity,
80
+ createNumberField,
81
+ createTextField,
82
+ defineEntityCreateHandler,
83
+ defineEntityDeleteHandler,
84
+ defineEntityDetailHandler,
85
+ defineEntityListHandler,
86
+ defineEntityUpdateHandler,
87
+ defineFeature,
88
+ } from "@cosmicdrift/kumiko-framework/engine";
89
+ import type {
90
+ EntityEditScreenDefinition,
91
+ EntityListScreenDefinition,
92
+ } from "@cosmicdrift/kumiko-framework/ui-types";
93
+
94
+ const taskEntity = createEntity({
95
+ fields: {
96
+ title: createTextField({ required: true, sortable: true }),
97
+ status: createTextField({ sortable: true }),
98
+ priority: createNumberField(),
99
+ isUrgent: createBooleanField({ default: false }),
100
+ },
101
+ });
102
+
103
+ const listScreen: EntityListScreenDefinition = {
104
+ id: "task-list",
105
+ type: "entityList",
106
+ entity: "task",
107
+ columns: ["title", "status", "isUrgent", "priority"],
108
+ defaultSort: { field: "title", dir: "asc" },
109
+ };
110
+
111
+ const editScreen: EntityEditScreenDefinition = {
112
+ id: "task-edit",
113
+ type: "entityEdit",
114
+ entity: "task",
115
+ layout: {
116
+ sections: [{ title: "Task", fields: ["title", "status", "priority", "isUrgent"] }],
117
+ },
118
+ };
119
+
120
+ const open = { access: { openToAll: true } } as const;
121
+
122
+ export const tasksFeature = defineFeature("tasks", (r) => {
123
+ r.entity("task", taskEntity);
124
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
125
+ r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
126
+ r.writeHandler(defineEntityDeleteHandler("task", taskEntity, open));
127
+ r.queryHandler(defineEntityListHandler("task", taskEntity, open));
128
+ r.queryHandler(defineEntityDetailHandler("task", taskEntity, open));
129
+ r.screen(listScreen);
130
+ r.screen(editScreen);
131
+ r.nav({ id: "tasks", label: "Tasks", order: 10, screen: "tasks:screen:task-list" });
132
+ r.nav({
133
+ id: "task-new",
134
+ label: "New task",
135
+ parent: "tasks:nav:tasks",
136
+ screen: "tasks:screen:task-edit",
137
+ order: 10,
138
+ });
139
+ });
140
+ `;
141
+ }
142
+
143
+ export function renderDemoTasksIndex(): string {
144
+ return `export { tasksFeature } from "./feature";
145
+ `;
146
+ }
147
+
148
+ export function renderDemoSeedFile(): string {
149
+ return `// Demo seed — a few tasks so \`bun dev\` shows a non-empty list.
150
+ // Idempotent: skips when the tenant already has tasks (persistent dev DB).
151
+
152
+ import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
153
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
154
+
155
+ const DEMO_TASKS = [
156
+ { title: "Welcome to Kumiko", status: "todo", priority: 1, isUrgent: false },
157
+ { title: "Try editing me", status: "in progress", priority: 2, isUrgent: true },
158
+ ] as const;
159
+
160
+ export const seedDemoTasks: SeedFn = async (stack) => {
161
+ const admin = TestUsers.admin;
162
+ const existing = await stack.http.queryOk<{ rows: unknown[] }>(
163
+ "tasks:query:task:list",
164
+ {},
165
+ admin,
166
+ );
167
+ if (existing.rows.length > 0) return;
168
+ for (const task of DEMO_TASKS) {
169
+ await stack.http.write("tasks:write:task:create", task, admin);
170
+ }
171
+ };
172
+ `;
173
+ }