@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.172.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.172.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.172.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(60);
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);
@@ -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
- "PLATFORM_KEK_PREVIOUS and PLATFORM_KEK_PREVIOUS_VERSION must be set together " +
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
- CREATE TABLE IF NOT EXISTS kumiko_subject_keys (
187
- subject_id TEXT PRIMARY KEY,
188
- cipher_key BYTEA,
189
- kek_version INTEGER NOT NULL DEFAULT 1,
190
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
191
- created_by TEXT,
192
- erased_at TIMESTAMPTZ,
193
- erased_by TEXT,
194
- erase_reason TEXT
195
- )`;
196
- await this.sql`
197
- CREATE INDEX IF NOT EXISTS kumiko_subject_keys_erased_idx
198
- ON kumiko_subject_keys (erased_at) WHERE erased_at IS NOT NULL`;
199
- await this.sql`
200
- CREATE INDEX IF NOT EXISTS kumiko_subject_keys_audit_idx
201
- ON kumiko_subject_keys (created_at, erased_at)`;
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, schedulerIdForJobName } from "./job-runner";
2
+ export { createJobRunner } from "./job-runner";
@@ -45,7 +45,7 @@ export function schedulerIdForJobName(jobName: string): string {
45
45
  return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
46
46
  }
47
47
 
48
- /** Pre-sanitize id (`.` only) — remove on boot so colon-form ghosts die. */
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
  }