@cosmicdrift/kumiko-server-runtime 0.290.0 → 0.291.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-server-runtime",
3
- "version": "0.290.0",
3
+ "version": "0.291.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,9 +80,9 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.290.0",
84
- "@cosmicdrift/kumiko-framework": "0.290.0",
85
- "@cosmicdrift/kumiko-headless": "0.290.0",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.291.0",
84
+ "@cosmicdrift/kumiko-framework": "0.291.0",
85
+ "@cosmicdrift/kumiko-headless": "0.291.0",
86
86
  "temporal-polyfill": "^0.3.2"
87
87
  },
88
88
  "publishConfig": {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { z } from "zod";
4
5
  import { assertPiiBootInvariants } from "../pii-boot-gate";
5
6
 
6
7
  const piiFeature = defineFeature("gate-pii", (r) => {
@@ -22,6 +23,17 @@ const plainFeature = defineFeature("gate-plain", (r) => {
22
23
  );
23
24
  });
24
25
 
26
+ // Entity-free features: the gate must react to the event stance on its own,
27
+ // not ride along on an entity annotation (fw#2776).
28
+ const piiEventFeature = defineFeature("gate-pii-event", (r) => {
29
+ r.defineEvent("attempt", z.object({ userId: z.string(), email: z.string() }), {
30
+ piiFields: { email: { personal: { of: "userId" } } },
31
+ });
32
+ });
33
+ const noneEventFeature = defineFeature("gate-none-event", (r) => {
34
+ r.defineEvent("ping", z.object({ at: z.string() }), { piiFields: "none" });
35
+ });
36
+
25
37
  const kms = new InMemoryKmsAdapter();
26
38
  const KEY = Buffer.alloc(32, 7).toString("base64");
27
39
 
@@ -56,6 +68,31 @@ describe("assertPiiBootInvariants — prod", () => {
56
68
  test("no PII entities → nothing to gate", () => {
57
69
  expect(() => assertPiiBootInvariants([plainFeature], { mode: "prod" })).not.toThrow();
58
70
  });
71
+
72
+ test('an entity-free feature with a non-"none" event stance aborts boot without a KMS', () => {
73
+ expect(() => assertPiiBootInvariants([piiEventFeature], { mode: "prod" })).toThrow(
74
+ /BOOT ABORTED.*gate-pii-event:event:attempt.*PLAINTEXT/s,
75
+ );
76
+ });
77
+
78
+ test('events declaring piiFields: "none" are not gated', () => {
79
+ expect(() => assertPiiBootInvariants([noneEventFeature], { mode: "prod" })).not.toThrow();
80
+ });
81
+
82
+ test("a KMS satisfies the event gate", () => {
83
+ expect(() =>
84
+ assertPiiBootInvariants([piiEventFeature], { mode: "prod", kms, blindIndexKey: KEY }),
85
+ ).not.toThrow();
86
+ });
87
+
88
+ test("allowPlaintextPii acknowledges the event gate too", () => {
89
+ expect(() =>
90
+ assertPiiBootInvariants([piiEventFeature], {
91
+ mode: "prod",
92
+ allowPlaintextPii: "kms rollout pending",
93
+ }),
94
+ ).not.toThrow();
95
+ });
59
96
  });
60
97
 
61
98
  describe("assertPiiBootInvariants — dev", () => {
@@ -68,4 +105,8 @@ describe("assertPiiBootInvariants — dev", () => {
68
105
  /blindIndexKey/,
69
106
  );
70
107
  });
108
+
109
+ test("a PII event without a KMS only warns in dev", () => {
110
+ expect(() => assertPiiBootInvariants([piiEventFeature], { mode: "dev" })).not.toThrow();
111
+ });
71
112
  });
@@ -3,7 +3,7 @@
3
3
  // exits BEFORE any connection, and the KMS health gate runs BEFORE
4
4
  // createDbConnection/new Redis(...) — so neither path needs real infra.
5
5
 
6
- import { describe, expect, test } from "bun:test";
6
+ import { describe, expect, spyOn, test } from "bun:test";
7
7
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
8
8
  import { runWorkerApp } from "../run-worker-app";
9
9
  import { makeProbeFeature, withClearedBootEnv } from "./boot-probe-fixture";
@@ -96,4 +96,40 @@ describe("runWorkerApp boot-mode", () => {
96
96
  }
97
97
  expect(healthChecked).toBe(true);
98
98
  });
99
+
100
+ test("validateBootOptions reaches validateBoot — an opt-in warning fires only when passed", async () => {
101
+ // Reach-the-boot proof, not an acceptance proof: the probe feature's
102
+ // single "anonymous" handler makes warnOnUniqueAccessRoles emit a
103
+ // warning naming that role, and validateBoot emits it only when the
104
+ // option arrives (fw#3080).
105
+ const originalLog = console.log;
106
+ console.log = () => {};
107
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
108
+ const roleWarnings = (): string[] =>
109
+ warnSpy.mock.calls
110
+ .map((call) => String(call[0]))
111
+ .filter((line) => line.includes('Access role "anonymous"'));
112
+ try {
113
+ const withOption = await runWorkerApp({
114
+ features: [probeFeature],
115
+ migrations: false,
116
+ validateBootOptions: { warnOnUniqueAccessRoles: true },
117
+ envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
118
+ });
119
+ await withOption.stop();
120
+ expect(roleWarnings().length).toBeGreaterThan(0);
121
+
122
+ warnSpy.mockClear();
123
+ const withoutOption = await runWorkerApp({
124
+ features: [probeFeature],
125
+ migrations: false,
126
+ envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
127
+ });
128
+ await withoutOption.stop();
129
+ expect(roleWarnings()).toEqual([]);
130
+ } finally {
131
+ warnSpy.mockRestore();
132
+ console.log = originalLog;
133
+ }
134
+ });
99
135
  });
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.291.0",
4
+ "type": "fix",
5
+ "title": "runProdApp, runDevApp and runWorkerApp forward validateBootOptions to validateBoot (fw#3080)",
6
+ "detail": "All three run-app entrypoints called `validateBoot(features)` without options, so only `createApp` could pass a `navAllowlist` or `warnOnUniqueAccessRoles` through. An app could declare the option, test it against `validateBoot` directly and see green — while the process it actually boots never received it. The three option types now carry an optional `validateBootOptions`, passed straight through in the same shape `createApp` already uses. `ValidateBootOptions` is exported from `@cosmicdrift/kumiko-framework/engine` so consumers can type the value. Omitting it leaves boot behaviour unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.289.0",
4
10
  "type": "improvement",
@@ -39,13 +39,29 @@ export function assertPiiBootInvariants(
39
39
  .filter(([, entity]) => collectPiiSubjectFields(entity).length > 0)
40
40
  .map(([name]) => name),
41
41
  );
42
- // skip: no PII-annotated entities mounted — plaintext gate is moot.
43
- if (piiEntities.length === 0) return;
42
+ // fw#2776: a declared event stance is worth as much as an entity annotation —
43
+ // encryptEventPayloadPii no-ops without a KMS, so a catalogued event writes
44
+ // plaintext PII into kumiko_events with nothing to crypto-shred later.
45
+ const piiEvents = features.flatMap((feature) =>
46
+ Object.values(feature.events ?? {})
47
+ .filter((event) => event.piiFields !== undefined && event.piiFields !== "none")
48
+ .map((event) => event.name),
49
+ );
50
+ // skip: nothing PII-annotated is mounted — plaintext gate is moot.
51
+ if (piiEntities.length === 0 && piiEvents.length === 0) return;
52
+ const carriers = [
53
+ ...(piiEntities.length > 0 ? [`${piiEntities.length} entities`] : []),
54
+ ...(piiEvents.length > 0 ? [`${piiEvents.length} events`] : []),
55
+ ].join(" and ");
56
+ const named = [
57
+ ...(piiEntities.length > 0 ? [`entities [${piiEntities.join(", ")}]`] : []),
58
+ ...(piiEvents.length > 0 ? [`events [${piiEvents.join(", ")}]`] : []),
59
+ ].join(" and ");
44
60
 
45
61
  if (opts.mode === "dev") {
46
62
  // biome-ignore lint/suspicious/noConsole: boot-time security warning
47
63
  console.warn(
48
- `[${tag}] ${piiEntities.length} entities carry pii/userOwned/tenantOwned/recordOwned 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.`,
64
+ `[${tag}] ${carriers} carry pii/userOwned/tenantOwned/recordOwned annotations or a non-"none" defineEvent PII stance 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
65
  );
50
66
  // skip: dev mode, plaintext-PII warning already logged above
51
67
  return;
@@ -53,12 +69,12 @@ export function assertPiiBootInvariants(
53
69
  if (opts.allowPlaintextPii) {
54
70
  // biome-ignore lint/suspicious/noConsole: boot-time security warning
55
71
  console.warn(
56
- `[${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.`,
72
+ `[${tag}] ${carriers} 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.`,
57
73
  );
58
74
  // skip: operator explicitly acknowledged plaintext PII via allowPlaintextPii, warning already logged above
59
75
  return;
60
76
  }
61
77
  throw new Error(
62
- `[${tag}] BOOT ABORTED — entities [${piiEntities.join(", ")}] carry pii/userOwned/tenantOwned/recordOwned 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.`,
78
+ `[${tag}] BOOT ABORTED — ${named} carry pii/userOwned/tenantOwned/recordOwned annotations or a non-"none" defineEvent PII stance 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.`,
63
79
  );
64
80
  }
@@ -95,6 +95,7 @@ import {
95
95
  type FeatureDefinition,
96
96
  findTierResolverUsage,
97
97
  type TierResolverPlugin,
98
+ type ValidateBootOptions,
98
99
  validateAppCustomScreenWriteQns,
99
100
  validateBoot,
100
101
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -443,6 +444,8 @@ export type RunProdAppOptions = {
443
444
  /** App-specific features. config/user/tenant/auth-email-password are
444
445
  * auto-mixed when `auth:` is set — don't add them yourself. */
445
446
  readonly features: readonly FeatureDefinition[];
447
+ /** Opt-in boot-validator warnings — see ValidateBootOptions. */
448
+ readonly validateBootOptions?: ValidateBootOptions;
446
449
  /** Listen-Port. Default 3000 (or $PORT). */
447
450
  readonly port?: number;
448
451
  /** Auth-mode: standard features + routes wired, admin seeded. */
@@ -815,7 +818,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
815
818
  ...(composeAuthOptions && { authOptions: composeAuthOptions }),
816
819
  });
817
820
 
818
- validateBoot(features);
821
+ validateBoot(features, options.validateBootOptions);
819
822
  warnIfNonUtcServerTimeZone();
820
823
  validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
821
824
  assertPiiBootInvariants(features, {
@@ -33,6 +33,7 @@ import {
33
33
  findTierResolverUsage,
34
34
  type Registry,
35
35
  type TierResolverPlugin,
36
+ type ValidateBootOptions,
36
37
  validateBoot,
37
38
  } from "@cosmicdrift/kumiko-framework/engine";
38
39
  import {
@@ -87,6 +88,8 @@ export type RunWorkerAppOptions = {
87
88
  /** App-specific features — same array as in the API/all-in-one process,
88
89
  * so the registry + schema stay identical across processes. */
89
90
  readonly features: readonly FeatureDefinition[];
91
+ /** Opt-in boot-validator warnings — see ValidateBootOptions. */
92
+ readonly validateBootOptions?: ValidateBootOptions;
90
93
  /** Mount the auto-mixed config/user/tenant/auth-email-password features —
91
94
  * MUST match the API process's `includeBundled` value, otherwise API
92
95
  * and worker run with a diverging registry topology. Also controls
@@ -177,7 +180,7 @@ export async function runWorkerApp(options: RunWorkerAppOptions): Promise<Worker
177
180
  const features = composeFeatures(options.features, {
178
181
  includeBundled: !!options.includeBundled,
179
182
  });
180
- validateBoot(features);
183
+ validateBoot(features, options.validateBootOptions);
181
184
  warnIfNonUtcServerTimeZone();
182
185
  assertPiiBootInvariants(features, {
183
186
  kms: options.kms,