@cosmicdrift/kumiko-bundled-features 0.156.1 → 0.156.3
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 +6 -6
- package/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/src/cap-counter/__tests__/entity-ddl.test.ts +15 -0
- package/src/cap-counter/entity.ts +2 -2
- package/src/inbound-mail-foundation/entities.ts +6 -2
- package/src/jobs/__tests__/entity-ddl.test.ts +15 -0
- package/src/jobs/job-run-table.ts +1 -1
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/personal-access-tokens/schema/api-token.ts +3 -1
- package/src/secrets/__tests__/rotate.integration.test.ts +84 -18
- package/src/sessions/feature.ts +1 -1
- package/src/sessions/schema/user-session.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.156.
|
|
3
|
+
"version": "0.156.3",
|
|
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.
|
|
120
|
-
"@cosmicdrift/kumiko-framework": "0.156.
|
|
121
|
-
"@cosmicdrift/kumiko-headless": "0.156.
|
|
122
|
-
"@cosmicdrift/kumiko-renderer": "0.156.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer-web": "0.156.
|
|
119
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.156.3",
|
|
120
|
+
"@cosmicdrift/kumiko-framework": "0.156.3",
|
|
121
|
+
"@cosmicdrift/kumiko-headless": "0.156.3",
|
|
122
|
+
"@cosmicdrift/kumiko-renderer": "0.156.3",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer-web": "0.156.3",
|
|
124
124
|
"@mollie/api-client": "^4.5.0",
|
|
125
125
|
"imapflow": "^1.3.3",
|
|
126
126
|
"mailparser": "^3.9.8",
|
|
@@ -139,6 +139,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
139
139
|
// now. Deliberately NOT re-checking strictEmailVerification here:
|
|
140
140
|
// MfaVerifyOptions has no such flag, and email verification can't
|
|
141
141
|
// regress between login and verify within the 10-minute window.
|
|
142
|
+
if (!userRow) return invalidChallengeToken();
|
|
142
143
|
if (
|
|
143
144
|
userRow?.status === USER_STATUS.Restricted ||
|
|
144
145
|
userRow?.status === USER_STATUS.DeletionRequested ||
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { capCounterEntity } from "../entity";
|
|
4
|
+
|
|
5
|
+
function pgTypeOf(table: unknown, dbName: string): string | undefined {
|
|
6
|
+
const cols = (table as { columns?: ReadonlyArray<{ name: string; pgType?: string }> }).columns;
|
|
7
|
+
return cols?.find((c) => c.name === dbName)?.pgType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("capCounterEntity — DDL (#1205 regression)", () => {
|
|
11
|
+
test("value column is bigint, not double precision", () => {
|
|
12
|
+
const table = buildEntityTable("cap-counter", capCounterEntity);
|
|
13
|
+
expect(pgTypeOf(table, "value")).toBe("bigint");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createBigIntField,
|
|
2
3
|
createEntity,
|
|
3
|
-
createNumberField,
|
|
4
4
|
createTextField,
|
|
5
5
|
createTimestampField,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -41,7 +41,7 @@ export const capCounterEntity = createEntity({
|
|
|
41
41
|
table: "read_cap_counters",
|
|
42
42
|
fields: {
|
|
43
43
|
capName: createTextField({ required: true, maxLength: 100, sortable: true, searchable: true }),
|
|
44
|
-
value:
|
|
44
|
+
value: createBigIntField({ required: true, default: 0 }),
|
|
45
45
|
periodStart: createTimestampField({ required: true }),
|
|
46
46
|
lastSoftWarnedAt: createTimestampField(),
|
|
47
47
|
},
|
|
@@ -164,5 +164,9 @@ export const seenMessageEntity = createEntity({
|
|
|
164
164
|
// Plain EntityTableMeta (kein branded EntityTable) — unmanaged Direct-
|
|
165
165
|
// Write-Stores, Handler schreiben via ctx.db (siehe user-session.ts-
|
|
166
166
|
// Rationale).
|
|
167
|
-
export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity
|
|
168
|
-
|
|
167
|
+
export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity, {
|
|
168
|
+
source: "unmanaged",
|
|
169
|
+
});
|
|
170
|
+
export const seenMessageTable = buildEntityTableMeta("mail-seen-message", seenMessageEntity, {
|
|
171
|
+
source: "unmanaged",
|
|
172
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { jobRunEntity } from "../job-run-table";
|
|
4
|
+
|
|
5
|
+
function pgTypeOf(table: unknown, dbName: string): string | undefined {
|
|
6
|
+
const cols = (table as { columns?: ReadonlyArray<{ name: string; pgType?: string }> }).columns;
|
|
7
|
+
return cols?.find((c) => c.name === dbName)?.pgType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("jobRunEntity — DDL (#1205 regression)", () => {
|
|
11
|
+
test("duration column is integer, not double precision", () => {
|
|
12
|
+
const table = buildEntityTable("read_job_runs", jobRunEntity);
|
|
13
|
+
expect(pgTypeOf(table, "duration")).toBe("integer");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -48,7 +48,7 @@ export const jobRunEntity = createEntity({
|
|
|
48
48
|
attempt: createNumberField({ required: true, default: 1, integer: true }),
|
|
49
49
|
startedAt: createTimestampField({ required: true }),
|
|
50
50
|
finishedAt: createTimestampField(),
|
|
51
|
-
duration: createNumberField(),
|
|
51
|
+
duration: createNumberField({ integer: true }),
|
|
52
52
|
triggeredById: createTextField(),
|
|
53
53
|
},
|
|
54
54
|
});
|
|
@@ -53,7 +53,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
53
53
|
// Direct-write store like read_user_sessions: create/revoke write it, the
|
|
54
54
|
// resolver point-reads it. r.entity would make it a rebuildable projection
|
|
55
55
|
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
56
|
-
r.rawTable(buildEntityTableMeta("api-token", apiTokenEntity), {
|
|
56
|
+
r.rawTable(buildEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
|
|
57
57
|
reason: "read_side.api_tokens_direct_write",
|
|
58
58
|
// create.write encrypts `name` via encryptForDirectWrite (#820).
|
|
59
59
|
piiEncryptedOnWrite: true,
|
|
@@ -58,4 +58,6 @@ export const apiTokenEntity = createEntity({
|
|
|
58
58
|
// buildEntityTableMeta (not buildEntityTable): this is a direct-write store, so
|
|
59
59
|
// the table must be a WritableTable (post ES-write-brand #742) — same as
|
|
60
60
|
// sessions' userSessionTable. buildEntityTable is branded executor-only.
|
|
61
|
-
export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity
|
|
61
|
+
export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity, {
|
|
62
|
+
source: "unmanaged",
|
|
63
|
+
});
|
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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,69 @@ 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. Enforced below, not just documented: a
|
|
179
|
+
// precondition check fails loud (not silently-wrong) if a test inserted
|
|
180
|
+
// between this and the circuit-breaker tests already touched the
|
|
181
|
+
// fixture — rotateJob's batch scan has no per-row exclusion/ordering
|
|
182
|
+
// to fall back on, so isolating this test onto a second tenant doesn't
|
|
183
|
+
// help (rotator would just exhaust maxFailures on the other tenant's
|
|
184
|
+
// still-V1 rows before ever reaching its own).
|
|
185
|
+
test("successful rotation rewraps the DEK, bumps kekVersion, and preserves plaintext", async () => {
|
|
186
|
+
expect(await countV1Rows()).toBe(20);
|
|
187
|
+
|
|
188
|
+
const rotator = createEnvMasterKeyProvider({
|
|
189
|
+
env: {
|
|
190
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: SEED_V1_KEY,
|
|
191
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: ROTATED_V2_KEY,
|
|
192
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Snapshot pre-rotation ciphertext — rotate.job.ts's header comment
|
|
197
|
+
// claims "the ciphertext itself never changes, only the DEK wrapper".
|
|
198
|
+
// Assert that invariant below instead of only checking kekVersion/
|
|
199
|
+
// plaintext, which a rewrap-that-re-encrypts would also satisfy.
|
|
200
|
+
const preRotation = await selectMany<{ key: string; envelope: StoredEnvelope }>(
|
|
201
|
+
stack.db,
|
|
202
|
+
tenantSecretsTable,
|
|
203
|
+
{ tenantId: admin.tenantId },
|
|
204
|
+
);
|
|
205
|
+
const ciphertextByKey = new Map(preRotation.map((row) => [row.key, row.envelope.ciphertext]));
|
|
206
|
+
|
|
207
|
+
await rotateJob({ batchSize: 10, maxFailures: 5 }, jobCtx(rotator));
|
|
208
|
+
|
|
209
|
+
expect(await countV1Rows()).toBe(0);
|
|
210
|
+
|
|
211
|
+
// Decrypt with a V2-ONLY provider — no V1 key available. A rewrap
|
|
212
|
+
// that only bumped the kek_version column but left the envelope
|
|
213
|
+
// wrapped under V1 would throw "no KEK for version 1" here instead
|
|
214
|
+
// of silently passing, which a decrypt via `rotator` (V1+V2) can't
|
|
215
|
+
// catch since it still holds V1.
|
|
216
|
+
const verifier = createEnvMasterKeyProvider({
|
|
217
|
+
env: {
|
|
218
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: ROTATED_V2_KEY,
|
|
219
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const rows = await selectMany<{ key: string; envelope: StoredEnvelope; kekVersion: number }>(
|
|
224
|
+
stack.db,
|
|
225
|
+
tenantSecretsTable,
|
|
226
|
+
{ tenantId: admin.tenantId },
|
|
227
|
+
);
|
|
228
|
+
expect(rows).toHaveLength(20);
|
|
229
|
+
for (const row of rows) {
|
|
230
|
+
expect(row.kekVersion).toBe(2);
|
|
231
|
+
const expectedCiphertext = ciphertextByKey.get(row.key);
|
|
232
|
+
expect(expectedCiphertext).toBeDefined();
|
|
233
|
+
expect(row.envelope.ciphertext).toBe(expectedCiphertext as string);
|
|
234
|
+
const expectedIndex = row.key.split("-").at(-1);
|
|
235
|
+
const plaintext = await decryptValue(decodeStoredEnvelope(row.envelope), verifier);
|
|
236
|
+
expect(plaintext).toBe(`secret-${expectedIndex}`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
173
239
|
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -96,7 +96,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
96
96
|
// (#498/#494). r.rawTable keeps the migration DDL but opts the
|
|
97
97
|
// table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
|
|
98
98
|
// which are direct-write stores too.
|
|
99
|
-
r.rawTable(buildEntityTableMeta("user-session", userSessionEntity), {
|
|
99
|
+
r.rawTable(buildEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }), {
|
|
100
100
|
reason: "read_side.user_sessions_direct_write",
|
|
101
101
|
// sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
|
|
102
102
|
piiEncryptedOnWrite: true,
|
|
@@ -74,4 +74,6 @@ export const userSessionEntity = createEntity({
|
|
|
74
74
|
// handlers write it directly via ctx.db; the meta carries no executor-only
|
|
75
75
|
// brand so those writes stay legal. See feature.ts for the rebuild-exclusion
|
|
76
76
|
// rationale (#494/#498).
|
|
77
|
-
export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity
|
|
77
|
+
export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity, {
|
|
78
|
+
source: "unmanaged",
|
|
79
|
+
});
|