@cosmicdrift/kumiko-framework 0.165.4 → 0.167.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.165.4",
3
+ "version": "0.167.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>",
@@ -182,9 +182,10 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
+ "@cosmicdrift/kumiko-types": "0.167.0",
185
186
  "bullmq": "^5.76.7",
186
187
  "bun-types": "^1.3.13",
187
- "hono": "^4.12.18",
188
+ "hono": "^4.12.27",
188
189
  "i18next": "^26.1.0",
189
190
  "ioredis": "^5.10.1",
190
191
  "jose": "^6.2.3",
@@ -196,11 +197,8 @@
196
197
  "uuid": "^14.0.0",
197
198
  "zod": "^4.4.3"
198
199
  },
199
- "peerDependencies": {
200
- "@cosmicdrift/kumiko-types": "^0.165.4"
201
- },
202
200
  "devDependencies": {
203
- "@cosmicdrift/kumiko-dispatcher-live": "0.165.4",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.167.0",
204
202
  "bun-types": "^1.3.13",
205
203
  "pino-pretty": "^13.1.3"
206
204
  },
@@ -3,12 +3,9 @@
3
3
  // rot), Prod → redact + Error-Log, ohne KMS → kein Scan (pass-through).
4
4
 
5
5
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
6
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
6
7
  import { z } from "zod";
7
- import {
8
- configurePiiSubjectKms,
9
- InMemoryKmsAdapter,
10
- resetPiiSubjectKmsForTests,
11
- } from "../../crypto";
8
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "../../crypto";
12
9
  import { defineFeature } from "../../engine/define-feature";
13
10
  import { defineQueryHandler } from "../../engine/define-handler";
14
11
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
@@ -0,0 +1,175 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildPgKmsOptions,
4
+ type KmsWiringEnv,
5
+ requireKmsWiring,
6
+ resolveKmsWiring,
7
+ } from "../kms-wiring";
8
+
9
+ // 32 raw bytes -> base64, the shape PgKmsAdapter's decodePlatformKek demands.
10
+ const KEK_A = Buffer.alloc(32, 1).toString("base64");
11
+ const KEK_B = Buffer.alloc(32, 2).toString("base64");
12
+
13
+ const fullTrio: KmsWiringEnv = {
14
+ PLATFORM_KEK: KEK_A,
15
+ SUBJECT_KEYS_DATABASE_URL: "postgres://localhost:5432/subject_keys",
16
+ KUMIKO_BLIND_INDEX_KEY: "blind-index-key",
17
+ };
18
+
19
+ const rotationEnv = {
20
+ PLATFORM_KEK: KEK_A,
21
+ SUBJECT_KEYS_DATABASE_URL: "postgres://localhost:5432/subject_keys",
22
+ };
23
+
24
+ describe("buildPgKmsOptions", () => {
25
+ test("defaults kekVersion to 1 and omits previousKeks entirely when no rotation is active", () => {
26
+ const options = buildPgKmsOptions(rotationEnv);
27
+
28
+ expect(options.kekVersion).toBe(1);
29
+ // Absent, not `undefined` — this is what distinguishes rotation-inactive
30
+ // (the normal state of every app) from a half-configured rotation.
31
+ expect("previousKeks" in options).toBe(false);
32
+ });
33
+
34
+ test("maps the previous KEK into the version slot named by the env var", () => {
35
+ const options = buildPgKmsOptions({
36
+ ...rotationEnv,
37
+ PLATFORM_KEK_VERSION: "3",
38
+ PLATFORM_KEK_PREVIOUS: KEK_B,
39
+ PLATFORM_KEK_PREVIOUS_VERSION: "2",
40
+ });
41
+
42
+ expect(options.kekVersion).toBe(3);
43
+ expect(options.previousKeks).toEqual({ 2: KEK_B });
44
+ });
45
+
46
+ // The bug this parser exists for: every one of these used to pass through
47
+ // Number() and produce a wrong-but-silent version, which makes the rotation
48
+ // slot unreachable and its rows unreadable (phronexsis#323).
49
+ test.each([
50
+ ["1e21", "scientific notation survives Number.isInteger"],
51
+ ["0x10", "hex coerces to 16"],
52
+ ["-1", "negative"],
53
+ ["0", "zero is not a valid version"],
54
+ [" 2 ", "whitespace is trimmed by Number"],
55
+ ["2.5", "fractional"],
56
+ ["abc", "not a number at all"],
57
+ ])("rejects PLATFORM_KEK_VERSION=%p (%s)", (raw) => {
58
+ expect(() => buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_VERSION: raw })).toThrow(
59
+ /PLATFORM_KEK_VERSION must be a positive integer/,
60
+ );
61
+ });
62
+
63
+ // An empty env var is "unset", not "invalid" — `FOO=` in a .env file and an
64
+ // absent FOO are the same thing to every shell and dotenv loader. Pinned
65
+ // because it is the one falsy input that must NOT hit the strict parser.
66
+ test("treats an empty PLATFORM_KEK_VERSION as unset", () => {
67
+ expect(buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_VERSION: "" }).kekVersion).toBe(1);
68
+ });
69
+
70
+ test("rejects a bad PLATFORM_KEK_PREVIOUS_VERSION with the same strictness", () => {
71
+ expect(() =>
72
+ buildPgKmsOptions({
73
+ ...rotationEnv,
74
+ PLATFORM_KEK_VERSION: "2",
75
+ PLATFORM_KEK_PREVIOUS: KEK_B,
76
+ PLATFORM_KEK_PREVIOUS_VERSION: "1e21",
77
+ }),
78
+ ).toThrow(/PLATFORM_KEK_PREVIOUS_VERSION must be a positive integer/);
79
+ });
80
+
81
+ test("rejects PLATFORM_KEK_PREVIOUS without its version", () => {
82
+ expect(() => buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_PREVIOUS: KEK_B })).toThrow(
83
+ /PLATFORM_KEK_PREVIOUS_VERSION must be set/,
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("resolveKmsWiring", () => {
89
+ test("falls back to plaintext PII when the trio is entirely absent", () => {
90
+ const wiring = resolveKmsWiring({});
91
+
92
+ expect(wiring).toEqual({ allowPlaintextPii: "local dev without subject-keys KMS (fw#818)" });
93
+ });
94
+
95
+ // Regression: every member being optional made TypeScript's weak-type
96
+ // detection reject `process.env` ("no properties in common with
97
+ // KmsWiringEnv"), which pushed each app toward a cast or a six-key mapping.
98
+ // This compiles only while the index signature is there.
99
+ test("accepts process.env directly, with its unrelated keys", () => {
100
+ const processLike: NodeJS.ProcessEnv = { PATH: "/usr/bin", HOME: "/root" };
101
+
102
+ expect(resolveKmsWiring(processLike)).toHaveProperty("allowPlaintextPii");
103
+ });
104
+
105
+ test("carries an app-supplied fallback reason into the boot log", () => {
106
+ const wiring = resolveKmsWiring({}, { plaintextReason: "solon pre-UI gate" });
107
+
108
+ expect(wiring).toEqual({ allowPlaintextPii: "solon pre-UI gate" });
109
+ });
110
+
111
+ // All-or-none is the core of this module: a partial trio means someone
112
+ // dropped one env var and would otherwise boot with unencrypted PII.
113
+ test.each([
114
+ ["PLATFORM_KEK", { PLATFORM_KEK: KEK_A }],
115
+ ["SUBJECT_KEYS_DATABASE_URL", { SUBJECT_KEYS_DATABASE_URL: "postgres://localhost/x" }],
116
+ ["KUMIKO_BLIND_INDEX_KEY", { KUMIKO_BLIND_INDEX_KEY: "key" }],
117
+ ])("throws when only %s is set", (_name, env) => {
118
+ expect(() => resolveKmsWiring(env)).toThrow(/all-or-none/);
119
+ });
120
+
121
+ test("prefixes the all-or-none error with the app's boot-log prefix", () => {
122
+ expect(() => resolveKmsWiring({ PLATFORM_KEK: KEK_A }, { logPrefix: "[solon]" })).toThrow(
123
+ /^\[solon\] PLATFORM_KEK/,
124
+ );
125
+ });
126
+
127
+ test.each([
128
+ ["PLATFORM_KEK_PREVIOUS without version", { PLATFORM_KEK_PREVIOUS: KEK_B }],
129
+ ["version without PLATFORM_KEK_PREVIOUS", { PLATFORM_KEK_PREVIOUS_VERSION: "1" }],
130
+ ])("throws on a half-configured rotation: %s", (_name, extra) => {
131
+ expect(() => resolveKmsWiring({ ...fullTrio, ...extra })).toThrow(/must be set together/);
132
+ });
133
+
134
+ test("builds the adapter when the trio is complete", () => {
135
+ const wiring = resolveKmsWiring(fullTrio);
136
+
137
+ expect("kms" in wiring).toBe(true);
138
+ if (!("kms" in wiring)) throw new Error("unreachable");
139
+ expect(wiring.blindIndexKey).toBe("blind-index-key");
140
+ });
141
+
142
+ // Ownership check: the previous-version-must-be-older rule lives in
143
+ // PgKmsAdapter's constructor, not here. Asserting on ITS message keeps that
144
+ // documented — if the adapter ever stops enforcing it, this test breaks and
145
+ // tells the next person the rule lost its home.
146
+ test("leaves previous-version ordering to PgKmsAdapter, which rejects it", () => {
147
+ expect(() =>
148
+ resolveKmsWiring({
149
+ ...fullTrio,
150
+ PLATFORM_KEK_VERSION: "2",
151
+ PLATFORM_KEK_PREVIOUS: KEK_B,
152
+ PLATFORM_KEK_PREVIOUS_VERSION: "2",
153
+ }),
154
+ ).toThrow(/PgKmsAdapter: previousKeks\[2\] must be older than the active kekVersion 2/);
155
+ });
156
+ });
157
+
158
+ describe("requireKmsWiring", () => {
159
+ test("returns the active wiring without narrowing when the trio is complete", () => {
160
+ // No `in` check needed at the call site — that is the point of the split.
161
+ const { blindIndexKey } = requireKmsWiring(fullTrio);
162
+
163
+ expect(blindIndexKey).toBe("blind-index-key");
164
+ });
165
+
166
+ test("throws instead of falling back when the trio is absent", () => {
167
+ expect(() => requireKmsWiring({}, { logPrefix: "[money-horse]" })).toThrow(
168
+ /no plaintext-PII opt-out on this entry point/,
169
+ );
170
+ });
171
+
172
+ test("still reports a partial trio as all-or-none", () => {
173
+ expect(() => requireKmsWiring({ PLATFORM_KEK: KEK_A })).toThrow(/all-or-none/);
174
+ });
175
+ });
@@ -6,14 +6,12 @@ export {
6
6
  configureBlindIndexKey,
7
7
  configuredBlindIndexKey,
8
8
  decodeBlindIndexKey,
9
- resetBlindIndexKeyForTests,
10
9
  } from "./blind-index";
11
10
  export {
12
11
  configuredEventPiiCatalog,
13
12
  configureEventPiiCatalog,
14
13
  type EventPiiCatalog,
15
14
  encryptEventPayloadPii,
16
- resetEventPiiCatalogForTests,
17
15
  } from "./event-pii";
18
16
  export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
19
17
  export {
@@ -34,6 +32,17 @@ export {
34
32
  subjectKeyForTenant,
35
33
  subjectKeyForUser,
36
34
  } from "./kms-adapter";
35
+ export {
36
+ type ActiveKmsWiring,
37
+ buildPgKmsOptions,
38
+ type KmsWiring,
39
+ type KmsWiringEnv,
40
+ type KmsWiringOptions,
41
+ type PgKmsRotationEnv,
42
+ type PlaintextPiiWiring,
43
+ requireKmsWiring,
44
+ resolveKmsWiring,
45
+ } from "./kms-wiring";
37
46
  export {
38
47
  createPgKmsAdapter,
39
48
  PgKmsAdapter,
@@ -53,7 +62,6 @@ export {
53
62
  isPiiCiphertext,
54
63
  PII_CIPHERTEXT_PREFIX,
55
64
  PII_ERASED_SENTINEL,
56
- resetPiiSubjectKmsForTests,
57
65
  } from "./pii-field-encryption";
58
66
  export {
59
67
  createRequestKmsCache,
@@ -1,2 +1,27 @@
1
- // Legacy path re-exported for callers still importing this module directly.
1
+ import { type SubjectId, subjectIdToKey } from "@cosmicdrift/kumiko-types/kms-adapter-types";
2
+
2
3
  export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
4
+
5
+ // The KMS error classes live here and not in kumiko-types (#1629): callers
6
+ // branch on them with `instanceof`, which needs a single copy of the class.
7
+
8
+ export class KeyErasedError extends Error {
9
+ constructor(public readonly subject: SubjectId) {
10
+ super(`Subject key erased: ${subjectIdToKey(subject)}`);
11
+ this.name = "KeyErasedError";
12
+ }
13
+ }
14
+
15
+ export class KeyNotFoundError extends Error {
16
+ constructor(public readonly subject: SubjectId) {
17
+ super(`Subject key not found: ${subjectIdToKey(subject)}`);
18
+ this.name = "KeyNotFoundError";
19
+ }
20
+ }
21
+
22
+ export class KeyAlreadyExistsError extends Error {
23
+ constructor(public readonly subject: SubjectId) {
24
+ super(`Subject key already exists: ${subjectIdToKey(subject)}`);
25
+ this.name = "KeyAlreadyExistsError";
26
+ }
27
+ }
@@ -0,0 +1,163 @@
1
+ // Boot-time env validation for the subject-keys KMS, shared by every app that
2
+ // mounts crypto-shredding. Lived copy-pasted in four apps before fw#1617; the
3
+ // copies drifted apart in their parsers, which is the failure mode this module
4
+ // exists to end.
5
+ //
6
+ // Split in two on purpose: `buildPgKmsOptions` is pure and unit-testable
7
+ // (the rotation slot mapping is the part that silently corrupts data when it
8
+ // is wrong), `resolveKmsWiring` is the boot entry point that also constructs
9
+ // the adapter.
10
+
11
+ import { createPgKmsAdapter, type PgKmsAdapter, type PgKmsAdapterOptions } from "./pg-kms-adapter";
12
+
13
+ // The index signature is what lets callers pass `process.env` directly. Without
14
+ // it every member is optional, so TypeScript's weak-type detection rejects
15
+ // ProcessEnv for having "no properties in common" — and each app would need
16
+ // either a hand-written six-key mapping or a cast. An env map genuinely does
17
+ // carry arbitrary keys, so the signature states a fact rather than loosening
18
+ // the type for convenience.
19
+ export type KmsWiringEnv = {
20
+ readonly PLATFORM_KEK?: string | undefined;
21
+ readonly SUBJECT_KEYS_DATABASE_URL?: string | undefined;
22
+ readonly KUMIKO_BLIND_INDEX_KEY?: string | undefined;
23
+ readonly PLATFORM_KEK_VERSION?: string | undefined;
24
+ readonly PLATFORM_KEK_PREVIOUS?: string | undefined;
25
+ readonly PLATFORM_KEK_PREVIOUS_VERSION?: string | undefined;
26
+ readonly [key: string]: string | undefined;
27
+ };
28
+
29
+ /** Narrowed form for `buildPgKmsOptions` — presence of the two required
30
+ * members is the caller's job (`resolveKmsWiring` does it via the trio check). */
31
+ export type PgKmsRotationEnv = KmsWiringEnv & {
32
+ readonly PLATFORM_KEK: string;
33
+ readonly SUBJECT_KEYS_DATABASE_URL: string;
34
+ };
35
+
36
+ export type ActiveKmsWiring = {
37
+ readonly kms: PgKmsAdapter;
38
+ readonly blindIndexKey: string;
39
+ };
40
+
41
+ /** Plaintext fallback carries its reason so the boot log says WHY PII is
42
+ * unencrypted — an app running like this by accident is a reportable breach. */
43
+ export type PlaintextPiiWiring = { readonly allowPlaintextPii: string };
44
+
45
+ export type KmsWiring = ActiveKmsWiring | PlaintextPiiWiring;
46
+
47
+ export type KmsWiringOptions = {
48
+ /** Prefixes the all-or-none error. Use the app's existing boot-log prefix —
49
+ * people grep for it in production logs. */
50
+ readonly logPrefix?: string;
51
+ /** Reason recorded when the trio is absent and plaintext PII is accepted. */
52
+ readonly plaintextReason?: string;
53
+ };
54
+
55
+ const DEFAULT_PLAINTEXT_REASON = "local dev without subject-keys KMS (fw#818)";
56
+
57
+ // Number(raw) + Number.isInteger is the wrong parser for an env string:
58
+ // Number.isInteger(1e21) is true, so "1e21" passes, and the value then
59
+ // stringifies back to "1e+21" as a previousKeks object key — a lookup against
60
+ // the integer kek_version column never hits that slot, silently leaving
61
+ // rotation-window rows unreadable. Same class: "0x10" -> 16, " 2 " trimmed,
62
+ // "-1"/"0" accepted as versions, values past MAX_SAFE_INTEGER rounded.
63
+ // A digits-only regex closes all of these at once (phronexsis#323).
64
+ function parseKekVersion(raw: string, fieldName: string): number {
65
+ if (!/^[0-9]+$/.test(raw)) {
66
+ throw new Error(`${fieldName} must be a positive integer, got "${raw}".`);
67
+ }
68
+ const version = Number(raw);
69
+ if (!Number.isSafeInteger(version) || version < 1) {
70
+ throw new Error(`${fieldName} must be a positive integer, got "${raw}".`);
71
+ }
72
+ return version;
73
+ }
74
+
75
+ /** Maps the KEK env vars onto adapter options, including which version slot an
76
+ * old KEK lands in during a rotation window.
77
+ *
78
+ * Does NOT check that the previous version is older than the active one —
79
+ * `PgKmsAdapter`'s constructor already rejects `previousKeks[v] >= kekVersion`
80
+ * and stays the single source of that rule. */
81
+ export function buildPgKmsOptions(env: PgKmsRotationEnv): PgKmsAdapterOptions {
82
+ const kekVersion = env.PLATFORM_KEK_VERSION
83
+ ? parseKekVersion(env.PLATFORM_KEK_VERSION, "PLATFORM_KEK_VERSION")
84
+ : 1;
85
+ if (!env.PLATFORM_KEK_PREVIOUS) {
86
+ return {
87
+ databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,
88
+ platformKek: env.PLATFORM_KEK,
89
+ kekVersion,
90
+ };
91
+ }
92
+ if (!env.PLATFORM_KEK_PREVIOUS_VERSION) {
93
+ throw new Error("PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.");
94
+ }
95
+ const previousVersion = parseKekVersion(
96
+ env.PLATFORM_KEK_PREVIOUS_VERSION,
97
+ "PLATFORM_KEK_PREVIOUS_VERSION",
98
+ );
99
+ return {
100
+ databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,
101
+ platformKek: env.PLATFORM_KEK,
102
+ kekVersion,
103
+ previousKeks: { [previousVersion]: env.PLATFORM_KEK_PREVIOUS },
104
+ };
105
+ }
106
+
107
+ function assertTrioConsistent(env: KmsWiringEnv, logPrefix: string | undefined): boolean {
108
+ const trio = [env.PLATFORM_KEK, env.SUBJECT_KEYS_DATABASE_URL, env.KUMIKO_BLIND_INDEX_KEY];
109
+ const complete = trio.every(Boolean);
110
+ if (!complete && trio.some(Boolean)) {
111
+ throw new Error(
112
+ `${logPrefix ? `${logPrefix} ` : ""}PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / ` +
113
+ "KUMIKO_BLIND_INDEX_KEY are all-or-none — a partial set means the KMS wiring is broken.",
114
+ );
115
+ }
116
+ if (Boolean(env.PLATFORM_KEK_PREVIOUS) !== Boolean(env.PLATFORM_KEK_PREVIOUS_VERSION)) {
117
+ throw new Error(
118
+ "PLATFORM_KEK_PREVIOUS and PLATFORM_KEK_PREVIOUS_VERSION must be set together " +
119
+ "(KEK rotation, runbook kek-rotation.md).",
120
+ );
121
+ }
122
+ return complete;
123
+ }
124
+
125
+ /** Boot wiring for apps that may run without a KMS (dev, local): complete trio
126
+ * yields the adapter, empty trio yields the plaintext fallback, partial throws.
127
+ *
128
+ * Prod entry points that must never fall back: use `requireKmsWiring`. */
129
+ export function resolveKmsWiring(env: KmsWiringEnv, options: KmsWiringOptions = {}): KmsWiring {
130
+ const complete = assertTrioConsistent(env, options.logPrefix);
131
+ if (complete && env.PLATFORM_KEK && env.SUBJECT_KEYS_DATABASE_URL && env.KUMIKO_BLIND_INDEX_KEY) {
132
+ return {
133
+ kms: createPgKmsAdapter(
134
+ buildPgKmsOptions({
135
+ ...env,
136
+ PLATFORM_KEK: env.PLATFORM_KEK,
137
+ SUBJECT_KEYS_DATABASE_URL: env.SUBJECT_KEYS_DATABASE_URL,
138
+ }),
139
+ ),
140
+ blindIndexKey: env.KUMIKO_BLIND_INDEX_KEY,
141
+ };
142
+ }
143
+ return { allowPlaintextPii: options.plaintextReason ?? DEFAULT_PLAINTEXT_REASON };
144
+ }
145
+
146
+ /** Same as `resolveKmsWiring` but the trio is mandatory — an absent trio throws
147
+ * instead of silently accepting plaintext PII. Separate function rather than a
148
+ * flag so the return type is the active wiring, with no narrowing at the call
149
+ * site. */
150
+ export function requireKmsWiring(
151
+ env: KmsWiringEnv,
152
+ options: KmsWiringOptions = {},
153
+ ): ActiveKmsWiring {
154
+ const wiring = resolveKmsWiring(env, options);
155
+ if ("allowPlaintextPii" in wiring) {
156
+ throw new Error(
157
+ `${options.logPrefix ? `${options.logPrefix} ` : ""}PLATFORM_KEK / ` +
158
+ "SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY are required here — " +
159
+ "no plaintext-PII opt-out on this entry point.",
160
+ );
161
+ }
162
+ return wiring;
163
+ }
@@ -4,6 +4,10 @@
4
4
  // dem Ciphertext (erased → NULL), und der Forget-Sweep nullt sofort.
5
5
 
6
6
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import {
8
+ resetBlindIndexKeyForTests,
9
+ resetPiiSubjectKmsForTests,
10
+ } from "@cosmicdrift/kumiko-framework/testing";
7
11
  import {
8
12
  computeBlindIndex,
9
13
  configureBlindIndexKey,
@@ -11,8 +15,6 @@ import {
11
15
  decodeBlindIndexKey,
12
16
  InMemoryKmsAdapter,
13
17
  isPiiCiphertext,
14
- resetBlindIndexKeyForTests,
15
- resetPiiSubjectKmsForTests,
16
18
  subjectIdToKey,
17
19
  } from "../../crypto";
18
20
  import { defineFeature } from "../../engine/define-feature";
@@ -227,6 +227,10 @@ describe("implicit-projection / Live==Rebuild equivalence", () => {
227
227
  // damit auch für sensitive Spalten + Blind-Index. Einzige legitime Divergenz
228
228
  // bleibt Crypto-Shredding: DEK erased → bidx NULL, Wert unlesbar.
229
229
 
230
+ import {
231
+ resetBlindIndexKeyForTests,
232
+ resetPiiSubjectKmsForTests,
233
+ } from "@cosmicdrift/kumiko-framework/testing";
230
234
  import {
231
235
  computeBlindIndex,
232
236
  configureBlindIndexKey,
@@ -235,8 +239,6 @@ import {
235
239
  decryptPiiFieldValues,
236
240
  InMemoryKmsAdapter,
237
241
  isPiiCiphertext,
238
- resetBlindIndexKeyForTests,
239
- resetPiiSubjectKmsForTests,
240
242
  } from "../../crypto";
241
243
  import { asRawClient, selectMany } from "../../db/query";
242
244
 
package/src/db/index.ts CHANGED
@@ -48,7 +48,6 @@ export {
48
48
  configureEntityFieldEncryption,
49
49
  decryptEntityFieldValues,
50
50
  encryptEntityFieldValues,
51
- resetEntityFieldEncryptionCacheForTests,
52
51
  } from "./entity-field-encryption";
53
52
  export type {
54
53
  BuildEntityTableMetaOptions,
@@ -7,6 +7,10 @@
7
7
  // blind-index column, so equality lookups keep working.
8
8
 
9
9
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ resetBlindIndexKeyForTests,
12
+ resetPiiSubjectKmsForTests,
13
+ } from "@cosmicdrift/kumiko-framework/testing";
10
14
  import { z } from "zod";
11
15
  import {
12
16
  configureBlindIndexKey,
@@ -14,8 +18,6 @@ import {
14
18
  InMemoryKmsAdapter,
15
19
  isPiiCiphertext,
16
20
  PII_ERASED_SENTINEL,
17
- resetBlindIndexKeyForTests,
18
- resetPiiSubjectKmsForTests,
19
21
  } from "../../crypto";
20
22
  import { applyEntityEvent } from "../../db/apply-entity-event";
21
23
  import { backfillEventPiiEncryption } from "../../db/queries/backfill-pii";
@@ -1,2 +1,58 @@
1
- // Legacy path re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/event-store-errors";
1
+ // Failure modes of the event-store's append() path. Surfaced as typed
2
+ // errors so the executor layer can map them to the framework's
3
+ // WriteResult error contract (version_conflict).
4
+ //
5
+ // These live here and not in kumiko-types (#1629): `instanceof` needs a
6
+ // single copy of the class, which forced kumiko-types to be a peerDependency
7
+ // and made every minor changeset resolve to a major bump.
8
+
9
+ export class VersionConflictError extends Error {
10
+ public readonly aggregateId: string;
11
+ public readonly expectedVersion: number;
12
+ constructor(aggregateId: string, expectedVersion: number) {
13
+ super(
14
+ `Version conflict on aggregate ${aggregateId}: expected predecessor version ${expectedVersion}`,
15
+ );
16
+ this.name = "VersionConflictError";
17
+ this.aggregateId = aggregateId;
18
+ this.expectedVersion = expectedVersion;
19
+ }
20
+ }
21
+
22
+ // Thrown when append() collides on the partial unique index over
23
+ // metadata.idempotencyKey (tenant-scoped). Distinct from VersionConflictError:
24
+ // a version conflict means two writers raced the same predecessor; this
25
+ // means the same idempotency key was used twice, which is a caller-side
26
+ // retry that must have already appended once. Callers that set
27
+ // idempotencyKey should treat this as "already applied" rather than retry.
28
+ export class IdempotentAppendConflictError extends Error {
29
+ public readonly tenantId: string;
30
+ public readonly idempotencyKey: string;
31
+ constructor(tenantId: string, idempotencyKey: string) {
32
+ super(
33
+ `Idempotency conflict on tenant ${tenantId}: an event with idempotencyKey "${idempotencyKey}" was already appended.`,
34
+ );
35
+ this.name = "IdempotentAppendConflictError";
36
+ this.tenantId = tenantId;
37
+ this.idempotencyKey = idempotencyKey;
38
+ }
39
+ }
40
+
41
+ // Thrown when ctx.appendEvent targets an archived stream. Archived aggregates
42
+ // are read-only — restoreStream() makes them writable again. The archive
43
+ // state is not carried on the events themselves; it lives on the sparse
44
+ // kumiko_archived_streams table. Handlers that need to branch on archive
45
+ // state should call ctx.isStreamArchived(id) first.
46
+ export class ArchivedStreamError extends Error {
47
+ public readonly tenantId: string;
48
+ public readonly aggregateId: string;
49
+ constructor(tenantId: string, aggregateId: string) {
50
+ super(
51
+ `Aggregate ${aggregateId} on tenant ${tenantId} is archived — appendEvent is blocked. ` +
52
+ `Call restoreStream() to re-open the stream before writing.`,
53
+ );
54
+ this.name = "ArchivedStreamError";
55
+ this.tenantId = tenantId;
56
+ this.aggregateId = aggregateId;
57
+ }
58
+ }
@@ -2,11 +2,11 @@
2
2
  // plaintext in derived search index, purged on subject erase.
3
3
 
4
4
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
5
6
  import {
6
7
  configurePiiSubjectKms,
7
8
  InMemoryKmsAdapter,
8
9
  isPiiCiphertext,
9
- resetPiiSubjectKmsForTests,
10
10
  subjectIdToKey,
11
11
  } from "../../crypto";
12
12
  import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { derivePurposeSecret } from "../derive-purpose-secret";
3
+
4
+ const MASTER = "master-secret-for-tests-32-bytes-min";
5
+
6
+ describe("derivePurposeSecret", () => {
7
+ test("is deterministic — same master and purpose give the same secret", () => {
8
+ expect(derivePurposeSecret(MASTER, "mfa-setup-token-v1")).toBe(
9
+ derivePurposeSecret(MASTER, "mfa-setup-token-v1"),
10
+ );
11
+ });
12
+
13
+ // The whole point: two purposes must not share a key, or a token minted for
14
+ // one boundary verifies at another.
15
+ test("separates purposes", () => {
16
+ expect(derivePurposeSecret(MASTER, "mfa-setup-token-v1")).not.toBe(
17
+ derivePurposeSecret(MASTER, "mfa-challenge-token-v1"),
18
+ );
19
+ });
20
+
21
+ test("versioning a purpose yields a different secret, so rotation is per-purpose", () => {
22
+ expect(derivePurposeSecret(MASTER, "deletion-token-v1")).not.toBe(
23
+ derivePurposeSecret(MASTER, "deletion-token-v2"),
24
+ );
25
+ });
26
+
27
+ test("rotating the master rotates every derived secret", () => {
28
+ expect(derivePurposeSecret(MASTER, "deletion-token-v1")).not.toBe(
29
+ derivePurposeSecret(`${MASTER}-rotated`, "deletion-token-v1"),
30
+ );
31
+ });
32
+
33
+ test("never returns the master itself", () => {
34
+ const derived = derivePurposeSecret(MASTER, "mfa-setup-token-v1");
35
+
36
+ expect(derived).not.toBe(MASTER);
37
+ expect(derived).not.toContain(MASTER);
38
+ });
39
+
40
+ test("returns 32 bytes as hex", () => {
41
+ expect(derivePurposeSecret(MASTER, "any-purpose-v1")).toMatch(/^[0-9a-f]{64}$/);
42
+ });
43
+
44
+ // An empty purpose would silently collapse every boundary onto one key —
45
+ // the failure mode this function exists to prevent, so it must be loud.
46
+ test.each([
47
+ ["empty master", "", "a-purpose"],
48
+ ["empty purpose", MASTER, ""],
49
+ ])("throws on %s", (_name, master, purpose) => {
50
+ expect(() => derivePurposeSecret(master, purpose)).toThrow(/must not be empty/);
51
+ });
52
+ });
@@ -89,4 +89,16 @@ describe("assertNoSecretLeak — walks the response tree for branded values", ()
89
89
  expect(() => assertNoSecretLeak(undefined)).not.toThrow();
90
90
  expect(() => assertNoSecretLeak(null)).not.toThrow();
91
91
  });
92
+
93
+ // Dual-package hazard: a second resolved copy of @cosmicdrift/kumiko-types
94
+ // brands with ITS OWN symbol. Constructing the brand from the global registry
95
+ // here stands in for that copy — with a plain Symbol() the guard walks past
96
+ // this value and serializes the plaintext (#1438-adjacent).
97
+ test("catches a Secret branded by another copy of the package", () => {
98
+ const foreign = {
99
+ [Symbol.for("kumiko.secret")]: true as const,
100
+ reveal: () => "plaintext-from-another-copy",
101
+ };
102
+ expect(() => assertNoSecretLeak({ payload: foreign })).toThrow(/leaked.*payload/);
103
+ });
92
104
  });
@@ -0,0 +1,27 @@
1
+ import { hkdfSync } from "node:crypto";
2
+
3
+ // One master secret, many purposes: HKDF turns `JWT_SECRET` into an
4
+ // independent secret per trust boundary, so a token-signing key for MFA setup
5
+ // cannot be used to forge a deletion token — and neither can be walked back to
6
+ // the master. Rotating the master rotates every purpose with it.
7
+ //
8
+ // The alternative is an env var per purpose. That is not more secure (same
9
+ // blast radius if the deploy is compromised), it is just more operations, and
10
+ // in practice one of them ends up unset in some environment.
11
+ //
12
+ // The purpose string is a domain separator and part of the contract: change it
13
+ // and every previously issued token for that purpose stops verifying. Version
14
+ // them ("mfa-setup-token-v1") so a single purpose can be rotated deliberately
15
+ // without touching the master or the other purposes.
16
+ //
17
+ // Lived copy-pasted in four apps before fw#1623 (money-horse, kumiko-studio,
18
+ // publicstatus, plus a stale worktree) — identical bodies, drifting comments.
19
+ export function derivePurposeSecret(masterSecret: string, purpose: string): string {
20
+ if (!masterSecret) {
21
+ throw new Error("derivePurposeSecret: masterSecret must not be empty.");
22
+ }
23
+ if (!purpose) {
24
+ throw new Error("derivePurposeSecret: purpose must not be empty — it is the domain separator.");
25
+ }
26
+ return Buffer.from(hkdfSync("sha256", masterSecret, "", purpose, 32)).toString("hex");
27
+ }
@@ -1,4 +1,5 @@
1
1
  export { createDekCache, type DekCache, type DekCacheOptions, withDekCache } from "./dek-cache";
2
+ export { derivePurposeSecret } from "./derive-purpose-secret";
2
3
  export {
3
4
  createEnvMasterKeyProvider,
4
5
  type EnvMasterKeyProviderOptions,
@@ -1,7 +1,16 @@
1
- // Test-Assertions, Domain-Test-Fixtures und Vitest-spezifische Helpers.
2
- // Production-Code (dev-server, bin/) darf NICHTS aus diesem Sub-Path importieren
3
- // die Stack-Builder leben in `@cosmicdrift/kumiko-framework/stack`, dieses Modul darf
4
- // vitest-Imports top-level enthalten (siehe expect-error.ts).
1
+ // Test assertions and domain test fixtures. Production code (dev-server, bin/)
2
+ // must import nothing from this subpath the stack builders live in
3
+ // `@cosmicdrift/kumiko-framework/stack`.
4
+
5
+ // The four cache/injection resets stay in their own modules (they close over
6
+ // module-private state) and are only re-exported here — they are out of /crypto
7
+ // and /db as of #1631. A production call to resetPiiSubjectKmsForTests() silently
8
+ // switches the PII layer off, and subject-annotated fields are written in
9
+ // plaintext from then on: no error, no log.
10
+ export { resetBlindIndexKeyForTests } from "../crypto/blind-index";
11
+ export { resetEventPiiCatalogForTests } from "../crypto/event-pii";
12
+ export { resetPiiSubjectKmsForTests } from "../crypto/pii-field-encryption";
13
+ export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encryption";
5
14
 
6
15
  export { rolesOf } from "./access-assertions";
7
16
  export { expectError, expectSuccess } from "./assertions";