@cosmicdrift/kumiko-dev-server 0.116.0 → 0.118.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.0",
3
+ "version": "0.118.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.0",
54
- "@cosmicdrift/kumiko-framework": "0.116.0",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.118.0",
54
+ "@cosmicdrift/kumiko-framework": "0.118.0",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -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
@@ -837,4 +844,43 @@ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
837
844
  false,
838
845
  );
839
846
  });
847
+
848
+ test("kms option: adapter reaches handler ctx; without the option ctx.kms is absent", async () => {
849
+ const probe = async (handle: ProdAppHandle): Promise<boolean | undefined> => {
850
+ const res = await handle.entrypoint.app.fetch(
851
+ new Request("http://test/api/query", {
852
+ method: "POST",
853
+ headers: { "content-type": "application/json" },
854
+ body: JSON.stringify({ type: "prod-probe:query:kms-probe", payload: {} }),
855
+ }),
856
+ );
857
+ expect(res.status).toBe(200);
858
+ const body = (await res.json()) as { data?: { hasKms?: boolean } };
859
+ return body.data?.hasKms;
860
+ };
861
+
862
+ const withKms = await boot(undefined, {
863
+ kms: new InMemoryKmsAdapter(),
864
+ anonymousAccess: { defaultTenantId: TENANT_ID },
865
+ });
866
+ expect(await probe(withKms)).toBe(true);
867
+
868
+ const withoutKms = await boot(undefined, {
869
+ anonymousAccess: { defaultTenantId: TENANT_ID },
870
+ });
871
+ expect(await probe(withoutKms)).toBe(false);
872
+ });
873
+
874
+ test("unhealthy kms aborts boot before any connection is opened", async () => {
875
+ const unhealthyKms: KmsAdapter = {
876
+ capabilities: { mode: "local-key" },
877
+ createKey: async () => {},
878
+ getKey: async () => {
879
+ throw new Error("unreachable");
880
+ },
881
+ eraseKey: async () => {},
882
+ health: async () => ({ ok: false, latencyMs: 3 }),
883
+ };
884
+ await expect(boot(undefined, { kms: unhealthyKms })).rejects.toThrow(/KMS health check failed/);
885
+ });
840
886
  });
@@ -77,6 +77,7 @@ 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
81
  import {
81
82
  configureEntityFieldEncryption,
82
83
  createDbConnection,
@@ -477,6 +478,12 @@ export type RunProdAppOptions = {
477
478
  * Override für KMS-Backends (AWS/GCP/Azure) statt env-KEK. Nur relevant
478
479
  * wenn das `secrets`-Feature gemountet ist. */
479
480
  readonly masterKey?: MasterKeyProvider;
481
+ /** Subject-Key-Adapter für Crypto-Shredding (DSGVO Art. 17). Wenn gesetzt,
482
+ * steht er Feature-Code als `ctx.kms` zur Verfügung und der Boot prüft
483
+ * seine health() — die App startet nicht gegen ein unerreichbares KMS
484
+ * (Silent-Start würde jeden PII-Read mit 503 beantworten). Kein Default:
485
+ * ohne Adapter bleibt Crypto-Shredding aus. */
486
+ readonly kms?: KmsAdapter;
480
487
  /** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
481
488
  * fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
482
489
  * (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
@@ -635,6 +642,7 @@ export function buildBootExtraContext(opts: {
635
642
  readonly crypto?: BootCrypto;
636
643
  readonly masterKey?: MasterKeyProvider;
637
644
  readonly sseBroker?: SseBroker;
645
+ readonly kms?: KmsAdapter;
638
646
  }): Record<string, unknown> {
639
647
  const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
640
648
  const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
@@ -642,6 +650,7 @@ export function buildBootExtraContext(opts: {
642
650
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
643
651
  return {
644
652
  textContent: createTextContentApi(opts.db),
653
+ ...(opts.kms && { kms: opts.kms }),
645
654
  ...(hasDeliveryFeature && {
646
655
  _notifyFactory: buildDeliveryNotifyFactory({
647
656
  db: opts.db,
@@ -848,6 +857,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
848
857
  // idempotency, event-dedup, entity-cache, rate-limit; failing to
849
858
  // construct here surfaces the misconfig immediately. `new Redis(...)`
850
859
  // connects eagerly, so it must stay AFTER the boot-mode exit above.
860
+ // KMS health gate: an app configured for crypto-shredding must not boot
861
+ // against an unreachable key store — a silent start would answer every
862
+ // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
863
+ if (options.kms) {
864
+ const kmsHealth = await options.kms.health();
865
+ if (!kmsHealth.ok) {
866
+ throw new Error(
867
+ `[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`,
868
+ );
869
+ }
870
+ }
871
+
851
872
  const { db, close: closeDb } = createDbConnection(databaseUrl);
852
873
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
853
874
 
@@ -930,6 +951,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
930
951
  hasAuth: !!effectiveAuth,
931
952
  sseBroker,
932
953
  crypto: bootCrypto,
954
+ ...(options.kms && { kms: options.kms }),
933
955
  });
934
956
  const extraContext = addConfigAccessorFactory(
935
957
  { ...autoExtraContext, ...resolvedExtraContext },