@cosmicdrift/kumiko-bundled-features 0.165.2 → 0.165.4

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.
Files changed (109) hide show
  1. package/package.json +7 -7
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +1 -1
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +80 -66
  4. package/src/admin-shell/changes.json +8 -0
  5. package/src/agent-tools/changes.json +1 -0
  6. package/src/audit/__tests__/audit.integration.test.ts +5 -2
  7. package/src/audit/changes.json +8 -0
  8. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -0
  9. package/src/auth-email-password/changes.json +26 -0
  10. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +3 -11
  11. package/src/auth-email-password/web/__tests__/fetch-mock.ts +14 -0
  12. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +3 -11
  13. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +3 -11
  14. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +3 -11
  15. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +3 -11
  16. package/src/auth-foundation/changes.json +8 -0
  17. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +6 -6
  18. package/src/auth-mfa/changes.json +38 -0
  19. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +3 -11
  20. package/src/auth-mfa-user-data/changes.json +1 -0
  21. package/src/billing-foundation/changes.json +1 -0
  22. package/src/cap-counter/changes.json +1 -0
  23. package/src/channel-email/changes.json +1 -0
  24. package/src/channel-in-app/changes.json +1 -0
  25. package/src/channel-push/changes.json +1 -0
  26. package/src/compliance-profiles/changes.json +1 -0
  27. package/src/config/changes.json +9 -0
  28. package/src/crypto-shredding/changes.json +14 -0
  29. package/src/crypto-shredding/handlers/forget-subject.write.ts +13 -0
  30. package/src/custom-fields/changes.json +8 -0
  31. package/src/data-retention/changes.json +8 -0
  32. package/src/delivery/changes.json +8 -0
  33. package/src/document-ingest-foundation/changes.json +1 -0
  34. package/src/feature-toggles/changes.json +26 -0
  35. package/src/file-foundation/changes.json +1 -0
  36. package/src/file-provider-inmemory/changes.json +1 -0
  37. package/src/file-provider-s3/changes.json +1 -0
  38. package/src/file-provider-s3-env/changes.json +1 -0
  39. package/src/files/changes.json +1 -0
  40. package/src/files-provider-s3/changes.json +1 -0
  41. package/src/folders/changes.json +1 -0
  42. package/src/folders-user-data/changes.json +1 -0
  43. package/src/foundation-shared/changes.json +1 -0
  44. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +3 -1
  45. package/src/inbound-mail-foundation/changes.json +8 -0
  46. package/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  47. package/src/inbound-provider-imap/changes.json +1 -0
  48. package/src/inbound-provider-inmemory/changes.json +1 -0
  49. package/src/jobs/__tests__/jobs-catalog.integration.test.ts +159 -0
  50. package/src/jobs/__tests__/jobs-screens.boot.test.ts +1 -0
  51. package/src/jobs/__tests__/jobs-security.integration.test.ts +4 -2
  52. package/src/jobs/changes.json +8 -0
  53. package/src/jobs/constants.ts +2 -0
  54. package/src/jobs/feature.ts +3 -1
  55. package/src/jobs/handlers/catalog.query.ts +43 -0
  56. package/src/jobs/handlers/trigger.write.ts +26 -2
  57. package/src/jobs/i18n.ts +33 -0
  58. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +223 -0
  59. package/src/jobs/web/job-runs-screen.tsx +218 -84
  60. package/src/ledger/changes.json +1 -0
  61. package/src/legal-pages/changes.json +1 -0
  62. package/src/mail-foundation/changes.json +1 -0
  63. package/src/mail-transport-inmemory/changes.json +1 -0
  64. package/src/mail-transport-smtp/changes.json +1 -0
  65. package/src/managed-pages/changes.json +20 -0
  66. package/src/page-render/changes.json +14 -0
  67. package/src/personal-access-tokens/changes.json +8 -0
  68. package/src/presets/changes.json +1 -0
  69. package/src/presets/dsgvo-self-service.ts +1 -1
  70. package/src/rate-limiting/changes.json +1 -0
  71. package/src/readiness/changes.json +1 -0
  72. package/src/renderer-foundation/changes.json +1 -0
  73. package/src/renderer-simple/changes.json +1 -0
  74. package/src/secrets/changes.json +1 -0
  75. package/src/seo/changes.json +20 -0
  76. package/src/sessions/changes.json +8 -0
  77. package/src/sessions/handlers/revoke-all-for-user.write.ts +9 -1
  78. package/src/shared/changes.json +1 -0
  79. package/src/step-dispatcher/changes.json +1 -0
  80. package/src/subscription-mollie/changes.json +1 -0
  81. package/src/subscription-stripe/changes.json +1 -0
  82. package/src/tags/changes.json +1 -0
  83. package/src/template-resolver/changes.json +1 -0
  84. package/src/tenant/changes.json +27 -0
  85. package/src/tenant-lifecycle/changes.json +1 -0
  86. package/src/tenant-settings/changes.json +1 -0
  87. package/src/text-content/changes.json +14 -0
  88. package/src/tier-engine/changes.json +1 -0
  89. package/src/user/changes.json +8 -0
  90. package/src/user/schema/user.ts +8 -5
  91. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +7 -3
  92. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +0 -1
  93. package/src/user-data-rights/__tests__/download.integration.test.ts +0 -1
  94. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +0 -1
  95. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +0 -1
  96. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +0 -1
  97. package/src/user-data-rights/__tests__/request-export.integration.test.ts +0 -1
  98. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +0 -2
  99. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +0 -1
  100. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +0 -1
  101. package/src/user-data-rights/changes.json +27 -0
  102. package/src/user-data-rights/feature.ts +7 -6
  103. package/src/user-data-rights/handlers/lift-restriction.write.ts +4 -20
  104. package/src/user-data-rights/handlers/restrict-account.write.ts +4 -20
  105. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -0
  106. package/src/user-data-rights/lib/deny-if-target-outside-admin-tenant.ts +32 -0
  107. package/src/user-data-rights/run-forget-cleanup.ts +23 -0
  108. package/src/user-data-rights-defaults/changes.json +8 -0
  109. package/src/user-profile/changes.json +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.165.2",
3
+ "version": "0.165.4",
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,11 +119,11 @@
119
119
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
120
120
  },
121
121
  "dependencies": {
122
- "@cosmicdrift/kumiko-dispatcher-live": "0.165.2",
123
- "@cosmicdrift/kumiko-framework": "0.165.2",
124
- "@cosmicdrift/kumiko-headless": "0.165.2",
125
- "@cosmicdrift/kumiko-renderer": "0.165.2",
126
- "@cosmicdrift/kumiko-renderer-web": "0.165.2",
122
+ "@cosmicdrift/kumiko-dispatcher-live": "0.165.4",
123
+ "@cosmicdrift/kumiko-framework": "0.165.4",
124
+ "@cosmicdrift/kumiko-headless": "0.165.4",
125
+ "@cosmicdrift/kumiko-renderer": "0.165.4",
126
+ "@cosmicdrift/kumiko-renderer-web": "0.165.4",
127
127
  "@mollie/api-client": "^4.5.0",
128
128
  "imapflow": "^1.3.3",
129
129
  "mailparser": "^3.9.8",
@@ -148,7 +148,7 @@
148
148
  "LICENSE"
149
149
  ],
150
150
  "peerDependencies": {
151
- "@cosmicdrift/kumiko-types": "^0.165.2"
151
+ "@cosmicdrift/kumiko-types": "^0.165.4"
152
152
  },
153
153
  "devDependencies": {
154
154
  "@testing-library/user-event": "^14.6.1",
@@ -72,7 +72,7 @@ let invalidated: string[];
72
72
  let unsubscribe: (() => void) | undefined;
73
73
 
74
74
  function trackInvalidation(userId: string): void {
75
- unsubscribe = stack.sseBroker.subscribeAccessInvalidation(userId, () => {
75
+ unsubscribe = stack.sseBroker.subscribeAccessInvalidation?.(userId, () => {
76
76
  invalidated.push(userId);
77
77
  });
78
78
  }
@@ -53,25 +53,28 @@ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
53
53
  const encryptionKey = randomBytes(32).toString("base64");
54
54
  const TENANT = testTenantId(1);
55
55
 
56
- // Gate between chunk 0 and chunk 1 so the test can revoke + drain the event
57
- // consumer before the stream's next pull otherwise an eager pump would
58
- // race past the invalidation window.
59
- let releaseNextChunk: (() => void) | undefined;
60
- let nextChunkGate: Promise<void>;
61
-
62
- function resetGate(): void {
63
- nextChunkGate = new Promise<void>((resolve) => {
64
- releaseNextChunk = resolve;
56
+ // Per-stream gate (gateId in payload) so parallel/second streams don't share
57
+ // one module-level Promise Map keyed by the id the test picks.
58
+ const chunkGates = new Map<string, { promise: Promise<void>; release: () => void }>();
59
+
60
+ function createGate(gateId: string): () => void {
61
+ let release!: () => void;
62
+ const promise = new Promise<void>((resolve) => {
63
+ release = resolve;
65
64
  });
65
+ chunkGates.set(gateId, { promise, release });
66
+ return release;
66
67
  }
67
68
 
68
69
  const streamProbeFeature = defineFeature("stream-probe", (r) => {
69
70
  r.streamHandler(
70
71
  "probe:tail",
71
- z.object({}).optional(),
72
- async function* () {
72
+ z.object({ gateId: z.string().min(1) }),
73
+ async function* (event) {
73
74
  yield { phase: "open" as const };
74
- await nextChunkGate;
75
+ const gate = chunkGates.get(event.payload.gateId);
76
+ if (!gate) throw new Error(`missing gate ${event.payload.gateId}`);
77
+ await gate.promise;
75
78
  // Reached only if mid-stream access check failed to cut the generator.
76
79
  yield { phase: "should-not-reach-client" as const };
77
80
  },
@@ -144,62 +147,73 @@ beforeEach(async () => {
144
147
  userSessionTable,
145
148
  eventsTable,
146
149
  ]);
147
- resetGate();
150
+ chunkGates.clear();
148
151
  });
149
152
 
150
- describe("access-invalidation mid-stream SSE teardown (#1561)", () => {
151
- test("session revoke mid-stream terminates the open HTTP SSE stream", async () => {
152
- await h.seedUser("stream-revoke@example.com", "pw-long-enough");
153
- const { token, sid } = await h.login("stream-revoke@example.com", "pw-long-enough");
154
-
155
- const { fetch, csrfToken } = buildLiveFetch(token);
156
- const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
157
-
158
- const iter = dispatcher
159
- .stream<{ phase: string }>("stream-probe:stream:probe:tail", {})
160
- [Symbol.asyncIterator]();
161
-
162
- const first = await iter.next();
163
- expect(first.done).toBe(false);
164
- expect(first.value).toEqual({ phase: "open" });
153
+ async function openProbeStream(token: string): Promise<{
154
+ iter: AsyncIterator<{ phase: string }>;
155
+ release: () => void;
156
+ }> {
157
+ const gateId = generateToken();
158
+ const release = createGate(gateId);
159
+ const { fetch, csrfToken } = buildLiveFetch(token);
160
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
161
+ const iter = dispatcher
162
+ .stream<{ phase: string }>("stream-probe:stream:probe:tail", { gateId })
163
+ [Symbol.asyncIterator]();
164
+ const first = await iter.next();
165
+ expect(first.done).toBe(false);
166
+ expect(first.value).toEqual({ phase: "open" });
167
+ return { iter, release };
168
+ }
165
169
 
166
- const revokeRes = await h.authedPost("/api/write", token, {
167
- type: SessionHandlers.revoke,
168
- payload: { id: sid },
170
+ describe("access-invalidation mid-stream SSE teardown (#1561)", () => {
171
+ const cases = [
172
+ {
173
+ name: "session revoke mid-stream terminates the open HTTP SSE stream",
174
+ setup: async () => {
175
+ await h.seedUser("stream-revoke@example.com", "pw-long-enough");
176
+ const { token, sid } = await h.login("stream-revoke@example.com", "pw-long-enough");
177
+ return {
178
+ token,
179
+ revoke: async () => {
180
+ const revokeRes = await h.authedPost("/api/write", token, {
181
+ type: SessionHandlers.revoke,
182
+ payload: { id: sid },
183
+ });
184
+ expect(revokeRes.status).toBe(200);
185
+ },
186
+ };
187
+ },
188
+ },
189
+ {
190
+ name: "tenant role strip mid-stream terminates the open HTTP SSE stream",
191
+ setup: async () => {
192
+ const { userId } = await h.seedUser("stream-roles@example.com", "pw-long-enough");
193
+ const { token } = await h.login("stream-roles@example.com", "pw-long-enough");
194
+ return {
195
+ token,
196
+ revoke: async () => {
197
+ const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
198
+ await stack.http.writeOk(
199
+ TenantHandlers.updateMemberRoles,
200
+ { userId, tenantId: TENANT, roles: ["Admin"] },
201
+ admin,
202
+ );
203
+ },
204
+ };
205
+ },
206
+ },
207
+ ] as const;
208
+
209
+ for (const c of cases) {
210
+ test(c.name, async () => {
211
+ const { token, revoke } = await c.setup();
212
+ const { iter, release } = await openProbeStream(token);
213
+ await revoke();
214
+ await stack.eventDispatcher?.runOnce();
215
+ release();
216
+ await expect(iter.next()).rejects.toMatchObject({ code: "access_denied" });
169
217
  });
170
- expect(revokeRes.status).toBe(200);
171
- await stack.eventDispatcher?.runOnce();
172
-
173
- releaseNextChunk?.();
174
-
175
- await expect(iter.next()).rejects.toMatchObject({ code: "access_denied" });
176
- });
177
-
178
- test("tenant role strip mid-stream terminates the open HTTP SSE stream", async () => {
179
- const { userId } = await h.seedUser("stream-roles@example.com", "pw-long-enough");
180
- const { token } = await h.login("stream-roles@example.com", "pw-long-enough");
181
-
182
- const { fetch, csrfToken } = buildLiveFetch(token);
183
- const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
184
-
185
- const iter = dispatcher
186
- .stream<{ phase: string }>("stream-probe:stream:probe:tail", {})
187
- [Symbol.asyncIterator]();
188
-
189
- const first = await iter.next();
190
- expect(first.done).toBe(false);
191
- expect(first.value).toEqual({ phase: "open" });
192
-
193
- const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
194
- await stack.http.writeOk(
195
- TenantHandlers.updateMemberRoles,
196
- { userId, tenantId: TENANT, roles: ["Admin"] },
197
- admin,
198
- );
199
- await stack.eventDispatcher?.runOnce();
200
-
201
- releaseNextChunk?.();
202
-
203
- await expect(iter.next()).rejects.toMatchObject({ code: "access_denied" });
204
- });
218
+ }
205
219
  });
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.125.0",
4
+ "type": "improvement",
5
+ "title": "admin-shell: neues bundled-feature für rollen-gated Tenant- und Platform-Workspaces mit O…",
6
+ "detail": "admin-shell: neues bundled-feature für rollen-gated Tenant- und Platform-Workspaces mit Overview-Home-Screens, Nav-Icons und Server-i18n. Komponiert Screens aus `tenant`, `audit`, `jobs` und optional `tier-engine` — mount nach diesen Features. Overview-Queries laufen über eine fest kodierte Allowlist pro Workspace, um versehentliche Cross-Workspace-Datenzugriffe zu verhindern."
7
+ }
8
+ ]
@@ -0,0 +1 @@
1
+ []
@@ -212,7 +212,7 @@ describe("audit: list query", () => {
212
212
  ];
213
213
  for (const [name, createdAt] of stamps) {
214
214
  const id = byName.get(name);
215
- expect(id).toBeDefined();
215
+ if (!id) throw new Error(`missing event for ${name}`);
216
216
  await asRawClient(stack.db).unsafe(
217
217
  `UPDATE kumiko_events SET created_at = $1::timestamptz WHERE id = $2::bigint`,
218
218
  [createdAt, id],
@@ -244,7 +244,10 @@ describe("audit: list query", () => {
244
244
  admin,
245
245
  );
246
246
  expect(untilBefore.rows).toHaveLength(1);
247
- expect((untilBefore.rows[0]?.payload as { name?: string }).name).toBe("before-window");
247
+ const untilRow = untilBefore.rows[0];
248
+ expect(untilRow).toBeDefined();
249
+ if (!untilRow) throw new Error("expected untilBefore row");
250
+ expect((untilRow.payload as { name?: string }).name).toBe("before-window");
248
251
  });
249
252
 
250
253
  test("rejects inverted from/to range with validation_error", async () => {
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.127.0",
4
+ "type": "improvement",
5
+ "title": "Harden admin operator UI: stricter boot i18n/entityList validation, job run logger wiring…",
6
+ "detail": "Harden admin operator UI: stricter boot i18n/entityList validation, job run logger wiring, audit/job filters, shell breadcrumbs, and bundled entityList/i18n standards."
7
+ }
8
+ ]
@@ -133,6 +133,9 @@ beforeAll(async () => {
133
133
  password: "warmup-password-not-asserted-on",
134
134
  tenants: [],
135
135
  });
136
+ // Pins the authHeader sid-cache key: one create for the seed actor, then
137
+ // we reset. If the cache key drifts, this fails instead of leaking into tests.
138
+ expect(store.created).toHaveLength(1);
136
139
  store.live.clear();
137
140
  store.created.length = 0;
138
141
  store.revoked.length = 0;
@@ -0,0 +1,26 @@
1
+ [
2
+ {
3
+ "version": "0.162.0",
4
+ "type": "improvement",
5
+ "title": "auth-email-password: runtime toggle for self-registration.",
6
+ "detail": "auth-email-password: runtime toggle for self-registration. A new handler-less companion feature (`auth-self-registration`, toggleable, default on) lets an operator flip self-signup off at runtime via feature-toggles without redeploying. `signup-request` silently no-ops when off (matching its always-200 anti-enumeration contract), and a new anonymous-readable query (`auth-email-password:query:signup-registration-status`) lets the public signup page hide its own link/form."
7
+ },
8
+ {
9
+ "version": "0.154.1",
10
+ "type": "fix",
11
+ "title": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled…",
12
+ "detail": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled-features/auth-email-password/web`. Every other auth screen (ForgotPasswordScreen, SignupScreen, ResetPasswordScreen, …) was already exported from the barrel with its props type; `LoginScreen` was missed. `makeAuthGate`'s second parameter is typed `LoginScreenProps`, so consumers passing a typed `loginScreenProps` override (e.g. `@cosmicdriftgamestudio/kumiko-designer`) couldn't import the type at all. - @cosmicdrift/kumiko-framework@0.154.1 - @cosmicdrift/kumiko-dispatcher-live@0.154.1 - @cosmicdrift/kumiko-headless@0.154.1 - @cosmicdrift/kumiko-renderer@0.154.1 - @cosmicdrift/kumiko-renderer-web@0.154.1"
13
+ },
14
+ {
15
+ "version": "0.151.0",
16
+ "type": "improvement",
17
+ "title": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly — u…",
18
+ "detail": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly — use the new `createLoginRoute({ loginScreenProps, mfaVerifyScreen, unaffected (they already build on the same logic internally now). Why: a raw `<LoginScreen />` render has no MFA-challenge handling unless the caller remembers to hand-wire `onMfaChallenge` + swap in a verify screen itself — exactly how kumiko-framework#266's login-time MFA step went missing in a real app's standalone apex/marketing login route (it renders outside `emailPasswordClient`'s own gate, which already handled this correctly). `createLoginRoute` is the one place this logic lives now, for both the gated and standalone cases — there's no lower-level piece left to misuse. The `apex-surface-auth` recipe is updated to match."
19
+ },
20
+ {
21
+ "version": "0.131.0",
22
+ "type": "improvement",
23
+ "title": "App-Mounting 2.0 Säule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, Secti…",
24
+ "detail": "App-Mounting 2.0 Säule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, SectionCard, StatusBadge, ProgressBar, CollapsibleSection, DetailList, ModeSwitch, StatusBarChart, TimeseriesChart, EmptyState/LoadingState/ErrorState, QueryTable) + Status-Farb-Tokens (--color-status-\\*). Neue Hooks useMutation + useDisclosure. Neues Core-Primitive Link (default/button/muted), Button-Variant \"link\", Text-Variant \"muted\"; auth-email-password nutzt sie (authButtonClass/authMutedLinkClass entfernt)."
25
+ }
26
+ ]
@@ -1,18 +1,10 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
  import { screen, waitFor } from "@testing-library/react";
3
3
  import { ConfirmAccountUnlockScreen } from "../confirm-account-unlock-screen";
4
+ import { installFetchMock } from "./fetch-mock";
4
5
  import { renderWithProviders } from "./test-utils";
5
6
 
6
- const originalFetch = globalThis.fetch;
7
-
8
- beforeEach(() => {
9
- globalThis.fetch = mock(
10
- async () => new Response(null, { status: 200 }),
11
- ) as unknown as typeof fetch;
12
- });
13
- afterEach(() => {
14
- globalThis.fetch = originalFetch;
15
- });
7
+ installFetchMock();
16
8
 
17
9
  describe("ConfirmAccountUnlockScreen", () => {
18
10
  test("ohne Token → missing-token page", () => {
@@ -0,0 +1,14 @@
1
+ import { afterEach, beforeEach, mock } from "bun:test";
2
+
3
+ /** Install a default 200-OK fetch mock; restores the pristine global after each test. */
4
+ export function installFetchMock(): void {
5
+ const pristine = globalThis.fetch;
6
+ beforeEach(() => {
7
+ globalThis.fetch = mock(
8
+ async () => new Response(null, { status: 200 }),
9
+ ) as unknown as typeof fetch;
10
+ });
11
+ afterEach(() => {
12
+ globalThis.fetch = pristine;
13
+ });
14
+ }
@@ -1,18 +1,10 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
  import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { ForgotPasswordScreen } from "../forgot-password-screen";
4
+ import { installFetchMock } from "./fetch-mock";
4
5
  import { renderWithProviders } from "./test-utils";
5
6
 
6
- const originalFetch = globalThis.fetch;
7
-
8
- beforeEach(() => {
9
- globalThis.fetch = mock(
10
- async () => new Response(null, { status: 200 }),
11
- ) as unknown as typeof fetch;
12
- });
13
- afterEach(() => {
14
- globalThis.fetch = originalFetch;
15
- });
7
+ installFetchMock();
16
8
 
17
9
  describe("ForgotPasswordScreen", () => {
18
10
  test("rendert title + email-input + submit-button (de)", () => {
@@ -1,18 +1,10 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
  import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { RequestAccountUnlockScreen } from "../request-account-unlock-screen";
4
+ import { installFetchMock } from "./fetch-mock";
4
5
  import { renderWithProviders } from "./test-utils";
5
6
 
6
- const originalFetch = globalThis.fetch;
7
-
8
- beforeEach(() => {
9
- globalThis.fetch = mock(
10
- async () => new Response(null, { status: 200 }),
11
- ) as unknown as typeof fetch;
12
- });
13
- afterEach(() => {
14
- globalThis.fetch = originalFetch;
15
- });
7
+ installFetchMock();
16
8
 
17
9
  describe("RequestAccountUnlockScreen", () => {
18
10
  test("renders title + email input + submit (de)", () => {
@@ -1,18 +1,10 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
  import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { ResetPasswordScreen } from "../reset-password-screen";
4
+ import { installFetchMock } from "./fetch-mock";
4
5
  import { renderWithProviders } from "./test-utils";
5
6
 
6
- const originalFetch = globalThis.fetch;
7
-
8
- beforeEach(() => {
9
- globalThis.fetch = mock(
10
- async () => new Response(null, { status: 200 }),
11
- ) as unknown as typeof fetch;
12
- });
13
- afterEach(() => {
14
- globalThis.fetch = originalFetch;
15
- });
7
+ installFetchMock();
16
8
 
17
9
  describe("ResetPasswordScreen", () => {
18
10
  test("ohne Token in URL UND ohne token-Prop → missing-token-Page", () => {
@@ -1,18 +1,10 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
  import { screen, waitFor } from "@testing-library/react";
3
3
  import { VerifyEmailScreen } from "../verify-email-screen";
4
+ import { installFetchMock } from "./fetch-mock";
4
5
  import { renderWithProviders } from "./test-utils";
5
6
 
6
- const originalFetch = globalThis.fetch;
7
-
8
- beforeEach(() => {
9
- globalThis.fetch = mock(
10
- async () => new Response(null, { status: 200 }),
11
- ) as unknown as typeof fetch;
12
- });
13
- afterEach(() => {
14
- globalThis.fetch = originalFetch;
15
- });
7
+ installFetchMock();
16
8
 
17
9
  describe("VerifyEmailScreen", () => {
18
10
  test("ohne Token → missing-token-Page", () => {
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.165.0",
4
+ "type": "fix",
5
+ "title": "Allow zero tokenVerifier when sessionStore present",
6
+ "detail": "Auth foundation now accepts zero tokenVerifier when a sessionStore is configured — prevents crash in session-store-only setups."
7
+ }
8
+ ]
@@ -92,10 +92,10 @@ type RawUserMfaRow = {
92
92
  version: number;
93
93
  };
94
94
 
95
- async function readRawRow(userId?: string): Promise<RawUserMfaRow> {
96
- const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, userId ? { userId } : {});
95
+ async function readRawRow(userId: string): Promise<RawUserMfaRow> {
96
+ const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, { userId });
97
97
  const row = rows[0];
98
- if (!row) throw new Error("no user-mfa row");
98
+ if (!row) throw new Error(`no user-mfa row for ${userId}`);
99
99
  return row;
100
100
  }
101
101
 
@@ -132,7 +132,7 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
132
132
  user,
133
133
  );
134
134
 
135
- const beforeRow = await readRawRow();
135
+ const beforeRow = await readRawRow(user.id);
136
136
  expect(JSON.parse(beforeRow.totpSecret).kekVersion).toBe(1);
137
137
 
138
138
  // Simulate "ops added a new master key version and flipped CURRENT=2" —
@@ -149,7 +149,7 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
149
149
 
150
150
  await mfaReencryptJob({}, jobCtx());
151
151
 
152
- const afterJobRow = await readRawRow();
152
+ const afterJobRow = await readRawRow(user.id);
153
153
  expect(JSON.parse(afterJobRow.totpSecret).kekVersion).toBe(2);
154
154
 
155
155
  // The regression guard: rebuild the projection from scratch (replays
@@ -161,7 +161,7 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
161
161
  registry: stack.registry,
162
162
  });
163
163
 
164
- const afterRebuildRow = await readRawRow();
164
+ const afterRebuildRow = await readRawRow(user.id);
165
165
  expect(JSON.parse(afterRebuildRow.totpSecret).kekVersion).toBe(2);
166
166
 
167
167
  // Decrypt-level proof, not just the version tag: a valid TOTP code
@@ -0,0 +1,38 @@
1
+ [
2
+ {
3
+ "version": "0.165.0",
4
+ "type": "fix",
5
+ "title": "KEK reencrypt job: rejects unrecognized envelopes",
6
+ "detail": "Reencrypt job now explicitly fails on non-envelope rows instead of silently passing through."
7
+ },
8
+ {
9
+ "version": "0.165.0",
10
+ "type": "fix",
11
+ "title": "MFA: fail-closed when ctx.redis is missing",
12
+ "detail": "Verify/enable-confirm operations now fail when Redis is unreachable instead of silently accepting."
13
+ },
14
+ {
15
+ "version": "0.150.0",
16
+ "type": "fix",
17
+ "title": "Fix `auth-mfa`'s two-step verify + enroll flow re-checking stale state and skipping repla…",
18
+ "detail": "Fix `auth-mfa`'s two-step verify + enroll flow re-checking stale state and skipping replay protection that `login.write.ts` already enforces: - `verify.write.ts` minted a session from a challenge token alone, without re-checking account status or tenant membership. A user restricted/deleted, or removed from the tenant, in the ~10-minute window between password login and MFA verify still got a full session — membership loss even silently fell back to global-only roles instead of refusing. Both gates now mirror `login.write.ts`. - `enable-confirm.write.ts` never burned the setup token on success: the same token could re-enable MFA with an old secret after a `disable` (replay), and two parallel confirms both hit the executor instead of the second seeing `mfa_already_enabled`. The token is now single-use, the same as `login.write.ts`'s magic-link tokens. - `auth-mfa` was missing `r.requires(\"tenant\")` despite `verify.write.ts` dispatching `tenant:query:memberships` — a mount without `tenant` now fails at boot instead of 500ing on first login. - Recovery codes are now normalized (uppercased, punctuation-stripped) before hashing and verifying, so a code retyped lowercase or without the dash still matches — this changes the stored hash format, safe because `auth-mfa` has no deployed consumer with enrolled users yet. Also fixes `samples/apps/user-data-rights-demo`'s forget-cleanup test, which asserted on the manual `runForgetCleanup` helper instead of driving the actually-registered cron job through its real `JobContext` wrapper."
19
+ },
20
+ {
21
+ "version": "0.149.1",
22
+ "type": "fix",
23
+ "title": "Fix `auth-mfa` crashing on enable/regenerate for any app with per-subject crypto-shreddin…",
24
+ "detail": "Fix `auth-mfa` crashing on enable/regenerate for any app with per-subject crypto-shredding (`configurePiiSubjectKms`) active: `recoveryCodes` was a `jsonb` field marked `userOwned`, but the PII-encryption pipeline requires a string value for any subject-owned field — every real enable-confirm write threw `PII field \"recoveryCodes\" must be a string, got object`. `recoveryCodes` is now a `createTextField({ encrypted: true, userOwned })` (JSON-stringified at the write/read boundary), matching `totpSecret`. That exposed a second, deeper bug: a field carrying both `encrypted: true` and a subject marker is written as `PII(envelope(plaintext))`, but `decryptForRead` peeled the layers in write order instead of reverse order, so the envelope cipher choked on a still-PII-wrapped string. Fixed `event-store-executor-context.ts`'s `decryptForRead` to unwrap PII first, then envelope — and updated `auth-mfa`'s KEK-reencrypt job (which reads raw rows, bypassing `decryptForRead`) to do the same. Also: `MfaEnableScreen` now takes an optional `onEnabled` callback, fired after a successful enable-confirm — hosts that compose it inside their own MFA-status view (like kumiko-studio's account-security screen) need it to know when to refetch and swap away from the embedded enable flow. - @cosmicdrift/kumiko-framework@0.149.1 - @cosmicdrift/kumiko-headless@0.149.1 - @cosmicdrift/kumiko-renderer@0.149.1 - @cosmicdrift/kumiko-dispatcher-live@0.149.1 - @cosmicdrift/kumiko-renderer-web@0.149.1"
25
+ },
26
+ {
27
+ "version": "0.149.0",
28
+ "type": "improvement",
29
+ "title": "`auth-mfa/web` gains `MfaDisableDialog`, `MfaRegenerateRecoveryDialog`, and `MfaRecoveryC…",
30
+ "detail": "`auth-mfa/web` gains `MfaDisableDialog`, `MfaRegenerateRecoveryDialog`, and `MfaRecoveryCodesReveal` — the disable/regenerate-recovery UI deferred from the initial MFA-UI PR (kumiko-framework#266). Also fixes `MfaEnableScreen`'s error banner, which templated the raw snake_case server error code straight into the i18n key (`auth.mfa.errors.invalid_totp_code`) instead of the camelCase key actually registered (`auth.mfa.errors.invalidCode`) — extracted into a shared `mfaManageErrorKey` helper, now used by all three write-triggering components."
31
+ },
32
+ {
33
+ "version": "0.149.0",
34
+ "type": "improvement",
35
+ "title": "`auth-mfa` gains a `status` query (`AuthMfaQueries.status`, wire QN `auth-mfa:query:user-…",
36
+ "detail": "`auth-mfa` gains a `status` query (`AuthMfaQueries.status`, wire QN `auth-mfa:query:user-mfa:status`) returning `{ enabled: boolean }` for the calling user — the one thing a settings/security screen needs to decide whether to show the enrollment flow or the disable/regenerate actions. No client-side signal carried this before."
37
+ }
38
+ ]
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
  import {
3
3
  createStaticLocaleResolver,
4
4
  LocaleProvider,
@@ -7,6 +7,7 @@ import {
7
7
  import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
8
  import { fireEvent, type RenderResult, render, screen, waitFor } from "@testing-library/react";
9
9
  import type { ReactElement } from "react";
10
+ import { installFetchMock } from "../../../auth-email-password/web/__tests__/fetch-mock";
10
11
  import { makeSessionApi } from "../../../auth-email-password/web/__tests__/test-utils";
11
12
  import { SessionContext } from "../../../auth-email-password/web/session";
12
13
  import { defaultTranslations } from "../i18n";
@@ -25,16 +26,7 @@ function renderScreen(ui: ReactElement, session = makeSessionApi()): RenderResul
25
26
  );
26
27
  }
27
28
 
28
- const originalFetch = globalThis.fetch;
29
-
30
- beforeEach(() => {
31
- globalThis.fetch = mock(
32
- async () => new Response(null, { status: 200 }),
33
- ) as unknown as typeof fetch;
34
- });
35
- afterEach(() => {
36
- globalThis.fetch = originalFetch;
37
- });
29
+ installFetchMock();
38
30
 
39
31
  describe("MfaVerifyScreen", () => {
40
32
  test("rendert Titel + Code-Feld + Submit-Button (de)", () => {
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "version": "0.165.0",
4
+ "type": "breaking",
5
+ "title": "Reencrypt job: removed legacy-decrypt path",
6
+ "detail": "The old single-key format (CONFIG_ENCRYPTION_KEY) is no longer supported. The reencrypt job now classifies rows as rotate/current/unrecognized — unreadable rows are counted as failed instead of silently attempted.",
7
+ "migration": "If any config values still exist in the old single-key format, re-encrypt them with the current envelope format before upgrading. The job will now throw an error instead of attempting migration."
8
+ }
9
+ ]
@@ -0,0 +1,14 @@
1
+ [
2
+ {
3
+ "version": "0.165.3",
4
+ "type": "improvement",
5
+ "title": "Searchable subject-PII via derived Meili index (fw#1610).",
6
+ "detail": "Fields may be searchable + userOwned/tenantOwned: the search consumer decrypts into Meili (ciphertext stays at rest). Forget erases the subject key and purgeSearchDocumentsForSubject removes Meili docs (ciphertext LIKE + ownership for post-anonymize). sortable + subject remains forbidden; sensitive + searchable is rejected at boot."
7
+ },
8
+ {
9
+ "version": "0.119.0",
10
+ "type": "improvement",
11
+ "title": "Crypto-shredding phase D: forget wire.",
12
+ "detail": "Crypto-shredding phase D: forget wire. New `crypto-shredding` bundled feature with the `forget-subject` operator command (DPO/SystemAdmin) — erases a user/tenant subject key and appends a `subject-forgotten` audit event. `user-data-rights` forget-cleanup now erases the user's subject key inside the per-user sub-tx (crash-safe, before the status flip). Fixes `list()` returning ciphertext for camelCase encrypted/pii fields and caching plaintext rows."
13
+ }
14
+ ]
@@ -8,6 +8,7 @@ import {
8
8
  import { nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
9
9
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
11
+ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
11
12
  import { z } from "zod";
12
13
  import { CRYPTO_SHREDDING_AGGREGATE_TYPE, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
13
14
 
@@ -75,6 +76,18 @@ export const forgetSubjectWrite = defineWriteHandler({
75
76
  // adressiert das Subject tenant-übergreifend.
76
77
  await nullBlindIndexesForSubject(ctx.db.raw, ctx.registry.features, subjectKey);
77
78
 
79
+ // Derived search index still holds plaintext (#1610) — purge next to the
80
+ // blind-index sweep. No adapter → no-op (apps without search).
81
+ if (ctx.searchAdapter) {
82
+ await purgeSearchDocumentsForSubject(
83
+ ctx.db.raw,
84
+ ctx.registry.features,
85
+ ctx.searchAdapter,
86
+ subjectKey,
87
+ subject,
88
+ );
89
+ }
90
+
78
91
  await ctx.unsafeAppendEvent({
79
92
  aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
80
93
  aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,