@cosmicdrift/kumiko-bundled-features 0.226.0 → 0.227.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-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.227.0",
|
|
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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.227.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.227.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.227.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.227.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.227.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.227.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.227.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.227.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// offlot#114 — DB integration for seedAdminGuarded, the boot-seed variant
|
|
2
|
+
// of seedAdmin that survives a blind-index miss on an otherwise-existing
|
|
3
|
+
// account. Runs against a KMS + blind-index setup, which is the only
|
|
4
|
+
// configuration where the bug reproduces: `email` holds per-row ciphertext,
|
|
5
|
+
// so seedAdmin's idempotency check can only match through the deterministic
|
|
6
|
+
// `email_bidx` companion column.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configureBlindIndexKey,
|
|
12
|
+
configurePiiSubjectKms,
|
|
13
|
+
InMemoryKmsAdapter,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
unsafePushTables,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import {
|
|
24
|
+
resetBlindIndexKeyForTests,
|
|
25
|
+
resetPiiSubjectKmsForTests,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
27
|
+
import { createConfigFeature } from "../../config/feature";
|
|
28
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
31
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
32
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
33
|
+
import { createUserFeature } from "../../user/feature";
|
|
34
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
35
|
+
import { seedUser } from "../../user/seeding";
|
|
36
|
+
import { seedAdmin, seedAdminGuarded } from "../seeding";
|
|
37
|
+
|
|
38
|
+
const BLIND_INDEX_KEY_B64 = Buffer.alloc(32, 14).toString("base64");
|
|
39
|
+
const TENANT_114: TenantId = "00000000-0000-4000-8000-0000000001d4" as TenantId;
|
|
40
|
+
const SYSADMIN_EMAIL = "sysadmin@offlot.app";
|
|
41
|
+
|
|
42
|
+
const seedOptions = {
|
|
43
|
+
email: SYSADMIN_EMAIL,
|
|
44
|
+
password: "sysadmin-pw-114",
|
|
45
|
+
displayName: "Sysadmin",
|
|
46
|
+
globalRoles: ["SystemAdmin"],
|
|
47
|
+
memberships: [
|
|
48
|
+
{ tenantId: TENANT_114, tenantKey: "demo-114", tenantName: "Demo 114", roles: ["TenantAdmin"] },
|
|
49
|
+
],
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
let stack: TestStack;
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
const resolver = createConfigResolver();
|
|
56
|
+
stack = await setupTestStack({
|
|
57
|
+
features: [createConfigFeature(), createUserFeature(), createTenantFeature()],
|
|
58
|
+
extraContext: { configResolver: resolver },
|
|
59
|
+
});
|
|
60
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
61
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
62
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
63
|
+
await createEventsTable(stack.db);
|
|
64
|
+
}, 60_000);
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
await stack.cleanup();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(async () => {
|
|
71
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
72
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
73
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
74
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${eventsTable.tableName}"`);
|
|
75
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
76
|
+
configureBlindIndexKey(BLIND_INDEX_KEY_B64);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
resetPiiSubjectKmsForTests();
|
|
81
|
+
resetBlindIndexKeyForTests();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
async function userIds(): Promise<readonly string[]> {
|
|
85
|
+
const rows = await selectMany<{ id: string }>(stack.db, userTable);
|
|
86
|
+
return rows.map((r) => r.id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function membershipUserIds(): Promise<readonly string[]> {
|
|
90
|
+
const rows = await selectMany<{ userId: string }>(stack.db, tenantMembershipsTable);
|
|
91
|
+
return rows.map((r) => r.userId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reproduces the offlot#112 row shape in prod: the email column keeps its
|
|
95
|
+
* ciphertext, but the blind index is blank — as it is for rows written
|
|
96
|
+
* before KUMIKO_BLIND_INDEX_KEY existed, or after a subject-key erase. */
|
|
97
|
+
async function blankBlindIndex(): Promise<void> {
|
|
98
|
+
await asRawClient(stack.db).unsafe(`UPDATE "${userTable.tableName}" SET email_bidx = NULL`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("seedAdminGuarded (offlot#114)", () => {
|
|
102
|
+
test("re-seeding twice on an empty DB stays idempotent", async () => {
|
|
103
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
104
|
+
const second = await seedAdminGuarded(stack.db, seedOptions);
|
|
105
|
+
|
|
106
|
+
expect(second.id).toBe(first.id);
|
|
107
|
+
expect(await userIds()).toEqual([first.id]);
|
|
108
|
+
expect(await membershipUserIds()).toEqual([first.id]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a blank blind index no longer produces a second account", async () => {
|
|
112
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
113
|
+
await blankBlindIndex();
|
|
114
|
+
|
|
115
|
+
const second = await seedAdminGuarded(stack.db, seedOptions);
|
|
116
|
+
|
|
117
|
+
expect(second.id).toBe(first.id);
|
|
118
|
+
expect(await userIds()).toEqual([first.id]);
|
|
119
|
+
// Membership is reconciled onto the existing account, not a new identity.
|
|
120
|
+
expect(await membershipUserIds()).toEqual([first.id]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("unguarded seedAdmin still duplicates on a blank blind index — the gap seedAdminGuarded covers", async () => {
|
|
124
|
+
// Pins the framework behaviour the guard compensates for: seedUser's
|
|
125
|
+
// existence check is bidx-only, and neither unique index on the user
|
|
126
|
+
// table can catch this (the plaintext unique index sits on randomized
|
|
127
|
+
// ciphertext, the bidx unique index is partial WHERE email_bidx IS NOT
|
|
128
|
+
// NULL). If this ever fails, seedAdmin's idempotency check changed and
|
|
129
|
+
// this guard can be revisited.
|
|
130
|
+
const first = await seedAdmin(stack.db, seedOptions);
|
|
131
|
+
await blankBlindIndex();
|
|
132
|
+
|
|
133
|
+
const second = await seedAdmin(stack.db, seedOptions);
|
|
134
|
+
|
|
135
|
+
expect(second.id).not.toBe(first.id);
|
|
136
|
+
expect(await userIds()).toHaveLength(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("pre-existing duplicates are reused, never extended", async () => {
|
|
140
|
+
// Build the exact prod state: two ciphertext rows for one email, the
|
|
141
|
+
// older one with a blank blind index.
|
|
142
|
+
const canonical = await seedAdmin(stack.db, seedOptions);
|
|
143
|
+
await blankBlindIndex();
|
|
144
|
+
const duplicate = await seedAdmin(stack.db, seedOptions);
|
|
145
|
+
expect(await userIds()).toHaveLength(2);
|
|
146
|
+
|
|
147
|
+
// Guards against a false pass: the canonical pick must be driven by
|
|
148
|
+
// insertedAt ordering, not a coincidental id sort — assert the rows
|
|
149
|
+
// actually carry distinct, comparable Temporal.Instant timestamps.
|
|
150
|
+
const rows = await selectMany<{ id: string; insertedAt: { epochNanoseconds: bigint } }>(
|
|
151
|
+
stack.db,
|
|
152
|
+
userTable,
|
|
153
|
+
);
|
|
154
|
+
const canonicalRow = rows.find((r) => r.id === canonical.id);
|
|
155
|
+
const duplicateRow = rows.find((r) => r.id === duplicate.id);
|
|
156
|
+
expect(typeof canonicalRow?.insertedAt.epochNanoseconds).toBe("bigint");
|
|
157
|
+
expect(typeof duplicateRow?.insertedAt.epochNanoseconds).toBe("bigint");
|
|
158
|
+
expect(
|
|
159
|
+
canonicalRow!.insertedAt.epochNanoseconds < duplicateRow!.insertedAt.epochNanoseconds,
|
|
160
|
+
).toBe(true);
|
|
161
|
+
|
|
162
|
+
const seeded = await seedAdminGuarded(stack.db, seedOptions);
|
|
163
|
+
|
|
164
|
+
// Oldest row wins.
|
|
165
|
+
expect(seeded.id).toBe(canonical.id);
|
|
166
|
+
expect(seeded.id).not.toBe(duplicate.id);
|
|
167
|
+
// No third row: the guard refuses to add to a broken state.
|
|
168
|
+
expect(await userIds()).toHaveLength(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("unrelated single-row accounts are untouched", async () => {
|
|
172
|
+
const unrelated = await seedUser(stack.db, {
|
|
173
|
+
email: "dealer@offlot.app",
|
|
174
|
+
displayName: "Dealer",
|
|
175
|
+
});
|
|
176
|
+
const admin = await seedAdminGuarded(stack.db, seedOptions);
|
|
177
|
+
await blankBlindIndex();
|
|
178
|
+
await seedAdminGuarded(stack.db, seedOptions);
|
|
179
|
+
|
|
180
|
+
expect(await userIds()).toHaveLength(2);
|
|
181
|
+
expect((await userIds()).slice().sort()).toEqual([unrelated.id, admin.id].sort());
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
// Damit Sample-Server und Tests keine drei sub-paths zusammensammeln
|
|
13
13
|
// müssen.
|
|
14
14
|
|
|
15
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import {
|
|
17
|
+
configuredPiiSubjectKms,
|
|
18
|
+
decryptPiiFieldValues,
|
|
19
|
+
type LocalKeyKmsAdapter,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
21
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
17
22
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
23
|
import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -21,7 +26,7 @@ import { hashPassword } from "../shared";
|
|
|
21
26
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
22
27
|
import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenant/seeding";
|
|
23
28
|
// kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
|
|
24
|
-
import { userTable } from "../user/schema/user";
|
|
29
|
+
import { USER_STATUS, userTable } from "../user/schema/user";
|
|
25
30
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
26
31
|
import { seedUser } from "../user/seeding";
|
|
27
32
|
|
|
@@ -235,3 +240,108 @@ export async function seedAdmin(
|
|
|
235
240
|
|
|
236
241
|
return { id: userId };
|
|
237
242
|
}
|
|
243
|
+
|
|
244
|
+
// offlot#114 — seedAdmin's idempotency check is a plain fetchOne(email),
|
|
245
|
+
// which the query layer rewrites into `(email = $plaintext OR
|
|
246
|
+
// email_bidx = $hmac)` once a blind-index key is configured. Under KMS
|
|
247
|
+
// encryption `email` holds per-row ciphertext, so only the bidx arm can
|
|
248
|
+
// ever match — and it misses whenever a row's `email_bidx` is NULL (written
|
|
249
|
+
// before the key existed, or after a subject-key erase) or was computed
|
|
250
|
+
// with a since-rotated key. seedAdmin then inserts a second row for an
|
|
251
|
+
// email that already has an account; neither the plaintext unique index
|
|
252
|
+
// (sits on randomized ciphertext) nor the partial bidx unique index (WHERE
|
|
253
|
+
// email_bidx IS NOT NULL, so a NULL row never collides) catches it.
|
|
254
|
+
//
|
|
255
|
+
// seedAdminGuarded is a separate, additive boot-seed guard — NOT a change
|
|
256
|
+
// to seedUser/seedAdmin/provisionSignupAccount, which also run on every
|
|
257
|
+
// real self-signup request and can't afford a decrypt-scan fallback there.
|
|
258
|
+
// Use this only where seedAdmin already runs (dev-boot / sample-server /
|
|
259
|
+
// bootstrap scripts), never as a login or signup hot path.
|
|
260
|
+
|
|
261
|
+
type ActiveUserRow = {
|
|
262
|
+
readonly id: string;
|
|
263
|
+
readonly email: string;
|
|
264
|
+
readonly insertedAt: { readonly epochNanoseconds: bigint };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
function compareInsertedAt(a: ActiveUserRow["insertedAt"], b: ActiveUserRow["insertedAt"]): number {
|
|
268
|
+
if (a.epochNanoseconds < b.epochNanoseconds) return -1;
|
|
269
|
+
if (a.epochNanoseconds > b.epochNanoseconds) return 1;
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Oldest row wins, tie-broken by id — the same rule a manual duplicate
|
|
274
|
+
* cleanup would apply, so this guard and any later cleanup never disagree
|
|
275
|
+
* on which row is the survivor. */
|
|
276
|
+
function pickCanonicalUserId(rows: readonly ActiveUserRow[]): string {
|
|
277
|
+
const sorted = [...rows].sort((a, b) => {
|
|
278
|
+
const byTime = compareInsertedAt(a.insertedAt, b.insertedAt);
|
|
279
|
+
return byTime !== 0 ? byTime : a.id.localeCompare(b.id);
|
|
280
|
+
});
|
|
281
|
+
const first = sorted[0];
|
|
282
|
+
if (first === undefined) throw new Error("pickCanonicalUserId: empty group");
|
|
283
|
+
return first.id;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function findExistingUserIdForEmail(
|
|
287
|
+
db: DbConnection,
|
|
288
|
+
kms: LocalKeyKmsAdapter,
|
|
289
|
+
email: string,
|
|
290
|
+
): Promise<string | undefined> {
|
|
291
|
+
// ponytail: decrypts every active user row to compare emails. Fine at
|
|
292
|
+
// boot-seed scale (tens of accounts, once per boot); if the user table
|
|
293
|
+
// grows past a few thousand rows, narrow this to rows whose email_bidx is
|
|
294
|
+
// NULL or doesn't match the expected HMAC — only those need decrypting.
|
|
295
|
+
const rows = await selectMany<ActiveUserRow>(db, userTable, {
|
|
296
|
+
isDeleted: false,
|
|
297
|
+
status: { ne: USER_STATUS.Deleted },
|
|
298
|
+
});
|
|
299
|
+
const matches: ActiveUserRow[] = [];
|
|
300
|
+
for (const row of rows) {
|
|
301
|
+
// Compared byte-exact, deliberately not case-folded: email_bidx is an
|
|
302
|
+
// HMAC over the raw value and login matches case-sensitively, so a case
|
|
303
|
+
// variant is a different account to every other code path.
|
|
304
|
+
const decrypted = await decryptPiiFieldValues({ email: row.email }, ["email"], kms, {
|
|
305
|
+
requestId: "seed-admin-guarded",
|
|
306
|
+
});
|
|
307
|
+
if (decrypted["email"] === email) matches.push(row);
|
|
308
|
+
}
|
|
309
|
+
return matches.length === 0 ? undefined : pickCanonicalUserId(matches);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Boot-seed variant of seedAdmin that survives a blind-index miss on an
|
|
314
|
+
* otherwise-existing account (see the module comment above). Not for the
|
|
315
|
+
* self-signup hot path — seedAdmin/seedUser stay the unguarded entry point
|
|
316
|
+
* there.
|
|
317
|
+
*
|
|
318
|
+
* No cheap `fetchOne` fast path here on purpose: a hit there only proves
|
|
319
|
+
* SOME row matches (plaintext or bidx), never that it's the canonical one —
|
|
320
|
+
* a stale row's bidx can go NULL while a later duplicate's stays valid, so
|
|
321
|
+
* fetchOne keeps finding the duplicate forever and the seed never converges
|
|
322
|
+
* on the oldest row. The only cheap short-circuit that stays correct is
|
|
323
|
+
* skipping the scan entirely when no KMS is configured at all (plaintext
|
|
324
|
+
* DBs have no bidx ambiguity to guard against in the first place).
|
|
325
|
+
*/
|
|
326
|
+
export async function seedAdminGuarded(
|
|
327
|
+
db: DbConnection,
|
|
328
|
+
options: SeedAdminOptions,
|
|
329
|
+
): Promise<{ id: string }> {
|
|
330
|
+
const kms = configuredPiiSubjectKms();
|
|
331
|
+
if (kms === undefined) return seedAdmin(db, options);
|
|
332
|
+
|
|
333
|
+
const existingId = await findExistingUserIdForEmail(db, kms, options.email);
|
|
334
|
+
if (existingId === undefined) return seedAdmin(db, options);
|
|
335
|
+
|
|
336
|
+
const by = options.by ?? TestUsers.systemAdmin;
|
|
337
|
+
for (const m of options.memberships) {
|
|
338
|
+
await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
|
|
339
|
+
await seedTenantMembership(db, {
|
|
340
|
+
userId: existingId,
|
|
341
|
+
tenantId: m.tenantId,
|
|
342
|
+
roles: m.roles,
|
|
343
|
+
by,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return { id: existingId };
|
|
347
|
+
}
|