@cosmicdrift/kumiko-framework 0.80.0 → 0.81.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-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.81.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// V1 — GDPR storage-persistence boot guard. Catches the prod failure class:
|
|
2
|
+
// user-data-rights mounted but exports land in an ephemeral / missing store,
|
|
3
|
+
// and s3-env selected as the GDPR store without its env vars set.
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
6
|
+
import { validateGdprStoragePersistence } from "../boot-validator/gdpr-storage";
|
|
7
|
+
import { defineFeature } from "../define-feature";
|
|
8
|
+
|
|
9
|
+
const udr = () => defineFeature("user-data-rights", () => {});
|
|
10
|
+
const fileProvider = (name: string) =>
|
|
11
|
+
defineFeature(`file-provider-${name}`, (r) => {
|
|
12
|
+
r.useExtension("fileProvider", name);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
|
|
16
|
+
|
|
17
|
+
describe("validateGdprStoragePersistence (V1)", () => {
|
|
18
|
+
let warnSpy: ReturnType<typeof spyOn>;
|
|
19
|
+
let savedEnv: Array<readonly [string, string | undefined]>;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
23
|
+
savedEnv = S3_ENV.map((k) => [k, process.env[k]] as const);
|
|
24
|
+
for (const k of S3_ENV) delete process.env[k];
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
warnSpy.mockRestore();
|
|
29
|
+
for (const [k, v] of savedEnv) {
|
|
30
|
+
if (v === undefined) delete process.env[k];
|
|
31
|
+
else process.env[k] = v;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("no user-data-rights → never warns", () => {
|
|
36
|
+
validateGdprStoragePersistence([fileProvider("inmemory")]);
|
|
37
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("user-data-rights + only inmemory → ephemeral-store warn", () => {
|
|
41
|
+
validateGdprStoragePersistence([udr(), fileProvider("inmemory")]);
|
|
42
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("LOST on restart");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("user-data-rights + no file provider at all → ephemeral-store warn", () => {
|
|
47
|
+
validateGdprStoragePersistence([udr()]);
|
|
48
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("user-data-rights + s3 provider → no warn", () => {
|
|
52
|
+
validateGdprStoragePersistence([udr(), fileProvider("s3")]);
|
|
53
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("user-data-rights + s3-env, env missing → s3-env env warn naming the vars", () => {
|
|
57
|
+
validateGdprStoragePersistence([udr(), fileProvider("s3-env")]);
|
|
58
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
59
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
60
|
+
expect(msg).toContain("S3_BUCKET");
|
|
61
|
+
expect(msg).toContain("s3-env");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("user-data-rights + s3-env, env set → no warn", () => {
|
|
65
|
+
for (const k of S3_ENV) process.env[k] = "x";
|
|
66
|
+
validateGdprStoragePersistence([udr(), fileProvider("s3-env")]);
|
|
67
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("user-data-rights + s3 AND s3-env (use-all-bundled shape) → no warn even with env unset", () => {
|
|
71
|
+
validateGdprStoragePersistence([udr(), fileProvider("s3"), fileProvider("s3-env")]);
|
|
72
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// Providers whose bytes do not survive a process restart. Only "inmemory"
|
|
4
|
+
// today; extend if another ephemeral bundled provider lands.
|
|
5
|
+
const EPHEMERAL_PROVIDERS: ReadonlySet<string> = new Set(["inmemory"]);
|
|
6
|
+
|
|
7
|
+
// Env vars file-provider-s3-env reads (see files-provider-s3/env-helper).
|
|
8
|
+
// Missing any → createS3ProviderFromEnv throws on the first file op.
|
|
9
|
+
const S3_ENV_REQUIRED = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
|
|
10
|
+
|
|
11
|
+
// Cross-feature GDPR storage guard (V1). Catches the failure class we shipped
|
|
12
|
+
// to prod: user-data-rights mounted but exports land in an ephemeral / missing
|
|
13
|
+
// store (lost on restart → the download 500s), and — once s3-env is the GDPR
|
|
14
|
+
// store — its env vars unset (the provider throws lazily on the first export
|
|
15
|
+
// inside the cron instead of failing loud at boot).
|
|
16
|
+
//
|
|
17
|
+
// Registry-only signal: validateBoot can't see the app's effective `provider`
|
|
18
|
+
// config-override, so it reasons from the registered fileProvider plugins. A
|
|
19
|
+
// false negative is possible (mount a persistent provider, then override the
|
|
20
|
+
// config back to inmemory) — accepted; the common shape (no persistent store)
|
|
21
|
+
// is caught, and this is a WARN, not a hard gate.
|
|
22
|
+
export function validateGdprStoragePersistence(features: readonly FeatureDefinition[]): void {
|
|
23
|
+
const featureNames = new Set(features.map((f) => f.name));
|
|
24
|
+
if (!featureNames.has("user-data-rights")) {
|
|
25
|
+
// skip: this guard only applies to apps that mount user-data-rights
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const registeredProviders = new Set<string>();
|
|
30
|
+
for (const f of features) {
|
|
31
|
+
for (const usage of f.extensionUsages) {
|
|
32
|
+
if (usage.extensionName === "fileProvider") registeredProviders.add(usage.entityName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const persistent = [...registeredProviders].filter((p) => !EPHEMERAL_PROVIDERS.has(p));
|
|
37
|
+
|
|
38
|
+
if (persistent.length === 0) {
|
|
39
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
40
|
+
console.warn(
|
|
41
|
+
"[kumiko:boot] user-data-rights is mounted but no persistent file provider is — GDPR exports use an ephemeral/in-memory store and are LOST on restart (the download then 500s). Mount file-provider-s3 or file-provider-s3-env and select it via the file-foundation provider config.",
|
|
42
|
+
);
|
|
43
|
+
// skip: ephemeral store already warned; the s3-env env-var check below is moot
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// s3-env is the sole persistent store → it's the effective GDPR store. Its
|
|
48
|
+
// credentials come from env; a missing var only surfaces at the first file op
|
|
49
|
+
// (inside the export cron). Surface it at boot instead.
|
|
50
|
+
if (persistent.length === 1 && persistent[0] === "s3-env") {
|
|
51
|
+
const missing = S3_ENV_REQUIRED.filter((v) => !process.env[v]);
|
|
52
|
+
if (missing.length > 0) {
|
|
53
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
54
|
+
console.warn(
|
|
55
|
+
`[kumiko:boot] file-provider-s3-env is the GDPR file store but these env vars are unset: ${missing.join(", ")}. The provider throws on the first export (inside the cron), not here — set them so GDPR storage works.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
validateReferenceFields,
|
|
26
26
|
validateTransitions,
|
|
27
27
|
} from "./entity-handler";
|
|
28
|
+
import { validateGdprStoragePersistence } from "./gdpr-storage";
|
|
28
29
|
import { validateOwnershipRules } from "./ownership";
|
|
29
30
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
30
31
|
import {
|
|
@@ -160,6 +161,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
160
161
|
validateNavCycles(allNavQns);
|
|
161
162
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
162
163
|
validateExtensionPreSaveWiring(features);
|
|
164
|
+
validateGdprStoragePersistence(features);
|
|
163
165
|
|
|
164
166
|
if (hasEncryptedFields && !process.env["ENCRYPTION_KEY"]) {
|
|
165
167
|
throw new Error("ENCRYPTION_KEY environment variable is required (encrypted fields in use)");
|
|
@@ -43,10 +43,33 @@ export type UserDataDeleteStrategy = "delete" | "anonymize";
|
|
|
43
43
|
* raw-DB-Operationen machen funktionieren auf beiden Shapes via
|
|
44
44
|
* Drizzle's polymorphem select/insert/update/delete-Chain.
|
|
45
45
|
*/
|
|
46
|
+
/**
|
|
47
|
+
* Minimal storage surface a file-aware forget hook needs to erase binaries.
|
|
48
|
+
* Structural on purpose — the engine stays free of a dependency on the files
|
|
49
|
+
* package; `FileStorageProvider` is assignable here. The forget/export
|
|
50
|
+
* orchestrator resolves the concrete provider per tenant from the mounted
|
|
51
|
+
* file-foundation and injects it via `UserDataHookCtx.buildStorageProvider`.
|
|
52
|
+
*/
|
|
53
|
+
export interface UserDataStorageProvider {
|
|
54
|
+
delete(storageKey: string): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
export interface UserDataHookCtx {
|
|
47
58
|
readonly db: DbRunner;
|
|
48
59
|
readonly tenantId: TenantId;
|
|
49
60
|
readonly userId: UserId;
|
|
61
|
+
/**
|
|
62
|
+
* Per-tenant storage-provider resolver, injected by the forget orchestrator
|
|
63
|
+
* from the mounted file-foundation — so a hook deletes binaries from the
|
|
64
|
+
* SAME store the upload/export path uses (delete-target == upload-target by
|
|
65
|
+
* construction). Undefined when no file provider is resolvable; file-aware
|
|
66
|
+
* hooks then skip binary cleanup (row-only delete) and warn. Resolution
|
|
67
|
+
* failures (provider not configured) should be caught by the hook so a
|
|
68
|
+
* misconfigured store never permanently blocks the user's erasure.
|
|
69
|
+
*/
|
|
70
|
+
readonly buildStorageProvider?: (
|
|
71
|
+
tenantId: TenantId,
|
|
72
|
+
) => Promise<UserDataStorageProvider | undefined>;
|
|
50
73
|
}
|
|
51
74
|
|
|
52
75
|
/**
|