@cosmicdrift/kumiko-bundled-features 0.172.0 → 0.173.1

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.172.0",
3
+ "version": "0.173.1",
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>",
@@ -119,12 +119,12 @@
119
119
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
120
120
  },
121
121
  "dependencies": {
122
- "@cosmicdrift/kumiko-dispatcher-live": "0.172.0",
123
- "@cosmicdrift/kumiko-framework": "0.172.0",
124
- "@cosmicdrift/kumiko-headless": "0.172.0",
125
- "@cosmicdrift/kumiko-renderer": "0.172.0",
126
- "@cosmicdrift/kumiko-renderer-web": "0.172.0",
127
- "@cosmicdrift/kumiko-types": "0.172.0",
122
+ "@cosmicdrift/kumiko-dispatcher-live": "0.173.1",
123
+ "@cosmicdrift/kumiko-framework": "0.173.1",
124
+ "@cosmicdrift/kumiko-headless": "0.173.1",
125
+ "@cosmicdrift/kumiko-renderer": "0.173.1",
126
+ "@cosmicdrift/kumiko-renderer-web": "0.173.1",
127
+ "@cosmicdrift/kumiko-types": "0.173.1",
128
128
  "@mollie/api-client": "^4.5.0",
129
129
  "imapflow": "^1.3.3",
130
130
  "mailparser": "^3.9.8",
@@ -245,7 +245,6 @@ describe("audit: list query", () => {
245
245
  );
246
246
  expect(untilBefore.rows).toHaveLength(1);
247
247
  const untilRow = untilBefore.rows[0];
248
- expect(untilRow).toBeDefined();
249
248
  if (!untilRow) throw new Error("expected untilBefore row");
250
249
  expect((untilRow.payload as { name?: string }).name).toBe("before-window");
251
250
  });
@@ -55,8 +55,7 @@ describe("login.write gates (fw#1284)", () => {
55
55
  ["User"],
56
56
  "Asia/Tokyo",
57
57
  );
58
- expect(g.ok).toBe(true);
59
- if (g.ok) expect(g.value.session.timezone).toBe("Asia/Tokyo");
58
+ expect(g.session.timezone).toBe("Asia/Tokyo");
60
59
  });
61
60
 
62
61
  test("gateBuildSession omits timezone when the user never set one (fw#1636)", async () => {
@@ -67,7 +66,6 @@ describe("login.write gates (fw#1284)", () => {
67
66
  ["User"],
68
67
  null,
69
68
  );
70
- expect(g.ok).toBe(true);
71
- if (g.ok) expect(g.value.session.timezone).toBeUndefined();
69
+ expect(g.session.timezone).toBeUndefined();
72
70
  });
73
71
  });
@@ -111,6 +111,33 @@ describe("seedAdmin", () => {
111
111
  expect(betaMembership[0]?.["roles"]).toBe(JSON.stringify(["User"]));
112
112
  });
113
113
 
114
+ test("emailVerified: true wird an seedUserWithPassword durchgereicht", async () => {
115
+ await seedAdmin(stack.db, {
116
+ email: "verified@example.com",
117
+ password: "secret-pw",
118
+ displayName: "Verified Admin",
119
+ emailVerified: true,
120
+ memberships: [
121
+ { tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
122
+ ],
123
+ });
124
+ const [user] = await selectMany(stack.db, userTable, { email: "verified@example.com" });
125
+ expect(user?.["emailVerified"]).toBe(true);
126
+ });
127
+
128
+ test("emailVerified: ohne Flag bleibt der User unverifiziert (Default)", async () => {
129
+ await seedAdmin(stack.db, {
130
+ email: "unverified@example.com",
131
+ password: "secret-pw",
132
+ displayName: "Unverified Admin",
133
+ memberships: [
134
+ { tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
135
+ ],
136
+ });
137
+ const [user] = await selectMany(stack.db, userTable, { email: "unverified@example.com" });
138
+ expect(user?.["emailVerified"]).toBe(false);
139
+ });
140
+
114
141
  test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
115
142
  // Erstaufruf
116
143
  const { id: userId1 } = await seedAdmin(stack.db, {
@@ -206,16 +206,16 @@ export async function gateEnforceMfa(
206
206
  userId: string,
207
207
  tenantId: TenantId,
208
208
  mergedRoles: readonly string[],
209
- ): Promise<GateOutcome<LoginResult | undefined>> {
210
- if (!opts.mfaStatusChecker) return ok(undefined);
209
+ ): Promise<LoginResult | undefined> {
210
+ if (!opts.mfaStatusChecker) return undefined;
211
211
  const mfaStatus = await opts.mfaStatusChecker(ctx, userId, tenantId, mergedRoles);
212
212
  if ("challengeToken" in mfaStatus) {
213
- return ok({ kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken });
213
+ return { kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken };
214
214
  }
215
215
  if ("setupRequired" in mfaStatus) {
216
- return ok({ kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken });
216
+ return { kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken };
217
217
  }
218
- return ok(undefined);
218
+ return undefined;
219
219
  }
220
220
 
221
221
  /** Auth-claims hooks → session. */
@@ -225,7 +225,7 @@ export async function gateBuildSession(
225
225
  tenantId: TenantId,
226
226
  mergedRoles: readonly string[],
227
227
  timezone?: string | null,
228
- ): Promise<GateOutcome<{ readonly kind: "auth-session"; readonly session: SessionUser }>> {
228
+ ): Promise<{ readonly kind: "auth-session"; readonly session: SessionUser }> {
229
229
  const baseSession: SessionUser = {
230
230
  id: userId,
231
231
  tenantId,
@@ -235,7 +235,7 @@ export async function gateBuildSession(
235
235
  const claims = await ctx.resolveAuthClaims(baseSession);
236
236
  const session: SessionUser =
237
237
  Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
238
- return ok({ kind: "auth-session", session });
238
+ return { kind: "auth-session", session };
239
239
  }
240
240
 
241
241
  // Login — unauthenticated entry point. The route is wired public (no JWT
@@ -291,20 +291,18 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
291
291
  const { chosen, mergedRoles } = membershipGate.value;
292
292
 
293
293
  const mfaGate = await gateEnforceMfa(ctx, opts, found.id, chosen.tenantId, mergedRoles);
294
- if (!mfaGate.ok) return mfaGate.result;
295
- if (mfaGate.value !== undefined) {
296
- return { isSuccess: true, data: mfaGate.value };
294
+ if (mfaGate !== undefined) {
295
+ return { isSuccess: true, data: mfaGate };
297
296
  }
298
297
 
299
- const sessionGate = await gateBuildSession(
298
+ const session = await gateBuildSession(
300
299
  ctx,
301
300
  found.id,
302
301
  chosen.tenantId,
303
302
  mergedRoles,
304
303
  found.timezone,
305
304
  );
306
- if (!sessionGate.ok) return sessionGate.result;
307
- return { isSuccess: true, data: sessionGate.value };
305
+ return { isSuccess: true, data: session };
308
306
  },
309
307
  });
310
308
  }
@@ -10,9 +10,25 @@
10
10
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
11
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
12
  import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
13
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import {
14
+ buildEntityTable,
15
+ createEventStoreExecutor,
16
+ createTenantDb,
17
+ } from "@cosmicdrift/kumiko-framework/db";
18
+ import {
19
+ createEntity,
20
+ createTextField,
21
+ defineFeature,
22
+ type TenantId,
23
+ } from "@cosmicdrift/kumiko-framework/engine";
14
24
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
- import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
25
+ import {
26
+ setupTestStack,
27
+ type TestStack,
28
+ TestUsers,
29
+ testTenantId,
30
+ unsafeCreateEntityTable,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
16
32
  import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
17
33
  import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
18
34
  import { createCryptoShreddingFeature } from "../feature";
@@ -142,3 +158,128 @@ describe("crypto-shredding :: forget-subject", () => {
142
158
  expect(err.httpStatus).toBe(400);
143
159
  });
144
160
  });
161
+
162
+ // fw#1611: forget-subject's ctx.searchAdapter wiring (forget-subject.write.ts
163
+ // → purgeSearchDocumentsForSubject) had zero end-to-end coverage — a broken
164
+ // extension registration would silently turn the whole GDPR search purge
165
+ // into a no-op. Own stack (own entity feature) so it doesn't perturb the
166
+ // scenarios above.
167
+ describe("crypto-shredding :: forget-subject purges the derived search index (#1611)", () => {
168
+ const probeEntity = createEntity({
169
+ table: "read_forget_subject_search_probe",
170
+ fields: {
171
+ ownerId: createTextField({ required: true }),
172
+ userNote: createTextField({
173
+ required: true,
174
+ maxLength: 100,
175
+ userOwned: { ownerField: "ownerId" },
176
+ searchable: true,
177
+ }),
178
+ tenantNote: createTextField({
179
+ required: true,
180
+ maxLength: 100,
181
+ tenantOwned: true,
182
+ searchable: true,
183
+ }),
184
+ },
185
+ });
186
+ const probeTable = buildEntityTable("forgetSubjectSearchProbe", probeEntity);
187
+ const probeFeature = defineFeature("forget-subject-search-probe", (r) => {
188
+ r.entity("probe", probeEntity);
189
+ });
190
+
191
+ let searchStack: TestStack;
192
+ let searchKms: InMemoryKmsAdapter;
193
+ const admin = TestUsers.admin;
194
+
195
+ beforeAll(async () => {
196
+ searchStack = await setupTestStack({
197
+ features: [createCryptoShreddingFeature(), probeFeature],
198
+ });
199
+ await unsafeCreateEntityTable(searchStack.db, probeEntity, "probe");
200
+ await createEventsTable(searchStack.db);
201
+ });
202
+
203
+ afterAll(async () => {
204
+ await searchStack.cleanup();
205
+ });
206
+
207
+ beforeEach(() => {
208
+ searchKms = new InMemoryKmsAdapter();
209
+ configurePiiSubjectKms(searchKms);
210
+ });
211
+
212
+ afterEach(() => {
213
+ resetPiiSubjectKmsForTests();
214
+ });
215
+
216
+ function probeExecutor() {
217
+ return createEventStoreExecutor(probeTable, probeEntity, {
218
+ entityName: "probe",
219
+ searchAdapter: searchStack.search,
220
+ });
221
+ }
222
+
223
+ function probeTenantDb() {
224
+ return createTenantDb(searchStack.db, admin.tenantId, "system");
225
+ }
226
+
227
+ test("forget-subject over HTTP purges userOwned rows from the search index", async () => {
228
+ const ownerId = crypto.randomUUID();
229
+ const userNote = "UniqueUserOwnedNote1611";
230
+ const created = await probeExecutor().create(
231
+ { ownerId, userNote, tenantNote: "irrelevant-tenant-note" },
232
+ admin,
233
+ probeTenantDb(),
234
+ );
235
+ if (!created.isSuccess) throw new Error("create failed");
236
+ const id = String(created.data.id);
237
+
238
+ await searchStack.eventDispatcher?.runOnce();
239
+ expect(
240
+ (await searchStack.search.search(admin.tenantId, userNote, { filterType: "probe" })).some(
241
+ (h) => String(h.entityId) === id,
242
+ ),
243
+ ).toBe(true);
244
+
245
+ await searchStack.http.writeOk(
246
+ FORGET,
247
+ { subject: { kind: "user", userId: ownerId }, reason: REASON },
248
+ dpoUser,
249
+ );
250
+
251
+ const after = await searchStack.search.search(admin.tenantId, userNote, {
252
+ filterType: "probe",
253
+ });
254
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
255
+ });
256
+
257
+ test("forget-subject over HTTP purges tenantOwned rows from the search index", async () => {
258
+ const tenantNote = "UniqueTenantOwnedNote1611";
259
+ const created = await probeExecutor().create(
260
+ { ownerId: crypto.randomUUID(), userNote: "irrelevant-user-note", tenantNote },
261
+ admin,
262
+ probeTenantDb(),
263
+ );
264
+ if (!created.isSuccess) throw new Error("create failed");
265
+ const id = String(created.data.id);
266
+
267
+ await searchStack.eventDispatcher?.runOnce();
268
+ expect(
269
+ (await searchStack.search.search(admin.tenantId, tenantNote, { filterType: "probe" })).some(
270
+ (h) => String(h.entityId) === id,
271
+ ),
272
+ ).toBe(true);
273
+
274
+ await searchStack.http.writeOk(
275
+ FORGET,
276
+ { subject: { kind: "tenant", tenantId: admin.tenantId }, reason: REASON },
277
+ dpoUser,
278
+ );
279
+
280
+ const after = await searchStack.search.search(admin.tenantId, tenantNote, {
281
+ filterType: "probe",
282
+ });
283
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
284
+ });
285
+ });
@@ -225,6 +225,11 @@ describe("delivery attempt log under KMS", () => {
225
225
  const otherSubjectBefore = beforeErase.rows.find(
226
226
  (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
227
227
  )?.["recipientAddress"];
228
+ // Guard against a vacuous pass: if the row fell outside the `limit 100`
229
+ // window, both sides below would be `undefined` and the "unaffected"
230
+ // assertion would prove nothing.
231
+ expect(otherSubjectBefore).toBeDefined();
232
+ expect(otherSubjectBefore).not.toBe(PII_ERASED_SENTINEL);
228
233
 
229
234
  await kms.eraseKey({ kind: "user", userId: subjectId });
230
235
 
@@ -69,6 +69,23 @@ describe("scenario 1: create + me", () => {
69
69
  expect(me["locale"] == null).toBe(true);
70
70
  });
71
71
 
72
+ test("locale sent by the client is persisted and returned via me", async () => {
73
+ const created = await stack.http.writeOk<{ id: number }>(
74
+ UserHandlers.create,
75
+ {
76
+ email: "locale@example.com",
77
+ displayName: "Locale User",
78
+ passwordHash: "seeded-hash",
79
+ locale: "de",
80
+ },
81
+ systemAdmin,
82
+ );
83
+
84
+ const signedIn = createTestUser({ id: created.id, roles: ["User"] });
85
+ const me = await stack.http.queryOk<Record<string, unknown>>(UserQueries.me, {}, signedIn);
86
+ expect(me["locale"]).toBe("de");
87
+ });
88
+
72
89
  test("normal user cannot create another user", async () => {
73
90
  const normal = createTestUser({ id: 42, roles: ["User"] });
74
91
  const error = await stack.http.writeErr(
@@ -129,8 +129,6 @@ describe("my-audit-log", () => {
129
129
  // ihre Payload, bob nur seine.
130
130
  const aliceRow = aliceLog.rows[0];
131
131
  const bobRow = bobLog.rows[0];
132
- expect(aliceRow).toBeDefined();
133
- expect(bobRow).toBeDefined();
134
132
  if (!aliceRow || !bobRow) throw new Error("expected audit rows");
135
133
  expect((aliceRow.payload as { foo: string }).foo).toBe("alice");
136
134
  expect((bobRow.payload as { foo: string }).foo).toBe("bob");
@@ -91,6 +91,37 @@ describe("default-mailers dispatch", () => {
91
91
  expect(cap.sent[1]?.subject).toBe("Acme — Dein Datenexport ist bereit");
92
92
  });
93
93
 
94
+ test("null user.locale (der Normalfall seit fw#1637, kein Entity-Default mehr): mailDefaults.locale entscheidet, sonst en", async () => {
95
+ const cap = capturingTransport();
96
+ const resolve = async () => cap.transport;
97
+
98
+ // userLocale: null + mailDefaults.locale: "de" → German subject.
99
+ await makeDefaultExportReadyEmail(resolve, { locale: "de", appName: "Acme" })({
100
+ userId: "u1",
101
+ userEmail: "u1@example.com",
102
+ userLocale: null,
103
+ tenantId: TENANT_A,
104
+ jobId: "j1",
105
+ downloadUrl: "u",
106
+ expiresAt: "x",
107
+ bytesWritten: null,
108
+ });
109
+ expect(cap.sent[0]?.subject).toBe("Acme — Dein Datenexport ist bereit");
110
+
111
+ // userLocale: null + no mailDefaults.locale → template default "en".
112
+ await makeDefaultExportReadyEmail(resolve, { appName: "Acme" })({
113
+ userId: "u2",
114
+ userEmail: "u2@example.com",
115
+ userLocale: null,
116
+ tenantId: TENANT_A,
117
+ jobId: "j2",
118
+ downloadUrl: "u",
119
+ expiresAt: "x",
120
+ bytesWritten: null,
121
+ });
122
+ expect(cap.sent[1]?.subject).toBe("Acme — Your data export is ready");
123
+ });
124
+
94
125
  test("deletion-executed: sendet ueber den ersten Membership-Tenant", async () => {
95
126
  const cap = capturingTransport();
96
127
  const resolved: string[] = [];