@cosmicdrift/kumiko-bundled-features 0.124.0 → 0.125.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 +14 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
- package/src/admin-shell/constants.ts +11 -0
- package/src/admin-shell/feature.ts +134 -0
- package/src/admin-shell/i18n.ts +35 -0
- package/src/admin-shell/index.ts +17 -0
- package/src/admin-shell/overview-allowlist.ts +32 -0
- package/src/admin-shell/web/client-plugin.tsx +26 -0
- package/src/admin-shell/web/i18n.ts +16 -0
- package/src/admin-shell/web/index.ts +2 -0
- package/src/admin-shell/web/overview-layout.tsx +68 -0
- package/src/admin-shell/web/overview-query.ts +15 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
- package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
- package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
- package/src/audit/__tests__/audit.integration.test.ts +0 -4
- package/src/audit/constants.ts +5 -2
- package/src/audit/feature.ts +24 -1
- package/src/audit/handlers/list.query.ts +2 -2
- package/src/audit/i18n.ts +18 -0
- package/src/audit/web/audit-log-screen.tsx +136 -0
- package/src/audit/web/client-plugin.tsx +20 -0
- package/src/audit/web/i18n.ts +12 -0
- package/src/audit/web/index.ts +4 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
- package/src/auth-email-password/web/use-shell-user.ts +5 -2
- package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
- package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
- package/src/cap-counter/constants.ts +2 -0
- package/src/cap-counter/feature.ts +20 -1
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
- package/src/compliance-profiles/constants.ts +15 -0
- package/src/compliance-profiles/feature.ts +19 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/web/client-plugin.tsx +22 -0
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
- package/src/compliance-profiles/web/i18n.ts +25 -0
- package/src/compliance-profiles/web/index.ts +6 -0
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
- package/src/delivery/constants.ts +13 -51
- package/src/delivery/feature.ts +19 -2
- package/src/delivery/handlers/log.query.ts +14 -6
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +36 -0
- package/src/delivery/web/client-plugin.tsx +20 -0
- package/src/delivery/web/delivery-log-screen.tsx +103 -0
- package/src/delivery/web/i18n.ts +25 -0
- package/src/delivery/web/index.ts +3 -0
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
- package/src/feature-toggles/constants.ts +14 -0
- package/src/feature-toggles/feature.ts +14 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +1 -1
- package/src/feature-toggles/index.ts +6 -0
- package/src/feature-toggles/web/client-plugin.tsx +22 -0
- package/src/feature-toggles/web/i18n.ts +29 -0
- package/src/feature-toggles/web/index.ts +6 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
- package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
- package/src/jobs/constants.ts +8 -1
- package/src/jobs/feature.ts +26 -0
- package/src/jobs/i18n.ts +32 -0
- package/src/jobs/web/client-plugin.tsx +22 -0
- package/src/jobs/web/i18n.ts +12 -0
- package/src/jobs/web/index.ts +4 -0
- package/src/jobs/web/job-run-detail-screen.tsx +169 -0
- package/src/jobs/web/job-runs-screen.tsx +148 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
- package/src/tenant/constants.ts +5 -0
- package/src/tenant/feature.ts +16 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
- package/src/tenant/handlers/invitations.query.ts +2 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/web/client-plugin.tsx +20 -0
- package/src/tenant/web/i18n.ts +43 -0
- package/src/tenant/web/index.ts +4 -0
- package/src/tenant/web/members-screen.tsx +265 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +7 -6
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { FeatureToggleHandlers, FeatureToggleQueries, TOGGLE_ADMIN_SCREEN_ID } from "../constants";
|
|
5
|
+
import { createFeatureTogglesFeature } from "../feature";
|
|
6
|
+
|
|
7
|
+
describe("feature-toggles screen + handler access alignment", () => {
|
|
8
|
+
const features = [createFeatureTogglesFeature()];
|
|
9
|
+
|
|
10
|
+
test("boot-validates with toggle-admin screen registered", () => {
|
|
11
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("toggle-admin screen is SystemAdmin-gated", () => {
|
|
15
|
+
const toggles = createFeatureTogglesFeature();
|
|
16
|
+
const screen = toggles.screens[TOGGLE_ADMIN_SCREEN_ID];
|
|
17
|
+
expect(screen?.type).toBe("custom");
|
|
18
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
19
|
+
expect(screen.access.roles).toEqual(["SystemAdmin"]);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("queries and set handler are SystemAdmin-only", () => {
|
|
24
|
+
const toggles = createFeatureTogglesFeature();
|
|
25
|
+
expect(rolesOf(toggles.queryHandlers["list"]?.access)).toEqual(["SystemAdmin"]);
|
|
26
|
+
expect(rolesOf(toggles.queryHandlers["registered"]?.access)).toEqual(["SystemAdmin"]);
|
|
27
|
+
expect(rolesOf(toggles.writeHandlers["set"]?.access)).toEqual(["SystemAdmin"]);
|
|
28
|
+
void FeatureToggleQueries;
|
|
29
|
+
void FeatureToggleHandlers;
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createTestUser,
|
|
4
|
+
setupTestStack,
|
|
5
|
+
type TestStack,
|
|
6
|
+
unsafePushTables,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
8
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
9
|
+
import { FeatureToggleHandlers, FeatureToggleQueries, TOGGLE_ADMIN_SCREEN_ID } from "../constants";
|
|
10
|
+
import { createFeatureTogglesFeature } from "../feature";
|
|
11
|
+
import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
12
|
+
|
|
13
|
+
let stack: TestStack;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
stack = await setupTestStack({
|
|
17
|
+
features: [createFeatureTogglesFeature()],
|
|
18
|
+
});
|
|
19
|
+
await unsafePushTables(stack.db, { globalFeatureStateTable });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await stack.cleanup();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("feature-toggles access matrix", () => {
|
|
27
|
+
test("queries, set, and screen are SystemAdmin-only", () => {
|
|
28
|
+
expect(rolesOf(stack.registry.getQueryHandler(FeatureToggleQueries.list)?.access)).toEqual([
|
|
29
|
+
"SystemAdmin",
|
|
30
|
+
]);
|
|
31
|
+
expect(
|
|
32
|
+
rolesOf(stack.registry.getQueryHandler(FeatureToggleQueries.registered)?.access),
|
|
33
|
+
).toEqual(["SystemAdmin"]);
|
|
34
|
+
expect(rolesOf(stack.registry.getWriteHandler(FeatureToggleHandlers.set)?.access)).toEqual([
|
|
35
|
+
"SystemAdmin",
|
|
36
|
+
]);
|
|
37
|
+
const feature = createFeatureTogglesFeature();
|
|
38
|
+
const screen = feature.screens[TOGGLE_ADMIN_SCREEN_ID];
|
|
39
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
40
|
+
expect(screen.access.roles).toEqual(["SystemAdmin"]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("feature-toggles HTTP access", () => {
|
|
46
|
+
test("SystemAdmin can query list and registered", async () => {
|
|
47
|
+
const admin = createTestUser({ id: 21, roles: ["SystemAdmin"] });
|
|
48
|
+
const list = await stack.http.queryOk<
|
|
49
|
+
{ items?: readonly unknown[] } | { rows?: readonly unknown[] }
|
|
50
|
+
>(FeatureToggleQueries.list, {}, admin);
|
|
51
|
+
expect(typeof list).toBe("object");
|
|
52
|
+
const registered = await stack.http.queryOk<{ items: readonly unknown[] }>(
|
|
53
|
+
FeatureToggleQueries.registered,
|
|
54
|
+
{},
|
|
55
|
+
admin,
|
|
56
|
+
);
|
|
57
|
+
expect(Array.isArray(registered.items)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("TenantAdmin gets 403 on list, registered, and set", async () => {
|
|
61
|
+
const user = createTestUser({ id: 22, roles: ["TenantAdmin"] });
|
|
62
|
+
expect((await stack.http.query(FeatureToggleQueries.list, {}, user)).status).toBe(403);
|
|
63
|
+
expect((await stack.http.query(FeatureToggleQueries.registered, {}, user)).status).toBe(403);
|
|
64
|
+
expect(
|
|
65
|
+
(await stack.http.write(FeatureToggleHandlers.set, { featureName: "x", enabled: true }, user))
|
|
66
|
+
.status,
|
|
67
|
+
).toBe(403);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
export const FEATURE_TOGGLES_FEATURE = "feature-toggles" as const;
|
|
3
|
+
|
|
1
4
|
// Fully-qualified event name for feature-toggle changes. Kept as a constant
|
|
2
5
|
// so write-handler + tests reference one source.
|
|
3
6
|
export const FEATURE_TOGGLE_SET_EVENT_NAME = "feature-toggles:event:toggle-set";
|
|
@@ -18,3 +21,14 @@ export const FeatureToggleErrors = {
|
|
|
18
21
|
// Prevents typos from silently piling up orphan rows.
|
|
19
22
|
unknownFeature: "unknown_feature",
|
|
20
23
|
} as const;
|
|
24
|
+
|
|
25
|
+
export const FeatureToggleHandlers = {
|
|
26
|
+
set: "feature-toggles:write:set",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const FeatureToggleQueries = {
|
|
30
|
+
list: "feature-toggles:query:list",
|
|
31
|
+
registered: "feature-toggles:query:registered",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export const TOGGLE_ADMIN_SCREEN_ID = "toggle-admin" as const;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { FEATURE_TOGGLE_SET_EVENT_NAME } from "./constants";
|
|
2
|
+
import { FEATURE_TOGGLE_SET_EVENT_NAME, TOGGLE_ADMIN_SCREEN_ID } from "./constants";
|
|
3
3
|
import { featureToggleSetSchema } from "./events";
|
|
4
4
|
import { listQuery } from "./handlers/list.query";
|
|
5
5
|
import { registeredQuery } from "./handlers/registered.query";
|
|
@@ -62,6 +62,19 @@ export function createFeatureTogglesFeature(
|
|
|
62
62
|
registered: r.queryHandler(registeredQuery),
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
r.screen({
|
|
66
|
+
id: TOGGLE_ADMIN_SCREEN_ID,
|
|
67
|
+
type: "custom",
|
|
68
|
+
renderer: { react: { __component: "ToggleAdminScreen" } },
|
|
69
|
+
access: { roles: ["SystemAdmin"] },
|
|
70
|
+
});
|
|
71
|
+
r.nav({
|
|
72
|
+
id: "toggle-admin",
|
|
73
|
+
label: "feature-toggles:nav.toggleAdmin",
|
|
74
|
+
screen: "feature-toggles:screen:toggle-admin",
|
|
75
|
+
order: 20,
|
|
76
|
+
});
|
|
77
|
+
|
|
65
78
|
// toggle-cache-sync — multi-instance snapshot propagation. Every
|
|
66
79
|
// API/worker instance runs its own dispatcher cursor on this MSP
|
|
67
80
|
// (delivery: "per-instance") and converges its in-memory snapshot on
|
|
@@ -10,7 +10,7 @@ import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
|
10
10
|
export const listQuery = defineQueryHandler({
|
|
11
11
|
name: "list",
|
|
12
12
|
schema: z.object({}),
|
|
13
|
-
access: { roles: ["SystemAdmin"
|
|
13
|
+
access: { roles: ["SystemAdmin"] },
|
|
14
14
|
handler: async (_event, ctx) => {
|
|
15
15
|
type Row = {
|
|
16
16
|
featureName: string;
|
|
@@ -14,7 +14,7 @@ import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
|
14
14
|
export const registeredQuery = defineQueryHandler({
|
|
15
15
|
name: "registered",
|
|
16
16
|
schema: z.object({}),
|
|
17
|
-
access: { roles: ["SystemAdmin"
|
|
17
|
+
access: { roles: ["SystemAdmin"] },
|
|
18
18
|
handler: async (_event, ctx) => {
|
|
19
19
|
type OverrideRow = { featureName: string; enabled: boolean };
|
|
20
20
|
const overrideRows = await selectMany<OverrideRow>(ctx.db.raw, globalFeatureStateTable);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import { FEATURE_TOGGLES_FEATURE, TOGGLE_ADMIN_SCREEN_ID } from "../constants";
|
|
5
|
+
import { defaultTranslations } from "./i18n";
|
|
6
|
+
import { ToggleAdminScreen } from "./toggle-admin-screen";
|
|
7
|
+
|
|
8
|
+
export type FeatureTogglesClientOptions = {
|
|
9
|
+
readonly translations?: TranslationsByLocale;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function featureTogglesClient(
|
|
13
|
+
options?: FeatureTogglesClientOptions,
|
|
14
|
+
): ClientFeatureDefinition {
|
|
15
|
+
return {
|
|
16
|
+
name: FEATURE_TOGGLES_FEATURE,
|
|
17
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
18
|
+
components: {
|
|
19
|
+
[TOGGLE_ADMIN_SCREEN_ID]: ToggleAdminScreen,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
|
|
4
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
5
|
+
de: {
|
|
6
|
+
"feature-toggles.admin.title": "Feature-Toggles",
|
|
7
|
+
"feature-toggles.admin.loading": "Lade Feature-Toggles…",
|
|
8
|
+
"feature-toggles.admin.col.feature": "Feature",
|
|
9
|
+
"feature-toggles.admin.col.default": "Default",
|
|
10
|
+
"feature-toggles.admin.col.override": "Override",
|
|
11
|
+
"feature-toggles.admin.col.effective": "Effektiv",
|
|
12
|
+
"feature-toggles.admin.toggle": "Umschalten",
|
|
13
|
+
"feature-toggles.admin.saving": "Speichert…",
|
|
14
|
+
"feature-toggles.admin.alwaysOn": "immer an",
|
|
15
|
+
"feature-toggles:nav.toggleAdmin": "Feature-Toggles",
|
|
16
|
+
},
|
|
17
|
+
en: {
|
|
18
|
+
"feature-toggles.admin.title": "Feature toggles",
|
|
19
|
+
"feature-toggles.admin.loading": "Loading feature toggles…",
|
|
20
|
+
"feature-toggles.admin.col.feature": "Feature",
|
|
21
|
+
"feature-toggles.admin.col.default": "Default",
|
|
22
|
+
"feature-toggles.admin.col.override": "Override",
|
|
23
|
+
"feature-toggles.admin.col.effective": "Effective",
|
|
24
|
+
"feature-toggles.admin.toggle": "Toggle",
|
|
25
|
+
"feature-toggles.admin.saving": "Saving…",
|
|
26
|
+
"feature-toggles.admin.alwaysOn": "always on",
|
|
27
|
+
"feature-toggles:nav.toggleAdmin": "Feature toggles",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
5
|
+
import { FeatureToggleHandlers, FeatureToggleQueries } from "../constants";
|
|
6
|
+
|
|
7
|
+
type ToggleRow = {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly toggleable: boolean;
|
|
10
|
+
readonly default: boolean | null;
|
|
11
|
+
readonly override: boolean | null;
|
|
12
|
+
readonly effective: boolean | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RegisteredResponse = {
|
|
16
|
+
readonly items: readonly ToggleRow[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type State =
|
|
20
|
+
| { readonly kind: "loading" }
|
|
21
|
+
| { readonly kind: "error"; readonly message: string }
|
|
22
|
+
| { readonly kind: "ready"; readonly items: readonly ToggleRow[] };
|
|
23
|
+
|
|
24
|
+
export function ToggleAdminScreen(): ReactNode {
|
|
25
|
+
const t = useTranslation();
|
|
26
|
+
const { Banner, Card, DataTable, Heading, Text } = usePrimitives();
|
|
27
|
+
const dispatcher = useDispatcher();
|
|
28
|
+
const [state, setState] = useState<State>({ kind: "loading" });
|
|
29
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
const load = useCallback(async (): Promise<void> => {
|
|
32
|
+
setState({ kind: "loading" });
|
|
33
|
+
const res = await dispatcher.query<RegisteredResponse>(FeatureToggleQueries.registered, {});
|
|
34
|
+
if (!res.isSuccess) {
|
|
35
|
+
setState({ kind: "error", message: res.error.message });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setState({ kind: "ready", items: res.data.items });
|
|
39
|
+
}, [dispatcher]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
void load();
|
|
43
|
+
}, [load]);
|
|
44
|
+
|
|
45
|
+
const onSet = async (featureName: string, enabled: boolean): Promise<void> => {
|
|
46
|
+
setActionError(null);
|
|
47
|
+
const res = await dispatcher.write(FeatureToggleHandlers.set, { featureName, enabled });
|
|
48
|
+
if (!res.isSuccess) {
|
|
49
|
+
setActionError(res.error.message);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await load();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (state.kind === "loading") {
|
|
56
|
+
return (
|
|
57
|
+
<FormScreenShell testId="toggle-admin-screen">
|
|
58
|
+
<Text variant="small">{t("feature-toggles.admin.loading")}</Text>
|
|
59
|
+
</FormScreenShell>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (state.kind === "error") {
|
|
64
|
+
return (
|
|
65
|
+
<FormScreenShell testId="toggle-admin-screen">
|
|
66
|
+
<Banner variant="error">{state.message}</Banner>
|
|
67
|
+
</FormScreenShell>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<FormScreenShell testId="toggle-admin-screen" className="flex max-w-5xl flex-col gap-6">
|
|
73
|
+
<Heading variant="page">{t("feature-toggles.admin.title")}</Heading>
|
|
74
|
+
|
|
75
|
+
{actionError !== null && <Banner variant="error">{actionError}</Banner>}
|
|
76
|
+
|
|
77
|
+
<Card options={{ padded: false }}>
|
|
78
|
+
<DataTable
|
|
79
|
+
testId="toggle-admin-table"
|
|
80
|
+
columns={[
|
|
81
|
+
{
|
|
82
|
+
field: "feature",
|
|
83
|
+
label: t("feature-toggles.admin.col.feature"),
|
|
84
|
+
type: "string",
|
|
85
|
+
sortable: false,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
field: "default",
|
|
89
|
+
label: t("feature-toggles.admin.col.default"),
|
|
90
|
+
type: "string",
|
|
91
|
+
sortable: false,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
field: "override",
|
|
95
|
+
label: t("feature-toggles.admin.col.override"),
|
|
96
|
+
type: "string",
|
|
97
|
+
sortable: false,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
field: "effective",
|
|
101
|
+
label: t("feature-toggles.admin.col.effective"),
|
|
102
|
+
type: "string",
|
|
103
|
+
sortable: false,
|
|
104
|
+
},
|
|
105
|
+
]}
|
|
106
|
+
rows={state.items.map((row) => ({
|
|
107
|
+
id: row.name,
|
|
108
|
+
values: {
|
|
109
|
+
feature: row.name,
|
|
110
|
+
default: formatFlag(row.default),
|
|
111
|
+
override: formatFlag(row.override),
|
|
112
|
+
effective: formatFlag(row.effective),
|
|
113
|
+
toggleable: row.toggleable,
|
|
114
|
+
effectiveBool: row.effective ?? row.default ?? false,
|
|
115
|
+
},
|
|
116
|
+
}))}
|
|
117
|
+
rowActions={[
|
|
118
|
+
{
|
|
119
|
+
id: "toggle",
|
|
120
|
+
label: t("feature-toggles.admin.toggle"),
|
|
121
|
+
style: "secondary",
|
|
122
|
+
isVisible: (row) => row.values["toggleable"] === true,
|
|
123
|
+
onTrigger: (row) => void onSet(row.id, !(row.values["effectiveBool"] as boolean)),
|
|
124
|
+
},
|
|
125
|
+
]}
|
|
126
|
+
rowActionMode="inline"
|
|
127
|
+
/>
|
|
128
|
+
</Card>
|
|
129
|
+
</FormScreenShell>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatFlag(value: boolean | null): string {
|
|
134
|
+
if (value === null) return "inherit";
|
|
135
|
+
return value ? "on" : "off";
|
|
136
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID } from "../constants";
|
|
6
|
+
import { createJobsFeature } from "../feature";
|
|
7
|
+
|
|
8
|
+
const SYSTEM_ADMIN_ROLES = ["SystemAdmin"] as const;
|
|
9
|
+
|
|
10
|
+
describe("jobs screens + handler access alignment", () => {
|
|
11
|
+
const features = [createConfigFeature(), createJobsFeature()];
|
|
12
|
+
|
|
13
|
+
test("boot-validates with job-runs screens registered", () => {
|
|
14
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("job-runs screens are SystemAdmin-gated", () => {
|
|
18
|
+
const jobs = createJobsFeature();
|
|
19
|
+
for (const id of [JOB_RUNS_SCREEN_ID, JOB_RUN_DETAIL_SCREEN_ID] as const) {
|
|
20
|
+
const screen = jobs.screens[id];
|
|
21
|
+
expect(screen?.type).toBe("custom");
|
|
22
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
23
|
+
expect(screen.access.roles).toEqual(SYSTEM_ADMIN_ROLES);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("jobs queries + operator writes share SystemAdmin access", () => {
|
|
29
|
+
const jobs = createJobsFeature();
|
|
30
|
+
const roles = [...SYSTEM_ADMIN_ROLES];
|
|
31
|
+
expect(rolesOf(jobs.queryHandlers["list"]?.access)).toEqual(roles);
|
|
32
|
+
expect(rolesOf(jobs.queryHandlers["details"]?.access)).toEqual(roles);
|
|
33
|
+
expect(rolesOf(jobs.writeHandlers["trigger"]?.access)).toEqual(roles);
|
|
34
|
+
expect(rolesOf(jobs.writeHandlers["retry"]?.access)).toEqual(roles);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Security integration tests for jobs operator HTTP surface.
|
|
2
|
+
// Real HTTP via setupTestStack — no mocks.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { defineFeature, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import {
|
|
7
|
+
createTestUser,
|
|
8
|
+
setupTestStack,
|
|
9
|
+
type TestStack,
|
|
10
|
+
TestUsers,
|
|
11
|
+
unsafePushTables,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
14
|
+
import { createConfigFeature } from "../../config/feature";
|
|
15
|
+
import {
|
|
16
|
+
JOB_RUN_DETAIL_SCREEN_ID,
|
|
17
|
+
JOB_RUNS_SCREEN_ID,
|
|
18
|
+
JobHandlers,
|
|
19
|
+
JobQueries,
|
|
20
|
+
} from "../constants";
|
|
21
|
+
import { createJobsFeature } from "../feature";
|
|
22
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
23
|
+
|
|
24
|
+
const SYSTEM_ADMIN_ROLES = ["SystemAdmin"] as const;
|
|
25
|
+
|
|
26
|
+
const noopJobFeature = defineFeature("jobs-sec-app", (r) => {
|
|
27
|
+
r.job("ping", { trigger: { manual: true } }, async () => {});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
let stack: TestStack;
|
|
31
|
+
|
|
32
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
33
|
+
const tenantAdmin: SessionUser = createTestUser({ id: 3, roles: ["TenantAdmin"] });
|
|
34
|
+
const regularUser: SessionUser = createTestUser({ id: 4, roles: ["User"] });
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
stack = await setupTestStack({
|
|
38
|
+
features: [createConfigFeature(), noopJobFeature, createJobsFeature()],
|
|
39
|
+
});
|
|
40
|
+
await unsafePushTables(stack.db, { jobRunsTable, jobRunLogsTable });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await stack.cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("access matrix: jobs operator surface is SystemAdmin-only", () => {
|
|
48
|
+
test("list, detail, trigger, retry handlers share SystemAdmin", () => {
|
|
49
|
+
const roles = [...SYSTEM_ADMIN_ROLES];
|
|
50
|
+
expect(rolesOf(stack.registry.getQueryHandler(JobQueries.list)?.access)).toEqual(roles);
|
|
51
|
+
expect(rolesOf(stack.registry.getQueryHandler(JobQueries.details)?.access)).toEqual(roles);
|
|
52
|
+
expect(rolesOf(stack.registry.getWriteHandler(JobHandlers.trigger)?.access)).toEqual(roles);
|
|
53
|
+
expect(rolesOf(stack.registry.getWriteHandler(JobHandlers.retry)?.access)).toEqual(roles);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("job screens match SystemAdmin handler access", () => {
|
|
57
|
+
const jobs = createJobsFeature();
|
|
58
|
+
for (const id of [JOB_RUNS_SCREEN_ID, JOB_RUN_DETAIL_SCREEN_ID] as const) {
|
|
59
|
+
const screen = jobs.screens[id];
|
|
60
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
61
|
+
expect(screen.access.roles).toEqual(SYSTEM_ADMIN_ROLES);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("non-SystemAdmin denied jobs HTTP surface", () => {
|
|
68
|
+
test("TenantAdmin 403 on list, detail, trigger", async () => {
|
|
69
|
+
for (const fn of [
|
|
70
|
+
() => stack.http.query(JobQueries.list, {}, tenantAdmin),
|
|
71
|
+
() =>
|
|
72
|
+
stack.http.query(
|
|
73
|
+
JobQueries.details,
|
|
74
|
+
{ runId: "00000000-0000-4000-8000-000000000099" },
|
|
75
|
+
tenantAdmin,
|
|
76
|
+
),
|
|
77
|
+
() =>
|
|
78
|
+
stack.http.write(JobHandlers.trigger, { jobName: "jobs-sec-app:job:ping" }, tenantAdmin),
|
|
79
|
+
] as const) {
|
|
80
|
+
const res = await fn();
|
|
81
|
+
expect(res.status).toBe(403);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("regular User 403 on list and trigger", async () => {
|
|
86
|
+
expect((await stack.http.query(JobQueries.list, {}, regularUser)).status).toBe(403);
|
|
87
|
+
expect(
|
|
88
|
+
(
|
|
89
|
+
await stack.http.write(
|
|
90
|
+
JobHandlers.trigger,
|
|
91
|
+
{ jobName: "jobs-sec-app:job:ping" },
|
|
92
|
+
regularUser,
|
|
93
|
+
)
|
|
94
|
+
).status,
|
|
95
|
+
).toBe(403);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("SystemAdmin can use jobs queries", () => {
|
|
100
|
+
test("empty list is OK", async () => {
|
|
101
|
+
const res = await stack.http.queryOk<{ rows: readonly unknown[] }>(
|
|
102
|
+
JobQueries.list,
|
|
103
|
+
{},
|
|
104
|
+
systemAdmin,
|
|
105
|
+
);
|
|
106
|
+
expect(Array.isArray(res.rows)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/jobs/constants.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Feature name + qualified names for server handlers and web screens.
|
|
2
3
|
export const JOBS_FEATURE = "jobs" as const;
|
|
3
4
|
|
|
4
5
|
// Qualified write handler names (QN format: scope:type:name)
|
|
@@ -19,3 +20,9 @@ export const JobErrors = {
|
|
|
19
20
|
notFound: "not_found",
|
|
20
21
|
onlyFailedCanRetry: "only_failed_jobs_can_be_retried",
|
|
21
22
|
} as const;
|
|
23
|
+
|
|
24
|
+
/** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
|
|
25
|
+
export const JOB_RUNS_SCREEN_ID = "job-runs" as const;
|
|
26
|
+
|
|
27
|
+
/** Run detail — route entityId is the run uuid. Nav: `jobs:screen:job-run-detail`. */
|
|
28
|
+
export const JOB_RUN_DETAIL_SCREEN_ID = "job-run-detail" as const;
|
package/src/jobs/feature.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type FeatureDefinition,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import type { z } from "zod";
|
|
8
|
+
import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID } from "./constants";
|
|
8
9
|
// Event-payload schemas live in a sibling module so the logger can import
|
|
9
10
|
// them without the cycle jobs-feature ↔ job-run-logger. The logger parses
|
|
10
11
|
// payloads against these schemas before low-level append() — that's what
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
} from "./handlers/projection-rebuild.job";
|
|
19
20
|
import { retryWrite } from "./handlers/retry.write";
|
|
20
21
|
import { triggerWrite } from "./handlers/trigger.write";
|
|
22
|
+
import { JOBS_I18N } from "./i18n";
|
|
21
23
|
import {
|
|
22
24
|
JOB_RUN_COMPLETED_EVENT,
|
|
23
25
|
JOB_RUN_FAILED_EVENT,
|
|
@@ -182,6 +184,30 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
182
184
|
detail: r.queryHandler(detailQuery),
|
|
183
185
|
};
|
|
184
186
|
|
|
187
|
+
const systemAdminAccess = { roles: ["SystemAdmin"] as const };
|
|
188
|
+
|
|
189
|
+
r.translations({ keys: JOBS_I18N });
|
|
190
|
+
|
|
191
|
+
r.screen({
|
|
192
|
+
id: JOB_RUNS_SCREEN_ID,
|
|
193
|
+
type: "custom",
|
|
194
|
+
renderer: { react: { __component: "JobRunsScreen" } },
|
|
195
|
+
access: systemAdminAccess,
|
|
196
|
+
});
|
|
197
|
+
r.screen({
|
|
198
|
+
id: JOB_RUN_DETAIL_SCREEN_ID,
|
|
199
|
+
type: "custom",
|
|
200
|
+
renderer: { react: { __component: "JobRunDetailScreen" } },
|
|
201
|
+
access: systemAdminAccess,
|
|
202
|
+
});
|
|
203
|
+
r.nav({
|
|
204
|
+
id: "job-runs",
|
|
205
|
+
label: "jobs:nav.jobRuns",
|
|
206
|
+
icon: "list",
|
|
207
|
+
screen: "jobs:screen:job-runs",
|
|
208
|
+
order: 10,
|
|
209
|
+
});
|
|
210
|
+
|
|
185
211
|
return { handlers, queries };
|
|
186
212
|
});
|
|
187
213
|
}
|
package/src/jobs/i18n.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Server + client i18n for jobs operator screens.
|
|
3
|
+
|
|
4
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
5
|
+
|
|
6
|
+
export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
|
+
"screen:job-runs.title": { de: "Job-Läufe", en: "Job runs" },
|
|
8
|
+
"screen:job-run-detail.title": { de: "Job-Lauf", en: "Job run" },
|
|
9
|
+
"jobs:nav.jobRuns": { de: "Jobs", en: "Jobs" },
|
|
10
|
+
"jobs.runs.title": { de: "Job-Läufe", en: "Job runs" },
|
|
11
|
+
"jobs.runs.loading": { de: "Lade Läufe…", en: "Loading runs…" },
|
|
12
|
+
"jobs.runs.empty": { de: "Keine Job-Läufe.", en: "No job runs." },
|
|
13
|
+
"jobs.runs.open": { de: "Details", en: "Details" },
|
|
14
|
+
"jobs.runs.filter.status": { de: "Status", en: "Status" },
|
|
15
|
+
"jobs.runs.filter.all": { de: "Alle", en: "All" },
|
|
16
|
+
"jobs.runs.col.job": { de: "Job", en: "Job" },
|
|
17
|
+
"jobs.runs.col.status": { de: "Status", en: "Status" },
|
|
18
|
+
"jobs.runs.col.started": { de: "Gestartet", en: "Started" },
|
|
19
|
+
"jobs.runs.col.duration": { de: "Dauer (ms)", en: "Duration (ms)" },
|
|
20
|
+
"jobs.detail.title": { de: "Job-Lauf", en: "Job run" },
|
|
21
|
+
"jobs.detail.loading": { de: "Lade Details…", en: "Loading details…" },
|
|
22
|
+
"jobs.detail.missing": { de: "Lauf nicht gefunden.", en: "Run not found." },
|
|
23
|
+
"jobs.detail.back": { de: "← Zurück zur Liste", en: "← Back to list" },
|
|
24
|
+
"jobs.detail.field.job": { de: "Job", en: "Job" },
|
|
25
|
+
"jobs.detail.field.status": { de: "Status", en: "Status" },
|
|
26
|
+
"jobs.detail.field.id": { de: "Run-ID", en: "Run ID" },
|
|
27
|
+
"jobs.detail.field.error": { de: "Fehler", en: "Error" },
|
|
28
|
+
"jobs.detail.logs": { de: "Logs", en: "Logs" },
|
|
29
|
+
"jobs.detail.logs.empty": { de: "Keine Log-Zeilen.", en: "No log lines." },
|
|
30
|
+
"jobs.detail.retry": { de: "Erneut ausführen", en: "Retry" },
|
|
31
|
+
"jobs.detail.retrying": { de: "Wird erneut gestartet…", en: "Retrying…" },
|
|
32
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID, JOBS_FEATURE } from "../constants";
|
|
5
|
+
import { defaultTranslations } from "./i18n";
|
|
6
|
+
import { JobRunDetailScreen } from "./job-run-detail-screen";
|
|
7
|
+
import { JobRunsScreen } from "./job-runs-screen";
|
|
8
|
+
|
|
9
|
+
export type JobsClientOptions = {
|
|
10
|
+
readonly translations?: TranslationsByLocale;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function jobsClient(options?: JobsClientOptions): ClientFeatureDefinition {
|
|
14
|
+
return {
|
|
15
|
+
name: JOBS_FEATURE,
|
|
16
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
17
|
+
components: {
|
|
18
|
+
[JOB_RUNS_SCREEN_ID]: JobRunsScreen,
|
|
19
|
+
[JOB_RUN_DETAIL_SCREEN_ID]: JobRunDetailScreen,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { JOBS_I18N } from "../i18n";
|
|
4
|
+
|
|
5
|
+
const LOCALES = ["de", "en"] as const;
|
|
6
|
+
|
|
7
|
+
export const defaultTranslations: TranslationsByLocale = Object.fromEntries(
|
|
8
|
+
LOCALES.map((locale) => [
|
|
9
|
+
locale,
|
|
10
|
+
Object.fromEntries(Object.entries(JOBS_I18N).map(([key, value]) => [key, value[locale]])),
|
|
11
|
+
]),
|
|
12
|
+
);
|