Skip to content
5 changes: 5 additions & 0 deletions clients/typescript/.changeset/social-tips-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"limen-auth": patch
---

Support resending an email verification without a session, and error codes such as `email_not_verified`.
2 changes: 1 addition & 1 deletion clients/typescript/packages/client/src/auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function createAuthStore<T>(options: CreateAuthStoreOptions<T>): DataStor
const raw = await ctx.fetch<unknown>(path, { method: "GET", ...init });
return parse === undefined ? (raw as T) : parse(raw);
} catch (error) {
if (error instanceof LimenError && error.isUnauthorized) {
if (error instanceof LimenError && error.is("unauthorized")) {
return null;
}
throw error;
Expand Down
8 changes: 8 additions & 0 deletions clients/typescript/packages/client/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export function unwrapPayload(body: unknown, envelope: EnvelopeConfig): unknown
return body;
}

export function unwrapErrorCode(body: unknown): string | undefined {
if (body === null || typeof body !== "object") {
return undefined;
}
const value = (body as Record<string, unknown>).code;
return typeof value === "string" && value !== "" ? value : undefined;
}

/**
* Pull the human-readable error message out of a non-2xx body.
*
Expand Down
45 changes: 35 additions & 10 deletions clients/typescript/packages/client/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
export type LimenErrorCode =
| "unauthorized"
| "forbidden"
| "not_found"
| "rate_limited"
| "validation_error"
| "conflict"
| "server_error"
| "timeout"
| "unknown";
import { unwrapErrorCode } from "./envelope";

const LIMEN_ERROR_CODES = [
"unauthorized",
"forbidden",
"not_found",
"rate_limited",
"validation_error",
"conflict",
"server_error",
"timeout",
"email_not_verified",
"unknown",
] as const;

export type LimenErrorCode = (typeof LIMEN_ERROR_CODES)[number];

const LIMEN_ERROR_CODE_SET = new Set<string>(LIMEN_ERROR_CODES);

export function isLimenErrorCode(code: string): code is LimenErrorCode {
return LIMEN_ERROR_CODE_SET.has(code);
}

/** Prefer a known server code on the body; otherwise derive one from the HTTP status. */
export function resolveErrorCode(status: number, body: unknown): LimenErrorCode {
const serverCode = unwrapErrorCode(body);
if (serverCode !== undefined && isLimenErrorCode(serverCode)) {
return serverCode;
}
return deriveErrorCode(status);
}

/** Map HTTP status → typed code. Anything unmapped becomes `"unknown"`. */
// prettier-ignore
Expand Down Expand Up @@ -37,6 +58,10 @@ export class LimenError extends Error {
this.code = code ?? deriveErrorCode(status);
}

is(code: LimenErrorCode): boolean {
return this.code === code;
}

get isUnauthorized(): boolean {
return this.code === "unauthorized";
}
Expand Down
4 changes: 2 additions & 2 deletions clients/typescript/packages/client/src/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DEFAULT_TIMEOUT_MS } from "./constants";
import { unwrapErrorMessage, unwrapPayload } from "./envelope";
import { LimenError, deriveErrorCode } from "./errors";
import { LimenError, resolveErrorCode } from "./errors";
import { ensureLeadingSlash, joinURL, stripTrailingSlash } from "./helpers";
import type { HookRunner } from "./hooks";
import type { FetchInit, FetchOptions, RequestContext, ResponseContext } from "./plugin";
Expand Down Expand Up @@ -129,7 +129,7 @@ export class Fetcher {
: (unwrapErrorMessage(resCtx.body, this.opts.envelope) ??
response.statusText ??
`Request failed with status ${response.status}`);
this.fail(reqCtx, response.status, new LimenError(message, response.status, deriveErrorCode(response.status)));
this.fail(reqCtx, response.status, new LimenError(message, response.status, resolveErrorCode(response.status, resCtx.body)));
}

private fail(reqCtx: RequestContext, status: number, error: LimenError): never {
Expand Down
2 changes: 1 addition & 1 deletion clients/typescript/packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type { DeclaredFields, FieldsOf, ModelFields, PluginSchema, RunRoute, Str
export type { RouteCallOptions, RouteHandler } from "./route";

export { coreClientPlugin } from "./routes";
export type { ActiveSession, CoreContribution, VerifyEmailInput } from "./routes";
export type { ActiveSession, CoreContribution, RequestEmailVerificationInput, VerifyEmailInput } from "./routes";

export type { CoreStores, StoresOf, StoreValues } from "./infer";

Expand Down
19 changes: 13 additions & 6 deletions clients/typescript/packages/client/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ export function normalizeUser<F = unknown>(raw: Record<string, unknown>): User<F
return out as User<F>;
}

export function defaultSessionParse<F = unknown>(raw: unknown): Session<F> {
if (!raw || typeof raw !== "object") {
throw new TypeError(`Expected session response to be an object, got ${raw === null ? "null" : typeof raw}`);
/**
* Map a default Limen session body into a `Session`. Returns `false` unless
* `user` is a non-null object, so non-session payloads (a pending two-factor
* challenge, a plain message) are not stored.
*/
export function defaultSessionParse<F = unknown>(raw: unknown): Session<F> | false {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return false;
}
const userRaw = (raw as Record<string, unknown>)["user"];
if (typeof userRaw !== "object" || userRaw === null || Array.isArray(userRaw)) {
return false;
}
const obj = raw as Record<string, unknown>;
const userRaw = (obj["user"] ?? obj) as Record<string, unknown>;
return { user: normalizeUser<F>(userRaw) };
return { user: normalizeUser<F>(userRaw as Record<string, unknown>) };
}
38 changes: 26 additions & 12 deletions clients/typescript/packages/client/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ import type { QueryParams, Session } from "./types";
/**
* Run the default HTTP steps for a route — merge defaults, resolve path params,
* serialize, dispatch, parse — without applying session effects.
* `onSession` receives a session `parseSession` accepted. Handlers omit it.
*/
async function runHttp(ctx: AnyRouteContext, def: AnyRoute, input: unknown, callInit?: FetchOptions): Promise<unknown> {
async function runHttp(
ctx: AnyRouteContext,
def: AnyRoute,
input: unknown,
callInit?: FetchOptions,
onSession?: (session: Session<unknown>) => void,
): Promise<unknown> {
let merged = input;
if (def.defaults !== undefined) {
merged = { ...(def.defaults as Record<string, unknown>), ...((input ?? {}) as Record<string, unknown>) };
Expand All @@ -29,8 +36,12 @@ async function runHttp(ctx: AnyRouteContext, def: AnyRoute, input: unknown, call

const raw = await ctx.fetch<unknown>(path, init);

if (def.parseSession === true && isSessionResponse(raw)) {
return ctx.parseSession(raw);
if (def.parseSession === true) {
const session = ctx.parseSession(raw);
if (session !== false) {
onSession?.(session);
return session;
}
}

if (def.parse !== undefined) {
Expand All @@ -42,13 +53,17 @@ async function runHttp(ctx: AnyRouteContext, def: AnyRoute, input: unknown, call
return Array.isArray(raw) ? camelizeEach(raw) : camelizeKeys(raw);
}

async function applyEffects(ctx: AnyRouteContext, def: AnyRoute, result: unknown): Promise<void> {
async function applyEffects(
ctx: AnyRouteContext,
def: AnyRoute,
session: Session<unknown> | undefined,
): Promise<void> {
if (def.clearSession === true) {
ctx.store.setData(null);
}

if (def.parseSession === true && def.skipStore !== true && isSessionResponse(result)) {
ctx.store.setData(result);
if (session !== undefined && def.skipStore !== true) {
ctx.store.setData(session);
}

if (def.refetchSession === true) {
Expand All @@ -67,10 +82,6 @@ function makeHttpRunner(
return run as HttpRunner<unknown>;
}

function isSessionResponse(raw: unknown): raw is Session<unknown> {
return typeof raw === "object" && raw !== null && "user" in raw;
}

/**
* Execute a route's behaviour: delegate to its `handler` when present (handler
* owns all behaviour, including any effects), otherwise run the default
Expand All @@ -85,8 +96,11 @@ async function dispatchRoute(
if (def.handler !== undefined) {
return def.handler(ctx, input, makeHttpRunner(ctx, def, input, callInit));
}
const result = await runHttp(ctx, def, input, callInit);
await applyEffects(ctx, def, result);
let session: Session<unknown> | undefined;
const result = await runHttp(ctx, def, input, callInit, (parsed) => {
session = parsed;
});
await applyEffects(ctx, def, session);
return result;
}

Expand Down
6 changes: 3 additions & 3 deletions clients/typescript/packages/client/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ export type RouteDef<I, O> = {
defaults?: Partial<I>;
/** SDK input → wire body/query. Defaults to shallow camelCase → snake_case. */
serialize?: (input: I) => unknown;
/** Raw response → typed output. Ignored when `parseSession` is set. */
/** Raw response → typed output. Used when `parseSession` is unset or returns `false`. */
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.
* Parse the response with the client's `parseSession`. A returned session is
* stored unless `skipStore` is set.
*/
parseSession?: boolean;
/** Resolve `path` from the client base path instead of the plugin base path. */
Expand Down
9 changes: 7 additions & 2 deletions clients/typescript/packages/client/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export type VerifyEmailInput = {
token: string;
};

export type RequestEmailVerificationInput = {
email?: string;
};

export type ActiveSession = {
id: string | number;
token: string;
Expand Down Expand Up @@ -36,12 +40,13 @@ export function coreClientPlugin<TFields = unknown>() {
path: "/revoke-sessions",
clearSession: true,
}),
route<VerifyEmailInput, string>()({
route<VerifyEmailInput, string | Session<TFields>>()({
method: "POST",
path: "/verify-email",
parseSession: true,
refetchSession: true,
}),
route<void, string>()({
route<RequestEmailVerificationInput | void, string>()({
method: "POST",
path: "/email-verifications",
as: "requestEmailVerification",
Expand Down
5 changes: 3 additions & 2 deletions clients/typescript/packages/client/src/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ export function createSessionStore<TFields = unknown>(options: CreateSessionStor
loader: async () => {
try {
const raw = await options.fetch<unknown>("/me", { method: "GET" });
return options.parseSession(raw);
const session = options.parseSession(raw);
return session === false ? null : session;
} catch (error) {
// Not an error — the user is simply signed out.
if (error instanceof LimenError && error.isUnauthorized) {
if (error instanceof LimenError && error.is("unauthorized")) {
return null;
}
throw error;
Expand Down
9 changes: 5 additions & 4 deletions clients/typescript/packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ export type CreateAuthClientOptions<Plugins extends readonly AnyClientPlugin[],
/** Options that modify how the SDK performs HTTP requests. */
fetchOptions?: ClientFetchOptions;
/**
* Optional transformer for non-default session payloads.
* Optional transformer for session payloads.
*
* Provide this when your server returns custom user/session fields. It must
* map the raw response into `Session`.
* Provide this when your server returns a custom shape. Return a `Session`
* when the body is a session, or `false` when it is not. `false` leaves the
* store unchanged on a route call;
*/
parseSession?: ParseSession<PrettyUserFields<Plugins, TFields>>;
/**
Expand Down Expand Up @@ -130,7 +131,7 @@ export type EnvelopeConfig = {
fields?: EnvelopeFields;
};

export type ParseSession<TFields = unknown> = (raw: unknown) => Session<TFields>;
export type ParseSession<TFields = unknown> = (raw: unknown) => Session<TFields> | false;

export type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";

Expand Down
49 changes: 49 additions & 0 deletions clients/typescript/packages/client/test/client-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,55 @@ describe("createAuthClient — session effects", () => {
expect(session?.user.id).toBe("u1");
expect(auth.$session.get().data?.user.id).toBe("u1");
});

it("does not store a non-session sign-in body", async () => {
const { auth } = setup(() => ({ body: { two_factor_required: true } }));

const result = await auth.signIn.credential({ credential: "ada@example.com", password: "pw" });

expect(result).toEqual({ twoFactorRequired: true });
expect(auth.$session.get().data).toBeNull();
});

it("stores a custom session shape when parseSession returns one", async () => {
const { impl } = mockFetch(() => ({
body: { account: { id: "u9", email: "ada@example.com" } },
}));
const auth = createAuthClient({
baseURL: "http://localhost:8080",
plugins: [credentialPasswordPlugin()],
fetchOptions: { impl },
crossTabSync: false,
refetchOnWindowFocus: false,
parseSession: (raw) => {
if (typeof raw !== "object" || raw === null || !("account" in raw)) {
return false;
}
const account = (raw as { account: { id: string; email: string } }).account;
return { user: { id: account.id, email: account.email, emailVerifiedAt: null } };
},
});

const session = await auth.signIn.credential({ credential: "ada@example.com", password: "pw" });

expect(session.user.id).toBe("u9");
expect(auth.$session.get().data?.user.id).toBe("u9");
});

it("treats a false parse of /me as signed out", async () => {
const { impl } = mockFetch(() => ({ body: { status: "anonymous" } }));
const auth = createAuthClient({
baseURL: "http://localhost:8080",
plugins: [credentialPasswordPlugin()],
fetchOptions: { impl },
crossTabSync: false,
refetchOnWindowFocus: false,
parseSession: () => false,
});

await expect(auth.getSession()).resolves.toBeNull();
expect(auth.$session.get().data).toBeNull();
});
});

describe("createAuthClient — custom parse / handlers", () => {
Expand Down
23 changes: 21 additions & 2 deletions clients/typescript/packages/client/test/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,33 @@ describe("fetcher — timeout", () => {
const err = (await auth.sessions().catch((e) => e)) as LimenError;
expect(err).toBeInstanceOf(LimenError);
expect(err.code).toBe("timeout");
expect(err.isTimeout).toBe(true);
expect(err.is("timeout")).toBe(true);
});

it("per-call timeout overrides the client default", async () => {
const auth = makeClient(hangingImpl(), { timeout: 0 }); // client default disabled
const err = (await auth.sessions(undefined, { timeout: 20 }).catch((e) => e)) as LimenError;
expect(err).toBeInstanceOf(LimenError);
expect(err.isTimeout).toBe(true);
expect(err.is("timeout")).toBe(true);
});
});

describe("fetcher — error codes", () => {
it("uses a known server code instead of the status-derived code", async () => {
const auth = makeClient(
staticImpl(JSON.stringify({ message: "email is not verified", code: "email_not_verified" }), { status: 403 }),
{ timeout: 0 },
);
const err = (await auth.sessions().catch((e) => e)) as LimenError;
expect(err).toBeInstanceOf(LimenError);
expect(err.status).toBe(403);
expect(err.code).toBe("email_not_verified");
});

it("derives forbidden when a 403 body has no code", async () => {
const auth = makeClient(staticImpl(JSON.stringify({ message: "nope" }), { status: 403 }), { timeout: 0 });
const err = (await auth.sessions().catch((e) => e)) as LimenError;
expect(err.code).toBe("forbidden");
});
});

Expand Down
Loading
Loading