@cosmicdrift/kumiko-bundled-features 0.146.4 → 0.147.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +1 -0
- package/src/admin-shell/overview-allowlist.ts +1 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +11 -13
- package/src/audit/__tests__/audit-screens.boot.test.ts +17 -8
- package/src/audit/handlers/details.query.ts +1 -1
- package/src/audit/web/audit-log-detail-screen.tsx +7 -9
- package/src/audit/web/audit-log-screen.tsx +16 -29
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +4 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -2
- package/src/auth-email-password/password-hashing.ts +4 -55
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/auth-mfa/__tests__/totp.test.ts +122 -0
- package/src/auth-mfa/base32.ts +45 -0
- package/src/auth-mfa/otpauth-uri.ts +21 -0
- package/src/auth-mfa/totp.ts +47 -0
- package/src/billing-foundation/events.ts +4 -4
- package/src/billing-foundation/tenant-destroy-hook.ts +5 -6
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +8 -5
- package/src/cap-counter/enforce-cap.ts +15 -18
- package/src/cap-counter/with-cap-enforcement.ts +2 -2
- package/src/config/__tests__/deserialize-value.test.ts +2 -2
- package/src/config/resolver.ts +7 -2
- package/src/custom-fields/__tests__/field-access.integration.test.ts +25 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +40 -0
- package/src/custom-fields/lib/field-access.ts +8 -1
- package/src/custom-fields/run-retention.ts +9 -1
- package/src/data-retention/__tests__/parse-override.test.ts +1 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +81 -2
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +39 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +29 -8
- package/src/inbound-mail-foundation/retention-sweep.ts +25 -12
- package/src/inbound-provider-imap/feature.ts +3 -7
- package/src/jobs/web/job-run-detail-screen.tsx +7 -9
- package/src/jobs/web/job-runs-screen.tsx +3 -21
- package/src/page-render/__tests__/layout.test.ts +10 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
- package/src/renderer-simple/__tests__/resolve-variables.test.ts +37 -0
- package/src/renderer-simple/resolve-variables.ts +3 -1
- package/src/seo/__tests__/seo.integration.test.ts +12 -0
- package/src/seo/feature.ts +16 -4
- package/src/seo/sitemap.ts +3 -0
- package/src/{auth-email-password/__tests__ → shared}/identity-v3-hash.test.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared/password-hashing.ts +55 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +47 -1
- package/src/tags/web/tag-section.tsx +6 -3
- package/src/tenant-lifecycle/lifecycle-gate.ts +17 -10
- package/src/text-content/web/client-plugin.tsx +1 -1
- package/src/tier-engine/__tests__/trial-config-validation.test.ts +30 -0
- package/src/tier-engine/feature.ts +5 -0
- package/src/user-data-rights/__tests__/email-templates.test.ts +15 -0
- package/src/user-data-rights/email-templates.ts +6 -6
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -0
- /package/src/{auth-email-password → shared}/identity-v3-hash.ts +0 -0
- /package/src/{auth-email-password → shared}/password-hashing.test.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -111,11 +111,11 @@
|
|
|
111
111
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
112
112
|
},
|
|
113
113
|
"dependencies": {
|
|
114
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
115
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
116
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
117
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
118
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
114
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.147.1",
|
|
115
|
+
"@cosmicdrift/kumiko-framework": "0.147.1",
|
|
116
|
+
"@cosmicdrift/kumiko-headless": "0.147.1",
|
|
117
|
+
"@cosmicdrift/kumiko-renderer": "0.147.1",
|
|
118
|
+
"@cosmicdrift/kumiko-renderer-web": "0.147.1",
|
|
119
119
|
"@mollie/api-client": "^4.5.0",
|
|
120
120
|
"imapflow": "^1.3.3",
|
|
121
121
|
"mailparser": "^3.9.8",
|
|
@@ -38,5 +38,6 @@ describe("overview query allowlist", () => {
|
|
|
38
38
|
test("platform queries are not tenant-allowlisted", () => {
|
|
39
39
|
expect(isOverviewQueryAllowed("tenant", TenantQueries.list)).toBe(false);
|
|
40
40
|
expect(isOverviewQueryAllowed("tenant", JobQueries.list)).toBe(false);
|
|
41
|
+
expect(isOverviewQueryAllowed("tenant", UserQueries.list)).toBe(false);
|
|
41
42
|
});
|
|
42
43
|
});
|
|
@@ -25,6 +25,7 @@ export const TENANT_OVERVIEW_FORBIDDEN_QUERIES = [
|
|
|
25
25
|
"jobs:query:list",
|
|
26
26
|
"feature-toggles:query:list",
|
|
27
27
|
"feature-toggles:query:registered",
|
|
28
|
+
"user:query:user:list",
|
|
28
29
|
] as const;
|
|
29
30
|
|
|
30
31
|
export function overviewAllowedQueries(kind: OverviewWorkspaceKind): readonly string[] {
|
|
@@ -16,11 +16,11 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
16
16
|
useEffect(() => {
|
|
17
17
|
let cancelled = false;
|
|
18
18
|
void (async (): Promise<void> => {
|
|
19
|
-
const tenantsRes = await overviewQuery<{ readonly
|
|
19
|
+
const tenantsRes = await overviewQuery<{ readonly total: number }>(
|
|
20
20
|
"platform",
|
|
21
21
|
dispatcher,
|
|
22
22
|
TenantQueries.list,
|
|
23
|
-
{},
|
|
23
|
+
{ totalCount: true },
|
|
24
24
|
);
|
|
25
25
|
if (cancelled) return;
|
|
26
26
|
if (!tenantsRes.isSuccess) {
|
|
@@ -28,11 +28,11 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const usersRes = await overviewQuery<{ readonly
|
|
31
|
+
const usersRes = await overviewQuery<{ readonly total: number }>(
|
|
32
32
|
"platform",
|
|
33
33
|
dispatcher,
|
|
34
34
|
UserQueries.list,
|
|
35
|
-
{},
|
|
35
|
+
{ totalCount: true },
|
|
36
36
|
);
|
|
37
37
|
if (cancelled) return;
|
|
38
38
|
if (!usersRes.isSuccess) {
|
|
@@ -40,11 +40,11 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const failedJobsRes = await overviewQuery<{ readonly
|
|
43
|
+
const failedJobsRes = await overviewQuery<{ readonly total: number }>(
|
|
44
44
|
"platform",
|
|
45
45
|
dispatcher,
|
|
46
46
|
JobQueries.list,
|
|
47
|
-
{ status: "failed",
|
|
47
|
+
{ status: "failed", totalCount: true },
|
|
48
48
|
);
|
|
49
49
|
if (cancelled) return;
|
|
50
50
|
if (!failedJobsRes.isSuccess) {
|
|
@@ -57,20 +57,18 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
57
57
|
cards: [
|
|
58
58
|
{
|
|
59
59
|
label: t("admin-shell:overview.tenants"),
|
|
60
|
-
value: String(tenantsRes.data.
|
|
60
|
+
value: String(tenantsRes.data.total),
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
label: t("admin-shell:overview.users"),
|
|
64
|
-
value: String(usersRes.data.
|
|
64
|
+
value: String(usersRes.data.total),
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
label: t("admin-shell:overview.failedJobs"),
|
|
68
|
-
value: String(failedJobsRes.data.
|
|
68
|
+
value: String(failedJobsRes.data.total),
|
|
69
69
|
hint:
|
|
70
|
-
failedJobsRes.data.
|
|
71
|
-
|
|
72
|
-
: undefined,
|
|
73
|
-
attention: failedJobsRes.data.rows.length > 0,
|
|
70
|
+
failedJobsRes.data.total > 0 ? t("admin-shell:overview.failedJobsHint") : undefined,
|
|
71
|
+
attention: failedJobsRes.data.total > 0,
|
|
74
72
|
},
|
|
75
73
|
],
|
|
76
74
|
});
|
|
@@ -15,22 +15,31 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
15
15
|
test("audit-log screen is custom, access.admin-gated", () => {
|
|
16
16
|
const audit = createAuditFeature();
|
|
17
17
|
const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
expect(screen.access.roles).toEqual(access.admin);
|
|
18
|
+
if (!screen || screen.type !== "custom") {
|
|
19
|
+
throw new Error(`expected a custom screen for ${AUDIT_LOG_SCREEN_ID}, got ${screen?.type}`);
|
|
21
20
|
}
|
|
21
|
+
if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
|
|
22
|
+
throw new Error(`expected role-gated access on ${AUDIT_LOG_SCREEN_ID}`);
|
|
23
|
+
}
|
|
24
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
22
25
|
});
|
|
23
26
|
|
|
24
27
|
test("audit-log-detail screen is custom, admin-gated, breadcrumb-linked to list", () => {
|
|
25
28
|
const audit = createAuditFeature();
|
|
26
29
|
const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
if (!screen || screen.type !== "custom") {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`expected a custom screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (!("listScreenId" in screen)) {
|
|
36
|
+
throw new Error(`expected listScreenId on ${AUDIT_LOG_DETAIL_SCREEN_ID}`);
|
|
30
37
|
}
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
expect(screen.listScreenId).toBe(AUDIT_LOG_SCREEN_ID);
|
|
39
|
+
if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
|
|
40
|
+
throw new Error(`expected role-gated access on ${AUDIT_LOG_DETAIL_SCREEN_ID}`);
|
|
33
41
|
}
|
|
42
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
34
43
|
});
|
|
35
44
|
|
|
36
45
|
test("audit queries use access.admin (screen ⊆ handler)", () => {
|
|
@@ -10,7 +10,7 @@ import { z } from "zod";
|
|
|
10
10
|
export const detailsQuery = defineQueryHandler({
|
|
11
11
|
name: "details",
|
|
12
12
|
schema: z.object({
|
|
13
|
-
id: z.string().regex(
|
|
13
|
+
id: z.string().regex(/^[1-9]\d*$/, "id must be a positive integer"),
|
|
14
14
|
}),
|
|
15
15
|
access: { roles: access.admin },
|
|
16
16
|
handler: async (query, ctx) => {
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// Single audit event detail. Route entityId = event-store id (bigint string).
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
formatWhen,
|
|
6
|
+
useDispatcher,
|
|
7
|
+
useNav,
|
|
8
|
+
usePrimitives,
|
|
9
|
+
useTranslation,
|
|
10
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
5
11
|
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
12
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
13
|
import { AuditQueries } from "../constants";
|
|
@@ -123,11 +129,3 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
123
129
|
</FormScreenShell>
|
|
124
130
|
);
|
|
125
131
|
}
|
|
126
|
-
|
|
127
|
-
function formatWhen(value: string): string {
|
|
128
|
-
try {
|
|
129
|
-
return new Date(value).toLocaleString();
|
|
130
|
-
} catch {
|
|
131
|
-
return value;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
type DataTableSort,
|
|
7
|
+
formatWhen,
|
|
8
|
+
sortByAccessor,
|
|
7
9
|
useDispatcher,
|
|
8
10
|
useNav,
|
|
9
11
|
usePrimitives,
|
|
10
12
|
useTranslation,
|
|
11
13
|
} from "@cosmicdrift/kumiko-renderer";
|
|
12
|
-
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
14
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
13
15
|
import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
|
|
14
16
|
|
|
15
17
|
type AuditRow = {
|
|
@@ -52,18 +54,22 @@ export function AuditLogScreen(): ReactNode {
|
|
|
52
54
|
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
|
53
55
|
const [sort, setSort] = useState<DataTableSort | null>(null);
|
|
54
56
|
|
|
57
|
+
const filtersRef = useRef(filters);
|
|
58
|
+
filtersRef.current = filters;
|
|
59
|
+
|
|
55
60
|
const load = useCallback(
|
|
56
|
-
async (cursor?: string): Promise<void> => {
|
|
61
|
+
async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
|
|
57
62
|
setState({ kind: "loading" });
|
|
63
|
+
const f = overrideFilters ?? filtersRef.current;
|
|
58
64
|
const res = await dispatcher.query<AuditResponse>(AuditQueries.list, {
|
|
59
65
|
limit: 50,
|
|
60
66
|
...(cursor !== undefined && { before: cursor }),
|
|
61
|
-
...(
|
|
62
|
-
...(
|
|
63
|
-
aggregateType:
|
|
67
|
+
...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
|
|
68
|
+
...(f.aggregateType.trim() !== "" && {
|
|
69
|
+
aggregateType: f.aggregateType.trim(),
|
|
64
70
|
}),
|
|
65
|
-
...(
|
|
66
|
-
...(
|
|
71
|
+
...(f.from !== "" && { from: toIsoStart(f.from) }),
|
|
72
|
+
...(f.to !== "" && { to: toIsoEnd(f.to) }),
|
|
67
73
|
});
|
|
68
74
|
if (!res.isSuccess) {
|
|
69
75
|
setState({ kind: "error", message: res.error.message });
|
|
@@ -71,7 +77,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
71
77
|
}
|
|
72
78
|
setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore });
|
|
73
79
|
},
|
|
74
|
-
[dispatcher
|
|
80
|
+
[dispatcher],
|
|
75
81
|
);
|
|
76
82
|
|
|
77
83
|
useEffect(() => {
|
|
@@ -156,6 +162,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
156
162
|
onClick={() => {
|
|
157
163
|
setFilters(EMPTY_FILTERS);
|
|
158
164
|
setBefore(undefined);
|
|
165
|
+
void load(undefined, EMPTY_FILTERS);
|
|
159
166
|
}}
|
|
160
167
|
testId="audit-log-reset-filters"
|
|
161
168
|
>
|
|
@@ -179,7 +186,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
179
186
|
]}
|
|
180
187
|
sort={sort}
|
|
181
188
|
onSortChange={setSort}
|
|
182
|
-
rows={
|
|
189
|
+
rows={sortByAccessor(state.rows, sort, SORT_ACCESSORS).map((row) => ({
|
|
183
190
|
id: row.id,
|
|
184
191
|
values: {
|
|
185
192
|
when: formatWhen(row.createdAt),
|
|
@@ -234,26 +241,6 @@ const SORT_ACCESSORS: Record<string, (r: AuditRow) => string> = {
|
|
|
234
241
|
type: (r) => r.type,
|
|
235
242
|
};
|
|
236
243
|
|
|
237
|
-
function sortAudit(rows: readonly AuditRow[], sort: DataTableSort | null): readonly AuditRow[] {
|
|
238
|
-
if (sort === null) return rows;
|
|
239
|
-
const accessor = SORT_ACCESSORS[sort.field];
|
|
240
|
-
if (accessor === undefined) return rows;
|
|
241
|
-
const factor = sort.dir === "asc" ? 1 : -1;
|
|
242
|
-
return [...rows].sort((a, b) => {
|
|
243
|
-
const av = accessor(a);
|
|
244
|
-
const bv = accessor(b);
|
|
245
|
-
return av < bv ? -factor : av > bv ? factor : 0;
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function formatWhen(value: string): string {
|
|
250
|
-
try {
|
|
251
|
-
return new Date(value).toLocaleString();
|
|
252
|
-
} catch {
|
|
253
|
-
return value;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
244
|
function toIsoStart(date: string): string {
|
|
258
245
|
return new Date(`${date}T00:00:00.000Z`).toISOString();
|
|
259
246
|
}
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
21
21
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
22
22
|
import {
|
|
23
|
+
buildSessionRoles,
|
|
23
24
|
createSystemUser,
|
|
24
25
|
defineWriteHandler,
|
|
25
26
|
type SessionUser,
|
|
26
|
-
stripForbiddenMembershipRoles,
|
|
27
27
|
type TenantId,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
29
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -169,7 +169,9 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
169
169
|
const session: SessionUser = {
|
|
170
170
|
id: userId,
|
|
171
171
|
tenantId: invitationTenantId,
|
|
172
|
-
|
|
172
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
173
|
+
// a reserved role on the invitation itself must never reach the session.
|
|
174
|
+
roles: buildSessionRoles([], [invitationRole]),
|
|
173
175
|
};
|
|
174
176
|
|
|
175
177
|
committed = true;
|
|
@@ -24,10 +24,10 @@ import {
|
|
|
24
24
|
type DbConnection,
|
|
25
25
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
26
|
import {
|
|
27
|
+
buildSessionRoles,
|
|
27
28
|
createSystemUser,
|
|
28
29
|
defineWriteHandler,
|
|
29
30
|
type SessionUser,
|
|
30
|
-
stripForbiddenMembershipRoles,
|
|
31
31
|
type TenantId,
|
|
32
32
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
33
33
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -165,7 +165,9 @@ export function createInviteSignupCompleteHandler() {
|
|
|
165
165
|
const session: SessionUser = {
|
|
166
166
|
id: userId,
|
|
167
167
|
tenantId: invitationTenantId,
|
|
168
|
-
|
|
168
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
169
|
+
// a reserved role on the invitation itself must never reach the session.
|
|
170
|
+
roles: buildSessionRoles([], [invitationRole]),
|
|
169
171
|
};
|
|
170
172
|
|
|
171
173
|
committed = true;
|
|
@@ -1,55 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// memoryCost: 19 MiB, timeCost: 2, parallelism: 1
|
|
6
|
-
// These strike a balance between login latency (~20ms on typical hardware)
|
|
7
|
-
// and brute-force resistance. If hashing becomes a bottleneck, tune memoryCost
|
|
8
|
-
// before parallelism — memory hardness is what defeats GPU attacks.
|
|
9
|
-
//
|
|
10
|
-
// algorithm: 2 = Argon2id (best of argon2i + argon2d).
|
|
11
|
-
// We inline the numeric value instead of importing Algorithm because the
|
|
12
|
-
// @node-rs/argon2 enum is const and breaks verbatimModuleSyntax imports.
|
|
13
|
-
const HASH_OPTIONS = {
|
|
14
|
-
algorithm: 2,
|
|
15
|
-
memoryCost: 19456,
|
|
16
|
-
timeCost: 2,
|
|
17
|
-
parallelism: 1,
|
|
18
|
-
} as const;
|
|
19
|
-
|
|
20
|
-
// @wrapper-known semantic-alias
|
|
21
|
-
export async function hashPassword(password: string): Promise<string> {
|
|
22
|
-
return argonHash(password, HASH_OPTIONS);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Returns true if the password matches. Never throws on wrong passwords —
|
|
26
|
-
// only on malformed hash strings (which would be a bug, not a login attempt).
|
|
27
|
-
//
|
|
28
|
-
// Two verifier paths:
|
|
29
|
-
// - argon2id (default, what `hashPassword` produces)
|
|
30
|
-
// - ASP.NET Core Identity V3 (verify-only, for legacy migrations from .NET
|
|
31
|
-
// stacks). Sniffed via the format marker; on a successful match the
|
|
32
|
-
// application can rehash to argon2 at the next password-change event.
|
|
33
|
-
export async function verifyPassword(hashString: string, password: string): Promise<boolean> {
|
|
34
|
-
if (isIdentityV3Hash(hashString)) {
|
|
35
|
-
return verifyIdentityV3Hash(password, hashString);
|
|
36
|
-
}
|
|
37
|
-
try {
|
|
38
|
-
return await argonVerify(hashString, password);
|
|
39
|
-
} catch {
|
|
40
|
-
// argon2 throws on unparseable hash — treat as mismatch rather than 500
|
|
41
|
-
// to avoid revealing which accounts have corrupted stored hashes.
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Anti-enumeration timing equaliser (#774). The login handler runs this on
|
|
47
|
-
// the no-user / no-hash path so a missing account costs the same argon2
|
|
48
|
-
// latency as a real verify — otherwise response timing leaks whether an
|
|
49
|
-
// email is registered. Derived from hashPassword once and cached, so it
|
|
50
|
-
// always tracks HASH_OPTIONS; the password never matches, result discarded.
|
|
51
|
-
let dummyHash: Promise<string> | undefined;
|
|
52
|
-
export async function verifyDummyPassword(password: string): Promise<void> {
|
|
53
|
-
dummyHash ??= hashPassword("anti-enumeration-dummy");
|
|
54
|
-
await verifyPassword(await dummyHash, password);
|
|
55
|
-
}
|
|
1
|
+
// Moved to shared/ — reused across admin-shell, audit, sessions, tenant,
|
|
2
|
+
// user-data-rights, user-profile, and now auth-mfa. Re-exported here for the
|
|
3
|
+
// existing call sites within this feature that import via "./password-hashing".
|
|
4
|
+
export { hashPassword, verifyDummyPassword, verifyPassword } from "../shared/password-hashing";
|
|
@@ -34,7 +34,7 @@ export type UserMenuProps = {
|
|
|
34
34
|
readonly children?: ReactNode;
|
|
35
35
|
/** "pill" (Default) = kompakter Topbar-Trigger; "sidebar" = volle NavUser-
|
|
36
36
|
* Row (Avatar + Name + Email) für den `sidebarFooter`-Slot der App-Shell.
|
|
37
|
-
* Requires a `SidebarProvider` ancestor
|
|
37
|
+
* Requires a `SidebarProvider` ancestor — the default App-Shell
|
|
38
38
|
* `sidebarFooter` slot already provides one. */
|
|
39
39
|
readonly variant?: UserMenuVariant;
|
|
40
40
|
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
import { base32Decode, base32Encode } from "../base32";
|
|
4
|
+
import { buildOtpauthUri } from "../otpauth-uri";
|
|
5
|
+
import { generateTotpSecret, verifyTotp } from "../totp";
|
|
6
|
+
|
|
7
|
+
// RFC 6238 Appendix B test vectors use the ASCII secret "12345678901234567890"
|
|
8
|
+
// (20 bytes) with SHA1 — the same algorithm this implementation hardcodes
|
|
9
|
+
// (RFC 6238's SHA256/SHA512 variants are out of scope, matching every real
|
|
10
|
+
// authenticator app which only speaks SHA1).
|
|
11
|
+
const RFC_SECRET = Buffer.from("12345678901234567890", "ascii");
|
|
12
|
+
|
|
13
|
+
// RFC 6238 Appendix B, SHA1 column: T=59s → code "94287082" (8-digit RFC
|
|
14
|
+
// vector). This codebase computes 6-digit codes (Google Authenticator
|
|
15
|
+
// convention) — take the last 6 digits, which is truncation-equivalent
|
|
16
|
+
// since dynamic truncation just modulos a wider range.
|
|
17
|
+
function rfcVectorCode8Digit(epochSeconds: number): string {
|
|
18
|
+
// Precomputed from RFC 6238 Appendix B for T=59.
|
|
19
|
+
if (epochSeconds === 59) return "94287082";
|
|
20
|
+
throw new Error("no precomputed vector for this timestamp");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("TOTP — RFC 6238 vectors", () => {
|
|
24
|
+
test("6-digit code at T=59 matches the last 6 digits of the RFC 8-digit vector", () => {
|
|
25
|
+
const expected8 = rfcVectorCode8Digit(59);
|
|
26
|
+
const expected6 = expected8.slice(-6);
|
|
27
|
+
expect(verifyTotp(RFC_SECRET, expected6, 59_000)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rejects a code from a different time window (>±1 step)", () => {
|
|
31
|
+
const expected8 = rfcVectorCode8Digit(59);
|
|
32
|
+
const expected6 = expected8.slice(-6);
|
|
33
|
+
// 59s + 3 steps (90s) is outside the ±1 (±30s) window.
|
|
34
|
+
expect(verifyTotp(RFC_SECRET, expected6, 59_000 + 3 * 30_000)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("accepts a code one step in the past or future (clock drift)", () => {
|
|
38
|
+
const secret = generateTotpSecret();
|
|
39
|
+
const now = 1_700_000_000_000;
|
|
40
|
+
// Derive the code for now, then verify it's still accepted 25s later —
|
|
41
|
+
// same 30s step, no window needed, sanity check for the step math.
|
|
42
|
+
const codeAtNow = deriveCodeForTest(secret, now);
|
|
43
|
+
expect(verifyTotp(secret, codeAtNow, now + 25_000)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects wrong-length or non-numeric input without throwing", () => {
|
|
47
|
+
const secret = generateTotpSecret();
|
|
48
|
+
expect(verifyTotp(secret, "12345")).toBe(false);
|
|
49
|
+
expect(verifyTotp(secret, "1234567")).toBe(false);
|
|
50
|
+
expect(verifyTotp(secret, "abcdef")).toBe(false);
|
|
51
|
+
expect(verifyTotp(secret, "")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("generateTotpSecret returns 20 random, non-zero bytes", () => {
|
|
55
|
+
const a = generateTotpSecret();
|
|
56
|
+
const b = generateTotpSecret();
|
|
57
|
+
expect(a.length).toBe(20);
|
|
58
|
+
expect(a.equals(b)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("base32", () => {
|
|
63
|
+
test("round-trips arbitrary byte buffers", () => {
|
|
64
|
+
for (const input of [
|
|
65
|
+
Buffer.from([]),
|
|
66
|
+
Buffer.from([0]),
|
|
67
|
+
Buffer.from([255]),
|
|
68
|
+
generateTotpSecret(),
|
|
69
|
+
Buffer.from("hello world", "ascii"),
|
|
70
|
+
]) {
|
|
71
|
+
expect(base32Decode(base32Encode(input)).equals(input)).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("decode is case-insensitive and tolerates stray whitespace", () => {
|
|
76
|
+
const encoded = base32Encode(Buffer.from("test-secret-bytes!!"));
|
|
77
|
+
expect(base32Decode(encoded.toLowerCase()).equals(base32Decode(encoded))).toBe(true);
|
|
78
|
+
expect(base32Decode(` ${encoded} `).equals(base32Decode(encoded))).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("decode throws on a genuinely invalid character (e.g. punctuation)", () => {
|
|
82
|
+
expect(() => base32Decode("!!!not-valid-base32!!!")).toThrow();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("otpauth URI", () => {
|
|
87
|
+
test("builds a well-formed otpauth://totp URI with the expected params", () => {
|
|
88
|
+
const secret = generateTotpSecret();
|
|
89
|
+
const uri = buildOtpauthUri({
|
|
90
|
+
issuer: "Kumiko",
|
|
91
|
+
accountLabel: "acme:jane@example.com",
|
|
92
|
+
secret,
|
|
93
|
+
});
|
|
94
|
+
expect(uri).toStartWith("otpauth://totp/Kumiko%3Aacme%3Ajane%40example.com?");
|
|
95
|
+
const query = new URLSearchParams(uri.split("?")[1]);
|
|
96
|
+
expect(query.get("issuer")).toBe("Kumiko");
|
|
97
|
+
expect(query.get("algorithm")).toBe("SHA1");
|
|
98
|
+
expect(query.get("digits")).toBe("6");
|
|
99
|
+
expect(query.get("period")).toBe("30");
|
|
100
|
+
expect(base32Decode(query.get("secret") ?? "").equals(secret)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Internal helper mirroring the private `hotp`/`totpAt` math in totp.ts, kept
|
|
105
|
+
// test-local so we don't have to export internals just for round-trip tests.
|
|
106
|
+
function deriveCodeForTest(secret: Buffer, epochMs: number): string {
|
|
107
|
+
const step = Math.floor(epochMs / 1000 / 30);
|
|
108
|
+
// Re-implements the exact truncation from totp.ts's hotp() — duplicated
|
|
109
|
+
// deliberately: verifyTotp is the thing under test, so this must NOT call
|
|
110
|
+
// through it to derive its own expectation.
|
|
111
|
+
const counterBuf = Buffer.alloc(8);
|
|
112
|
+
counterBuf.writeUInt32BE(Math.floor(step / 2 ** 32), 0);
|
|
113
|
+
counterBuf.writeUInt32BE(step % 2 ** 32, 4);
|
|
114
|
+
const hmac = createHmac("sha1", secret).update(counterBuf).digest();
|
|
115
|
+
const offset = (hmac[hmac.length - 1] ?? 0) & 0x0f;
|
|
116
|
+
const truncated =
|
|
117
|
+
(((hmac[offset] ?? 0) & 0x7f) << 24) |
|
|
118
|
+
(((hmac[offset + 1] ?? 0) & 0xff) << 16) |
|
|
119
|
+
(((hmac[offset + 2] ?? 0) & 0xff) << 8) |
|
|
120
|
+
((hmac[offset + 3] ?? 0) & 0xff);
|
|
121
|
+
return String(truncated % 10 ** 6).padStart(6, "0");
|
|
122
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// RFC 4648 Base32 (no padding) — the encoding TOTP secrets and otpauth://
|
|
2
|
+
// URIs use. No external dependency: the alphabet lookup is ~20 lines and every
|
|
3
|
+
// authenticator app (Google/Microsoft/Authy) expects exactly this variant.
|
|
4
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
5
|
+
|
|
6
|
+
export function base32Encode(bytes: Buffer): string {
|
|
7
|
+
let bits = 0;
|
|
8
|
+
let value = 0;
|
|
9
|
+
let output = "";
|
|
10
|
+
for (const byte of bytes) {
|
|
11
|
+
value = (value << 8) | byte;
|
|
12
|
+
bits += 8;
|
|
13
|
+
while (bits >= 5) {
|
|
14
|
+
output += ALPHABET[(value >>> (bits - 5)) & 0x1f];
|
|
15
|
+
bits -= 5;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (bits > 0) {
|
|
19
|
+
output += ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
20
|
+
}
|
|
21
|
+
return output;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Accepts lowercase and stray whitespace (authenticator apps and users
|
|
25
|
+
// copy-pasting secrets are inconsistent about casing) — normalizes case and
|
|
26
|
+
// strips whitespace before decoding, but throws on a genuinely invalid
|
|
27
|
+
// character so a corrupted stored secret fails loud at read time instead of
|
|
28
|
+
// silently producing wrong codes.
|
|
29
|
+
export function base32Decode(encoded: string): Buffer {
|
|
30
|
+
const clean = encoded.trim().toUpperCase().replace(/\s+/g, "");
|
|
31
|
+
let bits = 0;
|
|
32
|
+
let value = 0;
|
|
33
|
+
const bytes: number[] = [];
|
|
34
|
+
for (const char of clean) {
|
|
35
|
+
const index = ALPHABET.indexOf(char);
|
|
36
|
+
if (index === -1) throw new Error(`base32Decode: invalid character "${char}"`);
|
|
37
|
+
value = (value << 5) | index;
|
|
38
|
+
bits += 5;
|
|
39
|
+
if (bits >= 8) {
|
|
40
|
+
bytes.push((value >>> (bits - 8)) & 0xff);
|
|
41
|
+
bits -= 8;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return Buffer.from(bytes);
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { base32Encode } from "./base32";
|
|
2
|
+
|
|
3
|
+
// otpauth:// URI per Google Authenticator's key-uri-format (the de facto
|
|
4
|
+
// standard every authenticator app implements). The client renders this as
|
|
5
|
+
// a QR code (or lets the user type the secret manually) — the server never
|
|
6
|
+
// touches QR rendering.
|
|
7
|
+
export function buildOtpauthUri(opts: {
|
|
8
|
+
readonly issuer: string;
|
|
9
|
+
readonly accountLabel: string;
|
|
10
|
+
readonly secret: Buffer;
|
|
11
|
+
}): string {
|
|
12
|
+
const label = encodeURIComponent(`${opts.issuer}:${opts.accountLabel}`);
|
|
13
|
+
const params = new URLSearchParams({
|
|
14
|
+
secret: base32Encode(opts.secret),
|
|
15
|
+
issuer: opts.issuer,
|
|
16
|
+
algorithm: "SHA1",
|
|
17
|
+
digits: "6",
|
|
18
|
+
period: "30",
|
|
19
|
+
});
|
|
20
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// RFC 6238 TOTP on top of node:crypto — no otplib/speakeasy dependency. The
|
|
2
|
+
// algorithm is ~20 lines of HMAC-SHA1 + dynamic truncation; pulling in a
|
|
3
|
+
// package for it would be the opposite of lazy.
|
|
4
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const STEP_SECONDS = 30;
|
|
7
|
+
const DIGITS = 6;
|
|
8
|
+
const WINDOW = 1; // accept ±1 step (±30s) of clock drift, per the spec's guidance
|
|
9
|
+
|
|
10
|
+
export function generateTotpSecret(): Buffer {
|
|
11
|
+
return randomBytes(20); // 160 bits — RFC 4226's recommended HOTP secret length
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hotp(secret: Buffer, counter: number): string {
|
|
15
|
+
const counterBuf = Buffer.alloc(8);
|
|
16
|
+
counterBuf.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
|
|
17
|
+
counterBuf.writeUInt32BE(counter % 2 ** 32, 4);
|
|
18
|
+
|
|
19
|
+
const hmac = createHmac("sha1", secret).update(counterBuf).digest();
|
|
20
|
+
const offset = (hmac[hmac.length - 1] ?? 0) & 0x0f;
|
|
21
|
+
const truncated =
|
|
22
|
+
(((hmac[offset] ?? 0) & 0x7f) << 24) |
|
|
23
|
+
(((hmac[offset + 1] ?? 0) & 0xff) << 16) |
|
|
24
|
+
(((hmac[offset + 2] ?? 0) & 0xff) << 8) |
|
|
25
|
+
((hmac[offset + 3] ?? 0) & 0xff);
|
|
26
|
+
|
|
27
|
+
return String(truncated % 10 ** DIGITS).padStart(DIGITS, "0");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function totpAt(secret: Buffer, epochSeconds: number): string {
|
|
31
|
+
return hotp(secret, Math.floor(epochSeconds / STEP_SECONDS));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Timing-safe across the whole ±WINDOW check, not just the final compare —
|
|
35
|
+
// a TOTP code is the same sensitivity class as a password, brute-forceable
|
|
36
|
+
// over a public /auth/mfa/verify endpoint without it.
|
|
37
|
+
export function verifyTotp(secret: Buffer, code: string, nowMs: number = Date.now()): boolean {
|
|
38
|
+
if (!/^\d{6}$/.test(code)) return false;
|
|
39
|
+
const codeBuf = Buffer.from(code);
|
|
40
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
41
|
+
let matched = false;
|
|
42
|
+
for (let w = -WINDOW; w <= WINDOW; w++) {
|
|
43
|
+
const candidate = Buffer.from(totpAt(secret, nowSeconds + w * STEP_SECONDS));
|
|
44
|
+
if (timingSafeEqual(candidate, codeBuf)) matched = true;
|
|
45
|
+
}
|
|
46
|
+
return matched;
|
|
47
|
+
}
|
|
@@ -51,10 +51,10 @@ const statusEnum = z.enum([
|
|
|
51
51
|
// payload — payload ist domain-clean, metadata ist provider-truth).
|
|
52
52
|
export const subscriptionEventPayloadSchema = z.object({
|
|
53
53
|
providerName: z.string().min(1).max(50),
|
|
54
|
-
// 1000, not 200: these two are `
|
|
55
|
-
// entities.ts
|
|
56
|
-
// ciphertext here, not the raw
|
|
57
|
-
// becomes ~
|
|
54
|
+
// 1000, not 200: these two are `tenantOwned: true` on the entity (see
|
|
55
|
+
// entities.ts — crypto-shreds on tenant-destroy, unlike `encrypted`).
|
|
56
|
+
// process-event.write.ts stores the PII-ciphertext here, not the raw
|
|
57
|
+
// provider id. A 200-char plaintext id becomes ~300+ chars of ciphertext.
|
|
58
58
|
providerCustomerId: z.string().min(1).max(1000),
|
|
59
59
|
providerSubscriptionId: z.string().min(1).max(1000),
|
|
60
60
|
status: statusEnum,
|