@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 +3 -3
- package/src/__tests__/pii-boot-gate.test.ts +68 -0
- package/src/__tests__/run-prod-app.integration.test.ts +76 -1
- package/src/__tests__/scaffold-app.test.ts +7 -1
- package/src/pii-boot-gate.ts +62 -0
- package/src/run-dev-app.ts +26 -1
- package/src/run-prod-app.ts +57 -0
- package/src/scaffold-app.ts +43 -14
- package/src/scaffold-demo-tasks.ts +173 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "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.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "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
|
+
});
|
|
@@ -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, {
|
|
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
|
+
}
|
package/src/run-dev-app.ts
CHANGED
|
@@ -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,
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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 },
|
package/src/scaffold-app.ts
CHANGED
|
@@ -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
|
|
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: `[${
|
|
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(
|
|
651
|
-
|
|
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 \`
|
|
655
|
-
|
|
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
|
|
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
|
-
#
|
|
670
|
-
docker compose up -d # if you
|
|
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
|
|
675
|
-
|
|
676
|
-
|
|
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
|
+
}
|