@cosmicdrift/kumiko-framework 0.172.0 → 0.173.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 +3 -3
- package/src/api/__tests__/api.test.ts +7 -1
- package/src/crypto/__tests__/kms-wiring.test.ts +9 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +19 -0
- package/src/crypto/kms-wiring.ts +8 -1
- package/src/crypto/pg-kms-adapter.ts +24 -17
- package/src/engine/feature-manifest.ts +0 -4
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-runner.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.173.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,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.173.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.173.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -532,8 +532,14 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
532
532
|
// First .next() takes longer than heartbeatMs → settledInTime=false →
|
|
533
533
|
// streamSSE opens immediately and pumpStream emits ping until the chunk
|
|
534
534
|
// arrives (framework#1547 route-level contract).
|
|
535
|
+
//
|
|
536
|
+
// 25x the heartbeat, not 3x: the assertion rides on the timer winning the
|
|
537
|
+
// race, and a loaded CI runner delays timers by tens of milliseconds. At
|
|
538
|
+
// 60ms the timer occasionally fired after the chunk, the route took the
|
|
539
|
+
// settled path, and the run failed with ["chunk","done"] — a red main that
|
|
540
|
+
// blocks the release job.
|
|
535
541
|
const dispatcher = stubDispatcher(async function* () {
|
|
536
|
-
await Bun.sleep(
|
|
542
|
+
await Bun.sleep(500);
|
|
537
543
|
yield { i: 0 };
|
|
538
544
|
});
|
|
539
545
|
const app = mountStreamApp(dispatcher, 20);
|
|
@@ -124,6 +124,15 @@ describe("resolveKmsWiring", () => {
|
|
|
124
124
|
);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
test("rejects rotation settings when the main trio is absent", () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
resolveKmsWiring({
|
|
130
|
+
PLATFORM_KEK_PREVIOUS: KEK_B,
|
|
131
|
+
PLATFORM_KEK_PREVIOUS_VERSION: "1",
|
|
132
|
+
}),
|
|
133
|
+
).toThrow(/all-or-none/);
|
|
134
|
+
});
|
|
135
|
+
|
|
127
136
|
test.each([
|
|
128
137
|
["PLATFORM_KEK_PREVIOUS without version", { PLATFORM_KEK_PREVIOUS: KEK_B }],
|
|
129
138
|
["version without PLATFORM_KEK_PREVIOUS", { PLATFORM_KEK_PREVIOUS_VERSION: "1" }],
|
|
@@ -93,6 +93,25 @@ describe("PgKmsAdapter — pg specifics", () => {
|
|
|
93
93
|
expect(rows[0]?.["erase_reason"]).toBe("user-forget");
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test("concurrent cold-start createSchema across processes does not crash", async () => {
|
|
97
|
+
const coldDb = await createTestDb();
|
|
98
|
+
const coldUrl = baseUrl.replace(/\/[^/]+$/, `/${coldDb.dbName}`);
|
|
99
|
+
const kek = randomBytes(32).toString("base64");
|
|
100
|
+
const adapters = Array.from(
|
|
101
|
+
{ length: 4 },
|
|
102
|
+
() => new PgKmsAdapter({ databaseUrl: coldUrl, platformKek: kek, maxConnections: 1 }),
|
|
103
|
+
);
|
|
104
|
+
try {
|
|
105
|
+
// Simulates API + worker processes booting simultaneously against a
|
|
106
|
+
// schema-less DB — each adapter races createSchema on its own connection.
|
|
107
|
+
const results = await Promise.all(adapters.map((a) => a.health()));
|
|
108
|
+
expect(results.every((r) => r.ok)).toBe(true);
|
|
109
|
+
} finally {
|
|
110
|
+
await Promise.all(adapters.map((a) => a.close()));
|
|
111
|
+
await coldDb.cleanup();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
96
115
|
test("repeat erase keeps the original tombstone audit fields", async () => {
|
|
97
116
|
const user = freshUser();
|
|
98
117
|
await adapter.createKey(user, ctx);
|
package/src/crypto/kms-wiring.ts
CHANGED
|
@@ -115,10 +115,17 @@ function assertTrioConsistent(env: KmsWiringEnv, logPrefix: string | undefined):
|
|
|
115
115
|
}
|
|
116
116
|
if (Boolean(env.PLATFORM_KEK_PREVIOUS) !== Boolean(env.PLATFORM_KEK_PREVIOUS_VERSION)) {
|
|
117
117
|
throw new Error(
|
|
118
|
-
|
|
118
|
+
`${logPrefix ? `${logPrefix} ` : ""}PLATFORM_KEK_PREVIOUS and ` +
|
|
119
|
+
"PLATFORM_KEK_PREVIOUS_VERSION must be set together " +
|
|
119
120
|
"(KEK rotation, runbook kek-rotation.md).",
|
|
120
121
|
);
|
|
121
122
|
}
|
|
123
|
+
if (!complete && (env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_VERSION)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${logPrefix ? `${logPrefix} ` : ""}PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / ` +
|
|
126
|
+
"KUMIKO_BLIND_INDEX_KEY are all-or-none — rotation settings require the complete KMS wiring.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
122
129
|
return complete;
|
|
123
130
|
}
|
|
124
131
|
|
|
@@ -46,6 +46,10 @@ function decodePlatformKek(base64: string): Buffer {
|
|
|
46
46
|
|
|
47
47
|
const PG_UNIQUE_VIOLATION = "23505";
|
|
48
48
|
|
|
49
|
+
// pg_advisory_xact_lock key for createSchema — serializes concurrent cold-start
|
|
50
|
+
// DDL across processes sharing one subject-keys DB.
|
|
51
|
+
const SCHEMA_ADVISORY_LOCK_KEY = 0x6b_6d_73_31; // 'kms1'
|
|
52
|
+
|
|
49
53
|
function isPgUniqueViolation(error: unknown): boolean {
|
|
50
54
|
return (
|
|
51
55
|
typeof error === "object" &&
|
|
@@ -182,23 +186,26 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
|
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
private async createSchema(): Promise<void> {
|
|
185
|
-
await this.sql
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
189
|
+
await this.sql.begin(async (tx) => {
|
|
190
|
+
await tx`SELECT pg_advisory_xact_lock(${SCHEMA_ADVISORY_LOCK_KEY})`;
|
|
191
|
+
await tx`
|
|
192
|
+
CREATE TABLE IF NOT EXISTS kumiko_subject_keys (
|
|
193
|
+
subject_id TEXT PRIMARY KEY,
|
|
194
|
+
cipher_key BYTEA,
|
|
195
|
+
kek_version INTEGER NOT NULL DEFAULT 1,
|
|
196
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
197
|
+
created_by TEXT,
|
|
198
|
+
erased_at TIMESTAMPTZ,
|
|
199
|
+
erased_by TEXT,
|
|
200
|
+
erase_reason TEXT
|
|
201
|
+
)`;
|
|
202
|
+
await tx`
|
|
203
|
+
CREATE INDEX IF NOT EXISTS kumiko_subject_keys_erased_idx
|
|
204
|
+
ON kumiko_subject_keys (erased_at) WHERE erased_at IS NOT NULL`;
|
|
205
|
+
await tx`
|
|
206
|
+
CREATE INDEX IF NOT EXISTS kumiko_subject_keys_audit_idx
|
|
207
|
+
ON kumiko_subject_keys (created_at, erased_at)`;
|
|
208
|
+
});
|
|
202
209
|
}
|
|
203
210
|
}
|
|
204
211
|
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import { compareByCodepoint } from "../utils";
|
|
10
10
|
import { isEncryptedAtRest } from "./config-helpers";
|
|
11
|
-
import type { ChangelogEntry } from "./feature-changelog";
|
|
12
11
|
import { qualifyEntityName } from "./qualified-name";
|
|
13
12
|
import type { Registry, UiHints } from "./types/feature";
|
|
14
13
|
|
|
@@ -65,9 +64,6 @@ export type ManifestFeature = {
|
|
|
65
64
|
readonly uiHints?: UiHints;
|
|
66
65
|
/** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
|
|
67
66
|
readonly tier?: string;
|
|
68
|
-
/** Per-feature changelog entries (from changes.json). Optional —
|
|
69
|
-
* absent when no changes.json exists or feature has no entries. */
|
|
70
|
-
readonly changelog?: readonly ChangelogEntry[];
|
|
71
67
|
};
|
|
72
68
|
|
|
73
69
|
export type FeatureManifest = {
|
package/src/jobs/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { JobLogEntry, JobMeta, JobRunner, JobRunnerOptions } from "./job-runner";
|
|
2
|
-
export { createJobRunner
|
|
2
|
+
export { createJobRunner } from "./job-runner";
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function schedulerIdForJobName(jobName: string): string {
|
|
|
45
45
|
return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// ponytail: migration shim, remove after fw#1603 deploy is everywhere.
|
|
49
49
|
function legacySchedulerIdForJobName(jobName: string): string {
|
|
50
50
|
return `scheduler-${jobName.replace(/\./g, "-")}`;
|
|
51
51
|
}
|