@cosmicdrift/kumiko-bundled-features 0.211.0 → 0.213.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 +8 -8
- package/src/admin-shell/feature.ts +2 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
- package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
- package/src/audit/constants.ts +3 -0
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +1 -0
- package/src/audit/web/audit-log-detail-screen.tsx +28 -6
- package/src/audit/web/audit-log-screen.tsx +51 -23
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -2
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +175 -2
- package/src/auth-email-password/feature.ts +9 -4
- package/src/auth-email-password/handlers/invite-create.write.ts +14 -4
- package/src/auth-email-password/handlers/signup-request.write.ts +48 -34
- package/src/auth-email-password/handlers/token-request-handler.ts +20 -4
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/magic-link-mail.ts +11 -2
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +15 -6
- package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
- package/src/auth-email-password/web/auth-gate.tsx +1 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
- package/src/auth-email-password/web/session.tsx +1 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +29 -14
- package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
- package/src/auth-mfa/feature.ts +1 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
- package/src/compliance-profiles/feature.ts +1 -5
- package/src/config/db/queries/__tests__/resolver.test.ts +64 -0
- package/src/config/db/queries/resolver.ts +5 -3
- package/src/crypto-shredding/changes.json +2 -1
- package/src/custom-fields/__tests__/feature.test.ts +42 -0
- package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
- package/src/custom-fields/constants.ts +6 -0
- package/src/custom-fields/feature.ts +56 -10
- package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
- package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
- package/src/delivery/feature.ts +1 -3
- package/src/feature-toggles/feature.ts +1 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
- package/src/folders/web/folder-manager.tsx +1 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +141 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +152 -11
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
- package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
- package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
- package/src/jobs/feature.ts +25 -1
- package/src/jobs/handlers/detail.query.ts +13 -1
- package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
- package/src/jobs/job-run-logger.ts +68 -18
- package/src/jobs/web/job-run-detail-screen.tsx +1 -0
- package/src/page-render/web.ts +1 -0
- package/src/personal-access-tokens/feature.ts +1 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
- package/src/secrets/constants.ts +12 -0
- package/src/secrets/feature.ts +35 -9
- package/src/secrets/handlers/delete.write.ts +23 -16
- package/src/secrets/handlers/list.query.ts +37 -30
- package/src/secrets/handlers/set.write.ts +43 -36
- package/src/secrets/index.ts +5 -0
- package/src/seo/feature.ts +1 -0
- package/src/tags/feature.ts +1 -0
- package/src/tags/web/tag-chip.tsx +2 -0
- package/src/tags/web/tag-manager.tsx +2 -1
- package/src/tags/web/tag-picker.tsx +1 -0
- package/src/template-resolver/web/client-plugin.tsx +2 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
- package/src/user-data-rights/feature.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/src/user-profile/i18n.ts +1 -0
- package/src/user-profile/web/profile-screen.tsx +1 -1
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// SignupCompleteScreen — Magic-Link-Self-Signup, Step 2.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// Submit
|
|
6
|
-
//
|
|
7
|
-
// Post-Signup-Redirect.
|
|
4
|
+
// Reads `?token=...` from the URL, shows a form with password + confirm.
|
|
5
|
+
// Submit posts to /api/auth/signup-confirm — on success the server sets
|
|
6
|
+
// JWT + cookies (auto-login!) and returns the tenantKey.
|
|
8
7
|
//
|
|
9
|
-
// Token
|
|
10
|
-
//
|
|
11
|
-
// server-
|
|
8
|
+
// Token source is read-once via useUrlToken: reads `?token=...` on mount
|
|
9
|
+
// and scrubs the param from the URL afterwards (#774). Apps that inject a
|
|
10
|
+
// server-side token pass `token` as a prop instead.
|
|
12
11
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
12
|
+
// On success: shows a confirmation (account active, signed in) with a
|
|
13
|
+
// button to loggedInHref, instead of navigating away immediately — the
|
|
14
|
+
// server already logged the user in via cookies, but a silent redirect
|
|
15
|
+
// leaves no signal that activation worked. Default pattern is "/" — apps
|
|
16
|
+
// with multi-tenant routing pass `(data) => "/" + data.tenantKey + "/"`.
|
|
17
17
|
|
|
18
18
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
19
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
@@ -50,6 +50,7 @@ export function SignupCompleteScreen({
|
|
|
50
50
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
51
51
|
const [submitting, setSubmitting] = useState(false);
|
|
52
52
|
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [continueHref, setContinueHref] = useState<string | null>(null);
|
|
53
54
|
|
|
54
55
|
const doSubmit = async (): Promise<void> => {
|
|
55
56
|
setError(null);
|
|
@@ -66,9 +67,10 @@ export function SignupCompleteScreen({
|
|
|
66
67
|
const res = await confirmSignup(token, password);
|
|
67
68
|
setSubmitting(false);
|
|
68
69
|
if (res.ok) {
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
70
|
+
// Cookies are already set (auto-login). Show a confirmation with an
|
|
71
|
+
// explicit continue button instead of navigating away silently —
|
|
72
|
+
// the user otherwise gets no signal that activation worked.
|
|
73
|
+
setContinueHref(resolveLoggedInHref(loggedInHref, res.data.tenantKey));
|
|
72
74
|
return;
|
|
73
75
|
}
|
|
74
76
|
if (res.error.reason === "invalid_signup_token") {
|
|
@@ -104,6 +106,19 @@ export function SignupCompleteScreen({
|
|
|
104
106
|
);
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
if (continueHref !== null) {
|
|
110
|
+
return (
|
|
111
|
+
<AuthCard title={effectiveTitle}>
|
|
112
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
113
|
+
<p className="text-sm text-muted-foreground">{t("auth.signupComplete.activated")}</p>
|
|
114
|
+
<Link href={continueHref} variant="button">
|
|
115
|
+
{t("auth.signupComplete.continue")}
|
|
116
|
+
</Link>
|
|
117
|
+
</div>
|
|
118
|
+
</AuthCard>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
107
122
|
return (
|
|
108
123
|
<AuthCard title={effectiveTitle}>
|
|
109
124
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
@@ -36,6 +36,7 @@ export function VerifyEmailScreen({
|
|
|
36
36
|
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
37
37
|
const startedRef = useRef(false);
|
|
38
38
|
|
|
39
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
39
40
|
useEffect(() => {
|
|
40
41
|
if (status !== "verifying") return;
|
|
41
42
|
if (startedRef.current) return;
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -130,6 +130,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
130
130
|
// convention). App places it via r.nav in its logged-in settings area.
|
|
131
131
|
// dormant: true skips createKumikoApp's missing-client-plugin boot
|
|
132
132
|
// diagnostic (#2025) for apps that don't nav this screen (#2034).
|
|
133
|
+
// kumiko-lint-ignore app-feature-structure Multi-step auth flow (enable + recovery-codes reveal), no declarative screen type covers this
|
|
133
134
|
r.screen({
|
|
134
135
|
id: MFA_ENABLE_SCREEN_ID,
|
|
135
136
|
type: "custom",
|
|
@@ -21,6 +21,7 @@ export type MfaDisableDialogProps = {
|
|
|
21
21
|
readonly onError: (i18nKey: string) => void;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Dialog internally, name is an MFA domain action, not a primitive reimplementation
|
|
24
25
|
export function MfaDisableDialog({
|
|
25
26
|
open,
|
|
26
27
|
onOpenChange,
|
|
@@ -18,6 +18,7 @@ export type MfaRegenerateRecoveryDialogProps = {
|
|
|
18
18
|
readonly onError: (i18nKey: string) => void;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Dialog internally, name is an MFA domain action, not a primitive reimplementation
|
|
21
22
|
export function MfaRegenerateRecoveryDialog({
|
|
22
23
|
open,
|
|
23
24
|
onOpenChange,
|
|
@@ -103,11 +103,7 @@ export function createComplianceProfilesFeature(
|
|
|
103
103
|
id: "profile-picker",
|
|
104
104
|
label: "compliance-profiles:nav.profilePicker",
|
|
105
105
|
screen: "compliance-profiles:screen:profile-picker",
|
|
106
|
-
|
|
107
|
-
// vocabulary (packages/types/src/nav-icon.ts) — adding it needs a
|
|
108
|
-
// matching lucide-react entry in renderer-web's NAV_ICONS map too, both
|
|
109
|
-
// outside this feature. "shield" is the closest already-registered key.
|
|
110
|
-
icon: "shield",
|
|
106
|
+
icon: "shield-check",
|
|
111
107
|
order: 50,
|
|
112
108
|
});
|
|
113
109
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// #1163: config:query:readiness read config rows via asRawClient(db).unsafe()
|
|
2
|
+
// directly, bypassing the #1358 closed-connection retry that only covered
|
|
3
|
+
// bun-db/query.ts's own selectMany/countWhere. Routed through the exported
|
|
4
|
+
// unsafeReadRetrying helper instead — this test mirrors
|
|
5
|
+
// bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
|
|
6
|
+
// the retry now actually fires for this call site.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { selectConfigRowsForKeys, selectConfigRowsForScope } from "../resolver";
|
|
10
|
+
|
|
11
|
+
function closedConnectionError(): Error {
|
|
12
|
+
return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type FakeClient = {
|
|
16
|
+
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
17
|
+
begin: () => never;
|
|
18
|
+
calls: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function fakeClient(failures: Error[]): FakeClient {
|
|
22
|
+
const remaining = [...failures];
|
|
23
|
+
const client: FakeClient = {
|
|
24
|
+
calls: 0,
|
|
25
|
+
unsafe: async () => {
|
|
26
|
+
client.calls++;
|
|
27
|
+
const err = remaining.shift();
|
|
28
|
+
if (err) throw err;
|
|
29
|
+
return [{ id: "r1", key: "k", value: "v", tenantId: "t1", userId: null }];
|
|
30
|
+
},
|
|
31
|
+
// Top-level pool client — matches what dispatch-query.ts hands buildHandlerContext
|
|
32
|
+
// for a standalone query.execute() call (no tx passed, resolveDbSource falls back
|
|
33
|
+
// to the pool connection, which has .begin()).
|
|
34
|
+
begin: () => {
|
|
35
|
+
throw new Error("not used in test");
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return client;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("config db/queries/resolver — closed-connection retry (#1163)", () => {
|
|
42
|
+
test("selectConfigRowsForScope retries once and returns rows", async () => {
|
|
43
|
+
const db = fakeClient([closedConnectionError()]);
|
|
44
|
+
const rows = await selectConfigRowsForScope(db as never, "system", "t1", "u1");
|
|
45
|
+
expect(rows).toHaveLength(1);
|
|
46
|
+
expect(rows[0]?.key).toBe("k");
|
|
47
|
+
expect(db.calls).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("selectConfigRowsForKeys retries once and returns rows", async () => {
|
|
51
|
+
const db = fakeClient([closedConnectionError()]);
|
|
52
|
+
const rows = await selectConfigRowsForKeys(db as never, ["k"], "system", "t1", "u1");
|
|
53
|
+
expect(rows).toHaveLength(1);
|
|
54
|
+
expect(db.calls).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("gives up after the single retry when the connection stays closed", async () => {
|
|
58
|
+
const db = fakeClient([closedConnectionError(), closedConnectionError()]);
|
|
59
|
+
await expect(selectConfigRowsForScope(db as never, "system", "t1", "u1")).rejects.toThrow(
|
|
60
|
+
"connection was closed",
|
|
61
|
+
);
|
|
62
|
+
expect(db.calls).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
|
|
@@ -16,7 +16,8 @@ export async function selectConfigRowsForScope(
|
|
|
16
16
|
tenantId: TenantId,
|
|
17
17
|
userId: string,
|
|
18
18
|
): Promise<readonly ConfigRow[]> {
|
|
19
|
-
return
|
|
19
|
+
return unsafeReadRetrying<ConfigRow>(
|
|
20
|
+
db,
|
|
20
21
|
`SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
|
|
21
22
|
FROM read_config_values
|
|
22
23
|
WHERE (tenant_id = $1 AND user_id IS NULL)
|
|
@@ -33,7 +34,8 @@ export async function selectConfigRowsForKeys(
|
|
|
33
34
|
tenantId: TenantId,
|
|
34
35
|
userId: string,
|
|
35
36
|
): Promise<readonly ConfigRow[]> {
|
|
36
|
-
return
|
|
37
|
+
return unsafeReadRetrying<ConfigRow>(
|
|
38
|
+
db,
|
|
37
39
|
`SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
|
|
38
40
|
FROM read_config_values
|
|
39
41
|
WHERE key = ANY($1)
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"type": "breaking",
|
|
17
17
|
"title": "Test-only reset helpers moved from /crypto to /testing (fw#1631).",
|
|
18
18
|
"detail": "resetPiiSubjectKmsForTests and resetBlindIndexKeyForTests are no longer exported by the production barrels. The functions did not change — only the export path. Why it matters beyond tidiness: resetPiiSubjectKmsForTests clears the injected KMS, after which encryptForStorage sees no adapter and writes subject-annotated fields in plaintext, with no error and no log. Reachable from a production barrel, that is one stray import away from silent plaintext PII.",
|
|
19
|
-
"migration": "Change the import in your test files: `import { resetPiiSubjectKmsForTests } from \"@cosmicdrift/kumiko-framework/testing\"` instead of `.../crypto`. Type-check catches every occurrence; nothing else changes. Apps mounting crypto-shredding typically hit this in every test that configures an InMemoryKmsAdapter."
|
|
19
|
+
"migration": "Change the import in your test files: `import { resetPiiSubjectKmsForTests } from \"@cosmicdrift/kumiko-framework/testing\"` instead of `.../crypto`. Type-check catches every occurrence; nothing else changes. Apps mounting crypto-shredding typically hit this in every test that configures an InMemoryKmsAdapter.",
|
|
20
|
+
"codemod": "scripts/codemod/crypto-shredding-testing-move.ts"
|
|
20
21
|
},
|
|
21
22
|
{
|
|
22
23
|
"version": "0.165.3",
|
|
@@ -189,6 +189,33 @@ describe("createCustomFieldsFeature access-options", () => {
|
|
|
189
189
|
});
|
|
190
190
|
expect(listAccess(feature)).toEqual(["Viewer"]);
|
|
191
191
|
});
|
|
192
|
+
|
|
193
|
+
// #2296 — define-/update-/delete-tenant-field were hard-wired to
|
|
194
|
+
// ["TenantAdmin"] with no override; fieldDefinitionWriteRoles closes that gap.
|
|
195
|
+
test("fieldDefinitionWriteRoles überschreibt define-, update- UND delete-tenant-field", () => {
|
|
196
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin", "Editor"] });
|
|
197
|
+
expect(writeAccess(feature, "define-tenant-field")).toEqual(["Admin", "Editor"]);
|
|
198
|
+
expect(writeAccess(feature, "update-tenant-field")).toEqual(["Admin", "Editor"]);
|
|
199
|
+
expect(writeAccess(feature, "delete-tenant-field")).toEqual(["Admin", "Editor"]);
|
|
200
|
+
// System-scope stays hard-wired — a different trust boundary that must not
|
|
201
|
+
// become reachable through a tenant role vocabulary.
|
|
202
|
+
expect(writeAccess(feature, "define-system-field")).toEqual(["SystemAdmin"]);
|
|
203
|
+
expect(writeAccess(feature, "delete-system-field")).toEqual(["SystemAdmin"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("fieldDefinitionWriteRoles feeds into the list-default (a definer can load what they defined)", () => {
|
|
207
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin", "Editor"] });
|
|
208
|
+
const roles = listAccess(feature);
|
|
209
|
+
expect(roles).toContain("Admin");
|
|
210
|
+
expect(roles).toContain("Editor");
|
|
211
|
+
expect(roles).toContain("TenantAdmin");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("fieldDefinitionWriteRoles and valueWriteRoles are independent — setting one leaves the other's default", () => {
|
|
215
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin"] });
|
|
216
|
+
expect(writeAccess(feature, "set-custom-field")).toEqual(["TenantAdmin", "TenantMember"]);
|
|
217
|
+
expect(writeAccess(feature, "clear-custom-field")).toEqual(["TenantAdmin", "TenantMember"]);
|
|
218
|
+
});
|
|
192
219
|
});
|
|
193
220
|
|
|
194
221
|
describe("resolveFieldDefinitionListRoles", () => {
|
|
@@ -216,4 +243,19 @@ describe("resolveFieldDefinitionListRoles", () => {
|
|
|
216
243
|
}),
|
|
217
244
|
).toEqual(["Viewer"]);
|
|
218
245
|
});
|
|
246
|
+
|
|
247
|
+
test("fieldDefinitionWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
|
|
248
|
+
expect(
|
|
249
|
+
resolveFieldDefinitionListRoles({ fieldDefinitionWriteRoles: ["Admin", "Editor"] }),
|
|
250
|
+
).toEqual(["Admin", "Editor", "TenantAdmin"]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("valueWriteRoles UND fieldDefinitionWriteRoles gesetzt → Union aus beiden plus Default", () => {
|
|
254
|
+
expect(
|
|
255
|
+
resolveFieldDefinitionListRoles({
|
|
256
|
+
valueWriteRoles: ["Member"],
|
|
257
|
+
fieldDefinitionWriteRoles: ["Admin"],
|
|
258
|
+
}),
|
|
259
|
+
).toEqual(["Member", "Admin", "TenantAdmin"]);
|
|
260
|
+
});
|
|
219
261
|
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// #2296 — role-configurability of the definition-CRUD triad (define-/update-/
|
|
2
|
+
// delete-tenant-field), which was hard-wired to ["TenantAdmin"] with no override.
|
|
3
|
+
// Verifies createCustomFieldsFeature({ fieldDefinitionWriteRoles }) actually
|
|
4
|
+
// gates all three handlers with the configured roles (not just define), that the
|
|
5
|
+
// default-role user loses access once the override is set (Role-Naming-Drift is
|
|
6
|
+
// real, not just documented), and that a definer without an explicit
|
|
7
|
+
// fieldDefinitionListRoles override can still list what they defined.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
|
+
import {
|
|
13
|
+
createTestUser,
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { fieldDefinitionEntity } from "../entity";
|
|
19
|
+
import { createCustomFieldsFeature } from "../feature";
|
|
20
|
+
|
|
21
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
22
|
+
const customRoleUser = createTestUser({ roles: ["Admin"] });
|
|
23
|
+
|
|
24
|
+
function defPayload(fieldKey: string) {
|
|
25
|
+
return {
|
|
26
|
+
entityName: "customer",
|
|
27
|
+
fieldKey,
|
|
28
|
+
serializedField: { type: "text" },
|
|
29
|
+
required: false,
|
|
30
|
+
searchable: false,
|
|
31
|
+
displayOrder: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("custom-fields — fieldDefinitionWriteRoles (#2296)", () => {
|
|
36
|
+
let stack: TestStack;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
stack = await setupTestStack({
|
|
40
|
+
features: [createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin"] })],
|
|
41
|
+
});
|
|
42
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
43
|
+
await createEventsTable(stack.db);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await stack.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
52
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_custom_field_definitions");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a custom-role user can define, update and delete a tenant field definition", async () => {
|
|
56
|
+
await stack.http.writeOk(
|
|
57
|
+
"custom-fields:write:define-tenant-field",
|
|
58
|
+
defPayload("vip"),
|
|
59
|
+
customRoleUser,
|
|
60
|
+
);
|
|
61
|
+
const afterDefine = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
62
|
+
"custom-fields:query:field-definition:list",
|
|
63
|
+
{},
|
|
64
|
+
customRoleUser,
|
|
65
|
+
);
|
|
66
|
+
const defined = afterDefine.rows.find((r) => r["fieldKey"] === "vip");
|
|
67
|
+
expect(defined).toBeDefined();
|
|
68
|
+
expect(defined?.["searchable"]).toBe(false);
|
|
69
|
+
|
|
70
|
+
await stack.http.writeOk(
|
|
71
|
+
"custom-fields:write:update-tenant-field",
|
|
72
|
+
{ ...defPayload("vip"), searchable: true },
|
|
73
|
+
customRoleUser,
|
|
74
|
+
);
|
|
75
|
+
const afterUpdate = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
76
|
+
"custom-fields:query:field-definition:list",
|
|
77
|
+
{},
|
|
78
|
+
customRoleUser,
|
|
79
|
+
);
|
|
80
|
+
const updated = afterUpdate.rows.find((r) => r["fieldKey"] === "vip");
|
|
81
|
+
expect(updated?.["searchable"]).toBe(true);
|
|
82
|
+
|
|
83
|
+
await stack.http.writeOk(
|
|
84
|
+
"custom-fields:write:delete-tenant-field",
|
|
85
|
+
{ entityName: "customer", fieldKey: "vip" },
|
|
86
|
+
customRoleUser,
|
|
87
|
+
);
|
|
88
|
+
const afterDelete = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
89
|
+
"custom-fields:query:field-definition:list",
|
|
90
|
+
{},
|
|
91
|
+
customRoleUser,
|
|
92
|
+
);
|
|
93
|
+
expect(afterDelete.rows.some((r) => r["fieldKey"] === "vip")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the default TenantAdmin role is denied on all three once fieldDefinitionWriteRoles overrides it", async () => {
|
|
97
|
+
const defineDenied = await stack.http.writeErr(
|
|
98
|
+
"custom-fields:write:define-tenant-field",
|
|
99
|
+
defPayload("vip2"),
|
|
100
|
+
admin,
|
|
101
|
+
);
|
|
102
|
+
expect(defineDenied.httpStatus).toBe(403);
|
|
103
|
+
|
|
104
|
+
await stack.http.writeOk(
|
|
105
|
+
"custom-fields:write:define-tenant-field",
|
|
106
|
+
defPayload("vip2"),
|
|
107
|
+
customRoleUser,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const updateDenied = await stack.http.writeErr(
|
|
111
|
+
"custom-fields:write:update-tenant-field",
|
|
112
|
+
{ ...defPayload("vip2"), searchable: true },
|
|
113
|
+
admin,
|
|
114
|
+
);
|
|
115
|
+
expect(updateDenied.httpStatus).toBe(403);
|
|
116
|
+
|
|
117
|
+
const deleteDenied = await stack.http.writeErr(
|
|
118
|
+
"custom-fields:write:delete-tenant-field",
|
|
119
|
+
{ entityName: "customer", fieldKey: "vip2" },
|
|
120
|
+
admin,
|
|
121
|
+
);
|
|
122
|
+
expect(deleteDenied.httpStatus).toBe(403);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a definer without an explicit fieldDefinitionListRoles override can list their own definitions", async () => {
|
|
126
|
+
await stack.http.writeOk(
|
|
127
|
+
"custom-fields:write:define-tenant-field",
|
|
128
|
+
defPayload("vip3"),
|
|
129
|
+
customRoleUser,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const list = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
133
|
+
"custom-fields:query:field-definition:list",
|
|
134
|
+
{},
|
|
135
|
+
customRoleUser,
|
|
136
|
+
);
|
|
137
|
+
expect(list.rows.some((r) => r["fieldKey"] === "vip3")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -59,6 +59,12 @@ export const CUSTOM_FIELDS_EXTENSION = "customFields";
|
|
|
59
59
|
// für jeden App-User access_denied.
|
|
60
60
|
export const DEFAULT_VALUE_WRITE_ROLES = ["TenantAdmin", "TenantMember"] as const;
|
|
61
61
|
export const DEFAULT_FIELD_DEFINITION_LIST_ROLES = ["TenantAdmin"] as const;
|
|
62
|
+
// Default-RBAC of the definition-CRUD triad (define-/update-/delete-tenant-field,
|
|
63
|
+
// #2296). Apps with their own role vocabulary override via
|
|
64
|
+
// createCustomFieldsFeature({ fieldDefinitionWriteRoles }) — otherwise those three
|
|
65
|
+
// hard-wired handler QNs are access_denied for every app-user without a
|
|
66
|
+
// "TenantAdmin" role (same Role-Naming-Drift as DEFAULT_VALUE_WRITE_ROLES above).
|
|
67
|
+
export const DEFAULT_FIELD_DEFINITION_WRITE_ROLES = ["TenantAdmin"] as const;
|
|
62
68
|
|
|
63
69
|
// Field-type union — identisch zu Stammfeld-Field-Type-System (Spec Z.59-73:
|
|
64
70
|
// `Identisch zu Entity-Feld-Typen`). Builder-Reuse-Promise: was `r.field.X()`
|
|
@@ -73,12 +73,18 @@ import {
|
|
|
73
73
|
defineTenantFieldHandler,
|
|
74
74
|
} from "./handlers/define-tenant-field.write";
|
|
75
75
|
import { deleteSystemFieldHandler } from "./handlers/delete-system-field.write";
|
|
76
|
-
import {
|
|
76
|
+
import {
|
|
77
|
+
createDeleteTenantFieldHandler,
|
|
78
|
+
deleteTenantFieldHandler,
|
|
79
|
+
} from "./handlers/delete-tenant-field.write";
|
|
77
80
|
import {
|
|
78
81
|
createSetCustomFieldHandler,
|
|
79
82
|
setCustomFieldHandler,
|
|
80
83
|
} from "./handlers/set-custom-field.write";
|
|
81
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
createUpdateTenantFieldHandler,
|
|
86
|
+
updateTenantFieldHandler,
|
|
87
|
+
} from "./handlers/update-tenant-field.write";
|
|
82
88
|
|
|
83
89
|
const fieldDefinitionDeletedSchema = z.object({
|
|
84
90
|
entityName: z.string(),
|
|
@@ -97,6 +103,8 @@ const fieldDefinitionDeletedSchema = z.object({
|
|
|
97
103
|
// Options-Variante verfehlt.
|
|
98
104
|
type RegisterVariant = {
|
|
99
105
|
readonly defineTenantHandler: WriteHandlerDef;
|
|
106
|
+
readonly updateTenantHandler: WriteHandlerDef;
|
|
107
|
+
readonly deleteTenantHandler: WriteHandlerDef;
|
|
100
108
|
readonly setHandler: WriteHandlerDef;
|
|
101
109
|
readonly clearHandler: WriteHandlerDef;
|
|
102
110
|
readonly fieldDefinitionListRoles: readonly string[];
|
|
@@ -141,8 +149,8 @@ function registerCustomFields(
|
|
|
141
149
|
// Definition-CRUD handlers (B1; update kam mit Bug-Bash D2 2026-06-08).
|
|
142
150
|
r.writeHandler(variant.defineTenantHandler);
|
|
143
151
|
r.writeHandler(defineSystemFieldHandler);
|
|
144
|
-
r.writeHandler(
|
|
145
|
-
r.writeHandler(
|
|
152
|
+
r.writeHandler(variant.updateTenantHandler);
|
|
153
|
+
r.writeHandler(variant.deleteTenantHandler);
|
|
146
154
|
r.writeHandler(deleteSystemFieldHandler);
|
|
147
155
|
|
|
148
156
|
// Value-write handlers (B2). Emittieren events auf host-aggregate-stream.
|
|
@@ -169,6 +177,8 @@ function registerCustomFields(
|
|
|
169
177
|
export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
170
178
|
registerCustomFields(r, {
|
|
171
179
|
defineTenantHandler: defineTenantFieldHandler,
|
|
180
|
+
updateTenantHandler: updateTenantFieldHandler,
|
|
181
|
+
deleteTenantHandler: deleteTenantFieldHandler,
|
|
172
182
|
setHandler: setCustomFieldHandler,
|
|
173
183
|
clearHandler: clearCustomFieldHandler,
|
|
174
184
|
fieldDefinitionListRoles: DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
@@ -191,14 +201,37 @@ export type CustomFieldsFeatureOptions = {
|
|
|
191
201
|
* FormSection für Value-Writer nie (access_denied), während der Save-Pfad
|
|
192
202
|
* offen wäre (asymmetrischer Bruch). */
|
|
193
203
|
readonly fieldDefinitionListRoles?: readonly string[];
|
|
204
|
+
/** Access roles for the definition-CRUD triad (`define-tenant-field`,
|
|
205
|
+
* `update-tenant-field`, `delete-tenant-field`) — hard-wired to ["TenantAdmin"]
|
|
206
|
+
* before #2296. Apps with their own role vocabulary (e.g. ["Admin","Editor"])
|
|
207
|
+
* MUST set this, else field-definition management is access_denied for every
|
|
208
|
+
* app-user without a "TenantAdmin" role. Only covers the tenant-scope triad —
|
|
209
|
+
* define-/delete-system-field stay hard-wired to ["TenantAdmin"], since
|
|
210
|
+
* system-scope is a separate trust boundary that must not become reachable
|
|
211
|
+
* through a tenant role vocabulary. Also feeds into
|
|
212
|
+
* resolveFieldDefinitionListRoles (below) so a definer without an explicit
|
|
213
|
+
* fieldDefinitionListRoles override can still load the definitions they can
|
|
214
|
+
* write — mirrors the valueWriteRoles → list-default inheritance. */
|
|
215
|
+
readonly fieldDefinitionWriteRoles?: readonly string[];
|
|
194
216
|
};
|
|
195
217
|
|
|
196
218
|
export function resolveFieldDefinitionListRoles(
|
|
197
|
-
opts: Pick<
|
|
219
|
+
opts: Pick<
|
|
220
|
+
CustomFieldsFeatureOptions,
|
|
221
|
+
"valueWriteRoles" | "fieldDefinitionListRoles" | "fieldDefinitionWriteRoles"
|
|
222
|
+
>,
|
|
198
223
|
): readonly string[] {
|
|
199
224
|
if (opts.fieldDefinitionListRoles !== undefined) return opts.fieldDefinitionListRoles;
|
|
200
|
-
if (opts.valueWriteRoles === undefined)
|
|
201
|
-
|
|
225
|
+
if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
|
|
226
|
+
return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
|
|
227
|
+
}
|
|
228
|
+
return [
|
|
229
|
+
...new Set([
|
|
230
|
+
...(opts.valueWriteRoles ?? []),
|
|
231
|
+
...(opts.fieldDefinitionWriteRoles ?? []),
|
|
232
|
+
...DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
233
|
+
]),
|
|
234
|
+
];
|
|
202
235
|
}
|
|
203
236
|
|
|
204
237
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
@@ -212,17 +245,30 @@ export function createCustomFieldsFeature(
|
|
|
212
245
|
const hasOptions =
|
|
213
246
|
opts.fieldDefinitionLimitPerTenant !== undefined ||
|
|
214
247
|
opts.valueWriteRoles !== undefined ||
|
|
215
|
-
opts.fieldDefinitionListRoles !== undefined
|
|
248
|
+
opts.fieldDefinitionListRoles !== undefined ||
|
|
249
|
+
opts.fieldDefinitionWriteRoles !== undefined;
|
|
216
250
|
if (!hasOptions) {
|
|
217
251
|
return customFieldsFeature;
|
|
218
252
|
}
|
|
219
253
|
const limit = opts.fieldDefinitionLimitPerTenant;
|
|
254
|
+
const definitionWriteRoles = opts.fieldDefinitionWriteRoles;
|
|
220
255
|
return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
221
256
|
registerCustomFields(r, {
|
|
222
257
|
defineTenantHandler:
|
|
223
|
-
limit !== undefined
|
|
224
|
-
? createDefineTenantFieldHandler({
|
|
258
|
+
limit !== undefined || definitionWriteRoles !== undefined
|
|
259
|
+
? createDefineTenantFieldHandler({
|
|
260
|
+
fieldDefinitionLimitPerTenant: limit,
|
|
261
|
+
roles: definitionWriteRoles,
|
|
262
|
+
})
|
|
225
263
|
: defineTenantFieldHandler,
|
|
264
|
+
updateTenantHandler:
|
|
265
|
+
definitionWriteRoles !== undefined
|
|
266
|
+
? createUpdateTenantFieldHandler({ roles: definitionWriteRoles })
|
|
267
|
+
: updateTenantFieldHandler,
|
|
268
|
+
deleteTenantHandler:
|
|
269
|
+
definitionWriteRoles !== undefined
|
|
270
|
+
? createDeleteTenantFieldHandler({ roles: definitionWriteRoles })
|
|
271
|
+
: deleteTenantFieldHandler,
|
|
226
272
|
setHandler: createSetCustomFieldHandler(opts.valueWriteRoles),
|
|
227
273
|
clearHandler: createClearCustomFieldHandler(opts.valueWriteRoles),
|
|
228
274
|
fieldDefinitionListRoles: resolveFieldDefinitionListRoles(opts),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
4
|
+
import { DEFAULT_FIELD_DEFINITION_WRITE_ROLES } from "../constants";
|
|
4
5
|
import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect";
|
|
5
6
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
6
7
|
import { countTenantFieldDefinitions } from "../lib/quota";
|
|
@@ -37,6 +38,11 @@ import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
|
37
38
|
export interface DefineTenantFieldOptions {
|
|
38
39
|
/** Soft cap — `>= limit` definitions per tenant rejects further defines (see header: concurrent defines may overshoot). */
|
|
39
40
|
readonly fieldDefinitionLimitPerTenant?: number;
|
|
41
|
+
/** Access roles for this handler. Default ["TenantAdmin"] (#2296) — apps with
|
|
42
|
+
* their own role vocabulary pass this via createCustomFieldsFeature({
|
|
43
|
+
* fieldDefinitionWriteRoles }), which threads the same roles into
|
|
44
|
+
* update-/delete-tenant-field too (the definition-CRUD triad shares one gate). */
|
|
45
|
+
readonly roles?: readonly string[];
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export function createDefineTenantFieldHandler(
|
|
@@ -46,7 +52,7 @@ export function createDefineTenantFieldHandler(
|
|
|
46
52
|
return {
|
|
47
53
|
name: "define-tenant-field",
|
|
48
54
|
schema: defineFieldPayloadSchema,
|
|
49
|
-
access: { roles:
|
|
55
|
+
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
50
56
|
handler: async (event, ctx) => {
|
|
51
57
|
const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
|
|
52
58
|
const tenantId = event.user.tenantId;
|