@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.223.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 +10 -9
- package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
- package/src/audit/__tests__/audit.integration.test.ts +3 -1
- package/src/audit/__tests__/constants.test.ts +6 -1
- package/src/audit/constants.ts +10 -1
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +2 -0
- package/src/audit/web/audit-log-detail-screen.tsx +7 -3
- package/src/audit/web/audit-log-screen.tsx +27 -29
- package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
- package/src/auth-email-password/seeding.ts +3 -1
- package/src/auth-mfa/hmac-token-codec.ts +75 -0
- package/src/auth-mfa/mfa-challenge-token.ts +10 -65
- package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/channel-email/__tests__/email-channel.test.ts +19 -0
- package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
- package/src/channel-email/email-channel.ts +14 -7
- package/src/channel-email/pii-guard.ts +8 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/feature.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
- package/src/crypto-shredding/index.ts +2 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
- package/src/file-derivatives/feature.ts +2 -2
- package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
- package/src/files-provider-s3/s3-provider.ts +45 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
- package/src/files-tenant-data/changes.json +1 -0
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
- package/src/files-tenant-data/hooks.ts +93 -0
- package/src/files-tenant-data/index.ts +58 -0
- package/src/form-draft/db/queries/cleanup.ts +17 -0
- package/src/form-draft/handlers/cleanup.job.ts +20 -10
- package/src/form-draft/handlers/discard.write.ts +25 -7
- package/src/form-draft/handlers/get.query.ts +4 -2
- package/src/form-draft/handlers/list.query.ts +4 -1
- package/src/form-draft/lookup.ts +12 -5
- package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/handlers/retry.write.ts +26 -6
- package/src/jobs/i18n.ts +3 -0
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
- package/src/tenant/changes.json +1 -1
- package/src/tenant/constants.ts +5 -1
- package/src/tenant/handlers/remove-member.write.ts +12 -0
- package/src/tenant/handlers/update-member-roles.write.ts +10 -37
- package/src/tenant/last-tenant-admin.ts +39 -0
- package/src/tenant/screens.ts +2 -0
- package/src/tenant/seeding.ts +3 -1
- package/src/tenant-lifecycle/feature.ts +1 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
- package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/src/tenant-lifecycle/stages.ts +5 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
- package/src/user/changes.json +12 -5
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
- package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
- package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.223.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>",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"./file-derivatives": "./src/file-derivatives/index.ts",
|
|
72
72
|
"./derivatives-sharp": "./src/derivatives-sharp/index.ts",
|
|
73
73
|
"./files": "./src/files/index.ts",
|
|
74
|
+
"./files-tenant-data": "./src/files-tenant-data/index.ts",
|
|
74
75
|
"./user-data-rights": "./src/user-data-rights/index.ts",
|
|
75
76
|
"./user-data-rights/web": "./src/user-data-rights/web/index.ts",
|
|
76
77
|
"./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
|
|
@@ -126,12 +127,12 @@
|
|
|
126
127
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
128
|
},
|
|
128
129
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
130
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.223.0",
|
|
131
|
+
"@cosmicdrift/kumiko-framework": "0.223.0",
|
|
132
|
+
"@cosmicdrift/kumiko-headless": "0.223.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer": "0.223.0",
|
|
134
|
+
"@cosmicdrift/kumiko-renderer-web": "0.223.0",
|
|
135
|
+
"@cosmicdrift/kumiko-types": "0.223.0",
|
|
135
136
|
"@mollie/api-client": "^4.5.0",
|
|
136
137
|
"@node-rs/argon2": "^2.0.2",
|
|
137
138
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,7 +161,7 @@
|
|
|
160
161
|
"devDependencies": {
|
|
161
162
|
"@testing-library/user-event": "^14.6.1",
|
|
162
163
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
164
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
164
|
+
"@cosmicdrift/kumiko-locale-de": "0.223.0",
|
|
165
|
+
"@cosmicdrift/kumiko-locale-es": "0.223.0"
|
|
165
166
|
}
|
|
166
167
|
}
|
|
@@ -218,14 +218,14 @@ describe("AuditLogScreen — actor name resolution", () => {
|
|
|
218
218
|
expect(view.getByTestId("cell-2-actor").textContent).toBe("bob@example.com");
|
|
219
219
|
});
|
|
220
220
|
|
|
221
|
-
test("createdBy not in members list → cell
|
|
221
|
+
test("createdBy not in members list → cell shows Unknown actor, never the raw id", async () => {
|
|
222
222
|
const view = renderScreen();
|
|
223
223
|
await waitFor(() => {
|
|
224
224
|
const cell = view.queryByTestId("cell-3-actor");
|
|
225
225
|
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
226
226
|
});
|
|
227
227
|
const cell = view.getByTestId("cell-3-actor");
|
|
228
|
-
expect(cell.textContent).toBe("");
|
|
228
|
+
expect(cell.textContent).toBe("Unknown actor");
|
|
229
229
|
expect(cell.textContent).not.toBe("user-missing");
|
|
230
230
|
});
|
|
231
231
|
|
|
@@ -248,14 +248,14 @@ describe("AuditLogScreen — actor name resolution", () => {
|
|
|
248
248
|
expect(view.getByTestId("cell-4-actor").textContent).toBe("System");
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
test("createdBy is an unresolvable UUID →
|
|
251
|
+
test("createdBy is an unresolvable UUID → Unknown actor label, raw UUID never rendered", async () => {
|
|
252
252
|
const view = renderScreen();
|
|
253
253
|
await waitFor(() => {
|
|
254
254
|
const cell = view.queryByTestId("cell-5-actor");
|
|
255
255
|
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
256
256
|
});
|
|
257
257
|
const cell = view.getByTestId("cell-5-actor");
|
|
258
|
-
expect(cell.textContent).toBe("");
|
|
258
|
+
expect(cell.textContent).toBe("Unknown actor");
|
|
259
259
|
expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
|
|
260
260
|
});
|
|
261
261
|
});
|
|
@@ -271,7 +271,7 @@ describe("AuditLogScreen — members query failure", () => {
|
|
|
271
271
|
if (cell === null) throw new Error("table not rendered yet");
|
|
272
272
|
});
|
|
273
273
|
expect(view.getByTestId("cell-1-type").textContent).toBe("widget.created");
|
|
274
|
-
expect(view.getByTestId("cell-1-actor").textContent).toBe("");
|
|
274
|
+
expect(view.getByTestId("cell-1-actor").textContent).toBe("Unknown actor");
|
|
275
275
|
});
|
|
276
276
|
});
|
|
277
277
|
|
|
@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
4
|
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
5
6
|
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
6
7
|
import { createAuditFeature } from "../feature";
|
|
7
8
|
|
|
8
9
|
describe("audit log screen + handler access alignment", () => {
|
|
9
|
-
const features = [createConfigFeature(), createAuditFeature()];
|
|
10
|
+
const features = [createConfigFeature(), createTenantFeature(), createAuditFeature()];
|
|
10
11
|
|
|
11
12
|
test("boot-validates with audit-log screen registered", () => {
|
|
12
13
|
expect(() => validateBoot(features)).not.toThrow();
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
unsafeCreateEntityTable,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
23
|
import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
|
+
import { createConfigFeature } from "../../config";
|
|
25
|
+
import { createTenantFeature } from "../../tenant";
|
|
24
26
|
import { AuditQueries } from "../constants";
|
|
25
27
|
import { createAuditFeature } from "../feature";
|
|
26
28
|
|
|
@@ -59,7 +61,7 @@ const otherTenantAdmin: SessionUser = createTestUser({
|
|
|
59
61
|
|
|
60
62
|
beforeAll(async () => {
|
|
61
63
|
stack = await setupTestStack({
|
|
62
|
-
features: [widgetFeature, createAuditFeature()],
|
|
64
|
+
features: [widgetFeature, createConfigFeature(), createTenantFeature(), createAuditFeature()],
|
|
63
65
|
});
|
|
64
66
|
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
65
67
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { SYSTEM_ACTOR_ID } from "../constants";
|
|
2
|
+
import { SYSTEM_ACTOR_ID, SYSTEM_ACTOR_IDS } from "../constants";
|
|
3
3
|
|
|
4
4
|
describe("audit constants", () => {
|
|
5
5
|
test("SYSTEM_ACTOR_ID matches the event-store system user id", () => {
|
|
6
6
|
expect(SYSTEM_ACTOR_ID).toBe("system");
|
|
7
7
|
});
|
|
8
|
+
|
|
9
|
+
test("SYSTEM_ACTOR_IDS includes createSystemUser nil UUID", () => {
|
|
10
|
+
expect(SYSTEM_ACTOR_IDS.has(SYSTEM_ACTOR_ID)).toBe(true);
|
|
11
|
+
expect(SYSTEM_ACTOR_IDS.has("00000000-0000-0000-0000-000000000000")).toBe(true);
|
|
12
|
+
});
|
|
8
13
|
});
|
package/src/audit/constants.ts
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
// Qualified handler names + screen ids — shared by server feature and web client.
|
|
3
3
|
export const AUDIT_FEATURE = "audit" as const;
|
|
4
4
|
|
|
5
|
-
/** Literal `createdBy` written by
|
|
5
|
+
/** Literal `createdBy` written by hand-built system actors (secrets/jobs/…). */
|
|
6
6
|
export const SYSTEM_ACTOR_ID = "system" as const;
|
|
7
7
|
|
|
8
|
+
/** Literal `createdBy` for anonymous / unauthenticated actors. */
|
|
9
|
+
export const ANONYMOUS_USER_ID = "anonymous" as const;
|
|
10
|
+
|
|
11
|
+
/** All `createdBy` values that mean "system" in the audit UI (nil-UUID from createSystemUser + literal). */
|
|
12
|
+
export const SYSTEM_ACTOR_IDS: ReadonlySet<string> = new Set([
|
|
13
|
+
SYSTEM_ACTOR_ID,
|
|
14
|
+
"00000000-0000-0000-0000-000000000000",
|
|
15
|
+
]);
|
|
16
|
+
|
|
8
17
|
export const AuditQueries = {
|
|
9
18
|
list: "audit:query:list",
|
|
10
19
|
details: "audit:query:details",
|
package/src/audit/feature.ts
CHANGED
|
@@ -31,6 +31,8 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
31
31
|
recommended: false,
|
|
32
32
|
});
|
|
33
33
|
r.translations({ keys: AUDIT_I18N });
|
|
34
|
+
// Screens resolve actor names via tenant:query:members.
|
|
35
|
+
r.requires("tenant");
|
|
34
36
|
|
|
35
37
|
const queries = {
|
|
36
38
|
list: r.queryHandler(listQuery),
|
package/src/audit/i18n.ts
CHANGED
|
@@ -17,6 +17,8 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
17
17
|
"audit.log.col.aggregate": { en: "Aggregate" },
|
|
18
18
|
"audit.log.col.actor": { en: "Actor" },
|
|
19
19
|
"audit.log.actor.system": { en: "System" },
|
|
20
|
+
"audit.log.actor.anonymous": { en: "Anonymous" },
|
|
21
|
+
"audit.log.actor.unknown": { en: "Unknown actor" },
|
|
20
22
|
"audit.log.filter.eventType": { en: "Event type" },
|
|
21
23
|
"audit.log.filter.aggregateType": { en: "Aggregate type" },
|
|
22
24
|
"audit.log.filter.from": { en: "From" },
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
12
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
13
13
|
import { TenantQueries } from "../../tenant/constants";
|
|
14
|
-
import { AuditQueries,
|
|
14
|
+
import { ANONYMOUS_USER_ID, AuditQueries, SYSTEM_ACTOR_IDS } from "../constants";
|
|
15
15
|
|
|
16
16
|
type AuditDetail = {
|
|
17
17
|
readonly id: string;
|
|
@@ -68,7 +68,7 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
70
|
const event = res.data;
|
|
71
|
-
if (event.createdBy
|
|
71
|
+
if (SYSTEM_ACTOR_IDS.has(event.createdBy)) {
|
|
72
72
|
setState({ kind: "ready", event, memberLabel: "", isSystemActor: true });
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
@@ -117,7 +117,11 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
const { event, memberLabel, isSystemActor } = state;
|
|
120
|
-
const actorName = isSystemActor
|
|
120
|
+
const actorName = isSystemActor
|
|
121
|
+
? t("audit.log.actor.system")
|
|
122
|
+
: event.createdBy === ANONYMOUS_USER_ID
|
|
123
|
+
? t("audit.log.actor.anonymous")
|
|
124
|
+
: memberLabel || t("audit.log.actor.unknown");
|
|
121
125
|
|
|
122
126
|
return (
|
|
123
127
|
<FormScreenShell
|
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
import { Filter, RotateCcw } from "lucide-react";
|
|
15
15
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
16
16
|
import { TenantQueries } from "../../tenant/constants";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ANONYMOUS_USER_ID,
|
|
19
|
+
AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
20
|
+
AuditQueries,
|
|
21
|
+
SYSTEM_ACTOR_IDS,
|
|
22
|
+
} from "../constants";
|
|
18
23
|
|
|
19
24
|
type AuditRow = {
|
|
20
25
|
readonly id: string;
|
|
@@ -36,7 +41,6 @@ type MemberRow = {
|
|
|
36
41
|
|
|
37
42
|
type Filters = {
|
|
38
43
|
readonly eventType: string;
|
|
39
|
-
readonly aggregateType: string;
|
|
40
44
|
readonly from: string;
|
|
41
45
|
readonly to: string;
|
|
42
46
|
};
|
|
@@ -51,7 +55,17 @@ type State =
|
|
|
51
55
|
readonly names: ReadonlyMap<string, string>;
|
|
52
56
|
};
|
|
53
57
|
|
|
54
|
-
const EMPTY_FILTERS: Filters = { eventType: "",
|
|
58
|
+
const EMPTY_FILTERS: Filters = { eventType: "", from: "", to: "" };
|
|
59
|
+
|
|
60
|
+
function actorLabel(
|
|
61
|
+
createdBy: string,
|
|
62
|
+
names: ReadonlyMap<string, string>,
|
|
63
|
+
t: (key: string) => string,
|
|
64
|
+
): string {
|
|
65
|
+
if (SYSTEM_ACTOR_IDS.has(createdBy)) return t("audit.log.actor.system");
|
|
66
|
+
if (createdBy === ANONYMOUS_USER_ID) return t("audit.log.actor.anonymous");
|
|
67
|
+
return names.get(createdBy) ?? t("audit.log.actor.unknown");
|
|
68
|
+
}
|
|
55
69
|
|
|
56
70
|
export function AuditLogScreen(): ReactNode {
|
|
57
71
|
const t = useTranslation();
|
|
@@ -71,10 +85,11 @@ export function AuditLogScreen(): ReactNode {
|
|
|
71
85
|
const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
|
|
72
86
|
|
|
73
87
|
// Tenant/dispatcher identity can change without remounting — drop the cache.
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
const dispatcherIdentityRef = useRef(dispatcher);
|
|
89
|
+
if (dispatcherIdentityRef.current !== dispatcher) {
|
|
90
|
+
dispatcherIdentityRef.current = dispatcher;
|
|
76
91
|
membersRef.current = null;
|
|
77
|
-
}
|
|
92
|
+
}
|
|
78
93
|
|
|
79
94
|
const load = useCallback(
|
|
80
95
|
async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
|
|
@@ -100,9 +115,6 @@ export function AuditLogScreen(): ReactNode {
|
|
|
100
115
|
limit: 50,
|
|
101
116
|
...(cursor !== undefined && { before: cursor }),
|
|
102
117
|
...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
|
|
103
|
-
...(f.aggregateType.trim() !== "" && {
|
|
104
|
-
aggregateType: f.aggregateType.trim(),
|
|
105
|
-
}),
|
|
106
118
|
...(f.from !== "" && { from: toIsoStart(f.from) }),
|
|
107
119
|
...(f.to !== "" && { to: toIsoEnd(f.to) }),
|
|
108
120
|
}),
|
|
@@ -144,7 +156,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
144
156
|
return (
|
|
145
157
|
<div className="w-full" data-testid="audit-log-screen">
|
|
146
158
|
<div className="flex flex-wrap items-end gap-4 px-6 pt-6">
|
|
147
|
-
<div className="min-w-40 flex-1">
|
|
159
|
+
<div className="min-w-40 max-w-64 flex-1">
|
|
148
160
|
<Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
|
|
149
161
|
<Input
|
|
150
162
|
kind="text"
|
|
@@ -155,18 +167,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
155
167
|
/>
|
|
156
168
|
</Field>
|
|
157
169
|
</div>
|
|
158
|
-
<div className="min-w-40 flex-1">
|
|
159
|
-
<Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
|
|
160
|
-
<Input
|
|
161
|
-
kind="text"
|
|
162
|
-
id="audit-filter-aggregate"
|
|
163
|
-
name="audit-filter-aggregate"
|
|
164
|
-
value={filters.aggregateType}
|
|
165
|
-
onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
|
|
166
|
-
/>
|
|
167
|
-
</Field>
|
|
168
|
-
</div>
|
|
169
|
-
<div className="min-w-40 flex-1">
|
|
170
|
+
<div className="min-w-40 max-w-64 flex-1">
|
|
170
171
|
<Field id="audit-filter-from" label={t("audit.log.filter.from")}>
|
|
171
172
|
<Input
|
|
172
173
|
kind="date"
|
|
@@ -177,7 +178,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
177
178
|
/>
|
|
178
179
|
</Field>
|
|
179
180
|
</div>
|
|
180
|
-
<div className="min-w-40 flex-1">
|
|
181
|
+
<div className="min-w-40 max-w-64 flex-1">
|
|
181
182
|
<Field id="audit-filter-to" label={t("audit.log.filter.to")}>
|
|
182
183
|
<Input
|
|
183
184
|
kind="date"
|
|
@@ -198,7 +199,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
198
199
|
}}
|
|
199
200
|
testId="audit-log-apply-filters"
|
|
200
201
|
>
|
|
201
|
-
<Filter className="h-4 w-4" />
|
|
202
|
+
<Filter className="h-4 w-4" aria-hidden="true" />
|
|
202
203
|
<span>{t("audit.log.filter.apply")}</span>
|
|
203
204
|
</Button>
|
|
204
205
|
<Button
|
|
@@ -211,7 +212,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
211
212
|
}}
|
|
212
213
|
testId="audit-log-reset-filters"
|
|
213
214
|
>
|
|
214
|
-
<RotateCcw className="h-4 w-4" />
|
|
215
|
+
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
|
215
216
|
<span>{t("audit.log.filter.reset")}</span>
|
|
216
217
|
</Button>
|
|
217
218
|
</div>
|
|
@@ -231,10 +232,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
231
232
|
values: {
|
|
232
233
|
when: formatWhen(row.createdAt),
|
|
233
234
|
type: row.type,
|
|
234
|
-
actor:
|
|
235
|
-
row.createdBy === SYSTEM_ACTOR_ID
|
|
236
|
-
? t("audit.log.actor.system")
|
|
237
|
-
: (state.names.get(row.createdBy) ?? ""),
|
|
235
|
+
actor: actorLabel(row.createdBy, state.names, t),
|
|
238
236
|
},
|
|
239
237
|
}))}
|
|
240
238
|
onRowClick={(row) => openDetail(row.id)}
|
|
@@ -77,6 +77,9 @@ export type InviteCreateOptions = {
|
|
|
77
77
|
// roles, and cannot raise above the inviter's max framework rank — only
|
|
78
78
|
// further restrict assignable ranked roles.
|
|
79
79
|
readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
|
|
80
|
+
/** Opt-in allow-list for app-defined (unranked) roles the elevation guard
|
|
81
|
+
* would otherwise reject with unassignable_membership_role (fw#2398). */
|
|
82
|
+
readonly additionalAssignableRoles?: readonly string[];
|
|
80
83
|
};
|
|
81
84
|
|
|
82
85
|
const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
|
|
@@ -108,7 +111,10 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
108
111
|
[event.payload.role],
|
|
109
112
|
[],
|
|
110
113
|
);
|
|
111
|
-
if (
|
|
114
|
+
if (
|
|
115
|
+
elevationForbidden !== undefined &&
|
|
116
|
+
!opts.additionalAssignableRoles?.includes(elevationForbidden)
|
|
117
|
+
) {
|
|
112
118
|
return writeFailure(unassignableMembershipRoleError(elevationForbidden));
|
|
113
119
|
}
|
|
114
120
|
|
|
@@ -108,7 +108,9 @@ export async function seedUserWithPassword(
|
|
|
108
108
|
* (SessionUser-Konstruktion für JWT-Mint) — sonst hätten zwei
|
|
109
109
|
* Stellen unabhängig "TenantAdmin" hardcoded und würden bei einem
|
|
110
110
|
* Refactor zu role-mismatch zwischen DB und Session leiden. */
|
|
111
|
-
|
|
111
|
+
// TenantAdmin is the canonical owner role; Admin stays for handlers that still
|
|
112
|
+
// gate on the legacy literal until those call sites move to access.admin.
|
|
113
|
+
export const INITIAL_SIGNUP_ROLES = ["TenantAdmin", "Admin"] as const;
|
|
112
114
|
|
|
113
115
|
export type ProvisionSignupAccountOptions = {
|
|
114
116
|
readonly email: string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
|
|
4
|
+
function hmacSign(input: string, secret: string): string {
|
|
5
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type HmacTokenVerifyResult<TPayload> =
|
|
9
|
+
| { readonly ok: true; readonly payload: TPayload; readonly expiresAtMs: number }
|
|
10
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
11
|
+
|
|
12
|
+
export function createHmacTokenCodec<
|
|
13
|
+
TPayload extends { readonly userId: string; readonly tenantId: string },
|
|
14
|
+
>(domain: string, isPayload: (value: unknown) => value is TPayload) {
|
|
15
|
+
type EncodedBody = TPayload & { readonly expiresAtMs: number };
|
|
16
|
+
|
|
17
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
18
|
+
if (typeof value !== "object" || value === null) return false;
|
|
19
|
+
const v = value as Record<string, unknown>;
|
|
20
|
+
const expiresAtMs = v["expiresAtMs"];
|
|
21
|
+
if (typeof expiresAtMs !== "number") return false;
|
|
22
|
+
const { expiresAtMs: _drop, ...rest } = v;
|
|
23
|
+
return isPayload(rest);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
sign(
|
|
28
|
+
payload: TPayload,
|
|
29
|
+
ttlMinutes: number,
|
|
30
|
+
secret: string,
|
|
31
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
32
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
33
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
34
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
35
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
36
|
+
const signature = hmacSign(`${domain}:${bodyB64}`, secret);
|
|
37
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
verify(
|
|
41
|
+
token: string,
|
|
42
|
+
secret: string,
|
|
43
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
44
|
+
): HmacTokenVerifyResult<TPayload> {
|
|
45
|
+
const parts = token.split(".");
|
|
46
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
47
|
+
const [bodyB64, providedSig] = parts;
|
|
48
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
49
|
+
|
|
50
|
+
const expected = hmacSign(`${domain}:${bodyB64}`, secret);
|
|
51
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
52
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
53
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
54
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
55
|
+
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
59
|
+
} catch {
|
|
60
|
+
return { ok: false, reason: "malformed" };
|
|
61
|
+
}
|
|
62
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) >
|
|
66
|
+
0
|
|
67
|
+
) {
|
|
68
|
+
return { ok: false, reason: "expired" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
72
|
+
return { ok: true, payload: payload as unknown as TPayload, expiresAtMs };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -1,90 +1,35 @@
|
|
|
1
|
-
// HMAC-signed token minted by the login handler once the password check
|
|
2
|
-
// succeeds but a second factor is still owed. Carries just enough to look
|
|
3
|
-
// the user back up at `/auth/mfa/verify` — no secret material (unlike
|
|
4
|
-
// mfa-setup-token.ts, this token is never shown to the user, only round-
|
|
5
|
-
// tripped by the client between the two login requests).
|
|
6
|
-
//
|
|
7
|
-
// Format: <base64url(JSON body)>.<hmac-base64url> — same shape as
|
|
8
|
-
// mfa-setup-token.ts, distinct HMAC domain-separation string so a setup
|
|
9
|
-
// token can never be replayed as a challenge token or vice versa.
|
|
10
|
-
//
|
|
11
|
-
// Replay protection: burnToken()/unburnToken() from shared/token-burn-store
|
|
12
|
-
// (purpose "mfa-challenge") make the token single-use on a SUCCESSFUL
|
|
13
|
-
// verify. Brute-force protection is a SEPARATE mechanism — see
|
|
14
|
-
// mfa-verify-attempts.ts — because burn-on-success alone doesn't cap wrong
|
|
15
|
-
// guesses against a still-valid token.
|
|
16
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
17
1
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
2
|
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec";
|
|
19
4
|
|
|
20
5
|
export type MfaChallengePayload = {
|
|
21
6
|
readonly userId: string;
|
|
22
7
|
readonly tenantId: TenantId;
|
|
23
8
|
};
|
|
24
9
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return
|
|
10
|
+
function isPayload(value: unknown): value is MfaChallengePayload {
|
|
11
|
+
if (typeof value !== "object" || value === null) return false;
|
|
12
|
+
const v = value as Record<string, unknown>;
|
|
13
|
+
return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
|
|
29
14
|
}
|
|
30
15
|
|
|
16
|
+
const codec = createHmacTokenCodec("mfa-challenge", isPayload);
|
|
17
|
+
|
|
31
18
|
export function signMfaChallengeToken(
|
|
32
19
|
payload: MfaChallengePayload,
|
|
33
20
|
ttlMinutes: number,
|
|
34
21
|
secret: string,
|
|
35
22
|
now: Temporal.Instant = Temporal.Now.instant(),
|
|
36
23
|
): { token: string; expiresAt: Temporal.Instant } {
|
|
37
|
-
|
|
38
|
-
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
39
|
-
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
40
|
-
const signature = sign(`mfa-challenge:${bodyB64}`, secret);
|
|
41
|
-
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
24
|
+
return codec.sign(payload, ttlMinutes, secret, now);
|
|
42
25
|
}
|
|
43
26
|
|
|
44
|
-
export type VerifyMfaChallengeResult =
|
|
45
|
-
| { readonly ok: true; readonly payload: MfaChallengePayload; readonly expiresAtMs: number }
|
|
46
|
-
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
47
|
-
|
|
48
|
-
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
49
|
-
if (typeof value !== "object" || value === null) return false;
|
|
50
|
-
const v = value as Record<string, unknown>;
|
|
51
|
-
return (
|
|
52
|
-
typeof v["userId"] === "string" &&
|
|
53
|
-
typeof v["tenantId"] === "string" &&
|
|
54
|
-
typeof v["expiresAtMs"] === "number"
|
|
55
|
-
);
|
|
56
|
-
}
|
|
27
|
+
export type VerifyMfaChallengeResult = HmacTokenVerifyResult<MfaChallengePayload>;
|
|
57
28
|
|
|
58
29
|
export function verifyMfaChallengeToken(
|
|
59
30
|
token: string,
|
|
60
31
|
secret: string,
|
|
61
32
|
now: Temporal.Instant = Temporal.Now.instant(),
|
|
62
33
|
): VerifyMfaChallengeResult {
|
|
63
|
-
|
|
64
|
-
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
65
|
-
const [bodyB64, providedSig] = parts;
|
|
66
|
-
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
67
|
-
|
|
68
|
-
const expected = sign(`mfa-challenge:${bodyB64}`, secret);
|
|
69
|
-
const expectedBuf = Buffer.from(expected, "base64url");
|
|
70
|
-
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
71
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
72
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
73
|
-
|
|
74
|
-
let parsed: unknown;
|
|
75
|
-
try {
|
|
76
|
-
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
77
|
-
} catch {
|
|
78
|
-
return { ok: false, reason: "malformed" };
|
|
79
|
-
}
|
|
80
|
-
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
84
|
-
) {
|
|
85
|
-
return { ok: false, reason: "expired" };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const { expiresAtMs, ...payload } = parsed;
|
|
89
|
-
return { ok: true, payload, expiresAtMs };
|
|
34
|
+
return codec.verify(token, secret, now);
|
|
90
35
|
}
|
|
@@ -1,86 +1,35 @@
|
|
|
1
|
-
// HMAC-signed token minted by login.write.ts when enforcement policy blocks
|
|
2
|
-
// an unenrolled user (mfa-setup-required) — proves the password check
|
|
3
|
-
// already succeeded, so a later pre-auth enable-start variant (#1231) can
|
|
4
|
-
// let the user enroll without a full session. No secret material (unlike
|
|
5
|
-
// mfa-setup-token.ts) — just enough to look the user back up.
|
|
6
|
-
//
|
|
7
|
-
// Same domain-separated HMAC shape as mfa-challenge-token.ts, distinct
|
|
8
|
-
// signing string so this can never be replayed as a login challenge or
|
|
9
|
-
// vice versa. Reuses challengeTokenSecret rather than adding a third
|
|
10
|
-
// feature secret: both tokens assert the identical trust boundary
|
|
11
|
-
// ("password already verified, short-lived follow-up allowed").
|
|
12
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
1
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
2
|
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec";
|
|
15
4
|
|
|
16
5
|
export type MfaPreauthSetupPayload = {
|
|
17
6
|
readonly userId: string;
|
|
18
7
|
readonly tenantId: TenantId;
|
|
19
8
|
};
|
|
20
9
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return
|
|
10
|
+
function isPayload(value: unknown): value is MfaPreauthSetupPayload {
|
|
11
|
+
if (typeof value !== "object" || value === null) return false;
|
|
12
|
+
const v = value as Record<string, unknown>;
|
|
13
|
+
return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
|
|
25
14
|
}
|
|
26
15
|
|
|
16
|
+
const codec = createHmacTokenCodec("mfa-preauth-setup", isPayload);
|
|
17
|
+
|
|
27
18
|
export function signMfaPreauthSetupToken(
|
|
28
19
|
payload: MfaPreauthSetupPayload,
|
|
29
20
|
ttlMinutes: number,
|
|
30
21
|
secret: string,
|
|
31
22
|
now: Temporal.Instant = Temporal.Now.instant(),
|
|
32
23
|
): { token: string; expiresAt: Temporal.Instant } {
|
|
33
|
-
|
|
34
|
-
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
35
|
-
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
36
|
-
const signature = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
37
|
-
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
24
|
+
return codec.sign(payload, ttlMinutes, secret, now);
|
|
38
25
|
}
|
|
39
26
|
|
|
40
|
-
export type VerifyMfaPreauthSetupResult =
|
|
41
|
-
| { readonly ok: true; readonly payload: MfaPreauthSetupPayload; readonly expiresAtMs: number }
|
|
42
|
-
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
43
|
-
|
|
44
|
-
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
45
|
-
if (typeof value !== "object" || value === null) return false;
|
|
46
|
-
const v = value as Record<string, unknown>;
|
|
47
|
-
return (
|
|
48
|
-
typeof v["userId"] === "string" &&
|
|
49
|
-
typeof v["tenantId"] === "string" &&
|
|
50
|
-
typeof v["expiresAtMs"] === "number"
|
|
51
|
-
);
|
|
52
|
-
}
|
|
27
|
+
export type VerifyMfaPreauthSetupResult = HmacTokenVerifyResult<MfaPreauthSetupPayload>;
|
|
53
28
|
|
|
54
29
|
export function verifyMfaPreauthSetupToken(
|
|
55
30
|
token: string,
|
|
56
31
|
secret: string,
|
|
57
32
|
now: Temporal.Instant = Temporal.Now.instant(),
|
|
58
33
|
): VerifyMfaPreauthSetupResult {
|
|
59
|
-
|
|
60
|
-
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
61
|
-
const [bodyB64, providedSig] = parts;
|
|
62
|
-
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
63
|
-
|
|
64
|
-
const expected = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
65
|
-
const expectedBuf = Buffer.from(expected, "base64url");
|
|
66
|
-
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
67
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
68
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
69
|
-
|
|
70
|
-
let parsed: unknown;
|
|
71
|
-
try {
|
|
72
|
-
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
73
|
-
} catch {
|
|
74
|
-
return { ok: false, reason: "malformed" };
|
|
75
|
-
}
|
|
76
|
-
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
80
|
-
) {
|
|
81
|
-
return { ok: false, reason: "expired" };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const { expiresAtMs, ...payload } = parsed;
|
|
85
|
-
return { ok: true, payload, expiresAtMs };
|
|
34
|
+
return codec.verify(token, secret, now);
|
|
86
35
|
}
|
|
@@ -49,7 +49,7 @@ export function createStockCapGuard<TCaps>(
|
|
|
49
49
|
return writeFailure(
|
|
50
50
|
new UnprocessableError(spec.code, {
|
|
51
51
|
i18nKey: spec.i18nKey,
|
|
52
|
-
details: { field: spec.field,
|
|
52
|
+
details: { field: spec.field, current, limit },
|
|
53
53
|
}),
|
|
54
54
|
);
|
|
55
55
|
}
|