@cosmicdrift/kumiko-bundled-features 0.156.1 → 0.156.2

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-bundled-features",
3
- "version": "0.156.1",
3
+ "version": "0.156.2",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -116,11 +116,11 @@
116
116
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
117
117
  },
118
118
  "dependencies": {
119
- "@cosmicdrift/kumiko-dispatcher-live": "0.156.1",
120
- "@cosmicdrift/kumiko-framework": "0.156.1",
121
- "@cosmicdrift/kumiko-headless": "0.156.1",
122
- "@cosmicdrift/kumiko-renderer": "0.156.1",
123
- "@cosmicdrift/kumiko-renderer-web": "0.156.1",
119
+ "@cosmicdrift/kumiko-dispatcher-live": "0.156.2",
120
+ "@cosmicdrift/kumiko-framework": "0.156.2",
121
+ "@cosmicdrift/kumiko-headless": "0.156.2",
122
+ "@cosmicdrift/kumiko-renderer": "0.156.2",
123
+ "@cosmicdrift/kumiko-renderer-web": "0.156.2",
124
124
  "@mollie/api-client": "^4.5.0",
125
125
  "imapflow": "^1.3.3",
126
126
  "mailparser": "^3.9.8",
@@ -5,11 +5,12 @@
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { randomBytes } from "node:crypto";
8
- import { deleteMany, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
9
  import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import {
11
11
  createEnvMasterKeyProvider,
12
- encryptValue,
12
+ decodeStoredEnvelope,
13
+ decryptValue,
13
14
  type MasterKeyProvider,
14
15
  } from "@cosmicdrift/kumiko-framework/secrets";
15
16
  import {
@@ -21,7 +22,7 @@ import {
21
22
  import { createSecretsFeature } from "../feature";
22
23
  import { rotateJob } from "../handlers/rotate.job";
23
24
  import { createSecretsContext } from "../secrets-context";
24
- import { tenantSecretsTable } from "../table";
25
+ import { type StoredEnvelope, tenantSecretsTable } from "../table";
25
26
 
26
27
  const admin = createTestUser({
27
28
  id: "00000000-0000-4000-8000-000000000010",
@@ -29,6 +30,15 @@ const admin = createTestUser({
29
30
  roles: ["TenantAdmin"],
30
31
  });
31
32
 
33
+ // Captured so the happy-path rotation test can build a provider whose V1
34
+ // key matches the seeded rows exactly — a fresh random V1 would fail the
35
+ // GCM auth tag on every unwrap.
36
+ const SEED_V1_KEY = randomBytes(32).toString("base64");
37
+ // Shared with the happy-path test's rotator AND its verifier — the verifier
38
+ // must decrypt with ONLY this key so a rewrap that bumps kek_version but
39
+ // leaves the envelope V1-wrapped fails loud instead of silently passing.
40
+ const ROTATED_V2_KEY = randomBytes(32).toString("base64");
41
+
32
42
  // A provider that encrypts happily on wrapDek but always rejects
33
43
  // unwrapDek. Simulates "KEK is unreachable / corrupt" — the failure mode
34
44
  // the circuit-breaker exists to contain. The returned object carries a
@@ -64,7 +74,7 @@ beforeAll(async () => {
64
74
  // Seeding the table uses a sane V1-only provider so writes land on V1.
65
75
  const seedProvider = createEnvMasterKeyProvider({
66
76
  env: {
67
- KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
77
+ KUMIKO_SECRETS_MASTER_KEY_V1: SEED_V1_KEY,
68
78
  KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
69
79
  },
70
80
  });
@@ -80,21 +90,12 @@ beforeAll(async () => {
80
90
  tenant_secrets: tenantSecretsTable,
81
91
  });
82
92
 
83
- // Seed 20 V1 rows directly too many for any maxFailures default.
93
+ // Seed 20 V1 rows through the real write path (executor.create) instead
94
+ // of a raw insertOne — the rotate job's executor.update needs an actual
95
+ // event stream to update against, which a headless projection row lacks.
96
+ const seedSecrets = createSecretsContext({ db: stack.db, masterKeyProvider: seedProvider });
84
97
  for (let i = 0; i < 20; i++) {
85
- const envelope = await encryptValue(`secret-${i}`, seedProvider);
86
- await insertOne(stack.db, tenantSecretsTable, {
87
- tenantId: admin.tenantId,
88
- key: `test:secret:k-${i}`,
89
- envelope: {
90
- ciphertext: envelope.ciphertext.toString("base64"),
91
- iv: envelope.iv.toString("base64"),
92
- authTag: envelope.authTag.toString("base64"),
93
- encryptedDek: envelope.encryptedDek.toString("base64"),
94
- kekVersion: envelope.kekVersion,
95
- },
96
- kekVersion: envelope.kekVersion,
97
- });
98
+ await seedSecrets.set(admin.tenantId, `test:secret:k-${i}`, `secret-${i}`);
98
99
  }
99
100
  });
100
101
 
@@ -170,4 +171,47 @@ describe("rotate-job circuit-breaker", () => {
170
171
  expect(await countV1Rows()).toBe(20);
171
172
  expect(broken.calls()).toBe(25);
172
173
  });
174
+
175
+ // Happy-path counterpart to the failure tests above: a working provider
176
+ // that actually rewraps. Run last — it's the only test that mutates the
177
+ // shared 20-row fixture, so the earlier "stays at 20" assertions must
178
+ // see it before this runs.
179
+ test("successful rotation rewraps the DEK, bumps kekVersion, and preserves plaintext", async () => {
180
+ const rotator = createEnvMasterKeyProvider({
181
+ env: {
182
+ KUMIKO_SECRETS_MASTER_KEY_V1: SEED_V1_KEY,
183
+ KUMIKO_SECRETS_MASTER_KEY_V2: ROTATED_V2_KEY,
184
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
185
+ },
186
+ });
187
+
188
+ await rotateJob({ batchSize: 10, maxFailures: 5 }, jobCtx(rotator));
189
+
190
+ expect(await countV1Rows()).toBe(0);
191
+
192
+ // Decrypt with a V2-ONLY provider — no V1 key available. A rewrap
193
+ // that only bumped the kek_version column but left the envelope
194
+ // wrapped under V1 would throw "no KEK for version 1" here instead
195
+ // of silently passing, which a decrypt via `rotator` (V1+V2) can't
196
+ // catch since it still holds V1.
197
+ const verifier = createEnvMasterKeyProvider({
198
+ env: {
199
+ KUMIKO_SECRETS_MASTER_KEY_V2: ROTATED_V2_KEY,
200
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
201
+ },
202
+ });
203
+
204
+ const rows = await selectMany<{ key: string; envelope: StoredEnvelope; kekVersion: number }>(
205
+ stack.db,
206
+ tenantSecretsTable,
207
+ { tenantId: admin.tenantId },
208
+ );
209
+ expect(rows).toHaveLength(20);
210
+ for (const row of rows) {
211
+ expect(row.kekVersion).toBe(2);
212
+ const expectedIndex = row.key.split("-").at(-1);
213
+ const plaintext = await decryptValue(decodeStoredEnvelope(row.envelope), verifier);
214
+ expect(plaintext).toBe(`secret-${expectedIndex}`);
215
+ }
216
+ });
173
217
  });