@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.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 +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +22 -7
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Thin wrapper around signed-token.ts pinning the purpose to "unlock".
|
|
2
|
+
// Mirrors reset-token.ts / verification-token.ts.
|
|
3
|
+
|
|
4
|
+
import type { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { signToken, TokenPurpose, verifyToken } from "./signed-token";
|
|
6
|
+
|
|
7
|
+
export type VerifyResult =
|
|
8
|
+
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
9
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
10
|
+
|
|
11
|
+
// @wrapper-known semantic-alias
|
|
12
|
+
export function signUnlockToken(
|
|
13
|
+
userId: string,
|
|
14
|
+
ttlMinutes: number,
|
|
15
|
+
secret: string,
|
|
16
|
+
now?: Temporal.Instant,
|
|
17
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
18
|
+
return signToken(userId, TokenPurpose.accountUnlock, ttlMinutes, secret, now);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// @wrapper-known semantic-alias
|
|
22
|
+
export function verifyUnlockToken(
|
|
23
|
+
token: string,
|
|
24
|
+
secret: string,
|
|
25
|
+
now?: Temporal.Instant,
|
|
26
|
+
): VerifyResult {
|
|
27
|
+
return verifyToken(token, TokenPurpose.accountUnlock, secret, now);
|
|
28
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { screen } from "@testing-library/react";
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { fireEvent, screen } from "@testing-library/react";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
|
-
import { makeAuthGate } from "../auth-gate";
|
|
4
|
+
import { createLoginRoute, makeAuthGate } from "../auth-gate";
|
|
5
5
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
6
6
|
|
|
7
7
|
describe("makeAuthGate", () => {
|
|
@@ -48,3 +48,65 @@ describe("makeAuthGate", () => {
|
|
|
48
48
|
expect(screen.queryByTestId("custom-login")).toBeNull();
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("createLoginRoute", () => {
|
|
53
|
+
function CustomLogin(): ReactNode {
|
|
54
|
+
return <div data-testid="custom-login">custom-login</div>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function LoginWithMfaTrigger({
|
|
58
|
+
onMfaChallenge,
|
|
59
|
+
}: {
|
|
60
|
+
readonly onMfaChallenge?: (challengeToken: string) => void;
|
|
61
|
+
}): ReactNode {
|
|
62
|
+
return (
|
|
63
|
+
<button type="button" data-testid="trigger-mfa" onClick={() => onMfaChallenge?.("token-123")}>
|
|
64
|
+
trigger
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function CustomMfaVerify({ challengeToken }: { readonly challengeToken: string }): ReactNode {
|
|
70
|
+
return <div data-testid="mfa-verify">{challengeToken}</div>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("authenticated + onAuthenticated → renders nothing, fires onAuthenticated exactly once", () => {
|
|
74
|
+
const onAuthenticated = mock(() => {});
|
|
75
|
+
const LoginRoute = createLoginRoute({ loginScreen: CustomLogin, onAuthenticated });
|
|
76
|
+
const session = makeSessionApi({ status: "authenticated" });
|
|
77
|
+
const { container } = renderWithProviders(<LoginRoute />, { session });
|
|
78
|
+
expect(container.firstChild).toBeNull();
|
|
79
|
+
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("authenticated without onAuthenticated → falls through to LoginComponent (standalone mount)", () => {
|
|
83
|
+
const LoginRoute = createLoginRoute({ loginScreen: CustomLogin });
|
|
84
|
+
const session = makeSessionApi({ status: "authenticated" });
|
|
85
|
+
renderWithProviders(<LoginRoute />, { session });
|
|
86
|
+
expect(screen.getByTestId("custom-login")).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("onMfaChallenge → renders MfaVerifyComponent with the challenge token", () => {
|
|
90
|
+
const LoginRoute = createLoginRoute({
|
|
91
|
+
loginScreen: LoginWithMfaTrigger,
|
|
92
|
+
mfaVerifyScreen: CustomMfaVerify,
|
|
93
|
+
});
|
|
94
|
+
const session = makeSessionApi({ status: "unauthenticated" });
|
|
95
|
+
renderWithProviders(<LoginRoute />, { session });
|
|
96
|
+
fireEvent.click(screen.getByTestId("trigger-mfa"));
|
|
97
|
+
expect(screen.getByTestId("mfa-verify").textContent).toBe("token-123");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("makeAuthGate delegates mfaVerifyScreen wiring to createLoginRoute", () => {
|
|
101
|
+
const Gate = makeAuthGate(LoginWithMfaTrigger, undefined, CustomMfaVerify);
|
|
102
|
+
const session = makeSessionApi({ status: "unauthenticated" });
|
|
103
|
+
renderWithProviders(
|
|
104
|
+
<Gate>
|
|
105
|
+
<div data-testid="protected">secret</div>
|
|
106
|
+
</Gate>,
|
|
107
|
+
{ session },
|
|
108
|
+
);
|
|
109
|
+
fireEvent.click(screen.getByTestId("trigger-mfa"));
|
|
110
|
+
expect(screen.getByTestId("mfa-verify").textContent).toBe("token-123");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -233,6 +233,39 @@ export async function verifyEmail(
|
|
|
233
233
|
return { ok: false, error: await parseTokenFailure(res) };
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// POST /api/auth/request-account-unlock. Same silent-success semantik wie
|
|
237
|
+
// request-password-reset. 429 → rate-limit-Failure.
|
|
238
|
+
export async function requestAccountUnlock(
|
|
239
|
+
email: string,
|
|
240
|
+
): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
|
|
241
|
+
const res = await fetch("/api/auth/request-account-unlock", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
credentials: "same-origin",
|
|
244
|
+
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
245
|
+
body: JSON.stringify({ email }),
|
|
246
|
+
});
|
|
247
|
+
if (res.ok) return { ok: true };
|
|
248
|
+
return { ok: false, error: await parseTokenFailure(res) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// POST /api/auth/confirm-account-unlock. Auto-submitted by
|
|
252
|
+
// ConfirmAccountUnlockScreen after parsing `?token=...`. Server collapses
|
|
253
|
+
// every verify failure into `invalid_unlock_token` (anti-enumeration,
|
|
254
|
+
// parallel to reset/verify). Only clears the Redis lockout state, no
|
|
255
|
+
// entity write.
|
|
256
|
+
export async function confirmAccountUnlock(
|
|
257
|
+
token: string,
|
|
258
|
+
): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
|
|
259
|
+
const res = await fetch("/api/auth/confirm-account-unlock", {
|
|
260
|
+
method: "POST",
|
|
261
|
+
credentials: "same-origin",
|
|
262
|
+
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
263
|
+
body: JSON.stringify({ token }),
|
|
264
|
+
});
|
|
265
|
+
if (res.ok) return { ok: true };
|
|
266
|
+
return { ok: false, error: await parseTokenFailure(res) };
|
|
267
|
+
}
|
|
268
|
+
|
|
236
269
|
// POST /api/auth/signup-request. Always-200 (anti-enumeration; wir
|
|
237
270
|
// sagen nicht ob die Email schon registriert ist). Server schickt
|
|
238
271
|
// Activation-Mail an die Adresse — beim Klick auf den Link landet der
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// ConfirmAccountUnlockScreen — reads `?token=...` from the URL, auto-posts
|
|
3
|
+
// it to /api/auth/confirm-account-unlock on mount, shows success/error.
|
|
4
|
+
// Idempotent server-side (clearLockoutState is a no-op when already
|
|
5
|
+
// unlocked), so no re-submit risk. Mirrors VerifyEmailScreen 1:1.
|
|
6
|
+
//
|
|
7
|
+
// useEffect with empty deps + a once-guard so React Strict Mode's double
|
|
8
|
+
// useEffect dispatch in dev fires the confirm call only once.
|
|
9
|
+
|
|
10
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
12
|
+
import { confirmAccountUnlock } from "./auth-client";
|
|
13
|
+
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
14
|
+
|
|
15
|
+
export type ConfirmAccountUnlockScreenProps = {
|
|
16
|
+
readonly title?: string;
|
|
17
|
+
/** Override for the token from the URL. Default: parsed from `?token=...`. */
|
|
18
|
+
readonly token?: string;
|
|
19
|
+
/** href for the "go to sign in" link. Default "/login". */
|
|
20
|
+
readonly loginHref?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Status = "verifying" | "success" | "error" | "missing-token";
|
|
24
|
+
|
|
25
|
+
export function ConfirmAccountUnlockScreen({
|
|
26
|
+
title,
|
|
27
|
+
token: tokenProp,
|
|
28
|
+
loginHref = "/login",
|
|
29
|
+
}: ConfirmAccountUnlockScreenProps): ReactNode {
|
|
30
|
+
const t = useTranslation();
|
|
31
|
+
const { Link } = usePrimitives();
|
|
32
|
+
const token = useUrlToken(tokenProp);
|
|
33
|
+
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
34
|
+
const startedRef = useRef(false);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (status !== "verifying") return;
|
|
38
|
+
if (startedRef.current) return;
|
|
39
|
+
startedRef.current = true;
|
|
40
|
+
void (async () => {
|
|
41
|
+
const res = await confirmAccountUnlock(token);
|
|
42
|
+
setStatus(res.ok ? "success" : "error");
|
|
43
|
+
})();
|
|
44
|
+
}, [status, token]);
|
|
45
|
+
|
|
46
|
+
if (status === "missing-token") {
|
|
47
|
+
return (
|
|
48
|
+
<AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
|
|
49
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
50
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.missingToken")}</p>
|
|
51
|
+
<Link href={loginHref} variant="muted">
|
|
52
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
53
|
+
</Link>
|
|
54
|
+
</div>
|
|
55
|
+
</AuthCard>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (status === "verifying") {
|
|
60
|
+
return (
|
|
61
|
+
<AuthCard>
|
|
62
|
+
<div className="p-6">
|
|
63
|
+
<p className="text-sm text-muted-foreground" role="status">
|
|
64
|
+
{t("auth.unlockAccount.verifying")}
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
</AuthCard>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (status === "success") {
|
|
72
|
+
return (
|
|
73
|
+
<AuthCard title={title ?? t("auth.unlockAccount.successTitle")}>
|
|
74
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
75
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.successBody")}</p>
|
|
76
|
+
<Link href={loginHref} variant="button">
|
|
77
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
78
|
+
</Link>
|
|
79
|
+
</div>
|
|
80
|
+
</AuthCard>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// status === "error"
|
|
85
|
+
return (
|
|
86
|
+
<AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
|
|
87
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
88
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.errorBody")}</p>
|
|
89
|
+
<Link href={loginHref} variant="muted">
|
|
90
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
91
|
+
</Link>
|
|
92
|
+
</div>
|
|
93
|
+
</AuthCard>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -18,7 +18,9 @@ export type {
|
|
|
18
18
|
VerifyEmailFailure,
|
|
19
19
|
} from "./auth-client";
|
|
20
20
|
export {
|
|
21
|
+
confirmAccountUnlock,
|
|
21
22
|
confirmSignup,
|
|
23
|
+
requestAccountUnlock,
|
|
22
24
|
requestEmailVerification,
|
|
23
25
|
requestPasswordReset,
|
|
24
26
|
requestSignup,
|
|
@@ -34,6 +36,8 @@ export type {
|
|
|
34
36
|
EmailPasswordClientOptions,
|
|
35
37
|
} from "./client-plugin";
|
|
36
38
|
export { emailPasswordClient } from "./client-plugin";
|
|
39
|
+
export type { ConfirmAccountUnlockScreenProps } from "./confirm-account-unlock-screen";
|
|
40
|
+
export { ConfirmAccountUnlockScreen } from "./confirm-account-unlock-screen";
|
|
37
41
|
export type { DefaultTopbarActionsProps } from "./default-topbar-actions";
|
|
38
42
|
export { DefaultTopbarActions } from "./default-topbar-actions";
|
|
39
43
|
export type { ForgotPasswordScreenProps } from "./forgot-password-screen";
|
|
@@ -42,6 +46,8 @@ export type { InviteAcceptScreenProps } from "./invite-accept-screen";
|
|
|
42
46
|
export { InviteAcceptScreen } from "./invite-accept-screen";
|
|
43
47
|
export type { AuthLegalLink, LoginScreenProps } from "./login-screen";
|
|
44
48
|
export { LoginScreen } from "./login-screen";
|
|
49
|
+
export type { RequestAccountUnlockScreenProps } from "./request-account-unlock-screen";
|
|
50
|
+
export { RequestAccountUnlockScreen } from "./request-account-unlock-screen";
|
|
45
51
|
export type { ResetPasswordScreenProps } from "./reset-password-screen";
|
|
46
52
|
export { ResetPasswordScreen } from "./reset-password-screen";
|
|
47
53
|
export type { SessionApi, SessionState, SessionStatus } from "./session";
|
|
@@ -40,6 +40,13 @@ export type LoginScreenProps = {
|
|
|
40
40
|
* Link. Apps die den Reset-Flow NICHT anbieten (z.B. nur Magic-Link),
|
|
41
41
|
* setzen das einfach nicht — Login bleibt minimalistisch. */
|
|
42
42
|
readonly forgotPasswordHref?: string;
|
|
43
|
+
/** Optional href to RequestAccountUnlockScreen (#1266). When set, an
|
|
44
|
+
* account_locked login error renders an "Unlock account?" link inline —
|
|
45
|
+
* the account-lockout escape hatch, since forgotPasswordHref does NOT
|
|
46
|
+
* help here: the lockout gate blocks login before password verify even
|
|
47
|
+
* runs, so it also blocks the post-reset login. Apps without
|
|
48
|
+
* accountUnlock configured just leave this unset. */
|
|
49
|
+
readonly unlockAccountHref?: string;
|
|
43
50
|
/** Optional href zum SignupScreen. Wenn gesetzt rendert die LoginScreen
|
|
44
51
|
* einen "Account erstellen"-Link. Apps die kein Self-Signup wollen
|
|
45
52
|
* (closed-invite-only) setzen das einfach nicht. */
|
|
@@ -100,6 +107,7 @@ export function LoginScreen({
|
|
|
100
107
|
subtitle,
|
|
101
108
|
submitLabel,
|
|
102
109
|
forgotPasswordHref,
|
|
110
|
+
unlockAccountHref,
|
|
103
111
|
signupHref,
|
|
104
112
|
legalLinks,
|
|
105
113
|
onMfaChallenge,
|
|
@@ -229,6 +237,13 @@ export function LoginScreen({
|
|
|
229
237
|
</Button>
|
|
230
238
|
</span>
|
|
231
239
|
)}
|
|
240
|
+
{error.reason === "account_locked" && unlockAccountHref !== undefined && (
|
|
241
|
+
<span className="self-start">
|
|
242
|
+
<Link href={unlockAccountHref} variant="default">
|
|
243
|
+
{t("auth.login.unlockAccount")}
|
|
244
|
+
</Link>
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
232
247
|
{resendStatus.kind === "rateLimited" && (
|
|
233
248
|
<span className="text-xs">{t("auth.login.resendRateLimited")}</span>
|
|
234
249
|
)}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// RequestAccountUnlockScreen — form with an email input. Submit triggers
|
|
3
|
+
// /api/auth/request-account-unlock (silent-success, no account
|
|
4
|
+
// enumeration). UI unconditionally shows an "if your account is locked,
|
|
5
|
+
// a mail is on its way" confirmation — even when the server internally
|
|
6
|
+
// knows the email doesn't exist or isn't locked. Mirrors
|
|
7
|
+
// ForgotPasswordScreen 1:1.
|
|
8
|
+
//
|
|
9
|
+
// The app is responsible for mounting this screen under a URL (e.g.
|
|
10
|
+
// /unlock-account) and for reaching it — LoginScreen can render a link
|
|
11
|
+
// to the app route on accountLocked errors.
|
|
12
|
+
|
|
13
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
15
|
+
import { requestAccountUnlock } from "./auth-client";
|
|
16
|
+
import { AuthCard } from "./auth-form-primitives";
|
|
17
|
+
|
|
18
|
+
export type RequestAccountUnlockScreenProps = {
|
|
19
|
+
readonly title?: string;
|
|
20
|
+
readonly subtitle?: ReactNode;
|
|
21
|
+
/** href for the "back to sign in" link in success + form state. App-
|
|
22
|
+
* specific — default "/login". */
|
|
23
|
+
readonly loginHref?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function RequestAccountUnlockScreen({
|
|
27
|
+
title,
|
|
28
|
+
subtitle,
|
|
29
|
+
loginHref = "/login",
|
|
30
|
+
}: RequestAccountUnlockScreenProps): ReactNode {
|
|
31
|
+
const t = useTranslation();
|
|
32
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
33
|
+
const [email, setEmail] = useState("");
|
|
34
|
+
const [submitting, setSubmitting] = useState(false);
|
|
35
|
+
const [done, setDone] = useState(false);
|
|
36
|
+
const [error, setError] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
// guard:dup-ok — same submit pattern as forgot-password-screen, but different API endpoint and state
|
|
39
|
+
const doSubmit = async (): Promise<void> => {
|
|
40
|
+
setSubmitting(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
try {
|
|
43
|
+
const res = await requestAccountUnlock(email);
|
|
44
|
+
if (res.ok) {
|
|
45
|
+
setDone(true);
|
|
46
|
+
} else if (res.error.reason === "rate_limited") {
|
|
47
|
+
const minutes =
|
|
48
|
+
res.error.retryAfterSeconds !== undefined
|
|
49
|
+
? Math.ceil(res.error.retryAfterSeconds / 60)
|
|
50
|
+
: undefined;
|
|
51
|
+
setError(
|
|
52
|
+
minutes !== undefined
|
|
53
|
+
? t("auth.errors.accountLockedRetry", { minutes })
|
|
54
|
+
: t("auth.errors.rateLimited"),
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
setError(t("auth.errors.unknownError"));
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
setError(t("auth.errors.unknownError"));
|
|
61
|
+
} finally {
|
|
62
|
+
setSubmitting(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
67
|
+
e?.preventDefault();
|
|
68
|
+
void doSubmit();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const effectiveTitle = title ?? t("auth.requestUnlock.title");
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<AuthCard title={effectiveTitle} subtitle={subtitle}>
|
|
75
|
+
{done ? (
|
|
76
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
77
|
+
<Banner variant="info">
|
|
78
|
+
<p className="font-medium text-foreground">{t("auth.requestUnlock.successTitle")}</p>
|
|
79
|
+
<p className="mt-1">{t("auth.requestUnlock.successBody")}</p>
|
|
80
|
+
</Banner>
|
|
81
|
+
<Link href={loginHref} variant="muted">
|
|
82
|
+
{t("auth.requestUnlock.backToLogin")}
|
|
83
|
+
</Link>
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
87
|
+
<p className="text-sm text-muted-foreground">{t("auth.requestUnlock.intro")}</p>
|
|
88
|
+
<Form onSubmit={onSubmit}>
|
|
89
|
+
<Field id="unlock-email" label={t("auth.requestUnlock.email")} required>
|
|
90
|
+
<Input
|
|
91
|
+
kind="text"
|
|
92
|
+
id="unlock-email"
|
|
93
|
+
name="unlock-email"
|
|
94
|
+
value={email}
|
|
95
|
+
onChange={setEmail}
|
|
96
|
+
disabled={submitting}
|
|
97
|
+
required
|
|
98
|
+
/>
|
|
99
|
+
</Field>
|
|
100
|
+
{error !== null && <Banner variant="error">{error}</Banner>}
|
|
101
|
+
<Button type="submit" loading={submitting} disabled={submitting}>
|
|
102
|
+
{submitting ? t("auth.requestUnlock.submitting") : t("auth.requestUnlock.submit")}
|
|
103
|
+
</Button>
|
|
104
|
+
</Form>
|
|
105
|
+
<Link href={loginHref} variant="muted" className="self-center">
|
|
106
|
+
{t("auth.requestUnlock.backToLogin")}
|
|
107
|
+
</Link>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</AuthCard>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// feature.ts contract tests — pin the public surface of the Plugin-API-
|
|
2
|
+
// shaped auth-foundation. Provider-specific verification logic is tested in
|
|
3
|
+
// each provider-feature's own __tests__.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { authFoundationFeature } from "../feature";
|
|
7
|
+
import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "../types";
|
|
8
|
+
|
|
9
|
+
describe("authFoundationFeature — shape", () => {
|
|
10
|
+
test("has the expected name", () => {
|
|
11
|
+
expect(authFoundationFeature.name).toBe("auth-foundation");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("has no hard requirements — no config-key, unlike file/mail-foundation", () => {
|
|
15
|
+
expect(authFoundationFeature.requires).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("authFoundationFeature — registers extension-point", () => {
|
|
20
|
+
test("declares the tokenVerifier extension-point", () => {
|
|
21
|
+
expect(authFoundationFeature.registrarExtensions[EXT_TOKEN_VERIFIER]).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("declares the sessionStore extension-point", () => {
|
|
25
|
+
expect(authFoundationFeature.registrarExtensions[EXT_SESSION_STORE]).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("authFoundationFeature — multiplicity boot-check", () => {
|
|
30
|
+
test("registers a bootCheck per extension-point (tokenVerifier + sessionStore)", () => {
|
|
31
|
+
expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// resolveSessionStore + the sessionStore multiplicity boot-check (#1370).
|
|
2
|
+
// Mock providers registered via r.useExtension(EXT_SESSION_STORE, ...) in
|
|
3
|
+
// throwaway test-features — no real sessions provider exists yet.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { validateSessionStoreMultiplicity } from "../boot-checks";
|
|
9
|
+
import { authFoundationFeature, resolveSessionStore } from "../feature";
|
|
10
|
+
import { EXT_SESSION_STORE, type SessionStore, type SessionStoreProvider } from "../types";
|
|
11
|
+
|
|
12
|
+
const fakeDb = {} as DbConnection; // @cast-boundary test fixture, unused by mock providers
|
|
13
|
+
|
|
14
|
+
function mockStore(): SessionStore {
|
|
15
|
+
return {
|
|
16
|
+
creator: async () => "sid-1",
|
|
17
|
+
revoker: async () => undefined,
|
|
18
|
+
checker: async () => "live",
|
|
19
|
+
massRevoker: async () => 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function storeProvider(entityName = "mock-store"): ReturnType<typeof defineFeature> {
|
|
24
|
+
const plugin: SessionStoreProvider = { build: () => mockStore() };
|
|
25
|
+
return defineFeature(`mock-${entityName}`, (r) => {
|
|
26
|
+
r.useExtension(EXT_SESSION_STORE, entityName, plugin);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("resolveSessionStore — finds the single registered provider", () => {
|
|
31
|
+
test("returns the built SessionStore", async () => {
|
|
32
|
+
const registry = createRegistry([authFoundationFeature, storeProvider()]);
|
|
33
|
+
const store = await resolveSessionStore({ db: fakeDb, registry });
|
|
34
|
+
expect(await store.checker("sid-1", "u1")).toBe("live");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("no provider registered → throws", async () => {
|
|
38
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
39
|
+
await expect(resolveSessionStore({ db: fakeDb, registry })).rejects.toThrow(
|
|
40
|
+
/no sessionStore provider registered/,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("validateSessionStoreMultiplicity", () => {
|
|
46
|
+
test("0 providers registered → throws (fail-fast, nothing to store sessions in)", () => {
|
|
47
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
48
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
|
|
49
|
+
/no sessionStore provider registered/,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("exactly 1 provider → no throw", () => {
|
|
54
|
+
const registry = createRegistry([authFoundationFeature, storeProvider()]);
|
|
55
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("≥2 providers → throws a conflict error", () => {
|
|
59
|
+
const registry = createRegistry([
|
|
60
|
+
authFoundationFeature,
|
|
61
|
+
storeProvider("store-a"),
|
|
62
|
+
storeProvider("store-b"),
|
|
63
|
+
]);
|
|
64
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
|
|
65
|
+
/2 sessionStore providers registered/,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("malformed registration (no build) → throws a malformed-plugin error", () => {
|
|
70
|
+
const broken = defineFeature("mock-broken", (r) => {
|
|
71
|
+
r.useExtension(EXT_SESSION_STORE, "broken", {});
|
|
72
|
+
});
|
|
73
|
+
const registry = createRegistry([authFoundationFeature, broken]);
|
|
74
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
|
|
75
|
+
/without a valid SessionStoreProvider/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// resolveTokenVerifier + the tokenVerifier multiplicity boot-check (#1368).
|
|
2
|
+
// Mock providers registered via r.useExtension(EXT_TOKEN_VERIFIER, ...) in
|
|
3
|
+
// throwaway test-features — no real JWT/PAT provider exists yet.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { validateTokenVerifierMultiplicity } from "../boot-checks";
|
|
10
|
+
import { authFoundationFeature, resolveTokenVerifier } from "../feature";
|
|
11
|
+
import { type AuthProviderPlugin, EXT_TOKEN_VERIFIER } from "../types";
|
|
12
|
+
|
|
13
|
+
// Mock providers never read `deps.db` — a real provider (personal-access-
|
|
14
|
+
// tokens/resolver.ts) does a point-read + live-role-resolution against it.
|
|
15
|
+
const fakeDb = {} as DbConnection; // @cast-boundary test fixture, unused by mock providers
|
|
16
|
+
|
|
17
|
+
function jwtLikeUser(): SessionUser {
|
|
18
|
+
return { id: "u-jwt", tenantId: "t1", roles: ["User"] } as SessionUser; // @cast-boundary test fixture
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function prefixProvider(entityName: string, prefix: string): ReturnType<typeof defineFeature> {
|
|
22
|
+
const plugin: AuthProviderPlugin = {
|
|
23
|
+
shape: { kind: "prefix", prefix },
|
|
24
|
+
build: () => async (rawToken) => (rawToken === `${prefix}valid` ? jwtLikeUser() : null),
|
|
25
|
+
};
|
|
26
|
+
return defineFeature(`mock-${entityName}`, (r) => {
|
|
27
|
+
r.useExtension(EXT_TOKEN_VERIFIER, entityName, plugin);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jwtProvider(entityName = "jwt-mock"): ReturnType<typeof defineFeature> {
|
|
32
|
+
const plugin: AuthProviderPlugin = {
|
|
33
|
+
shape: { kind: "jwt" },
|
|
34
|
+
build: () => async () => jwtLikeUser(),
|
|
35
|
+
};
|
|
36
|
+
return defineFeature(`mock-${entityName}`, (r) => {
|
|
37
|
+
r.useExtension(EXT_TOKEN_VERIFIER, entityName, plugin);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("resolveTokenVerifier — middleware finds the matching provider", () => {
|
|
42
|
+
test("routes a prefixed token to the prefix-shape provider", async () => {
|
|
43
|
+
const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
|
|
44
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "kpat_valid");
|
|
45
|
+
expect(user?.id).toBe("u-jwt");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("routes a dot-separated token to the jwt-shape provider", async () => {
|
|
49
|
+
const registry = createRegistry([authFoundationFeature, jwtProvider()]);
|
|
50
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "header.payload.sig");
|
|
51
|
+
expect(user?.id).toBe("u-jwt");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("no provider's shape matches → null", async () => {
|
|
55
|
+
const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
|
|
56
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "not-a-pat-token");
|
|
57
|
+
expect(user).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("no providers registered at all → null", async () => {
|
|
61
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
62
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "anything");
|
|
63
|
+
expect(user).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("validateTokenVerifierMultiplicity", () => {
|
|
68
|
+
test("0 providers registered → throws (fail-fast, no provider to route to)", () => {
|
|
69
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
70
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
71
|
+
/no tokenVerifier providers registered/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("1 provider per shape → no throw", () => {
|
|
76
|
+
const registry = createRegistry([
|
|
77
|
+
authFoundationFeature,
|
|
78
|
+
jwtProvider(),
|
|
79
|
+
prefixProvider("pat-mock", "kpat_"),
|
|
80
|
+
]);
|
|
81
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).not.toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("≥2 providers claiming the same shape → throws a conflict error", () => {
|
|
85
|
+
const registry = createRegistry([
|
|
86
|
+
authFoundationFeature,
|
|
87
|
+
jwtProvider("jwt-mock-a"),
|
|
88
|
+
jwtProvider("jwt-mock-b"),
|
|
89
|
+
]);
|
|
90
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
91
|
+
/same shape "jwt"/,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("wrong-shape registration → throws a malformed-plugin error", () => {
|
|
96
|
+
const broken = defineFeature("mock-broken", (r) => {
|
|
97
|
+
r.useExtension(EXT_TOKEN_VERIFIER, "broken", { shape: { kind: "prefix" } });
|
|
98
|
+
});
|
|
99
|
+
const registry = createRegistry([authFoundationFeature, broken]);
|
|
100
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
101
|
+
/without a valid AuthProviderPlugin/,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("malformed shape (build present, prefix missing) → throws a malformed-plugin error", () => {
|
|
106
|
+
const broken = defineFeature("mock-broken-shape", (r) => {
|
|
107
|
+
r.useExtension(EXT_TOKEN_VERIFIER, "broken-shape", {
|
|
108
|
+
shape: { kind: "prefix" },
|
|
109
|
+
build: () => async () => null,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const registry = createRegistry([authFoundationFeature, broken]);
|
|
113
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
114
|
+
/without a valid AuthProviderPlugin/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|