;
+ /** Extra headers to merge with the defaults (`Content-Type`, `Accept`). */
+ headers?: HeadersInit;
+ /**
+ * Resolve `path` from the client base path instead of the plugin base path.
+ */
+ absolute?: boolean;
+};
+
+export type PluginIdOf = P extends { readonly id: infer Id extends string } ? Id : never;
+
+export type PluginClientOverride = {
+ /** Replace the plugin's default base path (relative to the client `basePath`). */
+ basePath?: string;
+};
+
+/**
+ * Per-plugin client overrides, keyed by camelCased plugin id.
+ */
+export type PluginOverrides = Partial<
+ Record>, PluginClientOverride>
+>;
diff --git a/clients/typescript/packages/client/src/plugins/bearer/index.ts b/clients/typescript/packages/client/src/plugins/bearer/index.ts
new file mode 100644
index 0000000..5f91ed9
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/bearer/index.ts
@@ -0,0 +1,60 @@
+import { DEFAULT_TOKEN_STORAGE_KEY, SET_AUTH_TOKEN_HEADER, SET_REFRESH_TOKEN_HEADER } from "../../constants";
+import { defineClientPlugin, defineRoutes } from "../../define-plugin";
+import { resolveDefaultStorage } from "./storage";
+import type { BearerPluginConfig, BearerTokens } from "./types";
+
+export function bearerPlugin(config: BearerPluginConfig = {}) {
+ const store = config.storage ?? resolveDefaultStorage(config.storageKey ?? DEFAULT_TOKEN_STORAGE_KEY);
+
+ return defineClientPlugin({
+ id: "bearer",
+ routes: defineRoutes(),
+ actions: () => ({
+ bearer: {
+ getTokens: () => store.get(),
+ setTokens: (tokens: BearerTokens) => store.set(tokens),
+ clear: () => store.clear(),
+ },
+ }),
+ hooks: {
+ beforeRequest: [
+ {
+ run: (req) => {
+ const tokens = store.get();
+ if (tokens?.accessToken && !req.headers.has("Authorization")) {
+ req.headers.set("Authorization", `Bearer ${tokens.accessToken}`);
+ }
+ return req;
+ },
+ },
+ ],
+ afterResponse: [
+ {
+ allowOnFailure: true,
+ run: (res) => {
+ const accessToken = res.headers.get(SET_AUTH_TOKEN_HEADER);
+ if (accessToken) {
+ const tokens: BearerTokens = { accessToken };
+ const refreshToken = res.headers.get(SET_REFRESH_TOKEN_HEADER);
+ if (refreshToken) {
+ tokens.refreshToken = refreshToken;
+ }
+ store.set(tokens);
+ }
+ return res;
+ },
+ },
+ {
+ match: ["/signout", "/revoke-sessions"],
+ run: (res) => {
+ store.clear();
+ return res;
+ },
+ },
+ ],
+ },
+ });
+}
+
+export { localStorageBearerStorage, memoryBearerStorage, resolveDefaultStorage } from "./storage";
+export type { BearerPluginConfig, BearerPlugin as BearerPublic, BearerStorage, BearerTokens } from "./types";
diff --git a/clients/typescript/packages/client/src/plugins/bearer/storage.ts b/clients/typescript/packages/client/src/plugins/bearer/storage.ts
new file mode 100644
index 0000000..706cec7
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/bearer/storage.ts
@@ -0,0 +1,48 @@
+import { DEFAULT_TOKEN_STORAGE_KEY } from "../../constants";
+import type { BearerStorage, BearerTokens } from "./types";
+
+export function memoryBearerStorage(): BearerStorage {
+ let tokens: BearerTokens | null = null;
+ return {
+ get: () => tokens,
+ set: (next) => {
+ tokens = next;
+ },
+ clear: () => {
+ tokens = null;
+ },
+ };
+}
+
+export function localStorageBearerStorage(key: string = DEFAULT_TOKEN_STORAGE_KEY): BearerStorage {
+ return {
+ get: () => {
+ try {
+ const raw = globalThis.localStorage.getItem(key);
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw) as Partial;
+ if (typeof parsed?.accessToken !== "string") {
+ return null;
+ }
+ return parsed as BearerTokens;
+ } catch {
+ return null;
+ }
+ },
+ set: (tokens) => {
+ globalThis.localStorage.setItem(key, JSON.stringify(tokens));
+ },
+ clear: () => {
+ globalThis.localStorage.removeItem(key);
+ },
+ };
+}
+
+export function resolveDefaultStorage(key: string): BearerStorage {
+ if (typeof globalThis.localStorage !== "undefined") {
+ return localStorageBearerStorage(key);
+ }
+ return memoryBearerStorage();
+}
diff --git a/clients/typescript/packages/client/src/plugins/bearer/types.ts b/clients/typescript/packages/client/src/plugins/bearer/types.ts
new file mode 100644
index 0000000..56efd1f
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/bearer/types.ts
@@ -0,0 +1,34 @@
+export type BearerPlugin = {
+ bearer: {
+ /** Currently stored tokens, or `null` if signed out / never set. */
+ getTokens: () => BearerTokens | null;
+ /** Manually persist tokens. */
+ setTokens: (tokens: BearerTokens) => void;
+ /** Discard stored tokens. Also runs automatically on `signOut`. */
+ clear: () => void;
+ };
+};
+
+export type BearerTokens = {
+ accessToken: string;
+ refreshToken?: string;
+};
+
+export interface BearerStorage {
+ get(): BearerTokens | null;
+ set(tokens: BearerTokens): void;
+ clear(): void;
+}
+
+export type BearerPluginConfig = {
+ /**
+ * Where to persist tokens. Defaults to `localStorage` when available,
+ * otherwise an in-memory store (SSR / non-browser).
+ */
+ storage?: BearerStorage;
+ /**
+ * Key used by the default `localStorage` adapter. Ignored when a custom
+ * `storage` is supplied. Defaults to `"limen.tokens"`.
+ */
+ storageKey?: string;
+};
diff --git a/clients/typescript/packages/client/src/plugins/credential/index.ts b/clients/typescript/packages/client/src/plugins/credential/index.ts
new file mode 100644
index 0000000..5968f5c
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/credential/index.ts
@@ -0,0 +1,76 @@
+import { defineClientPlugin, defineRoutes } from "../../define-plugin";
+import { route } from "../../route";
+import type { Session } from "../../types";
+import type {
+ ChangePasswordInput,
+ CheckUsernameInput,
+ RequestPasswordResetInput,
+ ResetPasswordInput,
+ SetPasswordInput,
+ SignInCredentialInput,
+ SignUpCredentialInput,
+} from "./types";
+
+export function credentialPasswordPlugin() {
+ const routes = defineRoutes(
+ route>()({
+ method: "POST",
+ path: "/signin/credential",
+ parseSession: true,
+ as: "signIn.credential",
+ defaults: { rememberMe: true },
+ }),
+ route>()({
+ method: "POST",
+ path: "/signup/credential",
+ parseSession: true,
+ as: "signUp.credential",
+ }),
+ route()({
+ method: "POST",
+ path: "/passwords/request-reset",
+ as: "password.requestReset",
+ }),
+ route()({
+ method: "POST",
+ path: "/passwords/reset",
+ as: "password.reset",
+ }),
+ route>()({
+ method: "POST",
+ path: "/passwords/change",
+ parseSession: true,
+ defaults: { revokeOtherSessions: true },
+ as: "password.change",
+ }),
+ route>()({
+ method: "PUT",
+ path: "/passwords",
+ as: "password.set",
+ parseSession: true,
+ defaults: { revokeOtherSessions: true },
+ }),
+ route()({
+ method: "POST",
+ path: "/usernames/check",
+ parse: (raw) => (raw as { available: boolean }).available,
+ as: "username.checkAvailability",
+ }),
+ );
+
+ return defineClientPlugin({
+ id: "credential-password",
+ basePath: "/",
+ routes,
+ });
+}
+
+export type {
+ ChangePasswordInput,
+ CheckUsernameInput,
+ RequestPasswordResetInput,
+ ResetPasswordInput,
+ SetPasswordInput,
+ SignInCredentialInput,
+ SignUpCredentialInput,
+} from "./types";
diff --git a/clients/typescript/packages/client/src/plugins/credential/types.ts b/clients/typescript/packages/client/src/plugins/credential/types.ts
new file mode 100644
index 0000000..d3e288d
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/credential/types.ts
@@ -0,0 +1,44 @@
+export type SignInCredentialInput = {
+ /** Email or username, depending on what the server enables. */
+ credential: string;
+ password: string;
+ /** When true (default), issues a long-lived session. */
+ rememberMe?: boolean;
+};
+
+export type SignUpCredentialInput = {
+ email: string;
+ password: string;
+ /** Required when username-on-signup is enabled. */
+ username?: string;
+ /** Optional profile fields the server records on the user. */
+ firstname?: string;
+ lastname?: string;
+ /** Extra fields recorded on the user at registration. */
+ additionalFields?: Record;
+};
+
+export type ChangePasswordInput = {
+ currentPassword: string;
+ newPassword: string;
+ /** Invalidate other sessions on success. Defaults to true. */
+ revokeOtherSessions?: boolean;
+};
+
+export type SetPasswordInput = {
+ newPassword: string;
+ revokeOtherSessions?: boolean;
+};
+
+export type RequestPasswordResetInput = {
+ email: string;
+};
+
+export type ResetPasswordInput = {
+ token: string;
+ newPassword: string;
+};
+
+export type CheckUsernameInput = {
+ username: string;
+};
diff --git a/clients/typescript/packages/client/src/plugins/index.ts b/clients/typescript/packages/client/src/plugins/index.ts
new file mode 100644
index 0000000..7e93685
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/index.ts
@@ -0,0 +1,6 @@
+export * from "./bearer";
+export * from "./credential";
+export * from "./magic-link";
+export * from "./oauth";
+export * from "./session-jwt";
+export * from "./two-factor";
diff --git a/clients/typescript/packages/client/src/plugins/magic-link/index.ts b/clients/typescript/packages/client/src/plugins/magic-link/index.ts
new file mode 100644
index 0000000..bb0d31f
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/magic-link/index.ts
@@ -0,0 +1,26 @@
+import { defineClientPlugin, defineRoutes } from "../../define-plugin";
+import { route } from "../../route";
+import type { Session } from "../../types";
+import type { RequestMagicLinkInput, VerifyMagicLinkInput } from "./types";
+
+export function magicLinkPlugin() {
+ const routes = defineRoutes(
+ route()({
+ method: "POST",
+ path: "/signin",
+ }),
+ route>()({
+ method: "GET",
+ path: "/verify",
+ parseSession: true,
+ }),
+ );
+
+ return defineClientPlugin({
+ id: "magic-link",
+ basePath: "/magic-link",
+ routes,
+ });
+}
+
+export type { RequestMagicLinkInput, VerifyMagicLinkInput } from "./types";
diff --git a/clients/typescript/packages/client/src/plugins/magic-link/types.ts b/clients/typescript/packages/client/src/plugins/magic-link/types.ts
new file mode 100644
index 0000000..e5758b2
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/magic-link/types.ts
@@ -0,0 +1,11 @@
+export type RequestMagicLinkInput = {
+ email: string;
+ redirectUri?: string;
+ newUserRedirectUri?: string;
+ errorRedirectUri?: string;
+ meta?: Record;
+};
+
+export type VerifyMagicLinkInput = {
+ token: string;
+};
diff --git a/clients/typescript/packages/client/src/plugins/oauth/index.ts b/clients/typescript/packages/client/src/plugins/oauth/index.ts
new file mode 100644
index 0000000..e87bacc
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/oauth/index.ts
@@ -0,0 +1,69 @@
+import { defineClientPlugin, defineRoutes } from "../../define-plugin";
+import { camelizeKeys } from "../../helpers";
+import { route, type RouteHandler } from "../../route";
+import type { LinkOAuthInput, OAuthAccount, OAuthAuthorizeResult, OAuthTokens, SignInOAuthInput } from "./types";
+
+const fetchThenRedirect: RouteHandler = async (ctx, input, http) => {
+ const { disableRedirect, ...rest } = input;
+ const { url } = await http<{ url: string }>(rest);
+ return { url, redirect: disableRedirect === true ? false : ctx.redirect(url) };
+};
+
+export function oauthClientPlugin() {
+ const routes = defineRoutes(
+ route()({
+ method: "GET",
+ path: "/:provider/authorize",
+ as: "signIn.social",
+ params: ["provider"],
+ handler: fetchThenRedirect,
+ }),
+ route()({
+ method: "GET",
+ path: "/:provider/link",
+ as: "social.link",
+ params: ["provider"],
+ handler: fetchThenRedirect,
+ }),
+ route<{ provider: string }, void>()({
+ method: "DELETE",
+ path: "/:provider/unlink",
+ as: "social.unlink",
+ params: ["provider"],
+ }),
+ route()({
+ method: "GET",
+ path: "/accounts",
+ as: "social.accounts",
+ }),
+ route<{ provider: string }, OAuthTokens>()({
+ method: "GET",
+ path: "/:provider/tokens",
+ as: "social.tokens",
+ params: ["provider"],
+ parse: camelizeKeys,
+ }),
+ route<{ provider: string }, OAuthTokens>()({
+ method: "POST",
+ path: "/:provider/tokens/refresh",
+ as: "social.refreshTokens",
+ params: ["provider"],
+ parse: camelizeKeys,
+ }),
+ );
+
+ return defineClientPlugin({
+ id: "oauth",
+ basePath: "/oauth",
+ routes,
+ });
+}
+
+export type {
+ LinkOAuthInput,
+ OAuthAccount,
+ OAuthAuthorizeQuery,
+ OAuthAuthorizeResult,
+ OAuthTokens,
+ SignInOAuthInput,
+} from "./types";
diff --git a/clients/typescript/packages/client/src/plugins/oauth/types.ts b/clients/typescript/packages/client/src/plugins/oauth/types.ts
new file mode 100644
index 0000000..89f82bf
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/oauth/types.ts
@@ -0,0 +1,42 @@
+export type OAuthAuthorizeQuery = {
+ /** Where the server redirects the browser after a successful callback. */
+ redirectUri?: string;
+ /** Where the server redirects on a failed callback. Falls back to `redirectUri`. */
+ errorRedirectUri?: string;
+};
+
+export type SignInOAuthInput = OAuthAuthorizeQuery & {
+ /** Provider id, e.g. `"google"` or `"github"`. */
+ provider: string;
+ /**
+ * When true, skip auto-navigation and only resolve with the authorization
+ * URL — the caller is responsible for navigating the browser.
+ */
+ disableRedirect?: boolean;
+};
+
+export type LinkOAuthInput = SignInOAuthInput;
+
+export type OAuthAuthorizeResult = {
+ /** Provider authorization URL. */
+ url: string;
+ /** Whether the SDK navigated to `url`. */
+ redirect: boolean;
+};
+
+export type OAuthAccount = {
+ provider: string;
+ providerAccountId: string;
+ scopes: string[];
+ accessTokenExpiresAt?: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type OAuthTokens = {
+ accessToken: string;
+ refreshToken?: string;
+ idToken?: string;
+ accessTokenExpiresAt?: string;
+ scope?: string;
+};
diff --git a/clients/typescript/packages/client/src/plugins/session-jwt/constants.ts b/clients/typescript/packages/client/src/plugins/session-jwt/constants.ts
new file mode 100644
index 0000000..b012e1a
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/session-jwt/constants.ts
@@ -0,0 +1,2 @@
+/** Refresh once the access token is within this many seconds of its `exp`. */
+export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
diff --git a/clients/typescript/packages/client/src/plugins/session-jwt/index.ts b/clients/typescript/packages/client/src/plugins/session-jwt/index.ts
new file mode 100644
index 0000000..55b9df1
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/session-jwt/index.ts
@@ -0,0 +1,134 @@
+import { DEFAULT_TOKEN_STORAGE_KEY } from "../../constants";
+import type { AnyRouteContext } from "../../context";
+import { defineClientPlugin, defineRoutes, type RunRoute } from "../../define-plugin";
+import { LimenError } from "../../errors";
+import { route } from "../../route";
+import type { Session } from "../../types";
+import { resolveDefaultStorage } from "../bearer";
+import { DEFAULT_EXPIRY_SKEW_SECONDS } from "./constants";
+import { isExpiring, tokensFromHeaders } from "./jwt";
+import type { RefreshInput, SessionJwtPluginConfig, SessionJwtTokens } from "./types";
+
+export function sessionJwtPlugin(config: SessionJwtPluginConfig = {}) {
+ const store = config.storage ?? resolveDefaultStorage(config.storageKey ?? DEFAULT_TOKEN_STORAGE_KEY);
+ const skewMs = (config.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS) * 1000;
+
+ const refreshRoute = route>()({
+ method: "POST",
+ path: "/refresh",
+ parseSession: true,
+ expose: false,
+ });
+
+ let ctx!: AnyRouteContext;
+ let run!: RunRoute;
+ let inFlight: Promise | null = null;
+
+ const runRefresh = async (): Promise => {
+ const current = store.get();
+ if (!current?.refreshToken) {
+ throw new LimenError("No refresh token found", 401, "unauthorized");
+ }
+ try {
+ await run(refreshRoute, { refreshToken: current.refreshToken });
+ const tokens = store.get();
+ if (!tokens?.accessToken) {
+ throw new LimenError("Refresh did not return a valid access token", 500, "unknown");
+ }
+ return tokens;
+ } catch (err) {
+ store.clear();
+ ctx.setSession(null);
+ throw err;
+ }
+ };
+
+ const refresh = (): Promise => {
+ if (!inFlight) {
+ inFlight = runRefresh().finally(() => {
+ inFlight = null;
+ });
+ }
+ return inFlight;
+ };
+
+ const getAccessToken = async (): Promise => {
+ const current = store.get();
+ if (!current?.accessToken) {
+ return null;
+ }
+
+ if (!isExpiring(current.accessToken, skewMs)) {
+ return current.accessToken;
+ }
+
+ if (!current.refreshToken) {
+ return null;
+ }
+ return (await refresh()).accessToken;
+ };
+
+ const applyAuthHeader = async (req: { headers: Headers }): Promise => {
+ if (req.headers.has("Authorization")) {
+ return;
+ }
+ const token = await getAccessToken().catch(() => null);
+ if (token) {
+ req.headers.set("Authorization", `Bearer ${token}`);
+ }
+ };
+
+ return defineClientPlugin({
+ id: "session-jwt",
+ routes: defineRoutes(refreshRoute),
+ actions: (pluginCtx, pluginRun) => {
+ ctx = pluginCtx;
+ run = pluginRun;
+ return {
+ sessionJwt: {
+ /**
+ * Get the current access token. If the token is expiring, refresh it.
+ * @returns The current access token or null if no token is found.
+ */
+ getAccessToken,
+ refresh,
+ getTokens: () => store.get(),
+ clear: () => store.clear(),
+ },
+ };
+ },
+ hooks: {
+ beforeRequest: [
+ {
+ match: (route) => route.path !== "/refresh",
+ run: async (req) => {
+ await applyAuthHeader(req);
+ return req;
+ },
+ },
+ ],
+ afterResponse: [
+ {
+ allowOnFailure: true,
+ run: (res) => {
+ const tokens = tokensFromHeaders(res.headers);
+ if (tokens) {
+ store.set(tokens);
+ }
+ return res;
+ },
+ },
+ {
+ match: ["/signout", "/revoke-sessions"],
+ allowOnFailure: true,
+ run: (res) => {
+ store.clear();
+ return res;
+ },
+ },
+ ],
+ },
+ });
+}
+
+export type { SessionJwtPluginConfig, SessionJwtTokens } from "./types";
diff --git a/clients/typescript/packages/client/src/plugins/session-jwt/jwt.ts b/clients/typescript/packages/client/src/plugins/session-jwt/jwt.ts
new file mode 100644
index 0000000..befbbfc
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/session-jwt/jwt.ts
@@ -0,0 +1,44 @@
+import { SET_AUTH_TOKEN_HEADER, SET_REFRESH_TOKEN_HEADER } from "../../constants";
+import type { SessionJwtTokens } from "./types";
+
+/**
+ * Read the `exp` (seconds since epoch) from a JWT without verifying its
+ * signature — the server is the source of truth; the client only needs expiry
+ * to decide when to refresh. Returns `null` for anything malformed.
+ */
+export function decodeJwtExp(token: string): number | null {
+ const payload = token.split(".")[1];
+ if (!payload) {
+ return null;
+ }
+ try {
+ const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
+ const claims = JSON.parse(atob(padded)) as { exp?: unknown };
+ return typeof claims.exp === "number" ? claims.exp : null;
+ } catch {
+ return null;
+ }
+}
+
+export function isExpiring(token: string, skewMs: number): boolean {
+ const exp = decodeJwtExp(token);
+ if (exp === null) {
+ return true;
+ }
+ return exp * 1000 - skewMs <= Date.now();
+}
+
+export function tokensFromHeaders(headers: Headers): SessionJwtTokens | null {
+ const accessToken = headers.get(SET_AUTH_TOKEN_HEADER);
+ if (!accessToken) {
+ return null;
+ }
+
+ const tokens: SessionJwtTokens = { accessToken };
+ const refreshToken = headers.get(SET_REFRESH_TOKEN_HEADER);
+ if (refreshToken) {
+ tokens.refreshToken = refreshToken;
+ }
+ return tokens;
+}
diff --git a/clients/typescript/packages/client/src/plugins/session-jwt/types.ts b/clients/typescript/packages/client/src/plugins/session-jwt/types.ts
new file mode 100644
index 0000000..2da76e3
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/session-jwt/types.ts
@@ -0,0 +1,10 @@
+import type { BearerPluginConfig, BearerTokens } from "../bearer";
+
+export type SessionJwtTokens = BearerTokens;
+
+export type SessionJwtPluginConfig = BearerPluginConfig & {
+ /** Refresh once the access token is within this many seconds of expiry. Default 30. */
+ expirySkewSeconds?: number;
+};
+
+export type RefreshInput = { refreshToken: string };
diff --git a/clients/typescript/packages/client/src/plugins/two-factor/index.ts b/clients/typescript/packages/client/src/plugins/two-factor/index.ts
new file mode 100644
index 0000000..5d7711a
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/two-factor/index.ts
@@ -0,0 +1,87 @@
+import { defineClientPlugin, defineRoutes } from "../../define-plugin";
+import { route } from "../../route";
+import type { Session } from "../../types";
+import type {
+ DisableTwoFactorInput,
+ FinalizeSetupInput,
+ InitiateSetupInput,
+ SendOTPInput,
+ TwoFactorConfig,
+ TwoFactorSetupURI,
+ VerifyInput,
+} from "./types";
+
+export function twoFactorPlugin(config: TwoFactorConfig) {
+ const routes = defineRoutes(
+ route()({
+ method: "POST",
+ path: "/initiate-setup",
+ }),
+ route>()({
+ method: "POST",
+ path: "/finalize-setup",
+ parseSession: true,
+ }),
+ route>()({
+ method: "POST",
+ path: "/disable",
+ parseSession: true,
+ }),
+ route>()({
+ method: "POST",
+ path: "/verify",
+ parseSession: true,
+ }),
+ route()({
+ method: "GET",
+ path: "/totp/uri",
+ as: "twoFactor.getTotpUri",
+ }),
+ route()({
+ method: "GET",
+ path: "/backup-codes",
+ as: "twoFactor.getBackupCodes",
+ }),
+ route()({
+ method: "PUT",
+ path: "/backup-codes",
+ as: "twoFactor.regenerateBackupCodes",
+ }),
+ route()({
+ method: "POST",
+ path: "/otp/send",
+ as: "twoFactor.sendOTP",
+ }),
+ );
+
+ return defineClientPlugin({
+ id: "two-factor",
+ basePath: "/two-factor",
+ routes,
+ hooks: {
+ afterResponse: [
+ {
+ match: "/signin/credential",
+ run: (ctx) => {
+ const body = ctx.body as Record;
+ if (body["two_factor_required"] === true) {
+ config.onTwoFactorRedirect();
+ }
+ return ctx;
+ },
+ },
+ ],
+ },
+ });
+}
+
+export type {
+ DisableTwoFactorInput,
+ FinalizeSetupInput,
+ InitiateSetupInput,
+ SendOTPInput,
+ TwoFactorConfig,
+ TwoFactorMethod,
+ TwoFactorSetupURI,
+ VerifyInput,
+} from "./types";
diff --git a/clients/typescript/packages/client/src/plugins/two-factor/types.ts b/clients/typescript/packages/client/src/plugins/two-factor/types.ts
new file mode 100644
index 0000000..b29eaed
--- /dev/null
+++ b/clients/typescript/packages/client/src/plugins/two-factor/types.ts
@@ -0,0 +1,31 @@
+export type TwoFactorConfig = {
+ /** Called when sign-in requires a two-factor challenge. */
+ onTwoFactorRedirect: () => void;
+};
+
+export type VerifyInput = {
+ code: string;
+ method?: TwoFactorMethod;
+};
+
+export type InitiateSetupInput = {
+ password: string;
+};
+
+export type FinalizeSetupInput = {
+ code: string;
+};
+
+export type DisableTwoFactorInput = {
+ password: string;
+};
+
+export type SendOTPInput = {
+ email: string;
+};
+
+export type TwoFactorSetupURI = {
+ uri: string;
+};
+
+export type TwoFactorMethod = "totp" | "otp";
diff --git a/clients/typescript/packages/client/src/react/index.ts b/clients/typescript/packages/client/src/react/index.ts
new file mode 100644
index 0000000..abbeff3
--- /dev/null
+++ b/clients/typescript/packages/client/src/react/index.ts
@@ -0,0 +1,35 @@
+import { createAuthClient as createCoreClient } from "../client";
+import type { AnyClientPlugin } from "../define-plugin";
+import type { SessionState } from "../session-store";
+import type { Prettify } from "../type-utils";
+import type { AuthClient, CreateAuthClientOptions } from "../types";
+import { useStore } from "./react-store";
+
+/**
+ * An {@link AuthClient} augmented with React hooks.
+ */
+export type ReactAuthClient = Prettify<
+ AuthClient & {
+ /**
+ * Reactively read the session store. Re-renders the component whenever
+ * `{ data, isPending, error }` changes.
+ */
+ useSession: () => SessionState;
+ }
+>;
+
+/**
+ * Create a Limen auth client with React hooks attached.
+ */
+export function createAuthClient(
+ opts: CreateAuthClientOptions,
+): ReactAuthClient {
+ const client = createCoreClient(opts);
+ const useSession = (): SessionState => useStore(client.$session);
+
+ return Object.assign(client, { useSession }) as ReactAuthClient;
+}
+
+export type { SessionState, SessionStore } from "../session-store";
+export type { AuthClient, CreateAuthClientOptions, Session, User } from "../types";
+export { useStore } from "./react-store";
diff --git a/clients/typescript/packages/client/src/react/react-store.ts b/clients/typescript/packages/client/src/react/react-store.ts
new file mode 100644
index 0000000..832abe2
--- /dev/null
+++ b/clients/typescript/packages/client/src/react/react-store.ts
@@ -0,0 +1,29 @@
+import type { Store, StoreValue } from "nanostores";
+import { useCallback, useRef, useSyncExternalStore } from "react";
+
+export function useStore(store: SomeStore): StoreValue {
+ type Value = StoreValue;
+
+ const snapshotRef = useRef(store.get());
+
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ const emitValue = (value: Value): void => {
+ if (snapshotRef.current === value) {
+ return;
+ }
+ snapshotRef.current = value;
+ onChange();
+ };
+ // Resync before listening: the value may have changed between render and
+ // this effect, and `listen` does not fire for the current value.
+ emitValue(store.value);
+ return store.listen(emitValue);
+ },
+ [store],
+ );
+
+ const get = (): Value => snapshotRef.current as Value;
+
+ return useSyncExternalStore(subscribe, get, get);
+}
diff --git a/clients/typescript/packages/client/src/route.ts b/clients/typescript/packages/client/src/route.ts
new file mode 100644
index 0000000..e2543fd
--- /dev/null
+++ b/clients/typescript/packages/client/src/route.ts
@@ -0,0 +1,113 @@
+import type { RouteContext } from "./context";
+import type { LimenError } from "./errors";
+import type { HTTPMethod } from "./types";
+
+declare const INPUT: unique symbol;
+declare const OUTPUT: unique symbol;
+
+/**
+ * Runs the route's default HTTP request and parser without session effects.
+ * Pass an input override when a handler needs to omit client-only fields.
+ */
+export type HttpRunner = (input?: I) => Promise;
+
+/**
+ * Custom route behavior for flows that need more than the default request
+ * pipeline.
+ */
+export type RouteHandler = (
+ ctx: RouteContext,
+ input: I,
+ http: HttpRunner,
+) => Promise;
+
+/**
+ * Per-call options accepted as the final argument of every generated route
+ * method.
+ */
+export type RouteCallOptions = {
+ /** Invoked with the resolved value after the call succeeds. */
+ onSuccess?: (data: O) => void;
+ /** Invoked with the error just before it is re-thrown. */
+ onError?: (error: LimenError) => void;
+};
+
+/**
+ * Declarative client route definition. The public client chain is derived from
+ * `path` unless `as` is provided.
+ */
+export type RouteDef = {
+ method: HTTPMethod;
+ path: `/${string}`;
+
+ /** Dotted client chain override, e.g. `"twoFactor.getTotpUri"`. */
+ as?: string;
+
+ /** Merged under the caller's input before serialization. */
+ defaults?: Partial;
+ /** SDK input → wire body/query. Defaults to shallow camelCase → snake_case. */
+ serialize?: (input: I) => unknown;
+ /** Raw response → typed output. Ignored when `parseSession` is set. */
+ parse?: (raw: unknown) => O;
+ /**
+ * Parse the response as a session and store it when it contains a `user`.
+ * Set `skipStore` to return the parsed session without writing it.
+ */
+ parseSession?: boolean;
+ /** Resolve `path` from the client base path instead of the plugin base path. */
+ absolute?: boolean;
+
+ /** Input keys used as `:param` path values. */
+ params?: readonly (keyof I & string)[];
+
+ /** For `parseSession` routes, skip the session-store write. */
+ skipStore?: boolean;
+ /** Clear the session store on success. */
+ clearSession?: boolean;
+ /** Revalidate the session after success. */
+ refetchSession?: boolean;
+
+ /** Set `false` to keep the route out of the public client API. */
+ expose?: boolean;
+
+ /** Override the default route call behavior. */
+ handler?: RouteHandler;
+};
+
+/**
+ * A route definition carrying its input and output types for inference.
+ */
+export type RouteDescriptor = RouteDef> = D & {
+ readonly [INPUT]: I;
+ readonly [OUTPUT]: O;
+};
+
+/**
+ * Loose route-descriptor constraint for route tuples and plugin definitions.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any route descriptor
+export type AnyRouteDescriptor = RouteDescriptor;
+
+/**
+ * Route definition with erased input/output types but typed fields.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- erases route I/O only
+export type AnyRoute = RouteDef;
+
+export type InputOf = R extends { readonly [INPUT]: infer I } ? I : never;
+export type OutputOf = R extends { readonly [OUTPUT]: infer O } ? O : never;
+
+/**
+ * Define an HTTP-backed client method for a plugin. The input/output types
+ * become the generated method's argument and resolved value.
+ *
+ * @example
+ * route>()({
+ * method: "POST",
+ * path: "/verify",
+ * parseSession: true,
+ * })
+ */
+export function route() {
+ return >(def: D): RouteDescriptor => def as RouteDescriptor;
+}
diff --git a/clients/typescript/packages/client/src/routes.ts b/clients/typescript/packages/client/src/routes.ts
new file mode 100644
index 0000000..e90a6b8
--- /dev/null
+++ b/clients/typescript/packages/client/src/routes.ts
@@ -0,0 +1,89 @@
+import type { RouteContext } from "./context";
+import type { InferPluginContribution } from "./define-plugin";
+import { defineClientPlugin, defineRoutes } from "./define-plugin";
+import { route } from "./route";
+import type { Session } from "./types";
+
+export type VerifyEmailInput = {
+ token: string;
+};
+
+export type ActiveSession = {
+ id: string | number;
+ token: string;
+ userId: unknown;
+ createdAt: string;
+ expiresAt: string;
+ lastAccess: string;
+ metadata?: Record;
+};
+
+/**
+ * Core routes available on every client.
+ */
+export function coreClientPlugin() {
+ const routes = defineRoutes(
+ route()({
+ method: "GET",
+ path: "/sessions",
+ }),
+ route()({
+ method: "POST",
+ path: "/signout",
+ clearSession: true,
+ }),
+ route()({
+ method: "POST",
+ path: "/revoke-sessions",
+ clearSession: true,
+ }),
+ route()({
+ method: "POST",
+ path: "/verify-email",
+ refetchSession: true,
+ }),
+ route()({
+ method: "POST",
+ path: "/email-verifications",
+ as: "requestEmailVerification",
+ }),
+ );
+
+ return defineClientPlugin({
+ id: "core",
+ basePath: "/",
+ routes,
+ actions: (ctx) => ({
+ /**
+ * Revalidate session state with `GET /me`, update `$session`, and return the
+ * resolved value (`null` when signed out).
+ *
+ * Prefer subscribing to `$session` for reactive UI state (`data`, `isPending`,
+ * `error`). Use `getSession()` when you need an awaited server re-check, such
+ * as route guards or SSR revalidation after `initialSession`.
+ */
+ getSession: async (): Promise | null> => {
+ await ctx.refetchSession();
+ const state = ctx.store.$session.get();
+ if (state.error) {
+ throw state.error;
+ }
+ return state.data as Session | null;
+ },
+ }),
+ });
+}
+
+export type CoreContribution = InferPluginContribution>>;
+
+/**
+ * Fetch and parse the current session for the reactive store.
+ */
+export function createSessionHydrator(
+ ctx: Pick, "fetch" | "parseSession">,
+): () => Promise> {
+ return async () => {
+ const raw = await ctx.fetch("/me", { method: "GET" });
+ return ctx.parseSession(raw);
+ };
+}
diff --git a/clients/typescript/packages/client/src/serialize.ts b/clients/typescript/packages/client/src/serialize.ts
new file mode 100644
index 0000000..422cdaf
--- /dev/null
+++ b/clients/typescript/packages/client/src/serialize.ts
@@ -0,0 +1,31 @@
+import { camelToSnake } from "./helpers";
+
+/**
+ * Default request serializer: shallow camelCase → snake_case, drops `undefined`,
+ * leaves non-objects unchanged.
+ *
+ * `additionalFields` entries are merged into the top-level body verbatim.
+ * Known route fields win on key collisions.
+ */
+export function defaultSerialize(input: unknown): unknown {
+ if (input === null || input === undefined) {
+ return input;
+ }
+ if (typeof input !== "object" || Array.isArray(input)) {
+ return input;
+ }
+ const { additionalFields, ...rest } = input as Record;
+ const out: Record = {};
+
+ if (additionalFields && typeof additionalFields === "object" && !Array.isArray(additionalFields)) {
+ Object.assign(out, additionalFields);
+ }
+
+ for (const [key, value] of Object.entries(rest)) {
+ if (value === undefined) {
+ continue;
+ }
+ out[camelToSnake(key)] = value;
+ }
+ return out;
+}
diff --git a/clients/typescript/packages/client/src/session-store.ts b/clients/typescript/packages/client/src/session-store.ts
new file mode 100644
index 0000000..11524f2
--- /dev/null
+++ b/clients/typescript/packages/client/src/session-store.ts
@@ -0,0 +1,124 @@
+import { atom, onMount, type ReadableAtom } from "nanostores";
+import { LimenError } from "./errors";
+import { createSessionSync } from "./session-sync";
+import type { Session } from "./types";
+
+export type SessionState = {
+ /** The current session, or `null` when signed out. */
+ data: Session | null;
+ /** True while a `/me` fetch is in flight. */
+ isPending: boolean;
+ /**
+ * The last non-401 failure (network error, 5xx, etc.). A 401 is not an error
+ * — it resolves to `data: null`. `error` is cleared on the next successful or
+ * 401 outcome.
+ */
+ error: LimenError | null;
+};
+
+type RefetchOptions = {
+ /** Skip the fetch when the last hydration ran within this many milliseconds. */
+ maxAgeMs?: number;
+ /** Skip the fetch when the session is signed out. */
+ skipSignedOut?: boolean;
+};
+
+export type SessionStore = {
+ readonly $session: ReadableAtom>;
+ setData(session: Session | null): void;
+ /**
+ * Re-validate the session from the server.
+ */
+ refetch(options?: RefetchOptions): Promise;
+};
+
+type CreateSessionStoreArgs = {
+ hydrator: () => Promise>;
+ initialSession?: Session | null;
+ /** Mirror session changes to other same-origin tabs. */
+ crossTabSync?: boolean;
+ /** Re-validate against `/me` when the tab returns to the foreground. */
+ refetchOnWindowFocus?: boolean;
+};
+
+export function createSessionStore(options: CreateSessionStoreArgs): SessionStore {
+ const $session = atom>({
+ data: options.initialSession ?? null,
+ isPending: false,
+ error: null,
+ });
+
+ let inFlightHydration: Promise | null = null;
+ // Bumped on every write so older async refresh results cannot overwrite newer state.
+ let writeVersion = 0;
+ // Timestamp the most recent session refetched.
+ let lastRefreshedAt = 0;
+ const isStale = (requestVersion: number): boolean => requestVersion !== writeVersion;
+
+ const fetchSessionFromServer = async (): Promise => {
+ const requestVersion = ++writeVersion;
+ $session.set({ data: $session.get().data, isPending: true, error: null });
+ try {
+ const session = await options.hydrator();
+
+ if (isStale(requestVersion)) {
+ return;
+ }
+
+ $session.set({ data: session, isPending: false, error: null });
+ } catch (err) {
+ if (isStale(requestVersion)) {
+ return;
+ }
+
+ if (err instanceof LimenError && err.isUnauthorized) {
+ // Not an error — the user is simply signed out.
+ $session.set({ data: null, isPending: false, error: null });
+ return;
+ }
+
+ const error =
+ err instanceof LimenError
+ ? err
+ : new LimenError(err instanceof Error ? err.message : "Failed to load session", 0, "unknown");
+ // Preserve the last known session; surface the failure via `error`.
+ $session.set({ data: $session.get().data, isPending: false, error });
+ }
+ };
+
+ const refetch = (options?: RefetchOptions): Promise => {
+ const { skipSignedOut, maxAgeMs } = options ?? {};
+ if (skipSignedOut && $session.get().data === null) {
+ return Promise.resolve();
+ }
+
+ if (maxAgeMs !== undefined && Date.now() - lastRefreshedAt < maxAgeMs) {
+ return Promise.resolve();
+ }
+
+ if (!inFlightHydration) {
+ inFlightHydration = fetchSessionFromServer().finally(() => {
+ inFlightHydration = null;
+ lastRefreshedAt = Date.now();
+ });
+ }
+ return inFlightHydration;
+ };
+
+ const setData = (session: Session | null): void => {
+ writeVersion++; // supersede any in-flight hydrate so it can't overwrite this
+ $session.set({ data: session, isPending: false, error: null });
+ };
+
+ const store: SessionStore = { $session, setData, refetch };
+
+ onMount($session, () =>
+ createSessionSync(store, {
+ fetchOnMount: options.initialSession === undefined,
+ crossTabSync: options.crossTabSync ?? false,
+ refetchOnWindowFocus: options.refetchOnWindowFocus ?? false,
+ }),
+ );
+
+ return store;
+}
diff --git a/clients/typescript/packages/client/src/session-sync.ts b/clients/typescript/packages/client/src/session-sync.ts
new file mode 100644
index 0000000..19701e9
--- /dev/null
+++ b/clients/typescript/packages/client/src/session-sync.ts
@@ -0,0 +1,82 @@
+import { onNotify } from "nanostores";
+import { createBroadcastChannel } from "./broadcast-channel";
+import { deepJsonEqual } from "./json-deep-equal";
+import type { SessionStore } from "./session-store";
+import type { Session } from "./types";
+
+const CHANNEL_NAME = "limen.session";
+const FOCUS_REFETCH_THROTTLE_MS = 5_000;
+
+type SyncMessage = { data: Session | null };
+
+type SessionSyncOptions = {
+ /** Fetch the session on mount. */
+ fetchOnMount: boolean;
+ /** Mirror session changes to other same-origin tabs. */
+ crossTabSync: boolean;
+ /** Re-validate against `/me` when the tab returns to the foreground. */
+ refetchOnWindowFocus: boolean;
+};
+
+export function createSessionSync(
+ store: SessionStore,
+ options: SessionSyncOptions,
+): () => void {
+ if (options.fetchOnMount) {
+ void store.refetch();
+ }
+
+ const teardowns: Array<() => void> = [];
+ if (options.crossTabSync) {
+ teardowns.push(syncAcrossTabs(store));
+ }
+
+ if (options.refetchOnWindowFocus) {
+ teardowns.push(refetchOnFocus(store));
+ }
+
+ return () => {
+ for (const teardown of teardowns) {
+ teardown();
+ }
+ };
+}
+
+function syncAcrossTabs(store: SessionStore): () => void {
+ const port = createBroadcastChannel>(CHANNEL_NAME);
+ let lastData = store.$session.get().data;
+
+ const unsubscribe = port.subscribe((message) => {
+ // Mark remote updates as seen before applying them to avoid echoing them.
+ lastData = message.data;
+ store.setData(message.data);
+ });
+
+ const unbindNotify = onNotify(store.$session, () => {
+ const data = store.$session.get().data;
+ if (deepJsonEqual(data, lastData)) {
+ return;
+ }
+ lastData = data;
+ port.post({ data: data });
+ });
+
+ return () => {
+ unbindNotify();
+ unsubscribe();
+ port.close();
+ };
+}
+
+function refetchOnFocus(store: SessionStore): () => void {
+ if (typeof document === "undefined") {
+ return () => {};
+ }
+ const onVisibilityChange = (): void => {
+ if (document.visibilityState === "visible") {
+ void store.refetch({ maxAgeMs: FOCUS_REFETCH_THROTTLE_MS, skipSignedOut: true });
+ }
+ };
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ return () => document.removeEventListener("visibilitychange", onVisibilityChange);
+}
diff --git a/clients/typescript/packages/client/src/solid/index.ts b/clients/typescript/packages/client/src/solid/index.ts
new file mode 100644
index 0000000..ad41c1d
--- /dev/null
+++ b/clients/typescript/packages/client/src/solid/index.ts
@@ -0,0 +1,36 @@
+import type { Accessor } from "solid-js";
+import { createAuthClient as createCoreClient } from "../client";
+import type { AnyClientPlugin } from "../define-plugin";
+import type { SessionState } from "../session-store";
+import type { Prettify } from "../type-utils";
+import type { AuthClient, CreateAuthClientOptions } from "../types";
+import { useStore } from "./solid-store";
+
+/**
+ * An {@link AuthClient} augmented with Solid primitives.
+ */
+export type SolidAuthClient = Prettify<
+ AuthClient & {
+ /**
+ * Reactively read the session store as a Solid accessor. Updates whenever
+ * `{ data, isPending, error }` changes.
+ */
+ useSession: () => Accessor>;
+ }
+>;
+
+/**
+ * Create a Limen auth client with Solid primitives attached.
+ */
+export function createAuthClient(
+ opts: CreateAuthClientOptions,
+): SolidAuthClient {
+ const client = createCoreClient(opts);
+ const useSession = (): Accessor> => useStore(client.$session);
+
+ return Object.assign(client, { useSession }) as SolidAuthClient