@cosmicdrift/kumiko-bundled-features 0.25.0 → 0.27.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.25.0",
3
+ "version": "0.27.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>",
@@ -1,6 +1,11 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import { asRawClient, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import {
4
+ asRawClient,
5
+ insertOne,
6
+ selectMany,
7
+ updateMany,
8
+ } from "@cosmicdrift/kumiko-framework/bun-db";
4
9
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
5
10
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
11
  import {
@@ -204,27 +209,86 @@ describe("POST /auth/verify-email", () => {
204
209
  });
205
210
 
206
211
  test("verify that fails before the write is retryable (burn released on failure)", async () => {
207
- // Symmetric to the reset-password retry test: if the confirm-flow
208
- // fails AFTER burning (here: no memberships empty tenantOrder),
209
- // the finally-block in runConfirmTokenFlow releases the burn so
210
- // the user can click the same link again once ops restores state.
212
+ // Symmetric to the reset-password retry test: if the confirm-flow fails
213
+ // AFTER burning, the finally-block in runConfirmTokenFlow releases the
214
+ // burn so the user can click the same link again once ops restores state.
215
+ //
216
+ // Trigger: delete the user READ-MODEL row (kumiko_events untouched) →
217
+ // loadValidatedUser returns null after the burn → invalidToken + unburn.
218
+ // Re-insert the same row verbatim → retry with the SAME token succeeds.
219
+ //
220
+ // (Membership-deletion no longer fails: the stream lives in
221
+ // systemAdmin.tenantId and is recovered with zero memberships — see the
222
+ // zero-membership-sysadmin test below.)
211
223
  const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
212
224
  const { token } = signVerificationToken(seed.id, 60, verifySecret);
213
225
 
214
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
226
+ const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
227
+ if (!userRow) throw new Error("seeded user row missing");
228
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
229
+ seed.id,
230
+ ]);
231
+
215
232
  const firstAttempt = await post("/api/auth/verify-email", { token });
216
233
  expect(firstAttempt.status).toBe(422);
217
234
 
218
- await seedTenantMembership(stack.db, {
219
- userId: seed.id,
220
- tenantId: seed.tenantId,
221
- roles: ["User"],
222
- });
235
+ await insertOne(stack.db, userTable, userRow);
223
236
 
224
237
  const secondAttempt = await post("/api/auth/verify-email", { token });
225
238
  expect(secondAttempt.status).toBe(200);
226
239
  });
227
240
 
241
+ test("zero-membership sysadmin can still verify (stream recovered without any membership)", async () => {
242
+ // 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
243
+ // (…0001) but who holds NO membership must still resolve. The stream-
244
+ // tenant recovery runs BEFORE the empty-membership check, so verify
245
+ // targets …0001 and lands instead of collapsing to invalid_token.
246
+ const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-lonely-1234" });
247
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
248
+
249
+ const streamRows = (await asRawClient(stack.db).unsafe(
250
+ `SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
251
+ [seed.id],
252
+ )) as ReadonlyArray<{ tenant_id: string }>;
253
+ expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
254
+
255
+ const { token } = signVerificationToken(seed.id, 60, verifySecret);
256
+ const res = await post("/api/auth/verify-email", { token });
257
+ expect(res.status).toBe(200);
258
+
259
+ const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
260
+ expect(row?.["emailVerified"]).toBe(true);
261
+ });
262
+
263
+ test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
264
+ // 205#1 "strikt sicher" boundary: a user inserted straight into the read
265
+ // model (no create-event → no event stream) with no membership has
266
+ // nothing to recover → invalidToken. Proves the fix only gains "empty
267
+ // memberships + recoverable stream", never blanket-opens zero-membership.
268
+ // Build a fully-populated user row with NO event stream: seed a normal
269
+ // user (gets all NOT-NULL columns), capture its row, then re-key it to a
270
+ // fresh id + email. getAggregateStreamTenant(orphanId) finds no events
271
+ // (the stream lives under the original id), and no membership is seeded
272
+ // → tenantOrder is empty.
273
+ const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
274
+ const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
275
+ if (!donorRow) throw new Error("donor row missing");
276
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
277
+
278
+ const orphanId = "00000000-0000-4000-8000-0000000000ff";
279
+ await insertOne(stack.db, userTable, {
280
+ ...donorRow,
281
+ id: orphanId,
282
+ email: "orphan@example.com",
283
+ });
284
+
285
+ const { token } = signVerificationToken(orphanId, 60, verifySecret);
286
+ const res = await post("/api/auth/verify-email", { token });
287
+ expect(res.status).toBe(422);
288
+ const body = await res.json();
289
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidVerificationToken);
290
+ });
291
+
228
292
  test("cross-purpose burn isolation: consuming a reset-token doesn't block a verify-token for the same user+expiry", async () => {
229
293
  // The burn-store key includes purpose ("reset" vs "verify"). Tokens
230
294
  // signed with the SAME userId + expiresAtMs but different purpose
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
4
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
5
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import {
@@ -251,31 +251,39 @@ describe("POST /auth/reset-password", () => {
251
251
 
252
252
  test("reset that fails before the write is retryable (burn is released on failure)", async () => {
253
253
  // The burn marker goes down BEFORE the state change so a racing replay
254
- // can't slip through. But if the state change itself fails — e.g. no
255
- // memberships in the row, every tenant stream rejected, DB error
256
- // the token was never actually consumed. The handler releases the
257
- // burn in those branches so the user can click the link again
258
- // without hitting a stuck "already-used".
254
+ // can't slip through. But if the state change itself fails — DB error,
255
+ // user-row vanished, every tenant stream rejected the token was never
256
+ // actually consumed. The handler releases the burn in those branches so
257
+ // the user can click the link again once ops restores state.
259
258
  //
260
- // Repro: drop the user's membership tenantOrder is empty
261
- // invalidToken + unburn. Re-insert membershipsecond attempt
262
- // with the same token succeeds (proves the burn was released).
259
+ // Repro: delete the user READ-MODEL row (kumiko_events untouched)
260
+ // loadValidatedUser returns null AFTER the burn invalidToken + unburn.
261
+ // Re-insert the same row verbatim (restores version optimistic write
262
+ // still matches the untouched event stream) → second attempt with the
263
+ // SAME token succeeds, proving the burn was released.
264
+ //
265
+ // (Deleting the membership no longer works as a failure trigger: the
266
+ // user aggregate stream lives in systemAdmin.tenantId and is recovered
267
+ // by resolveStreamTenants even with zero memberships — see the
268
+ // zero-membership-sysadmin test below.)
263
269
  const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
264
270
  const { token } = signResetToken(seed.id, 15, resetSecret);
265
271
 
266
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
272
+ const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
273
+ if (!userRow) throw new Error("seeded user row missing");
274
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
275
+ seed.id,
276
+ ]);
277
+
267
278
  const firstAttempt = await post("/api/auth/reset-password", {
268
279
  token,
269
280
  newPassword: "never-lands-1234",
270
281
  });
271
282
  expect(firstAttempt.status).toBe(422);
272
283
 
273
- // Re-insert the membership. Same userId, same token still valid.
274
- await seedTenantMembership(stack.db, {
275
- userId: seed.id,
276
- tenantId: seed.tenantId,
277
- roles: ["User"],
278
- });
284
+ // Re-insert the captured row verbatim. Same userId, same version, same
285
+ // token still valid.
286
+ await insertOne(stack.db, userTable, userRow);
279
287
 
280
288
  const secondAttempt = await post("/api/auth/reset-password", {
281
289
  token,
@@ -284,6 +292,68 @@ describe("POST /auth/reset-password", () => {
284
292
  expect(secondAttempt.status).toBe(200);
285
293
  });
286
294
 
295
+ test("zero-membership sysadmin can still reset (stream recovered without any membership)", async () => {
296
+ // 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
297
+ // (…0001) but who holds NO membership must still resolve. The stream-
298
+ // tenant recovery in resolveStreamTenants runs BEFORE the empty-
299
+ // membership check, so the reset targets …0001 and lands — instead of
300
+ // collapsing to invalid_token. Mirrors change-password's unconditional
301
+ // recovery.
302
+ const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-old-lonely!" });
303
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
304
+
305
+ // Confirm the stream tenant the recovery must find: the user aggregate
306
+ // was created via systemAdmin, so its stream lives in …0001.
307
+ const streamRows = (await asRawClient(stack.db).unsafe(
308
+ `SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
309
+ [seed.id],
310
+ )) as ReadonlyArray<{ tenant_id: string }>;
311
+ expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
312
+
313
+ const { token } = signResetToken(seed.id, 15, resetSecret);
314
+ const res = await post("/api/auth/reset-password", {
315
+ token,
316
+ newPassword: "pw-new-lonely-1234",
317
+ });
318
+ expect(res.status).toBe(200);
319
+
320
+ const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
321
+ expect(await verifyPassword(row?.["passwordHash"] as string, "pw-new-lonely-1234")).toBe(true);
322
+ });
323
+
324
+ test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
325
+ // 205#1 "strikt sicher" boundary: a user inserted straight into the read
326
+ // model (no create-event → no event stream) with no membership has
327
+ // nothing to recover. resolveStreamTenants returns [] → invalidToken.
328
+ // Proves the fix only gains "empty memberships + recoverable stream",
329
+ // never blanket-opens zero-membership.
330
+ // Build a fully-populated user row with NO event stream: seed a normal
331
+ // user (gets all NOT-NULL columns), capture its row, then re-key it to a
332
+ // fresh id + email. getAggregateStreamTenant(orphanId) finds no events
333
+ // (the stream lives under the original id), and no membership is seeded
334
+ // → tenantOrder is empty.
335
+ const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
336
+ const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
337
+ if (!donorRow) throw new Error("donor row missing");
338
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
339
+
340
+ const orphanId = "00000000-0000-4000-8000-0000000000ff";
341
+ await insertOne(stack.db, userTable, {
342
+ ...donorRow,
343
+ id: orphanId,
344
+ email: "orphan@example.com",
345
+ });
346
+
347
+ const { token } = signResetToken(orphanId, 15, resetSecret);
348
+ const res = await post("/api/auth/reset-password", {
349
+ token,
350
+ newPassword: "should-not-land-1234",
351
+ });
352
+ expect(res.status).toBe(422);
353
+ const body = await res.json();
354
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
355
+ });
356
+
287
357
  test("replayed reset-token → 422 invalid_reset_token (single-use burn)", async () => {
288
358
  // Reset tokens are single-use: the handler burns them in Redis via
289
359
  // SETNX before the state change. First click wins; replay within TTL
@@ -141,11 +141,14 @@ async function loadValidatedUser(
141
141
  return { ...me, version: me.version };
142
142
  }
143
143
 
144
- // Loads the user's memberships and returns a prioritised tenant list.
145
- // Empty when the user has no memberships at all — the caller treats that
146
- // as invalid_token (a user without memberships can't own a usable auth
147
- // flow anyway, and a deterministic early-return is cleaner than
148
- // discovering it at write time).
144
+ // Loads the user's memberships and returns a prioritised tenant list, with the
145
+ // aggregate's real stream tenant recovered from the event log prepended.
146
+ //
147
+ // Empty only when the user has NO memberships AND no recoverable stream tenant
148
+ // the caller treats that as invalid_token. A zero-membership systemScope user
149
+ // whose stream lives outside any membership (a platform operator seeded under a
150
+ // fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
151
+ // before the empty check rather than being short-circuited by it.
149
152
  async function resolveStreamTenants(
150
153
  ctx: HandlerContext,
151
154
  systemUser: SessionUser,
@@ -155,15 +158,16 @@ async function resolveStreamTenants(
155
158
  userId: me.id,
156
159
  })) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
157
160
  const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
158
- if (ordered.length === 0) return [];
159
161
 
160
162
  // The user aggregate is r.systemScope(): its event stream lives in whichever
161
163
  // tenant the creating executor used, which need NOT be a membership tenant.
162
164
  // A platform operator seeded under a fixture/platform tenant is the live case
163
165
  // — its stream tenant is absent from `ordered`, so a membership-only search
164
166
  // rejects every write and collapses to invalid_token. Recover the real stream
165
- // tenant from the event log and try it first; memberships stay as fallback,
166
- // and an empty/unknown lookup degrades to the prior membership-only behaviour.
167
+ // tenant from the event log and try it first; memberships stay as fallback.
168
+ // Pulled BEFORE the empty-membership check so a zero-membership operator whose
169
+ // stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
170
+ // invalid_token — mirrors change-password.write.ts's unconditional recovery.
167
171
  const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
168
172
  if (streamTenant && !ordered.includes(streamTenant)) {
169
173
  return [streamTenant, ...ordered];
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { enforceStockCap } from "../enforce-cap";
3
+
4
+ describe("enforceStockCap", () => {
5
+ test("hardSlot: Grenze ist exakt limit (kein Buffer)", () => {
6
+ const at = (current: number) => enforceStockCap({ current, limit: 5, profile: "hardSlot" });
7
+ expect(at(0).state).toBe("ok");
8
+ expect(at(4).state).toBe("ok");
9
+ // Tenant hat 5 → die Anlage der 6. wird geblockt (current=5 >= 5).
10
+ expect(at(5).state).toBe("exceeded");
11
+ expect(at(6).state).toBe("exceeded");
12
+ });
13
+
14
+ test("storage: 5% Buffer über dem limit", () => {
15
+ const at = (current: number) => enforceStockCap({ current, limit: 100, profile: "storage" });
16
+ expect(at(104).state).toBe("ok"); // 104 < 105 (=100×1.05)
17
+ expect(at(105).state).toBe("exceeded");
18
+ });
19
+
20
+ test("burstable: 20% Buffer", () => {
21
+ const at = (current: number) => enforceStockCap({ current, limit: 10, profile: "burstable" });
22
+ expect(at(11).state).toBe("ok"); // 11 < 12 (=10×1.2)
23
+ expect(at(12).state).toBe("exceeded");
24
+ });
25
+
26
+ test("limit 0 = keine Allowance → jede Anlage exceeded", () => {
27
+ expect(enforceStockCap({ current: 0, limit: 0, profile: "hardSlot" }).state).toBe("exceeded");
28
+ });
29
+
30
+ test("Result trägt current + limit für die Caller-Fehlermeldung", () => {
31
+ expect(enforceStockCap({ current: 7, limit: 5, profile: "hardSlot" })).toEqual({
32
+ state: "exceeded",
33
+ current: 7,
34
+ limit: 5,
35
+ });
36
+ });
37
+ });
@@ -394,3 +394,36 @@ export async function enforceRollingCapAndMaybeNotify(
394
394
 
395
395
  return result;
396
396
  }
397
+
398
+ // =============================================================================
399
+ // Stock-Cap (Bestand) — live-gezählte Kardinalität gegen Tier-Limit
400
+ // =============================================================================
401
+
402
+ export type StockCapResult =
403
+ | { readonly state: "ok"; readonly current: number; readonly limit: number }
404
+ | { readonly state: "exceeded"; readonly current: number; readonly limit: number };
405
+
406
+ /**
407
+ * Stock-Cap: prüft eine vom Caller LIVE gezählte Kardinalität (z.B. Anzahl
408
+ * existierender Components eines Tenants) gegen ein Tier-Limit.
409
+ *
410
+ * Anders als {@link enforceCap}/{@link enforceRollingCap} gibt es KEINEN
411
+ * gespeicherten Counter: der Caller zählt die Projektion selbst
412
+ * (`count(*) WHERE tenant_id = …`) und übergibt `current`. Das ist drift-frei
413
+ * (ein Delete gibt den Slot sofort frei), braucht keine Counter-Tabelle und
414
+ * kein Increment/Decrement-Bookkeeping. Misst einen Bestand, keinen Fluss.
415
+ *
416
+ * Reine Funktion — wirft NICHT und mappt KEINEN HTTP-Status. Ein erreichtes
417
+ * Stock-Limit heißt „Upgrade nötig", nicht „retry later" (429): der Caller
418
+ * entscheidet die Reaktion, typisch ein app-eigener 422/`upgrade_required`
419
+ * mit i18n. Mit `hardSlot` (soft=hard=1.0) ist die Grenze exakt `limit`.
420
+ */
421
+ export function enforceStockCap(options: {
422
+ readonly current: number;
423
+ readonly limit: number;
424
+ readonly profile: CapToleranceProfileName;
425
+ }): StockCapResult {
426
+ const hardThreshold = options.limit * CAP_TOLERANCES[options.profile].hard;
427
+ const state = options.current >= hardThreshold ? "exceeded" : "ok";
428
+ return { state, current: options.current, limit: options.limit };
429
+ }
@@ -20,7 +20,9 @@ export {
20
20
  enforceCapAndMaybeNotify,
21
21
  enforceRollingCap,
22
22
  enforceRollingCapAndMaybeNotify,
23
+ enforceStockCap,
23
24
  type SoftHitNotifier,
25
+ type StockCapResult,
24
26
  } from "./enforce-cap";
25
27
  export { capCounterEntity } from "./entity";
26
28
  export { capCounterFeature } from "./feature";
@@ -1,22 +1,33 @@
1
- // T1.5c — user-data-rights wiring for custom-fields.
1
+ // T1.5c — user-data-rights wiring for custom-fields, exercised through the
2
+ // REAL export/forget runners (runUserExport / runForgetCleanup), not by
3
+ // calling the registered hooks in isolation.
2
4
  //
3
- // Verifies the full DSGVO loop for custom-field values on a user-owned
4
- // host entity:
5
+ // What the real runners prove that direct hook calls cannot:
5
6
  //
6
- // * Export (Art. 15+20): every row owned by the user contributes its
7
- // customFields jsonb into the user's export bundle under
8
- // `<entity>.customFields`.
7
+ // * runUserExport actually picks up the custom-fields export hook from the
8
+ // registry and folds its snippet into the user's cross-tenant bundle.
9
9
  //
10
- // * Forget strategy=anonymize (Art. 17 with retention obligation):
11
- // sensitive customFields keys are stripped from the jsonb; non-
12
- // sensitive keys stay so co-tenants / co-authors keep useful data.
10
+ // * runForgetCleanup fires BOTH the host EXT_USER_DATA hook (owner-nulling
11
+ // anonymize) AND the custom-fields strip hook, in the order their declared
12
+ // `order` demands. The strip declares order -100 so it redacts sensitive
13
+ // jsonb keyed on `inserted_by_id` BEFORE the host hook nulls that column.
14
+ // If the ordering regressed, the host hook would null inserted_by_id first,
15
+ // the strip's `WHERE inserted_by_id = userId` would match 0 rows, and
16
+ // sensitive PII would silently survive (Art. 17 violation). Calling the
17
+ // hooks by hand never exercised this interaction.
13
18
  //
14
- // * Forget strategy=delete: no-op the host entity's own user-data-
15
- // rights hook handles the row delete, jsonb travels with the row.
19
+ // * The anonymize-vs-delete strategy comes from the data-retention policy:
20
+ // no override strategy "delete" (custom-fields strip is a no-op, host
21
+ // deletes the row); per-tenant anonymize override → strategy "anonymize"
22
+ // (strip runs, host nulls the owner).
16
23
 
17
24
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
19
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
25
+ import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
26
+ import {
27
+ buildEntityTable,
28
+ createEventStoreExecutor,
29
+ createTenantDb,
30
+ } from "@cosmicdrift/kumiko-framework/db";
20
31
  import {
21
32
  createEntity,
22
33
  createEntityExecutor,
@@ -32,19 +43,28 @@ import {
32
43
  resetEventStore,
33
44
  setupTestStack,
34
45
  type TestStack,
46
+ TestUsers,
35
47
  unsafeCreateEntityTable,
36
48
  } from "@cosmicdrift/kumiko-framework/stack";
49
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
37
50
  import { z } from "zod";
38
51
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
39
- import { createDataRetentionFeature } from "../../data-retention";
52
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
53
+ import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
40
54
  import { createSessionsFeature } from "../../sessions";
41
- import { createUserFeature, userEntity } from "../../user";
55
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
42
56
  import { createUserDataRightsFeature } from "../../user-data-rights";
57
+ import { runForgetCleanup } from "../../user-data-rights/run-forget-cleanup";
58
+ import { runUserExport } from "../../user-data-rights/run-user-export";
43
59
  import { fieldDefinitionEntity } from "../entity";
44
60
  import { createCustomFieldsFeature } from "../feature";
45
61
  import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
46
62
  import { wireCustomFieldsUserDataRightsFor } from "../wire-user-data-rights";
47
63
 
64
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
65
+ const NOW = (): Instant => getTemporal().Now.instant();
66
+ const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
67
+
48
68
  const propertyEntity = createEntity({
49
69
  table: "read_t15c_properties",
50
70
  fields: {
@@ -55,15 +75,12 @@ const propertyEntity = createEntity({
55
75
  const propertyTable = buildEntityTable("property", propertyEntity);
56
76
 
57
77
  // Host entity gets its own EXT_USER_DATA-registration too — that's the
58
- // canonical setup (host bundle handles row-anonymize/delete, custom-fields
59
- // adds its strip-sensitive-jsonb layer on top). Both hooks fire in the
60
- // same cleanup-run.
78
+ // canonical setup. The host's anonymize hook NULLS inserted_by_id (default
79
+ // order 0); the custom-fields strip (order -100) must run first. Both fire in
80
+ // the same runForgetCleanup sub-transaction.
61
81
  const hostExportHook: UserDataExportHook = async (ctx) => {
62
82
  const rows = await asRawClient(ctx.db).unsafe(
63
- `
64
- SELECT id, name FROM read_t15c_properties
65
- WHERE inserted_by_id = $1 AND tenant_id = $2
66
- `,
83
+ `SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
67
84
  [ctx.userId, ctx.tenantId],
68
85
  );
69
86
  const list = rows as ReadonlyArray<Record<string, unknown>>;
@@ -77,19 +94,15 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
77
94
  const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
78
95
  if (strategy === "delete") {
79
96
  await asRawClient(ctx.db).unsafe(
80
- `
81
- DELETE FROM read_t15c_properties
82
- WHERE inserted_by_id = $1 AND tenant_id = $2
83
- `,
97
+ `DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
84
98
  [ctx.userId, ctx.tenantId],
85
99
  );
86
100
  } else {
87
- // anonymize: clear owner, keep row + non-sensitive customFields
101
+ // anonymize: clear owner, keep row + non-sensitive customFields. Runs AFTER
102
+ // the custom-fields strip (order -100 < 0) — if it ran first, the strip's
103
+ // owner-keyed WHERE would match nothing.
88
104
  await asRawClient(ctx.db).unsafe(
89
- `
90
- UPDATE read_t15c_properties SET inserted_by_id = NULL
91
- WHERE inserted_by_id = $1 AND tenant_id = $2
92
- `,
105
+ `UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
93
106
  [ctx.userId, ctx.tenantId],
94
107
  );
95
108
  }
@@ -125,8 +138,10 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
125
138
 
126
139
  const customFieldsFeature = createCustomFieldsFeature();
127
140
  const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
141
+ const TENANT = admin.tenantId;
128
142
 
129
143
  let stack: TestStack;
144
+ let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
130
145
 
131
146
  beforeAll(async () => {
132
147
  stack = await setupTestStack({
@@ -143,7 +158,34 @@ beforeAll(async () => {
143
158
  await unsafeCreateEntityTable(stack.db, userEntity);
144
159
  await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
145
160
  await unsafeCreateEntityTable(stack.db, propertyEntity);
161
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
146
162
  await createEventsTable(stack.db);
163
+
164
+ // runForgetCleanup + runUserExport iterate the user's memberships. Provide a
165
+ // minimal membership read-model (same shape the user-data-rights suite uses).
166
+ await asRawClient(stack.db).unsafe(`
167
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
168
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
169
+ tenant_id UUID NOT NULL,
170
+ user_id TEXT NOT NULL,
171
+ version INTEGER NOT NULL DEFAULT 0,
172
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
173
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
174
+ inserted_by_id TEXT,
175
+ modified_by_id TEXT,
176
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
177
+ deleted_at TIMESTAMPTZ,
178
+ deleted_by_id TEXT,
179
+ roles TEXT NOT NULL DEFAULT '[]',
180
+ UNIQUE(user_id, tenant_id)
181
+ )
182
+ `);
183
+
184
+ overrideExecutor = createEventStoreExecutor(
185
+ tenantRetentionOverrideTable,
186
+ tenantRetentionOverrideEntity,
187
+ { entityName: "tenant-retention-override" },
188
+ );
147
189
  });
148
190
 
149
191
  afterAll(async () => {
@@ -154,6 +196,9 @@ beforeEach(async () => {
154
196
  await resetEventStore(stack);
155
197
  await asRawClient(stack.db).unsafe(`DELETE FROM read_t15c_properties`);
156
198
  await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
199
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
200
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
201
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantRetentionOverrideTable.tableName}"`);
157
202
  });
158
203
 
159
204
  async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
@@ -185,43 +230,78 @@ async function setField(entityId: string, fieldKey: string, value: unknown) {
185
230
 
186
231
  async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
187
232
  const rows = await asRawClient(stack.db).unsafe(
188
- `SELECT id, custom_fields FROM read_t15c_properties WHERE id = $1`,
233
+ `SELECT id, custom_fields, inserted_by_id FROM read_t15c_properties WHERE id = $1`,
189
234
  [id],
190
235
  );
191
236
  const list = rows as ReadonlyArray<Record<string, unknown>>;
192
237
  return list[0];
193
238
  }
194
239
 
195
- async function callExportHook(userId: string, tenantId: string) {
196
- const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
197
- const customFieldsUsage = usages.find(
198
- (u) =>
199
- u.entityName === "property" &&
200
- (u.options as { export?: unknown })?.export &&
201
- u.options !== undefined &&
202
- (u.options as Record<string, unknown>)["export"] !== hostExportHook,
240
+ // Seed the acting admin as a normal active user with a membership in TENANT so
241
+ // the export runner iterates their data. Status active = NOT picked up by
242
+ // runForgetCleanup.
243
+ async function seedActiveUserWithMembership(): Promise<void> {
244
+ await insertOne(stack.db, userTable, {
245
+ id: admin.id,
246
+ tenantId: TENANT,
247
+ email: `admin@example.com`,
248
+ passwordHash: "hashed",
249
+ displayName: "Admin",
250
+ locale: "de",
251
+ emailVerified: true,
252
+ roles: '["TenantAdmin"]',
253
+ status: USER_STATUS.Active,
254
+ });
255
+ await seedMembership();
256
+ }
257
+
258
+ // Seed the acting admin as DeletionRequested + grace expired so
259
+ // runForgetCleanup picks them up, with a membership in TENANT.
260
+ async function seedForgetUserWithMembership(): Promise<void> {
261
+ await insertOne(stack.db, userTable, {
262
+ id: admin.id,
263
+ tenantId: TENANT,
264
+ email: `admin@example.com`,
265
+ passwordHash: "hashed",
266
+ displayName: "Admin",
267
+ locale: "de",
268
+ emailVerified: true,
269
+ roles: '["TenantAdmin"]',
270
+ status: USER_STATUS.DeletionRequested,
271
+ gracePeriodEnd: PAST(),
272
+ });
273
+ await seedMembership();
274
+ }
275
+
276
+ async function seedMembership(): Promise<void> {
277
+ await asRawClient(stack.db).unsafe(
278
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
279
+ VALUES ($1, $2, '["TenantAdmin"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
280
+ [TENANT, admin.id],
203
281
  );
204
- if (!customFieldsUsage) throw new Error("custom-fields user-data-rights export hook not found");
205
- const hook = (customFieldsUsage.options as { export: UserDataExportHook }).export;
206
- return hook({ db: stack.db, tenantId, userId });
207
282
  }
208
283
 
209
- async function callDeleteHook(userId: string, tenantId: string, strategy: "anonymize" | "delete") {
210
- const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
211
- const customFieldsUsage = usages.find(
212
- (u) =>
213
- u.entityName === "property" &&
214
- (u.options as { delete?: unknown })?.delete &&
215
- u.options !== undefined &&
216
- (u.options as Record<string, unknown>)["delete"] !== hostDeleteHook,
284
+ // Set a per-tenant retention override for the property entity through the same
285
+ // event-store path the forget resolver reads — no test-only shortcut.
286
+ async function seedPropertyAnonymizeOverride(): Promise<void> {
287
+ const by = { ...TestUsers.systemAdmin, tenantId: TENANT };
288
+ const result = await overrideExecutor.create(
289
+ {
290
+ entityName: "property",
291
+ config: JSON.stringify({ keepFor: "30d", strategy: "anonymize" }),
292
+ reason: "test",
293
+ tenantId: TENANT,
294
+ },
295
+ by,
296
+ createTenantDb(stack.db, TENANT, "system"),
217
297
  );
218
- if (!customFieldsUsage) throw new Error("custom-fields user-data-rights delete hook not found");
219
- const hook = (customFieldsUsage.options as { delete: UserDataDeleteHook }).delete;
220
- return hook({ db: stack.db, tenantId, userId }, strategy);
298
+ if (!result.isSuccess)
299
+ throw new Error(`seedPropertyAnonymizeOverride failed: ${JSON.stringify(result)}`);
221
300
  }
222
301
 
223
- describe("T1.5c: user-data-rights wiring for custom-fields", () => {
224
- test("export: customFields jsonb travels into the user's export snippet", async () => {
302
+ describe("T1.5c: custom-fields user-data-rights through the real runners", () => {
303
+ test("export: customFields jsonb lands in the user's export bundle", async () => {
304
+ await seedActiveUserWithMembership();
225
305
  const propertyId = "11111111-1111-4000-8000-000000000001";
226
306
  await defineField("email", { type: "text", sensitive: true });
227
307
  await defineField("vipFlag", { type: "boolean" });
@@ -230,17 +310,27 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
230
310
  await setField(propertyId, "vipFlag", true);
231
311
  await stack.eventDispatcher?.runOnce();
232
312
 
233
- const snippet = await callExportHook(String(admin.id), admin.tenantId);
234
- expect(snippet).not.toBeNull();
235
- expect(snippet?.entity).toBe("property.customFields");
236
- expect(snippet?.rows).toHaveLength(1);
237
- expect(snippet?.rows[0]?.["customFields"]).toMatchObject({
313
+ const bundle = await runUserExport({
314
+ db: stack.db,
315
+ registry: stack.registry,
316
+ userId: admin.id,
317
+ now: NOW(),
318
+ });
319
+
320
+ const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
321
+ expect(tenantSection).toBeDefined();
322
+ const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
323
+ expect(cfSnippet).toBeDefined();
324
+ expect(cfSnippet?.rows).toHaveLength(1);
325
+ expect(cfSnippet?.rows[0]?.["customFields"]).toMatchObject({
238
326
  email: "alice@example.com",
239
327
  vipFlag: true,
240
328
  });
241
329
  });
242
330
 
243
- test("forget anonymize: sensitive keys stripped, non-sensitive keys kept", async () => {
331
+ test("forget anonymize: strip runs BEFORE host owner-nulling → sensitive key gone, non-sensitive kept", async () => {
332
+ await seedForgetUserWithMembership();
333
+ await seedPropertyAnonymizeOverride();
244
334
  const propertyId = "22222222-2222-4000-8000-000000000002";
245
335
  await defineField("email", { type: "text", sensitive: true });
246
336
  await defineField("vipFlag", { type: "boolean" });
@@ -249,50 +339,81 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
249
339
  await setField(propertyId, "vipFlag", true);
250
340
  await stack.eventDispatcher?.runOnce();
251
341
 
252
- await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
342
+ const result = await runForgetCleanup({
343
+ db: stack.db,
344
+ registry: stack.registry,
345
+ now: NOW(),
346
+ });
347
+ expect(result.processedUserIds).toContain(admin.id);
348
+ expect(result.errors).toHaveLength(0);
253
349
 
254
350
  const row = await readRow(propertyId);
351
+ // Host hook ran (owner nulled), and the strip ran BEFORE it (sensitive
352
+ // key removed despite the owner-keyed WHERE — proof of the -100 ordering).
353
+ expect(row?.["inserted_by_id"]).toBeNull();
255
354
  const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
256
355
  expect(customFields).toBeDefined();
257
356
  expect(customFields).not.toHaveProperty("email");
258
357
  expect(customFields).toMatchObject({ vipFlag: true });
259
358
  });
260
359
 
261
- test("forget delete: no-op on customFields (host hook removes the row)", async () => {
360
+ test("forget delete (no override strategy delete): host removes the row, strip is a no-op", async () => {
361
+ await seedForgetUserWithMembership();
362
+ // No retention override → policyToStrategy(null) = "delete".
262
363
  const propertyId = "33333333-3333-4000-8000-000000000003";
263
364
  await defineField("email", { type: "text", sensitive: true });
264
365
  await createProperty(propertyId, "Delete-Me");
265
366
  await setField(propertyId, "email", "alice@example.com");
266
367
  await stack.eventDispatcher?.runOnce();
267
368
 
268
- // call only the custom-fields delete hook (strategy=delete) — verify
269
- // it doesn't mutate the row (the host hook would handle the actual
270
- // row delete; we're proving custom-fields stays out of the way).
271
- await callDeleteHook(String(admin.id), admin.tenantId, "delete");
369
+ const result = await runForgetCleanup({
370
+ db: stack.db,
371
+ registry: stack.registry,
372
+ now: NOW(),
373
+ });
374
+ expect(result.processedUserIds).toContain(admin.id);
272
375
 
273
- const row = await readRow(propertyId);
274
- const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
275
- expect(customFields).toMatchObject({ email: "alice@example.com" });
376
+ // Host delete-hook removed the row; custom-fields strip stayed out of the
377
+ // way (it returns early on strategy=delete).
378
+ expect(await readRow(propertyId)).toBeUndefined();
276
379
  });
277
380
 
278
381
  test("export: rows without customFields are not included in the snippet", async () => {
382
+ await seedActiveUserWithMembership();
279
383
  const propertyId = "44444444-4444-4000-8000-000000000004";
280
384
  await createProperty(propertyId, "NoCustomFields");
385
+ await stack.eventDispatcher?.runOnce();
281
386
 
282
- const snippet = await callExportHook(String(admin.id), admin.tenantId);
283
- expect(snippet).toBeNull();
387
+ const bundle = await runUserExport({
388
+ db: stack.db,
389
+ registry: stack.registry,
390
+ userId: admin.id,
391
+ now: NOW(),
392
+ });
393
+
394
+ const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
395
+ const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
396
+ expect(cfSnippet).toBeUndefined();
284
397
  });
285
398
 
286
- test("anonymize without sensitive fields defined is a no-op (everything kept)", async () => {
399
+ test("forget anonymize without sensitive fields defined all customFields kept", async () => {
400
+ await seedForgetUserWithMembership();
401
+ await seedPropertyAnonymizeOverride();
287
402
  const propertyId = "55555555-5555-4000-8000-000000000005";
288
403
  await defineField("nonSensitive", { type: "text" });
289
404
  await createProperty(propertyId, "AllStay");
290
405
  await setField(propertyId, "nonSensitive", "still-here");
291
406
  await stack.eventDispatcher?.runOnce();
292
407
 
293
- await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
408
+ const result = await runForgetCleanup({
409
+ db: stack.db,
410
+ registry: stack.registry,
411
+ now: NOW(),
412
+ });
413
+ expect(result.processedUserIds).toContain(admin.id);
294
414
 
295
415
  const row = await readRow(propertyId);
416
+ expect(row?.["inserted_by_id"]).toBeNull();
296
417
  expect((row?.["custom_fields"] as Record<string, unknown>)?.["nonSensitive"]).toBe(
297
418
  "still-here",
298
419
  );
@@ -11,6 +11,6 @@ export async function selectSerializedFieldDefinition(
11
11
  "SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
12
12
  [entityName, fieldKey, tenantId],
13
13
  );
14
- const first = (rows as ReadonlyArray<Record<string, unknown>>)[0];
14
+ const first = (rows as ReadonlyArray<Record<string, unknown>>)[0]; // @cast-boundary db-row
15
15
  return first ? (first["serialized_field"] ?? null) : null;
16
16
  }
@@ -6,7 +6,7 @@ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string
6
6
  "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1",
7
7
  [tenantId],
8
8
  );
9
- const rows = rowsResult as ReadonlyArray<Record<string, unknown>>;
9
+ const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
10
10
  const first = rows[0];
11
11
  if (!first) return 0;
12
12
  const n = first["n"];
@@ -1,6 +1,7 @@
1
1
  // feature.ts contract tests for file-provider-inmemory.
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
+ import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
4
5
  import { clearStorage, fileProviderInMemoryFeature, listKeys } from "../feature";
5
6
 
6
7
  describe("fileProviderInMemoryFeature — shape", () => {
@@ -33,3 +34,57 @@ describe("listKeys / clearStorage — per-tenant store helpers", () => {
33
34
  expect(() => clearStorage("never-touched")).not.toThrow();
34
35
  });
35
36
  });
37
+
38
+ // extension-usage `options` is engine-payload (unknown) — structurally validate
39
+ // instead of casting blind.
40
+ function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
41
+ return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
42
+ }
43
+
44
+ function inmemoryPlugin(): FileProviderPlugin {
45
+ const options = fileProviderInMemoryFeature.extensionUsages.find(
46
+ (u) => u.extensionName === "fileProvider" && u.entityName === "inmemory",
47
+ )?.options;
48
+ if (!isFileProviderPlugin(options)) {
49
+ throw new Error("file-provider-inmemory: inmemory plugin not registered with a build()");
50
+ }
51
+ return options;
52
+ }
53
+
54
+ const bytes = (s: string) => new TextEncoder().encode(s);
55
+
56
+ describe("file-provider-inmemory — build() + per-tenant store", () => {
57
+ test("build liefert Provider; Write erscheint in listKeys(tenant)", async () => {
58
+ const provider = await inmemoryPlugin().build({}, "tenant-build-1");
59
+ await provider.write("doc.txt", bytes("x"));
60
+ expect(listKeys("tenant-build-1")).toContain("doc.txt");
61
+ clearStorage("tenant-build-1");
62
+ });
63
+
64
+ test("selber Tenant: zwei builds liefern identitätsstabilen Storage (State bleibt)", async () => {
65
+ const a = await inmemoryPlugin().build({}, "tenant-stable");
66
+ await a.write("first.txt", bytes("1"));
67
+ const b = await inmemoryPlugin().build({}, "tenant-stable");
68
+ expect(await b.exists("first.txt")).toBe(true);
69
+ clearStorage("tenant-stable");
70
+ });
71
+
72
+ test("Tenant-Isolation: Write in A erscheint nicht in B", async () => {
73
+ const a = await inmemoryPlugin().build({}, "tenant-iso-a");
74
+ const b = await inmemoryPlugin().build({}, "tenant-iso-b");
75
+ await a.write("only-in-a.txt", bytes("a"));
76
+ expect(listKeys("tenant-iso-a")).toContain("only-in-a.txt");
77
+ expect(listKeys("tenant-iso-b")).not.toContain("only-in-a.txt");
78
+ expect(await b.exists("only-in-a.txt")).toBe(false);
79
+ clearStorage("tenant-iso-a");
80
+ clearStorage("tenant-iso-b");
81
+ });
82
+
83
+ test("clearStorage leert den Tenant-Store", async () => {
84
+ const p = await inmemoryPlugin().build({}, "tenant-clear");
85
+ await p.write("gone.txt", bytes("x"));
86
+ expect(listKeys("tenant-clear")).toHaveLength(1);
87
+ clearStorage("tenant-clear");
88
+ expect(listKeys("tenant-clear")).toEqual([]);
89
+ });
90
+ });
@@ -1,6 +1,7 @@
1
1
  // feature.ts contract tests for file-provider-s3.
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
+ import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
4
5
  import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../feature";
5
6
 
6
7
  describe("fileProviderS3Feature — shape", () => {
@@ -52,3 +53,29 @@ describe("fileProviderS3Feature — plugin-registration", () => {
52
53
  );
53
54
  });
54
55
  });
56
+
57
+ // extension-usage `options` is engine-payload (unknown) — structurally validate.
58
+ function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
59
+ return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
60
+ }
61
+
62
+ function s3Plugin(): FileProviderPlugin {
63
+ const options = fileProviderS3Feature.extensionUsages.find(
64
+ (u) => u.extensionName === "fileProvider" && u.entityName === "s3",
65
+ )?.options;
66
+ if (!isFileProviderPlugin(options)) {
67
+ throw new Error("file-provider-s3: s3 plugin not registered with a build()");
68
+ }
69
+ return options;
70
+ }
71
+
72
+ describe("fileProviderS3Feature — build() guard", () => {
73
+ test("build ohne ctx.config wirft (config-feature nicht gemountet)", async () => {
74
+ // Die tieferen Value-/Secret-Pfade (bucket/region/accessKeyId leer,
75
+ // secret fehlt, Happy-Path) hängen an der echten Config-/Secrets-
76
+ // Pipeline → S3-Integration-Test (setupTestStack + MinIO), nicht hier.
77
+ // Die requireNonEmpty-Werteprüfung selbst ist über
78
+ // foundation-shared/config-helpers abgedeckt.
79
+ await expect(s3Plugin().build({}, "tenant-x")).rejects.toThrow("ctx.config is missing");
80
+ });
81
+ });
@@ -111,17 +111,45 @@ describe("s3-provider (Minio)", () => {
111
111
  await expect(provider.read(key)).rejects.toThrow();
112
112
  });
113
113
 
114
- test("writeStream round-trip via multipart writer preserves bytes", async () => {
115
- // Pinst die idiomatic Bun-S3-writer-Form (write + end, kein manual
116
- // flush). Chunks summieren absichtlich auf > 5 MiB (partSize) UND auf
117
- // einen krummen Rest, damit der multipart-finalizer auch dann greift,
118
- // wenn die Source-Chunks nicht auf die Part-Boundary aufgehen.
114
+ test("writeStream round-trip via multipart writer preserves byte-exact ordering", async () => {
115
+ // Regression guard for the multipart-flush bug: the source chunks are
116
+ // deliberately NON-ALIGNED to the 5 MiB part boundary ([3,3,2] MiB → the
117
+ // internal part split at 5 MiB lands mid-chunk #2). The old
118
+ // `buffered >= STREAM_PART_SIZE` flush would emit a non-final part of an
119
+ // odd size at that boundary; the Bun-writer (partSize) path produces the
120
+ // correct part topology. Either way we verify END-TO-END byte integrity,
121
+ // not just total size + first/last byte.
122
+ //
123
+ // Each chunk carries a chunk-distinct content pattern (incl. a per-chunk
124
+ // marker in byte[0]). A re-order, dropped, or duplicated part therefore
125
+ // changes the readback SHA256 — a same-pattern-per-part test would not
126
+ // catch that. We assert both the SHA256 over the whole stream AND the
127
+ // per-chunk-offset marker bytes.
128
+ //
129
+ // NOTE: MinIO does NOT enforce the AWS `MinPartSize` (5 MiB non-final
130
+ // part) rule, so this test cannot reproduce the genuine S3 `EntityTooSmall`
131
+ // rejection — that needs a manual smoke against AWS/R2. What it DOES guard
132
+ // is byte-ordering/integrity of the multipart round-trip, which is
133
+ // provider-agnostic.
119
134
  const key = uniqueKey("stream-multipart.bin");
120
135
  const partSize = 5 * 1024 * 1024;
121
- const chunk = new Uint8Array(1024 * 1024);
122
- for (let i = 0; i < chunk.length; i++) chunk[i] = i % 251;
123
- const chunks: Uint8Array[] = [];
124
- for (let i = 0; i < 7; i++) chunks.push(chunk);
136
+ const MiB = 1024 * 1024;
137
+ const chunkSizes = [3 * MiB, 3 * MiB, 2 * MiB]; // 8 MiB total, non-aligned to 5 MiB
138
+
139
+ function makeChunk(index: number, size: number): Uint8Array {
140
+ const c = new Uint8Array(size);
141
+ // byte[0] = chunk marker; remaining bytes mix index + position so each
142
+ // chunk's body is distinct (reorder/duplicate changes the hash).
143
+ c[0] = index;
144
+ for (let i = 1; i < size; i++) c[i] = (index * 31 + i) % 251;
145
+ return c;
146
+ }
147
+ const chunks = chunkSizes.map((size, i) => makeChunk(i, size));
148
+
149
+ // Expected hash over the concatenated source.
150
+ const sourceHasher = new Bun.CryptoHasher("sha256");
151
+ for (const c of chunks) sourceHasher.update(c);
152
+ const expectedHash = sourceHasher.digest("hex");
125
153
 
126
154
  if (!provider.writeStream) throw new Error("s3 provider should implement writeStream");
127
155
  await provider.writeStream(
@@ -132,10 +160,24 @@ describe("s3-provider (Minio)", () => {
132
160
  );
133
161
 
134
162
  const readBack = await provider.read(key);
135
- expect(readBack.byteLength).toBe(chunks.length * chunk.length);
163
+ const totalSize = chunkSizes.reduce((a, b) => a + b, 0);
164
+ expect(readBack.byteLength).toBe(totalSize);
136
165
  expect(readBack.byteLength).toBeGreaterThan(partSize);
137
- expect(readBack[0]).toBe(0);
138
- expect(readBack[readBack.byteLength - 1]).toBe(chunk[chunk.length - 1]);
166
+
167
+ // Byte-exact integrity over the full stream — catches any mid-stream
168
+ // corruption / reorder / off-by-part the size+endpoints check would miss.
169
+ const readHasher = new Bun.CryptoHasher("sha256");
170
+ readHasher.update(readBack);
171
+ expect(readHasher.digest("hex")).toBe(expectedHash);
172
+
173
+ // Explicit per-chunk marker check at the expected source offsets — proves
174
+ // the parts landed in order (not just that the bytes are collectively
175
+ // present).
176
+ let offset = 0;
177
+ for (let i = 0; i < chunks.length; i++) {
178
+ expect(readBack[offset]).toBe(i);
179
+ offset += chunkSizes[i] ?? 0;
180
+ }
139
181
  });
140
182
  });
141
183
 
@@ -0,0 +1,72 @@
1
+ // config-helpers Unit-Tests (Phase 1, test-luecken-integration).
2
+ //
3
+ // Pinnt das Verhalten der von ai-/mail-/file-foundation geteilten
4
+ // Narrowing-Helfer — inkl. der non-obvious Grenzfälle: requireDefined
5
+ // narrowt NUR `undefined` (nicht falsy), und requireNonEmpty hat zwei
6
+ // getrennte Fehlerpfade (undefined vs leer), die über die Error-Message
7
+ // unterschieden werden.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { requireDefined, requireNonEmpty } from "../config-helpers";
11
+
12
+ describe("requireDefined", () => {
13
+ test("undefined → wirft mit featureName + label + Misconfig-Hinweis", () => {
14
+ expect(() => requireDefined(undefined, "ai-foundation", "apiKey")).toThrow(
15
+ "ai-foundation: 'apiKey' config key resolved to undefined — registry misconfigured (no value + no default)",
16
+ );
17
+ });
18
+
19
+ test("defined Wert → unverändert zurück", () => {
20
+ expect(requireDefined("sk-123", "ai-foundation", "apiKey")).toBe("sk-123");
21
+ });
22
+
23
+ test("falsy-aber-defined Werte passieren (Check ist === undefined, nicht falsy)", () => {
24
+ // Würde hier ein falsy-Check stehen, bräche ein numerischer Key mit Wert 0
25
+ // oder ein leerer-String-Default. requireNonEmpty ist der strengere Helfer.
26
+ expect(requireDefined(0, "f", "n")).toBe(0);
27
+ expect(requireDefined("", "f", "n")).toBe("");
28
+ expect(requireDefined(false, "f", "n")).toBe(false);
29
+ expect(requireDefined(null, "f", "n")).toBeNull();
30
+ });
31
+
32
+ test("Objekt-Wert → identische Referenz zurück (generischer Typ erhalten)", () => {
33
+ const cfg = { host: "smtp.example.com", port: 587 };
34
+ expect(requireDefined(cfg, "mail-foundation", "smtp")).toBe(cfg);
35
+ });
36
+ });
37
+
38
+ describe("requireNonEmpty", () => {
39
+ test("undefined → wirft die requireDefined-Message (delegiert, NICHT empty-Pfad)", () => {
40
+ expect(() => requireNonEmpty(undefined, "mail-foundation", "host")).toThrow(
41
+ "config key resolved to undefined",
42
+ );
43
+ });
44
+
45
+ test("leerer String → wirft empty-Message mit Default-uiHint", () => {
46
+ expect(() => requireNonEmpty("", "file-foundation", "bucket")).toThrow(
47
+ "file-foundation: 'bucket' is empty — tenant must configure it before use. Set via tenant-admin UI or seed-handler.",
48
+ );
49
+ });
50
+
51
+ test("leerer String mit custom uiHint → Hint landet in der Message", () => {
52
+ expect(() =>
53
+ requireNonEmpty("", "ai-foundation", "model", "Choose a model in Settings → AI."),
54
+ ).toThrow("is empty — tenant must configure it before use. Choose a model in Settings → AI.");
55
+ });
56
+
57
+ test("non-empty Wert → unverändert zurück", () => {
58
+ expect(requireNonEmpty("smtp.example.com", "mail-foundation", "host")).toBe("smtp.example.com");
59
+ });
60
+
61
+ test("reiner Whitespace → wirft empty-Message (getrimmt, gilt als leer)", () => {
62
+ expect(() => requireNonEmpty(" ", "mail-foundation", "host")).toThrow(
63
+ "mail-foundation: 'host' is empty — tenant must configure it before use.",
64
+ );
65
+ });
66
+
67
+ test("umgebender Whitespace wird vom Rückgabewert getrimmt", () => {
68
+ expect(requireNonEmpty(" smtp.example.com ", "mail-foundation", "host")).toBe(
69
+ "smtp.example.com",
70
+ );
71
+ });
72
+ });
@@ -50,6 +50,10 @@ export function requireDefined<T>(value: T | undefined, featureName: string, lab
50
50
  * Typical use: SMTP host, S3 bucket, model id — values without which the
51
51
  * downstream SDK would 400 with a cryptic message. The clearer "tenant
52
52
  * must configure X via tenant-admin UI" lands at the call-site instead.
53
+ *
54
+ * Whitespace is trimmed: a whitespace-only value counts as empty, and the
55
+ * returned string has surrounding whitespace removed — so a stray " host "
56
+ * never reaches the SDK as-is.
53
57
  */
54
58
  export function requireNonEmpty(
55
59
  value: string | undefined,
@@ -57,11 +61,11 @@ export function requireNonEmpty(
57
61
  label: string,
58
62
  uiHint = "Set via tenant-admin UI or seed-handler.",
59
63
  ): string {
60
- const defined = requireDefined(value, featureName, label);
61
- if (defined.length === 0) {
64
+ const trimmed = requireDefined(value, featureName, label).trim();
65
+ if (trimmed.length === 0) {
62
66
  throw new Error(
63
67
  `${featureName}: '${label}' is empty — tenant must configure it before use. ${uiHint}`,
64
68
  );
65
69
  }
66
- return defined;
70
+ return trimmed;
67
71
  }
@@ -223,6 +223,61 @@ describe("scenario 6: access control", () => {
223
223
  expectErrorIncludes(error, "access_denied");
224
224
  });
225
225
 
226
+ test("TenantAdmin (ohne SystemAdmin) cannot create a tier-assignment — Self-Upgrade-Schutz", async () => {
227
+ // Tier-Wechsel ist Plattform-/Billing-Hoheit: ein Tenant-Admin darf
228
+ // seinen eigenen Tier NICHT setzen (sonst Gratis-Self-Upgrade).
229
+ const tenantAdmin = createTestUser({
230
+ id: 310,
231
+ tenantId: testTenantId(310),
232
+ roles: ["TenantAdmin"],
233
+ });
234
+
235
+ const error = await stack.http.writeErr(
236
+ TierEngineHandlers.create,
237
+ { tier: "pro" },
238
+ tenantAdmin,
239
+ );
240
+
241
+ expectErrorIncludes(error, "access_denied");
242
+ });
243
+
244
+ test("TenantAdmin cannot update a tier-assignment", async () => {
245
+ const sysadmin = createTestUser({
246
+ id: 311,
247
+ tenantId: testTenantId(311),
248
+ roles: ["SystemAdmin"],
249
+ });
250
+ const created = await stack.http.writeOk(TierEngineHandlers.create, { tier: "free" }, sysadmin);
251
+ const id = (created!["data"] as Record<string, unknown>)["id"] as string;
252
+
253
+ // Selber Tenant, aber reiner TenantAdmin → darf den Tier nicht ändern.
254
+ const tenantAdmin = createTestUser({
255
+ id: 312,
256
+ tenantId: testTenantId(311),
257
+ roles: ["TenantAdmin"],
258
+ });
259
+
260
+ const error = await stack.http.writeErr(
261
+ TierEngineHandlers.update,
262
+ { id, version: 1, changes: { tier: "agency" } },
263
+ tenantAdmin,
264
+ );
265
+
266
+ expectErrorIncludes(error, "access_denied");
267
+ });
268
+
269
+ test("SystemAdmin (ohne TenantAdmin) CAN create a tier-assignment", async () => {
270
+ const sysadmin = createTestUser({
271
+ id: 313,
272
+ tenantId: testTenantId(313),
273
+ roles: ["SystemAdmin"],
274
+ });
275
+
276
+ const result = await stack.http.writeOk(TierEngineHandlers.create, { tier: "team" }, sysadmin);
277
+
278
+ expect((result!["data"] as Record<string, unknown>)["tier"]).toBe("team");
279
+ });
280
+
226
281
  test("query handlers carry the admin-only access rule (config-level check)", () => {
227
282
  // Read-access is enforced by the same role-rule set on the query handler.
228
283
  // We assert the rule is registered correctly — covers regression when
@@ -81,6 +81,12 @@ const tierAssignmentExecutor = createEventStoreExecutor(tierAssignmentTable, tie
81
81
  });
82
82
 
83
83
  const adminAccess = { access: { roles: ["TenantAdmin", "SystemAdmin"] } } as const;
84
+ // Tier-Wechsel ist Plattform-/Billing-Hoheit — ein Tenant-Admin darf den
85
+ // eigenen Tier NIE setzen (sonst Gratis-Self-Upgrade). Daher sind die
86
+ // Writes SystemAdmin-only; Reads (list, get-active-tier) bleiben
87
+ // TenantAdmin-sichtbar. Auto-default-Hook + Billing schreiben als System,
88
+ // hängen also nicht an diesem Handler-Access.
89
+ const writeAccess = { access: { roles: ["SystemAdmin"] } } as const;
84
90
 
85
91
  /**
86
92
  * Options for createTierEngineFeature. Both fields optional — wenn beide
@@ -167,8 +173,8 @@ export function createTierEngineFeature<
167
173
  r.entity("tier-assignment", tierAssignmentEntity);
168
174
 
169
175
  // Standard-CRUD via Helper.
170
- r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
171
- r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
176
+ r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, writeAccess));
177
+ r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, writeAccess));
172
178
 
173
179
  // Reads.
174
180
  r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, adminAccess));