@cosmicdrift/kumiko-bundled-features 0.112.0 → 0.113.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) 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/web/__tests__/tree.test.ts +8 -0
  18. package/src/folders/web/folder-manager.tsx +1 -3
  19. package/src/folders/web/tree.ts +13 -2
  20. package/src/ledger/__tests__/ledger.integration.test.ts +8 -3
  21. package/src/ledger/handlers/confirm-schedule-period.write.ts +9 -1
  22. package/src/legal-pages/feature.ts +1 -3
  23. package/src/managed-pages/feature.ts +1 -3
  24. package/src/tags/__tests__/schemas.test.ts +34 -0
  25. package/src/tags/__tests__/tags.integration.test.ts +12 -1
  26. package/src/tags/entity.ts +6 -1
  27. package/src/tags/schemas.ts +9 -2
  28. package/src/tags/web/__tests__/tag-manager.test.tsx +64 -0
  29. package/src/tags/web/tag-manager.tsx +1 -1
  30. package/src/user-data-rights/feature.ts +1 -3
  31. package/src/user-data-rights/web/client-plugin.tsx +6 -1
  32. package/src/user-profile/__tests__/profile-screen.test.tsx +4 -0
  33. 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.112.0",
3
+ "version": "0.113.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -95,11 +95,11 @@
95
95
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
96
96
  },
97
97
  "dependencies": {
98
- "@cosmicdrift/kumiko-dispatcher-live": "0.112.0",
99
- "@cosmicdrift/kumiko-framework": "0.112.0",
100
- "@cosmicdrift/kumiko-headless": "0.112.0",
101
- "@cosmicdrift/kumiko-renderer": "0.112.0",
102
- "@cosmicdrift/kumiko-renderer-web": "0.112.0",
98
+ "@cosmicdrift/kumiko-dispatcher-live": "0.113.1",
99
+ "@cosmicdrift/kumiko-framework": "0.113.1",
100
+ "@cosmicdrift/kumiko-headless": "0.113.1",
101
+ "@cosmicdrift/kumiko-renderer": "0.113.1",
102
+ "@cosmicdrift/kumiko-renderer-web": "0.113.1",
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
 
@@ -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
  }
@@ -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
  >
@@ -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);
@@ -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
  }
@@ -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 ? (