@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.
Files changed (149) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  29. package/src/auth-email-password/web/auth-client.ts +33 -0
  30. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  31. package/src/auth-email-password/web/index.ts +6 -0
  32. package/src/auth-email-password/web/login-screen.tsx +15 -0
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  34. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  35. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  37. package/src/auth-foundation/boot-checks.ts +91 -0
  38. package/src/auth-foundation/feature.ts +103 -0
  39. package/src/auth-foundation/index.ts +23 -0
  40. package/src/auth-foundation/types.ts +107 -0
  41. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  42. package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
  43. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  44. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  45. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  46. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  47. package/src/channel-email/pii-guard.ts +6 -3
  48. package/src/config/__tests__/config.integration.test.ts +14 -0
  49. package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
  50. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  51. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  52. package/src/config/feature.ts +2 -4
  53. package/src/config/handlers/set.write.ts +7 -1
  54. package/src/config/resolver.ts +1 -1
  55. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  56. package/src/delivery/handlers/log.query.ts +1 -1
  57. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  58. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  59. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  60. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  61. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  62. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  63. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  64. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  65. package/src/inbound-provider-inmemory/feature.ts +2 -2
  66. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  67. package/src/jobs/handlers/detail.query.ts +1 -1
  68. package/src/jobs/handlers/list.query.ts +1 -1
  69. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  70. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  71. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  72. package/src/managed-pages/handlers/branding.query.ts +5 -1
  73. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  74. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  75. package/src/personal-access-tokens/feature.ts +22 -7
  76. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  77. package/src/personal-access-tokens/index.ts +0 -14
  78. package/src/personal-access-tokens/resolver.ts +2 -2
  79. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  80. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  81. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  82. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  83. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  84. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  85. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  86. package/src/sessions/__tests__/test-helpers.ts +15 -2
  87. package/src/sessions/feature.ts +31 -3
  88. package/src/sessions/handlers/detail.query.ts +2 -2
  89. package/src/sessions/handlers/list.query.ts +4 -2
  90. package/src/sessions/handlers/mine.query.ts +4 -2
  91. package/src/sessions/testing.ts +14 -1
  92. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  93. package/src/shared/decrypt-stored-pii.ts +12 -5
  94. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  95. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  96. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  97. package/src/tenant/handlers/invitations.query.ts +4 -2
  98. package/src/tenant/handlers/members.query.ts +2 -2
  99. package/src/tenant/seeding.ts +2 -2
  100. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  101. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  102. package/src/tenant-lifecycle/feature.ts +7 -0
  103. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  104. package/src/tier-engine/feature.ts +2 -2
  105. package/src/user-data-rights/COMPLIANCE.md +20 -0
  106. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  107. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  108. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  109. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  110. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  111. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  112. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  113. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  114. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  115. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  116. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  117. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  118. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  120. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  122. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  123. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  129. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
  131. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  135. package/src/user-data-rights/boot-checks.ts +86 -0
  136. package/src/user-data-rights/feature.ts +116 -6
  137. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  138. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  139. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  140. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  141. package/src/user-data-rights/run-export-jobs.ts +3 -3
  142. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  143. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  144. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  145. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  146. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  147. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  148. package/src/user-profile/handlers/change-email.write.ts +1 -1
  149. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -82,4 +82,5 @@ export function verifyToken(
82
82
  export const TokenPurpose = {
83
83
  passwordReset: "reset",
84
84
  emailVerification: "verify",
85
+ accountUnlock: "unlock",
85
86
  } as const;
@@ -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
+ });