@cosmicdrift/kumiko-dev-server 0.118.0 → 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.118.0",
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.118.0",
54
- "@cosmicdrift/kumiko-framework": "0.118.0",
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
+ });
@@ -789,7 +789,10 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
789
789
 
790
790
  test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
791
791
  await expect(
792
- 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
+ }),
793
796
  ).rejects.toThrow(/allowedOrigins is empty/);
794
797
  });
795
798
 
@@ -884,3 +887,29 @@ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
884
887
  await expect(boot(undefined, { kms: unhealthyKms })).rejects.toThrow(/KMS health check failed/);
885
888
  });
886
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
+ });
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,7 +77,11 @@ import {
77
77
  createSseBroker,
78
78
  type SseBroker,
79
79
  } from "@cosmicdrift/kumiko-framework/api";
80
- import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
80
+ import {
81
+ configureBlindIndexKey,
82
+ configurePiiSubjectKms,
83
+ type KmsAdapter,
84
+ } from "@cosmicdrift/kumiko-framework/crypto";
81
85
  import {
82
86
  configureEntityFieldEncryption,
83
87
  createDbConnection,
@@ -134,6 +138,7 @@ import { ASSETS_DIR } from "./build-prod-bundle";
134
138
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
135
139
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
136
140
  import { injectSchema } from "./inject-schema";
141
+ import { assertPiiBootInvariants } from "./pii-boot-gate";
137
142
  import {
138
143
  type ProdSessionsConfig,
139
144
  type ProdSessionsOption,
@@ -484,6 +489,20 @@ export type RunProdAppOptions = {
484
489
  * (Silent-Start würde jeden PII-Read mit 503 beantworten). Kein Default:
485
490
  * ohne Adapter bleibt Crypto-Shredding aus. */
486
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;
487
506
  /** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
488
507
  * fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
489
508
  * (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
@@ -837,6 +856,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
837
856
  validateBoot(features);
838
857
  warnIfNonUtcServerTimeZone();
839
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
+ });
840
865
  const registry = createRegistry(features);
841
866
 
842
867
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
@@ -943,6 +968,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
943
968
  // App-wide cipher for `encrypted: true` entity fields — executors resolve
944
969
  // it lazily, entities without encrypted fields never touch it.
945
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);
946
981
  const autoExtraContext = buildBootExtraContext({
947
982
  db,
948
983
  features,
@@ -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
+ }