@cosmicdrift/kumiko-bundled-features 0.111.0 → 0.113.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.
Files changed (39) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/handlers/login.write.ts +4 -1
  3. package/src/auth-email-password/password-hashing.test.ts +35 -0
  4. package/src/auth-email-password/password-hashing.ts +11 -0
  5. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +29 -0
  6. package/src/auth-email-password/web/auth-form-primitives.tsx +18 -1
  7. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  8. package/src/auth-email-password/web/reset-password-screen.tsx +6 -11
  9. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  10. package/src/auth-email-password/web/user-menu.tsx +3 -1
  11. package/src/auth-email-password/web/verify-email-screen.tsx +2 -7
  12. package/src/custom-fields/events.ts +6 -0
  13. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  14. package/src/custom-fields/handlers/update-tenant-field.write.ts +10 -0
  15. package/src/custom-fields/wire-for-entity.ts +16 -4
  16. package/src/data-retention/__tests__/parse-override.test.ts +2 -7
  17. package/src/folders/__tests__/folders.integration.test.ts +3 -0
  18. package/src/folders/web/__tests__/tree.test.ts +8 -0
  19. package/src/folders/web/folder-manager.tsx +1 -3
  20. package/src/folders/web/tree.ts +13 -2
  21. package/src/folders-user-data/__tests__/hooks.integration.test.ts +1 -0
  22. package/src/ledger/__tests__/ledger.integration.test.ts +8 -3
  23. package/src/ledger/handlers/confirm-schedule-period.write.ts +9 -1
  24. package/src/legal-pages/feature.ts +1 -3
  25. package/src/managed-pages/feature.ts +1 -3
  26. package/src/tags/__tests__/schemas.test.ts +34 -0
  27. package/src/tags/__tests__/tags.integration.test.ts +12 -1
  28. package/src/tags/entity.ts +6 -1
  29. package/src/tags/schemas.ts +9 -2
  30. package/src/tags/web/__tests__/tag-manager.test.tsx +64 -0
  31. package/src/tags/web/tag-manager.tsx +1 -1
  32. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +108 -0
  33. package/src/user-data-rights/feature.ts +1 -3
  34. package/src/user-data-rights/run-forget-cleanup.ts +1 -0
  35. package/src/user-data-rights/run-user-export.ts +2 -1
  36. package/src/user-data-rights/web/client-plugin.tsx +6 -1
  37. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +31 -9
  38. package/src/user-profile/__tests__/profile-screen.test.tsx +4 -0
  39. package/src/user-profile/web/profile-screen.tsx +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.111.0",
3
+ "version": "0.113.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>",
@@ -95,11 +95,11 @@
95
95
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
96
96
  },
97
97
  "dependencies": {
98
- "@cosmicdrift/kumiko-dispatcher-live": "0.111.0",
99
- "@cosmicdrift/kumiko-framework": "0.111.0",
100
- "@cosmicdrift/kumiko-headless": "0.111.0",
101
- "@cosmicdrift/kumiko-renderer": "0.111.0",
102
- "@cosmicdrift/kumiko-renderer-web": "0.111.0",
98
+ "@cosmicdrift/kumiko-dispatcher-live": "0.113.0",
99
+ "@cosmicdrift/kumiko-framework": "0.113.0",
100
+ "@cosmicdrift/kumiko-headless": "0.113.0",
101
+ "@cosmicdrift/kumiko-renderer": "0.113.0",
102
+ "@cosmicdrift/kumiko-renderer-web": "0.113.0",
103
103
  "@mollie/api-client": "^4.5.0",
104
104
  "@node-rs/argon2": "^2.0.2",
105
105
  "@types/nodemailer": "^8.0.0",
@@ -21,7 +21,7 @@ import {
21
21
  noMembership,
22
22
  } from "../errors";
23
23
  import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
24
- import { verifyPassword } from "../password-hashing";
24
+ import { verifyDummyPassword, verifyPassword } from "../password-hashing";
25
25
 
26
26
  export type LoginHandlerOptions = {
27
27
  // When true, a valid (email + password) login fails with email_not_verified
@@ -74,6 +74,9 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
74
74
  // Uniform response on any credential mismatch (no user, wrong password,
75
75
  // soft-deleted user) — prevents email enumeration.
76
76
  if (!found?.passwordHash || found.isDeleted) {
77
+ // Burn the same argon2 verify cost as the hit path so response
78
+ // latency doesn't reveal whether the email is registered (#774).
79
+ await verifyDummyPassword(event.payload.password);
77
80
  return invalidCredentials();
78
81
  }
79
82
 
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
3
+
4
+ const medianMs = async (fn: () => Promise<unknown>, runs = 5): Promise<number> => {
5
+ const samples: number[] = [];
6
+ for (let i = 0; i < runs; i++) {
7
+ const start = performance.now();
8
+ await fn();
9
+ samples.push(performance.now() - start);
10
+ }
11
+ samples.sort((a, b) => a - b);
12
+ return samples[Math.floor(samples.length / 2)] ?? 0;
13
+ };
14
+
15
+ // #774: the login no-user path must cost the same argon2 latency as a real
16
+ // verify, otherwise response timing leaks whether an email is registered.
17
+ describe("verifyDummyPassword (anti-enumeration timing)", () => {
18
+ test("resolves to void, never matches", async () => {
19
+ expect(await verifyDummyPassword("whatever")).toBeUndefined();
20
+ });
21
+
22
+ test("burns real argon2 cost, comparable to a failed verify (not a no-op)", async () => {
23
+ const realHash = await hashPassword("correct-horse-battery");
24
+ // warm the lazily-cached dummy hash so we time the verify, not the one-off hash
25
+ await verifyDummyPassword("warmup");
26
+
27
+ const realMs = await medianMs(() => verifyPassword(realHash, "wrong-password"));
28
+ const dummyMs = await medianMs(() => verifyDummyPassword("wrong-password"));
29
+
30
+ // A skipped miss-path (the bug) is sub-millisecond; a real argon2 verify
31
+ // is ~20ms. A half-of-real floor is a wide, non-flaky bound that still
32
+ // fails hard if the dummy verify is ever removed.
33
+ expect(dummyMs).toBeGreaterThan(realMs * 0.5);
34
+ });
35
+ });
@@ -42,3 +42,14 @@ export async function verifyPassword(hashString: string, password: string): Prom
42
42
  return false;
43
43
  }
44
44
  }
45
+
46
+ // Anti-enumeration timing equaliser (#774). The login handler runs this on
47
+ // the no-user / no-hash path so a missing account costs the same argon2
48
+ // latency as a real verify — otherwise response timing leaks whether an
49
+ // email is registered. Derived from hashPassword once and cached, so it
50
+ // always tracks HASH_OPTIONS; the password never matches, result discarded.
51
+ let dummyHash: Promise<string> | undefined;
52
+ export async function verifyDummyPassword(password: string): Promise<void> {
53
+ dummyHash ??= hashPassword("anti-enumeration-dummy");
54
+ await verifyPassword(await dummyHash, password);
55
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderHook } from "@testing-library/react";
3
+ import { useUrlToken } from "../auth-form-primitives";
4
+
5
+ // #774: magic-link tokens must not linger in browser history / Referer.
6
+ // useUrlToken reads the token once, then scrubs the param via replaceState.
7
+ // (dom.preload resets window.location to http://localhost/ after each test.)
8
+ describe("useUrlToken (magic-link history hygiene)", () => {
9
+ test("reads ?token= and strips it from the URL, keeping other params", () => {
10
+ window.history.replaceState(null, "", "http://localhost/reset?token=secret-abc&keep=1");
11
+ const { result } = renderHook(() => useUrlToken());
12
+ expect(result.current).toBe("secret-abc");
13
+ expect(window.location.search).toBe("?keep=1");
14
+ });
15
+
16
+ test("no token param → URL untouched", () => {
17
+ window.history.replaceState(null, "", "http://localhost/reset?keep=1");
18
+ const { result } = renderHook(() => useUrlToken());
19
+ expect(result.current).toBe("");
20
+ expect(window.location.search).toBe("?keep=1");
21
+ });
22
+
23
+ test("explicit override short-circuits both URL read and scrub", () => {
24
+ window.history.replaceState(null, "", "http://localhost/reset?token=url-token");
25
+ const { result } = renderHook(() => useUrlToken("prop-token"));
26
+ expect(result.current).toBe("prop-token");
27
+ expect(window.location.search).toBe("?token=url-token");
28
+ });
29
+ });
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
19
19
  import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer-web";
20
- import { createContext, type ReactNode, useContext } from "react";
20
+ import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
21
21
 
22
22
  // Wrappt die zentrierte Auth-Card in ihre Umgebung. Default = Fullscreen-
23
23
  // zentriert (Standalone-Auth-Page). Eine Apex-/Marketing-Chrome reicht über
@@ -106,3 +106,20 @@ export function parseUrlToken(paramName = "token"): string {
106
106
  if (typeof window === "undefined") return "";
107
107
  return new URLSearchParams(window.location.search).get(paramName) ?? "";
108
108
  }
109
+
110
+ // Reads the magic-link token from `?<paramName>=` once on mount, then strips
111
+ // that param from the URL via history.replaceState so the single-use token
112
+ // doesn't linger in browser history / Referer (#774). An explicit `override`
113
+ // (server-injected token) short-circuits both the URL read and the scrub.
114
+ export function useUrlToken(override?: string, paramName = "token"): string {
115
+ const [token] = useState(() => override ?? parseUrlToken(paramName));
116
+ useEffect(() => {
117
+ if (override !== undefined) return;
118
+ if (typeof window === "undefined") return;
119
+ const url = new URL(window.location.href);
120
+ if (!url.searchParams.has(paramName)) return;
121
+ url.searchParams.delete(paramName);
122
+ window.history.replaceState(window.history.state, "", url.toString());
123
+ }, [override, paramName]);
124
+ return token;
125
+ }
@@ -19,7 +19,7 @@
19
19
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
20
20
  import { type FormEvent, type ReactNode, useContext, useState } from "react";
21
21
  import { csrfHeader } from "./auth-client";
22
- import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
22
+ import { AuthCard, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
23
23
  import { SessionContext, UNAUTHENTICATED } from "./session";
24
24
 
25
25
  export type InviteAcceptScreenProps = {
@@ -47,7 +47,7 @@ export function InviteAcceptScreen({
47
47
  // <SessionProvider> ancestor. Read the context directly and fall back to
48
48
  // the anonymous state when no provider wraps this screen (632/1).
49
49
  const session = useContext(SessionContext) ?? UNAUTHENTICATED;
50
- const [token] = useState(() => tokenProp ?? parseUrlToken());
50
+ const token = useUrlToken(tokenProp);
51
51
  const [mode, setMode] = useState<Mode>(() =>
52
52
  session.status === "authenticated" ? "loggedin" : "anon-existing",
53
53
  );
@@ -5,20 +5,15 @@
5
5
  // Code (anti-enumeration); UI zeigt unified "Link ungültig oder
6
6
  // abgelaufen"-message.
7
7
  //
8
- // Token-Quelle ist read-once: wir lesen via parseUrlToken() einmalig
9
- // im useState-Initializer. Apps die das anders brauchen (server-
10
- // injected Token-Prop, andere Parameter-Namen) reichen einen
11
- // expliziten `token` als Prop durch.
8
+ // Token-Quelle ist read-once via useUrlToken(): liest `?token=...` beim
9
+ // Mount und scrubbt den Param danach aus der URL (#774). Apps die das
10
+ // anders brauchen (server-injected Token-Prop) reichen `token` als Prop
11
+ // durch dann bleibt die URL unangetastet.
12
12
 
13
13
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
14
14
  import { type FormEvent, type ReactNode, useState } from "react";
15
15
  import { resetPassword } from "./auth-client";
16
- import {
17
- AuthCard,
18
- authButtonClass,
19
- authMutedLinkClass,
20
- parseUrlToken,
21
- } from "./auth-form-primitives";
16
+ import { AuthCard, authButtonClass, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
22
17
 
23
18
  export type ResetPasswordScreenProps = {
24
19
  readonly title?: string;
@@ -37,7 +32,7 @@ export function ResetPasswordScreen({
37
32
  }: ResetPasswordScreenProps): ReactNode {
38
33
  const t = useTranslation();
39
34
  const { Form, Field, Input, Button, Banner } = usePrimitives();
40
- const [token] = useState(() => tokenProp ?? parseUrlToken());
35
+ const token = useUrlToken(tokenProp);
41
36
  const [newPassword, setNewPassword] = useState("");
42
37
  const [confirmPassword, setConfirmPassword] = useState("");
43
38
  const [submitting, setSubmitting] = useState(false);
@@ -6,9 +6,9 @@
6
6
  // Server JWT + Cookies (Auto-Login!) und liefert tenantKey für den
7
7
  // Post-Signup-Redirect.
8
8
  //
9
- // Token-Quelle ist read-once: parseUrlToken im useState-Initializer.
10
- // Apps die einen anderen URL-Param nutzen, reichen `token` als Prop
11
- // durch.
9
+ // Token-Quelle ist read-once via useUrlToken: liest `?token=...` beim
10
+ // Mount und scrubbt den Param danach aus der URL (#774). Apps die einen
11
+ // server-injected Token nutzen, reichen `token` als Prop durch.
12
12
  //
13
13
  // Nach success: redirect via window.location.assign zu loggedInHref.
14
14
  // Default-Pattern ist "/<tenantKey>/" — die App reicht ein Template
@@ -18,7 +18,7 @@
18
18
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
19
  import { type FormEvent, type ReactNode, useState } from "react";
20
20
  import { confirmSignup } from "./auth-client";
21
- import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
21
+ import { AuthCard, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
22
22
 
23
23
  export type SignupCompleteScreenProps = {
24
24
  readonly title?: string;
@@ -44,7 +44,7 @@ export function SignupCompleteScreen({
44
44
  }: SignupCompleteScreenProps): ReactNode {
45
45
  const t = useTranslation();
46
46
  const { Form, Field, Input, Button, Banner } = usePrimitives();
47
- const [token] = useState(() => tokenProp ?? parseUrlToken());
47
+ const token = useUrlToken(tokenProp);
48
48
  const [password, setPassword] = useState("");
49
49
  const [confirmPassword, setConfirmPassword] = useState("");
50
50
  const [submitting, setSubmitting] = useState(false);
@@ -33,7 +33,9 @@ export type UserMenuProps = {
33
33
  * controlliert der Caller — wir packen nur den Frame drumrum. */
34
34
  readonly children?: ReactNode;
35
35
  /** "pill" (Default) = kompakter Topbar-Trigger; "sidebar" = volle NavUser-
36
- * Row (Avatar + Name + Email) für den `sidebarFooter`-Slot der App-Shell. */
36
+ * Row (Avatar + Name + Email) für den `sidebarFooter`-Slot der App-Shell.
37
+ * Requires a `SidebarProvider` ancestor (547/3) — the default App-Shell
38
+ * `sidebarFooter` slot already provides one. */
37
39
  readonly variant?: UserMenuVariant;
38
40
  };
39
41
 
@@ -13,12 +13,7 @@
13
13
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
14
14
  import { type ReactNode, useEffect, useRef, useState } from "react";
15
15
  import { verifyEmail } from "./auth-client";
16
- import {
17
- AuthCard,
18
- authButtonClass,
19
- authMutedLinkClass,
20
- parseUrlToken,
21
- } from "./auth-form-primitives";
16
+ import { AuthCard, authButtonClass, authMutedLinkClass, useUrlToken } from "./auth-form-primitives";
22
17
 
23
18
  export type VerifyEmailScreenProps = {
24
19
  readonly title?: string;
@@ -36,7 +31,7 @@ export function VerifyEmailScreen({
36
31
  loginHref = "/login",
37
32
  }: VerifyEmailScreenProps): ReactNode {
38
33
  const t = useTranslation();
39
- const [token] = useState(() => tokenProp ?? parseUrlToken());
34
+ const token = useUrlToken(tokenProp);
40
35
  const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
41
36
  const startedRef = useRef(false);
42
37
 
@@ -15,6 +15,12 @@ export const customFieldSetSchema = z.object({
15
15
  // self-projected into the host row by the write handler and must never enter
16
16
  // the immutable log. Non-sensitive sets always carry the value.
17
17
  value: z.unknown().optional(),
18
+ // Explicit protocol marker (527/1) — `value === undefined` alone is also a
19
+ // valid-looking "clear" shape; `_sensitive` names the reason so a future
20
+ // reader can't mistake one for the other. Optional so historical events
21
+ // (persisted before this field existed) still parse: apply-side readers
22
+ // keep `value === undefined` as their actual branch condition.
23
+ _sensitive: z.literal(true).optional(),
18
24
  });
19
25
  export type CustomFieldSetPayload = z.infer<typeof customFieldSetSchema>;
20
26
 
@@ -135,7 +135,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
135
135
  aggregateType: payload.entityName,
136
136
  type: customFieldsFeature.exports.setEvent.name,
137
137
  payload: sensitive
138
- ? { fieldKey: payload.fieldKey }
138
+ ? { fieldKey: payload.fieldKey, _sensitive: true as const }
139
139
  : { fieldKey: payload.fieldKey, value: payload.value },
140
140
  });
141
141
 
@@ -65,6 +65,16 @@ export const updateTenantFieldHandler: WriteHandlerDef = {
65
65
  // values already written to host rows by past sets (set-custom-field only
66
66
  // routes the PII-safe path at write time). To change sensitivity, delete +
67
67
  // re-define (the honest cut — same rationale as the type guard above).
68
+ //
69
+ // 527/2 — even that "honest cut" is incomplete: it clears the projection
70
+ // row, but kumiko_events is immutable, so historical customField.set
71
+ // events from the field's non-sensitive era keep their plaintext values
72
+ // in the log forever. There is no event-payload redaction mechanism for
73
+ // this today (EventStoreExecutor.forget() hard-deletes a projection row
74
+ // and is rebuild-safe, but doesn't touch or redact past event payloads).
75
+ // A tenant that genuinely needs those values erased needs an operator-run
76
+ // data-retention pass over the raw event log — not something this
77
+ // handler, or delete-and-redefine, can give them.
68
78
  const currentSensitive = parseSerializedField(existing["serializedField"])?.sensitive === true;
69
79
  const requestedSensitive = payload.serializedField.sensitive === true;
70
80
  if (currentSensitive !== requestedSensitive) {
@@ -107,10 +107,22 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
107
107
 
108
108
  // skip: sensitive fields self-project in the write handler (see
109
109
  // set-custom-field) and persist a value-less event so PII never enters
110
- // the log. Such events arrive here with value === undefined skipping
111
- // is correct both live (the handler already wrote the row) and on replay
112
- // (the value is intentionally gone, the accepted rebuild-loss).
113
- if (payload.value === undefined) return;
110
+ // the log. Skipping is correct both live (the handler already wrote the
111
+ // row) and on replay (the value is intentionally gone, the accepted
112
+ // rebuild-loss). `value === undefined` stays the actual branch condition
113
+ // (historical events predate `_sensitive`) — the warning below is a
114
+ // canary (527/1): a NEW event missing both means something emitted a
115
+ // value-less customField.set outside the sensitive-field path.
116
+ if (payload.value === undefined) {
117
+ if (payload._sensitive !== true) {
118
+ // biome-ignore lint/suspicious/noConsole: boot-adjacent correctness canary, no logger available in an apply function
119
+ console.warn(
120
+ `[custom-fields] customField.set for "${payload.fieldKey}" on ${event.aggregateType}/${event.aggregateId} has no value and no _sensitive marker — skipping, but this event didn't come from the known sensitive-field path.`,
121
+ );
122
+ }
123
+ // skip: value-less set, handled above (warned if unexpectedly so)
124
+ return;
125
+ }
114
126
 
115
127
  // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
116
128
  // wird angelegt (create_missing=true ist default). value muss als
@@ -9,13 +9,8 @@ afterEach(() => {
9
9
  warn.mockRestore?.();
10
10
  });
11
11
 
12
- // parseRetentionOverrideOrNull is the read boundary for a tenant's stored
13
- // data-retention override (DSGVO-relevant policy in a config column). It must
14
- // never let a corrupt or schema-violating value reach the retention decision:
15
- // invalid JSON and schema drift both collapse to null (the resolver then falls
16
- // back to preset/entity defaults) AND surface one operator warning. The schema
17
- // itself is covered by override-schema.test.ts — this pins the parser's
18
- // defensive wrapping: empty guard, no-throw on corruption, drop-not-leak on drift.
12
+ // DSGVO invariant: corrupt or schema-drifted overrides collapse to null +
13
+ // warn, never leak through (schema itself is covered by override-schema.test.ts).
19
14
 
20
15
  const parse = (raw: string | null) => parseRetentionOverrideOrNull(raw, "tenant-1", "test");
21
16
 
@@ -331,6 +331,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
331
331
  await seedOneFolderWithAssignment();
332
332
  const ctx = {
333
333
  db: stack.db,
334
+ registry: stack.registry,
334
335
  tenantId: admin.tenantId,
335
336
  userId: admin.id,
336
337
  tenantModel: "multi-user" as const,
@@ -345,6 +346,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
345
346
  await seedOneFolderWithAssignment();
346
347
  const ctx = {
347
348
  db: stack.db,
349
+ registry: stack.registry,
348
350
  tenantId: admin.tenantId,
349
351
  userId: admin.id,
350
352
  tenantModel: "single-user" as const,
@@ -359,6 +361,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
359
361
  await seedOneFolderWithAssignment();
360
362
  const ctx = {
361
363
  db: stack.db,
364
+ registry: stack.registry,
362
365
  tenantId: admin.tenantId,
363
366
  userId: admin.id,
364
367
  tenantModel: "single-user" as const,
@@ -55,4 +55,12 @@ describe("folderPath", () => {
55
55
  test("unknown id → empty string", () => {
56
56
  expect(folderPath(rows, "nope")).toBe("");
57
57
  });
58
+
59
+ // 658/4: a parentId cycle must terminate at the row-count cap, not loop
60
+ // forever or run one iteration past it.
61
+ test("a parentId cycle terminates instead of looping forever", () => {
62
+ const cyclic = [f("x", "X", "y"), f("y", "Y", "x")];
63
+ expect(() => folderPath(cyclic, "x")).not.toThrow();
64
+ expect(folderPath(cyclic, "x").split(" / ")).toHaveLength(cyclic.length);
65
+ });
58
66
  });
@@ -47,9 +47,7 @@ export type FolderLeaf = {
47
47
  readonly label: string;
48
48
  readonly trailing?: ReactNode;
49
49
  readonly onOpen?: () => void;
50
- // Overrides filing.entityType for this leaf lets one filing tree hold leaves
51
- // of mixed entity types (e.g. credits + Bausparverträge), each filed/cleared
52
- // under its own type. Omit for single-type trees.
50
+ // Overrides filing.entityType (mixed-entity-type trees); omit for single-type trees.
53
51
  readonly entityType?: string;
54
52
  };
55
53
 
@@ -31,6 +31,15 @@ export function buildFolderTree(rows: readonly FolderRow[]): readonly FolderNode
31
31
  if (siblings) siblings.push(row);
32
32
  else byParent.set(key, [row]);
33
33
  }
34
+ // visited guards against a parent cycle recursing forever (658/4) —
35
+ // currently impossible (no reparent feature yet), but cheap insurance for
36
+ // when one lands; a cyclic node's subtree just stops re-descending.
37
+ // No cycle guard needed (658/4, verified not just asserted): build() only
38
+ // descends via byParent[parentId] starting from roots (parentId === null
39
+ // or dangling) — a row in a cycle has a non-null, existing parentId by
40
+ // definition, so it can never be a root, and each row contributes exactly
41
+ // one parentId edge, so nothing outside a cycle points into it either. A
42
+ // pure a<->b cycle is structurally unreachable from build(null).
34
43
  const build = (parentId: string | null, depth: number): readonly FolderNode[] =>
35
44
  (byParent.get(parentId) ?? [])
36
45
  .slice()
@@ -41,12 +50,14 @@ export function buildFolderTree(rows: readonly FolderRow[]): readonly FolderNode
41
50
 
42
51
  // "Immobilie Berlin / Person Müller" for a folder id, walking parentId up.
43
52
  // Empty string if the id is unknown. The row-count cap stops a (currently
44
- // impossible — no reparent yet) parent cycle from spinning forever.
53
+ // impossible — no reparent yet) parent cycle from spinning forever — `< `,
54
+ // not `<=` (658/4): a cycle of exactly rows.length nodes must stop AT the
55
+ // cap, not run one iteration past it.
45
56
  export function folderPath(rows: readonly FolderRow[], id: string, separator = " / "): string {
46
57
  const byId = new Map(rows.map((r) => [r.id, r]));
47
58
  const names: string[] = [];
48
59
  let current = byId.get(id);
49
- for (let i = 0; current !== undefined && i <= rows.length; i += 1) {
60
+ for (let i = 0; current !== undefined && i < rows.length; i += 1) {
50
61
  names.unshift(current.name);
51
62
  current = current.parentId !== null ? byId.get(current.parentId) : undefined;
52
63
  }
@@ -57,6 +57,7 @@ describe("folderAssignmentExportHook", () => {
57
57
 
58
58
  const snippet = await folderAssignmentExportHook({
59
59
  db: stack.db,
60
+ registry: stack.registry,
60
61
  tenantId: admin.tenantId,
61
62
  userId: admin.id,
62
63
  });
@@ -371,8 +371,8 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
371
371
  scheduleId: string,
372
372
  period: string,
373
373
  amount?: number,
374
- ): Promise<{ id: string }> {
375
- return stack.http.writeOk<{ id: string }>(
374
+ ): Promise<{ id: string; alreadyBooked: boolean }> {
375
+ return stack.http.writeOk<{ id: string; alreadyBooked: boolean }>(
376
376
  LedgerHandlers.confirmSchedulePeriod,
377
377
  amount === undefined ? { scheduleId, period } : { scheduleId, period, amount },
378
378
  admin,
@@ -384,7 +384,10 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
384
384
  const rent = await createAccount("Mieterträge", "income");
385
385
  const scheduleId = await createSchedule(bank, rent);
386
386
 
387
- await confirm(scheduleId, "2026-01");
387
+ const fresh = await confirm(scheduleId, "2026-01");
388
+ // 684/6: fresh bookings carry alreadyBooked: false, same shape as the
389
+ // idempotent-path response — no "does the field even exist" check needed.
390
+ expect(fresh.alreadyBooked).toBe(false);
388
391
 
389
392
  const rows = await listTransactions();
390
393
  expect(rows).toHaveLength(1);
@@ -405,6 +408,8 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
405
408
  const second = await confirm(scheduleId, "2026-01");
406
409
 
407
410
  expect(second.id).toBe(first.id);
411
+ expect(first.alreadyBooked).toBe(false);
412
+ expect(second.alreadyBooked).toBe(true);
408
413
  expect(await listTransactions()).toHaveLength(1);
409
414
  });
410
415
 
@@ -94,7 +94,7 @@ export function createConfirmSchedulePeriodHandler(
94
94
  }
95
95
 
96
96
  const amount = payload.amount ?? Number(schedule["amount"]);
97
- return transactionExecutor.create(
97
+ const created = await transactionExecutor.create(
98
98
  {
99
99
  id: generateId(),
100
100
  date: payload.date ?? `${payload.period}-01`,
@@ -109,6 +109,14 @@ export function createConfirmSchedulePeriodHandler(
109
109
  event.user,
110
110
  ctx.db,
111
111
  );
112
+ // Same shape as the idempotent-path return above (684/6) — a caller
113
+ // shouldn't have to check "does alreadyBooked exist" to know which
114
+ // branch fired.
115
+ if (!created.isSuccess) return created;
116
+ return {
117
+ isSuccess: true,
118
+ data: { ...created.data, alreadyBooked: false as const },
119
+ };
112
120
  },
113
121
  };
114
122
  }
@@ -26,9 +26,7 @@ type ByslugQueryBody = {
26
26
  data: { title: string; body: string | null; updatedAt: string } | null;
27
27
  };
28
28
 
29
- // Legal-Content ändert sich selten ein 60s-Shared-Cache-Fenster spart den
30
- // Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst die Content-Query),
31
- // ohne dass Edits spürbar stale wirken.
29
+ // 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
32
30
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
33
31
 
34
32
  // legal-pages — Opt-in-Wrapper um text-content für DACH-Compliance.
@@ -28,9 +28,7 @@ import { pageEntity } from "./table";
28
28
  // Alias (publicstatus = "Admin") müssen TenantAdmin granten/mappen.
29
29
  const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
30
30
 
31
- // Published CMS-Content ändert sich selten ein 60s-Shared-Cache-Fenster
32
- // spart den Origin-Revalidate-Roundtrip (jeder 304 re-runt sonst Page- +
33
- // Branding-Query), Edits sind nach spätestens 60s live.
31
+ // 60s-shared-cache saves the origin-revalidate roundtrip; CMS edits are live within 60s.
34
32
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
35
33
 
36
34
  // QN-Konstante als dokumentierter Public-Contract — der Render-Pfad ruft
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { assignTagPayloadSchema } from "../schemas";
3
+
4
+ // 456/3: tagAssignmentAggregateId joins tenantId/tagId/entityType/entityId
5
+ // with "|" to derive the stream id — a literal "|" in entityType/entityId
6
+ // could shift tuple boundaries and collide with an unrelated combination.
7
+ describe("assignTagPayloadSchema — no pipe in entityType/entityId", () => {
8
+ test("accepts normal values", () => {
9
+ const parsed = assignTagPayloadSchema.safeParse({
10
+ tagId: "tag-1",
11
+ entityType: "credit",
12
+ entityId: "entity-1",
13
+ });
14
+ expect(parsed.success).toBe(true);
15
+ });
16
+
17
+ test("rejects a pipe in entityType", () => {
18
+ const parsed = assignTagPayloadSchema.safeParse({
19
+ tagId: "tag-1",
20
+ entityType: "credit|forged",
21
+ entityId: "entity-1",
22
+ });
23
+ expect(parsed.success).toBe(false);
24
+ });
25
+
26
+ test("rejects a pipe in entityId", () => {
27
+ const parsed = assignTagPayloadSchema.safeParse({
28
+ tagId: "tag-1",
29
+ entityType: "credit",
30
+ entityId: "entity|forged",
31
+ });
32
+ expect(parsed.success).toBe(false);
33
+ });
34
+ });
@@ -358,6 +358,11 @@ describe("tags integration — multi-tenant isolation", () => {
358
358
  // "Admin", not "TenantAdmin"). Default-mounted tags deny that same user.
359
359
  describe("tags integration — openToAll access model", () => {
360
360
  let openStack: TestStack;
361
+ // Dedicated stack (477/1), not the outer file's `stack` — a describe-level
362
+ // `--test-name-pattern` run of just this block would otherwise crash on an
363
+ // undefined outer `stack` instead of failing cleanly, and this block
364
+ // shouldn't depend on describe-execution order for its setup to exist.
365
+ let defaultStack: TestStack;
361
366
  // role deliberately not in DEFAULT_TAG_ROLES nor "Admin" — proves openToAll,
362
367
  // not an accidental role match.
363
368
  const unprivileged = createTestUser({ roles: ["Viewer"] });
@@ -369,10 +374,16 @@ describe("tags integration — openToAll access model", () => {
369
374
  await unsafeCreateEntityTable(openStack.db, tagEntity);
370
375
  await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
371
376
  await createEventsTable(openStack.db);
377
+
378
+ defaultStack = await setupTestStack({ features: [tagsFeature] });
379
+ await unsafeCreateEntityTable(defaultStack.db, tagEntity);
380
+ await unsafeCreateEntityTable(defaultStack.db, tagAssignmentEntity);
381
+ await createEventsTable(defaultStack.db);
372
382
  });
373
383
 
374
384
  afterAll(async () => {
375
385
  await openStack.cleanup();
386
+ await defaultStack.cleanup();
376
387
  });
377
388
 
378
389
  test("a non-tag-role user can create, assign, list and remove", async () => {
@@ -409,7 +420,7 @@ describe("tags integration — openToAll access model", () => {
409
420
  });
410
421
 
411
422
  test("the SAME user is denied on a default-role-mounted feature", async () => {
412
- const denied = await stack.http.writeErr(
423
+ const denied = await defaultStack.http.writeErr(
413
424
  TagsHandlers.createTag,
414
425
  { name: "nope" },
415
426
  unprivileged,
@@ -6,7 +6,12 @@ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/eng
6
6
  export const tagEntity = createEntity({
7
7
  table: "read_tags",
8
8
  fields: {
9
- name: createTextField({ required: true, maxLength: 64 }),
9
+ // Catalog labels ("urgent", "billing"), not user-identifying content —
10
+ // allowPlaintext silences the user-content heuristic (456/5). A tenant
11
+ // COULD name a tag after a person; if that becomes a real requirement,
12
+ // this needs a userOwned annotation + forget/export hooks in the
13
+ // user-data-rights pipeline (none exist for tags today).
14
+ name: createTextField({ required: true, maxLength: 64, allowPlaintext: "catalog-label" }),
10
15
  // Optional UI hint (hex or token). No enforcement — purely for rendering.
11
16
  color: createTextField({ maxLength: 32 }),
12
17
  // Optional entity-type scope (GitLab project-vs-group labels): empty = global
@@ -41,10 +41,17 @@ export const deleteTagPayloadSchema = z.object({
41
41
  export type DeleteTagPayload = z.infer<typeof deleteTagPayloadSchema>;
42
42
 
43
43
  // assign + remove share the (tag, entity) reference shape.
44
+ // entityType/entityId are app-supplied, unlike tagId (always a real tag's
45
+ // UUID) — tagAssignmentAggregateId joins all four with "|" to derive the
46
+ // stream id, so a literal "|" in either could shift tuple boundaries and
47
+ // collide with an unrelated (tenant, tag, entity) combination (456/3).
48
+ // Rejecting it here is cheaper and non-breaking compared to changing the
49
+ // derivation's separator, which would re-key every existing stream.
50
+ const NO_PIPE = /^[^|]*$/;
44
51
  const entityTagRef = {
45
52
  tagId: z.string().min(1).max(64),
46
- entityType: z.string().min(1).max(64),
47
- entityId: z.string().min(1).max(128),
53
+ entityType: z.string().min(1).max(64).regex(NO_PIPE, 'entityType must not contain "|"'),
54
+ entityId: z.string().min(1).max(128).regex(NO_PIPE, 'entityId must not contain "|"'),
48
55
  } as const;
49
56
 
50
57
  export const assignTagPayloadSchema = z.object(entityTagRef);
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { fireEvent, render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { defaultTranslations } from "../i18n";
11
+ import { TagManager } from "../tag-manager";
12
+
13
+ type TagRow = { id: string; name: string; color?: string; scope?: string; version: number };
14
+
15
+ let catalogRows: readonly TagRow[] = [];
16
+
17
+ beforeEach(() => {
18
+ catalogRows = [{ id: "t1", name: "urgent", color: "#ef4444", version: 1 }];
19
+ });
20
+
21
+ const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
22
+
23
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
24
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
25
+ ...actual_renderer,
26
+ useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
27
+ useQuery: mock(() => ({
28
+ data: { rows: catalogRows },
29
+ loading: false,
30
+ error: null,
31
+ refetch: mock(),
32
+ })),
33
+ }));
34
+
35
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
36
+ return (
37
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
38
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
39
+ </LocaleProvider>
40
+ );
41
+ }
42
+
43
+ // 695/4: saveEdit silently no-ops on an empty trimmed name — the Save button
44
+ // must reflect that in its disabled state, not just in the click handler.
45
+ describe("TagManager — edit Save button", () => {
46
+ test("Save is disabled once the name is cleared to empty/whitespace", () => {
47
+ render(
48
+ <Wrapper>
49
+ <TagManager />
50
+ </Wrapper>,
51
+ );
52
+ fireEvent.click(screen.getByTestId("tag-manager-edit-btn-t1"));
53
+
54
+ const save = screen.getByTestId("tag-manager-save-t1") as HTMLButtonElement;
55
+ expect(save.disabled).toBe(false);
56
+
57
+ const nameInput = document.getElementById("tag-edit-name-t1") as HTMLInputElement;
58
+ fireEvent.change(nameInput, { target: { value: " " } });
59
+ expect(save.disabled).toBe(true);
60
+
61
+ fireEvent.change(nameInput, { target: { value: "renamed" } });
62
+ expect(save.disabled).toBe(false);
63
+ });
64
+ });
@@ -308,7 +308,7 @@ export function TagManager({
308
308
  <div className="flex gap-2">
309
309
  <Button
310
310
  variant="primary"
311
- disabled={busy}
311
+ disabled={busy || editName.trim() === ""}
312
312
  onClick={() => saveEdit(tag)}
313
313
  testId={`tag-manager-save-${tag.id}`}
314
314
  >
@@ -0,0 +1,108 @@
1
+ // A forget delete-hook receives the app registry so it can cascade custom child
2
+ // projections for the executor's `<entity>.forgotten` event (runProjectionsForEvent).
3
+ // executor.forget purges only the entity's OWN projection, and the forget pipeline
4
+ // is a job — not a dispatched command — so the dispatcher's post-command projection
5
+ // pass never fires. Without registry in the hook ctx the cascade is unreachable and
6
+ // child read-model rows (m:n joins, per-parent detail projections) are orphaned on
7
+ // live forget (a DSGVO gap). This pins that the plumbing delivers the real,
8
+ // functional registry instance; the end-to-end cascade is covered consumer-side.
9
+
10
+ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
11
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
12
+ import {
13
+ createEntity,
14
+ createTextField,
15
+ defineFeature,
16
+ EXT_USER_DATA,
17
+ type Registry,
18
+ type UserDataDeleteHook,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
21
+ import {
22
+ setupTestStack,
23
+ type TestStack,
24
+ unsafeCreateEntityTable,
25
+ } from "@cosmicdrift/kumiko-framework/stack";
26
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
27
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
28
+ import { createSessionsFeature } from "../../sessions";
29
+ import { createUserFeature, userEntity } from "../../user";
30
+ import { createUserDataRightsFeature } from "../feature";
31
+ import { runForgetCleanup } from "../run-forget-cleanup";
32
+ import {
33
+ createForgetSeeders,
34
+ nowInstant,
35
+ READ_TENANT_MEMBERSHIPS_DDL,
36
+ TENANT_SYSTEM,
37
+ } from "./forget-test-helpers";
38
+
39
+ const PROBE = "forget-registry-probe";
40
+ let capturedRegistry: Registry | undefined;
41
+
42
+ const captureHook: UserDataDeleteHook = async (ctx) => {
43
+ capturedRegistry = ctx.registry;
44
+ };
45
+
46
+ const probeEntity = createEntity({
47
+ table: `read_${PROBE.replace(/-/g, "_")}`,
48
+ fields: { name: createTextField({ required: true }) },
49
+ });
50
+
51
+ const probeFeature = defineFeature(PROBE, (r) => {
52
+ r.entity(PROBE, probeEntity);
53
+ r.useExtension(EXT_USER_DATA, PROBE, {
54
+ export: async () => null,
55
+ delete: captureHook,
56
+ });
57
+ });
58
+
59
+ const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000b1";
60
+ let stack: TestStack;
61
+ const seed = (db: unknown) =>
62
+ // biome-ignore lint/suspicious/noExplicitAny: dummy writer; this seeder never writes binaries.
63
+ createForgetSeeders(db as any, { write: async () => {} });
64
+
65
+ beforeAll(async () => {
66
+ stack = await setupTestStack({
67
+ features: [
68
+ createUserFeature(),
69
+ createSessionsFeature(),
70
+ createDataRetentionFeature(),
71
+ createComplianceProfilesFeature(),
72
+ createUserDataRightsFeature(),
73
+ probeFeature,
74
+ ],
75
+ });
76
+ await unsafeCreateEntityTable(stack.db, userEntity);
77
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
78
+ await unsafeCreateEntityTable(stack.db, probeEntity);
79
+ await createEventsTable(stack.db);
80
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
81
+ });
82
+
83
+ afterAll(async () => {
84
+ await stack.cleanup();
85
+ });
86
+
87
+ beforeEach(() => {
88
+ capturedRegistry = undefined;
89
+ });
90
+
91
+ test("forget delete-hook receives the app registry instance", async () => {
92
+ await seed(stack.db).seedForgetUser(FORGET_USER);
93
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_SYSTEM);
94
+
95
+ const result = await runForgetCleanup({
96
+ db: stack.db,
97
+ registry: stack.registry,
98
+ now: nowInstant(),
99
+ });
100
+
101
+ expect(result.errors, JSON.stringify(result.errors)).toHaveLength(0);
102
+ expect(result.processedUserIds).toContain(FORGET_USER);
103
+ // The hook ran and got the exact registry passed to runForgetCleanup — not
104
+ // undefined, not a stand-in — so runProjectionsForEvent(event, ctx.registry, db)
105
+ // reaches the real projections.
106
+ expect(capturedRegistry).toBe(stack.registry);
107
+ expect(capturedRegistry?.getProjectionsForSource(PROBE)).toBeDefined();
108
+ });
@@ -249,9 +249,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
249
249
  r.queryHandler(myAuditLogQuery);
250
250
  r.queryHandler(listDownloadAttemptsQuery);
251
251
 
252
- // Read-only operator inspector over the GDPR read-models (SystemAdmin).
253
- // Convention list/detail handlers so entityList/entityEdit resolve by QN;
254
- // the screens stay inert until an app navs them (opt-in at wire time).
252
+ // Read-only GDPR operator inspector (SystemAdmin) screens stay inert until an app navs them.
255
253
  r.queryHandler(exportJobListQuery);
256
254
  r.queryHandler(exportJobDetailQuery);
257
255
  r.queryHandler(downloadAttemptListQuery);
@@ -316,6 +316,7 @@ async function processUser(args: {
316
316
  await entry.deleteHook(
317
317
  {
318
318
  db: tx,
319
+ registry,
319
320
  tenantId,
320
321
  userId,
321
322
  buildStorageProvider,
@@ -142,7 +142,7 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
142
142
  for (const tenantId of tenantList) {
143
143
  const entities: UserDataExportSnippet[] = [];
144
144
  for (const entry of hookEntries) {
145
- const rawSnippet = await entry.exportHook({ db, tenantId, userId });
145
+ const rawSnippet = await entry.exportHook({ db, registry, tenantId, userId });
146
146
  if (rawSnippet === null) continue;
147
147
  const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
148
148
  entities.push(snippet);
@@ -177,6 +177,7 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
177
177
  for (const entry of hookEntries) {
178
178
  const rawSnippet = await entry.exportHook({
179
179
  db,
180
+ registry,
180
181
  tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
181
182
  userId,
182
183
  });
@@ -45,5 +45,10 @@ export function userDataRightsClient(
45
45
  },
46
46
  };
47
47
  if (options?.publicDeletion === undefined) return base;
48
- return { ...base, gates: [makePublicDeletionGate(options.publicDeletion)] };
48
+ // Extend, not overwrite (570/2) — base.gates is empty today, but a spread
49
+ // that clobbers it instead of appending would silently drop future gates.
50
+ return {
51
+ ...base,
52
+ gates: [...(base.gates ?? []), makePublicDeletionGate(options.publicDeletion)],
53
+ };
49
54
  }
@@ -162,6 +162,7 @@ describe("S2.H1 :: userExportHook", () => {
162
162
 
163
163
  const result = await userExportHook({
164
164
  db: stack.db,
165
+ registry: stack.registry,
165
166
  tenantId: TENANT_A,
166
167
  userId: uuid(1001),
167
168
  });
@@ -182,6 +183,7 @@ describe("S2.H1 :: userExportHook", () => {
182
183
  test("returns null wenn User nicht existiert", async () => {
183
184
  const result = await userExportHook({
184
185
  db: stack.db,
186
+ registry: stack.registry,
185
187
  tenantId: TENANT_A,
186
188
  userId: uuid(1002),
187
189
  });
@@ -193,7 +195,10 @@ describe("S2.H1 :: userDeleteHook", () => {
193
195
  test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
194
196
  await seedUser(uuid(1003));
195
197
 
196
- await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1003) }, "delete");
198
+ await userDeleteHook(
199
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1003) },
200
+ "delete",
201
+ );
197
202
 
198
203
  const row = await fetchUser(uuid(1003));
199
204
  expect(row).not.toBeNull();
@@ -209,7 +214,10 @@ describe("S2.H1 :: userDeleteHook", () => {
209
214
  test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
210
215
  await seedUser(uuid(1004));
211
216
 
212
- await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1004) }, "anonymize");
217
+ await userDeleteHook(
218
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1004) },
219
+ "anonymize",
220
+ );
213
221
 
214
222
  const row = await fetchUser(uuid(1004));
215
223
  if (!row) throw new Error("row should exist");
@@ -222,13 +230,19 @@ describe("S2.H1 :: userDeleteHook", () => {
222
230
  test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
223
231
  await seedUser(uuid(1005));
224
232
 
225
- await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete");
233
+ await userDeleteHook(
234
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
235
+ "delete",
236
+ );
226
237
  const afterFirst = await fetchUser(uuid(1005));
227
238
  if (!afterFirst) throw new Error("user should exist after first delete");
228
239
 
229
240
  // Zweiter Call: kein Crash + State unverändert
230
241
  await expect(
231
- userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete"),
242
+ userDeleteHook(
243
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
244
+ "delete",
245
+ ),
232
246
  ).resolves.toBeUndefined();
233
247
 
234
248
  // State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
@@ -249,6 +263,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
249
263
 
250
264
  const result = await fileRefExportHook({
251
265
  db: stack.db,
266
+ registry: stack.registry,
252
267
  tenantId: TENANT_A,
253
268
  userId: "user-files-1",
254
269
  });
@@ -263,6 +278,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
263
278
  test("returns null wenn User keine Files hat", async () => {
264
279
  const result = await fileRefExportHook({
265
280
  db: stack.db,
281
+ registry: stack.registry,
266
282
  tenantId: TENANT_A,
267
283
  userId: "ghost-user-no-files",
268
284
  });
@@ -276,7 +292,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
276
292
  await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
277
293
 
278
294
  await fileRefDeleteHook(
279
- { db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
295
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-delete-files" },
280
296
  "delete",
281
297
  );
282
298
 
@@ -288,7 +304,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
288
304
  await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
289
305
 
290
306
  await fileRefDeleteHook(
291
- { db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
307
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-anon-files" },
292
308
  "anonymize",
293
309
  );
294
310
 
@@ -305,7 +321,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
305
321
  await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
306
322
 
307
323
  // Tenant A loescht alle Files von "shared-user"
308
- await fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "shared-user" }, "delete");
324
+ await fileRefDeleteHook(
325
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "shared-user" },
326
+ "delete",
327
+ );
309
328
 
310
329
  const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
311
330
  const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
@@ -319,7 +338,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
319
338
  await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
320
339
 
321
340
  await fileRefDeleteHook(
322
- { db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
341
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
323
342
  "delete",
324
343
  );
325
344
  const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
@@ -327,7 +346,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
327
346
 
328
347
  // Zweiter Call: kein Crash + State weiter 0 Files
329
348
  await expect(
330
- fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" }, "delete"),
349
+ fileRefDeleteHook(
350
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
351
+ "delete",
352
+ ),
331
353
  ).resolves.toBeUndefined();
332
354
  const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
333
355
  expect(afterSecond).toHaveLength(0);
@@ -76,6 +76,10 @@ describe("ProfileScreen", () => {
76
76
  expect(view.getByTestId("profile-email")).toBeTruthy();
77
77
  expect(view.getByTestId("profile-password")).toBeTruthy();
78
78
  expect(view.getByTestId("profile-danger")).toBeTruthy();
79
+ // 607/4: page heading is <h1> (Heading variant="page") — the danger-zone
80
+ // title must be <h2>, not Card's default <h3>, so the levels don't skip.
81
+ expect(view.getByTestId("profile-danger").querySelector("h2")).not.toBeNull();
82
+ expect(view.getByTestId("profile-danger").querySelector("h3")).toBeNull();
79
83
  expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
80
84
  expect(view.getByTestId("profile-danger-delete")).toBeTruthy();
81
85
  // Echte i18n: kein einziger roher Key im sichtbaren Text.
@@ -246,6 +246,17 @@ function DangerZoneSection({
246
246
  }): ReactNode {
247
247
  const t = useTranslation();
248
248
  const { Button, Banner, Dialog, Card } = usePrimitives();
249
+ // Card.slots.title always renders <h3> — with the page's <h1> (variant="page")
250
+ // as this screen's only other heading, that skips <h2> (607/4). An explicit
251
+ // header slot with a real <h2> keeps the levels contiguous; layout mirrors
252
+ // DefaultCard's own default-header markup.
253
+ const dangerZoneHeader = (
254
+ <div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-6 pb-4">
255
+ <h2 className="text-base font-semibold leading-none tracking-tight">
256
+ {t("profile.danger.title")}
257
+ </h2>
258
+ </div>
259
+ );
249
260
  const dispatcher = useDispatcher();
250
261
  const [dialogOpen, setDialogOpen] = useState(false);
251
262
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
@@ -290,7 +301,7 @@ function DangerZoneSection({
290
301
  <Card
291
302
  testId="profile-danger"
292
303
  className="border-destructive/40"
293
- slots={{ title: t("profile.danger.title"), footer }}
304
+ slots={{ header: dangerZoneHeader, footer }}
294
305
  >
295
306
  <div className="flex flex-col gap-4">
296
307
  {deletionRequested ? (