@cosmicdrift/kumiko-server-runtime 0.290.0 → 0.292.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.
|
|
3
|
+
"version": "0.292.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.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
83
|
+
"@cosmicdrift/kumiko-bundled-features": "0.292.0",
|
|
84
|
+
"@cosmicdrift/kumiko-framework": "0.292.0",
|
|
85
|
+
"@cosmicdrift/kumiko-headless": "0.292.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",
|
package/src/pii-boot-gate.ts
CHANGED
|
@@ -39,13 +39,29 @@ export function assertPiiBootInvariants(
|
|
|
39
39
|
.filter(([, entity]) => collectPiiSubjectFields(entity).length > 0)
|
|
40
40
|
.map(([name]) => name),
|
|
41
41
|
);
|
|
42
|
-
//
|
|
43
|
-
|
|
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}] ${
|
|
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}] ${
|
|
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 —
|
|
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
|
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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, {
|
package/src/run-worker-app.ts
CHANGED
|
@@ -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,
|