@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.80.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.80.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
  /**
@@ -86,6 +86,7 @@ export type {
86
86
  UserDataExportSnippet,
87
87
  UserDataExtensionHooks,
88
88
  UserDataHookCtx,
89
+ UserDataStorageProvider,
89
90
  } from "./extensions/user-data";
90
91
  export {
91
92
  createBigIntField,