@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.
- package/package.json +6 -6
- package/src/auth-email-password/handlers/login.write.ts +4 -1
- package/src/auth-email-password/password-hashing.test.ts +35 -0
- package/src/auth-email-password/password-hashing.ts +11 -0
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +29 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +18 -1
- package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
- package/src/auth-email-password/web/reset-password-screen.tsx +6 -11
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
- package/src/auth-email-password/web/user-menu.tsx +3 -1
- package/src/auth-email-password/web/verify-email-screen.tsx +2 -7
- package/src/custom-fields/events.ts +6 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/update-tenant-field.write.ts +10 -0
- package/src/custom-fields/wire-for-entity.ts +16 -4
- package/src/data-retention/__tests__/parse-override.test.ts +2 -7
- package/src/folders/__tests__/folders.integration.test.ts +3 -0
- package/src/folders/web/__tests__/tree.test.ts +8 -0
- package/src/folders/web/folder-manager.tsx +1 -3
- package/src/folders/web/tree.ts +13 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +1 -0
- package/src/ledger/__tests__/ledger.integration.test.ts +8 -3
- package/src/ledger/handlers/confirm-schedule-period.write.ts +9 -1
- package/src/legal-pages/feature.ts +1 -3
- package/src/managed-pages/feature.ts +1 -3
- package/src/tags/__tests__/schemas.test.ts +34 -0
- package/src/tags/__tests__/tags.integration.test.ts +12 -1
- package/src/tags/entity.ts +6 -1
- package/src/tags/schemas.ts +9 -2
- package/src/tags/web/__tests__/tag-manager.test.tsx +64 -0
- package/src/tags/web/tag-manager.tsx +1 -1
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +108 -0
- package/src/user-data-rights/feature.ts +1 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -0
- package/src/user-data-rights/run-user-export.ts +2 -1
- package/src/user-data-rights/web/client-plugin.tsx +6 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +31 -9
- package/src/user-profile/__tests__/profile-screen.test.tsx +4 -0
- 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.
|
|
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.
|
|
99
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-web": "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,
|
|
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
|
|
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
|
|
9
|
-
//
|
|
10
|
-
// injected Token-Prop
|
|
11
|
-
//
|
|
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
|
|
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:
|
|
10
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
|
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
|
|
package/src/folders/web/tree.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
423
|
+
const denied = await defaultStack.http.writeErr(
|
|
413
424
|
TagsHandlers.createTag,
|
|
414
425
|
{ name: "nope" },
|
|
415
426
|
unprivileged,
|
package/src/tags/entity.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/tags/schemas.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
}
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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={{
|
|
304
|
+
slots={{ header: dangerZoneHeader, footer }}
|
|
294
305
|
>
|
|
295
306
|
<div className="flex flex-col gap-4">
|
|
296
307
|
{deletionRequested ? (
|