npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@verdify/sdk-ts

v0.13.0

Published

Verdify TypeScript SDK — generated typed REST client for auth.v1 + identity.v1, server-side only

Readme

verdify-sdk-ts

The TypeScript client SDK for the Verdify platform, generated from verdify-contracts (auth.v1 + identity.v1). Server-side only (D-On7): the browser never holds raw tokens; consume this from a BFF / route handler.

Posture note. This is the mirror image of @verdify/auth-browser, the Tier-2 browser client, which deliberately holds body tokens in the SPA. This package assumes the opposite — tokens stay on the server, and the auth service issues them as httpOnly cookies (ADR-0032). Do not substitute one for the other.

Install

pnpm add @verdify/sdk-ts

Quick start

import { createVerdifyClient } from "@verdify/sdk-ts";

const sdk = createVerdifyClient({
  authBaseUrl: process.env.AUTH_BASE_URL!,
  identityBaseUrl: process.env.IDENTITY_BASE_URL!,
});

// Per-request cookie/trace forwarding via the optional context arg:
const r = await sdk.identity.getProfile(id, { headers: { cookie, traceparent } });
if (r.ok) useProfile(r.data);
else passThrough(r.status, r.error); // {error:{type,message,code,traceId,docsUrl?}}

createVerdifyClient is a convenience over the two clients — build either on its own with createAuthClient({ baseUrl }) or createIdentityClient({ baseUrl }).

The client attaches no credentials of its own. Every cookie, trace header and X-Verdify-Client-Id a call needs is supplied per request through the optional RequestContext ({ headers }). The BFF decides what to forward.

Results

Every method returns a non-throwing Result<T>. A transport failure becomes { ok: false, status: 0 } carrying a synthesized envelope, not a rejected promise.

type Ok<T> = { ok: true; status: number; data: T; setCookies: string[] };
type Err = { ok: false; status: number; error: ErrorEnvelope; setCookies: string[] };
type Result<T> = Ok<T> | Err;

setCookies carries the upstream Set-Cookie headers verbatim (string[]) so a server-side caller can forward or rewrite them — the auth service issues tokens only as httpOnly cookies (ADR-0032). It is [] when the upstream sent none, and on a transport failure.

Login, and the MFA-required 202

POST /login has two 2xx meanings: 200 carrying a SessionInfo, and 202 carrying an MFA challenge — verdify-auth answers 202 whenever the identity has a confirmed TOTP. So login() does not return Result<SessionInfo>; it returns LoginResult, and the response body's shape decides the arm, not the status code (ADR-0071).

An MFA-required login is ok: false by design. No session was issued, so a caller that knows nothing about MFA refuses the login rather than reading a SessionInfo whose every field is undefined.

import type { LoginResult } from "@verdify/sdk-ts";

const r: LoginResult = await sdk.auth.login({ email, password }, { headers });

if (r.ok) {
  // Session issued. r.data is SessionInfo; forward r.setCookies to the browser.
} else if (Object.hasOwn(r, "mfaRequired") && r.mfaRequired) {
  // Second factor outstanding. r.expiresIn is seconds until the grant expires.
  // The vfy_mfa grant arrives in r.setCookies — an httpOnly cookie, never a body field.
  promptForSecondFactor(r.setCookies);
} else {
  // Ordinary failure. r.error is the ErrorEnvelope.
  show(r.error.error.message);
}

Narrow with Object.hasOwn(r, "mfaRequired") && r.mfaRequired — not "mfaRequired" in r. in walks the prototype chain, so a prototype-pollution gadget anywhere else in the consuming bundle could make a genuine 401 narrow as an MFA challenge. Both ok: false arms carry mfaRequired as an own property for exactly that reason.

Unlike the Tier-2 RpMfaRequired, the Tier-1 MfaRequired body carries only { mfaRequired, expiresIn }. There is deliberately no mfaToken — the grant is the httpOnly vfy_mfa cookie, and setCookies is the only place it is delivered.

Only login() returns this union. Tier-1 POST /verify-email has no 202 branch in the contract, so verifyEmail() stays Result<SessionInfo>. That differs from @verdify/auth-browser, whose Tier-2 verifyEmail() does return the MFA union.

Completing the challenge — completeMfaChallenge()

sdk.auth.completeMfaChallenge(factor, ctx) is POST /mfa/challenge, the second half of the login ceremony. Forward the vfy_mfa grant cookie you received in setCookies — it is the only thing that identifies the pending challenge, and it never appears in a body.

const r = await sdk.auth.completeMfaChallenge(
  { code: "123456" },                     // or { recoveryCode: "abcd-efgh-jkmn" }
  { headers: { cookie: vfyMfaCookie } },
);

if (r.ok) {
  // Session issued. r.data is SessionInfo; forward r.setCookies to the browser.
} else if (r.failure === "rate_limited") {
  show("Too many attempts — try again shortly.");
} else if (r.failure === "malformed") {
  // A bug in the CALL, not a failed challenge: neither or both factors were supplied.
} else {
  // "refused" or "failed". r.error is the ErrorEnvelope.
  show(r.error.error.message);
}

MfaChallengeResult, MfaChallengeFailedResult, MfaChallengeFailure and MfaChallengeFactor are all exported from the package root, so import type { MfaChallengeResult } from "@verdify/sdk-ts" resolves and you can name the type instead of inferring it.

Exactly one factor, enforced twice. The factor parameter is a union whose arms use ?: never, so { code, recoveryCode } and {} are compile errors; a runtime guard repeats the rule for JavaScript callers and refuses without sending the request (the route is rate-limited per-IP, so a request that cannot succeed still costs budget). Both produce failure: "malformed" with status: 422 — the same variant and the same status the server's own 422 produces, so a caller branching on either has one code path whether the rule was enforced locally or at the server (ADR-0077). A genuine transport failure, where no response was received at all, still reports status: 0.

They are two distinct fields, never one field sniffed for shape: a wrong TOTP spends the grant's attempt cap, while a wrong recovery code spends the identity's shared recovery-code failure budget.

failure: "refused" is deliberately coarse — do not try to refine it. A wrong code, a consumed grant, an expired grant, a spent attempt cap, a locked recovery-code set and an identity deactivated since the 202 are all the same generic 401 (VERDIFY-AUTH-401-104). The server makes them indistinguishable on purpose: telling them apart is an oracle for exactly the account state a second factor protects. The wire carries nothing to split that arm with.

Only ok: true carries data, so a caller written as if (r.ok) { … } can never read a refusal as a session — the same fail-closed default login() has (ADR-0071). Every ok: false arm also carries an own mfaRequired: false, so a consumer funnelling login() and completeMfaChallenge() results into one handler can narrow with Object.hasOwn(r, "mfaRequired") && r.mfaRequired and never needs the prototype-chain-walking in form.

@verdify/auth-browser implements the Tier-2 twin, completeRpMfaChallenge() on POST /rp/mfa/challenge. Both packages now cover challenge completion, and ADR-0077 fixes one shared vocabulary across them: the same failure discriminant, the same four values (refused / malformed / rate_limited / failed), the same own mfaRequired: false on every failure arm, and the same status: 422 for a locally-refused call. The one deliberate difference is the argument: Tier-2 bundles the mfaToken grant into the body because a cross-origin RP cannot use an httpOnly cookie, while Tier-1 carries it in vfy_mfa and so takes only the factor.

Auth client — sdk.auth.*

Every method takes an optional trailing ctx?: RequestContext.

| Method | Endpoint | Resolves to | |---|---|---| | signUpEmail(body) | POST /sign-up/email | Result<AuthChallenge> | | verifyEmail(body) | POST /verify-email | Result<SessionInfo> | | resendOtp(body) | POST /verify-email/resend | Result<AuthChallenge> | | login(body) | POST /login | LoginResult — 200 or 202, see above | | completeMfaChallenge(factor) | POST /mfa/challenge | MfaChallengeResult — see above | | googleStart() | GET /oauth/google/start | Result<{ authorizationUrl }> | | googleCallback({ code, state }) | GET /oauth/google/callback | Result<SessionInfo> | | passkeyRegisterBegin(body) | POST /passkey/register/begin | Result<WebAuthnBeginResponse> | | passkeyRegisterFinish(body) | POST /passkey/register/finish | Result<SessionInfo> | | passkeyAuthenticateBegin(body) | POST /passkey/authenticate/begin | Result<WebAuthnBeginResponse> | | passkeyAuthenticateFinish(body) | POST /passkey/authenticate/finish | Result<SessionInfo> | | refreshToken() | POST /token | Result<undefined> — 204, new cookies | | listSessions() | GET /sessions | Result<SessionList> | | revokeSession(id) | DELETE /sessions/{id} | Result<undefined> | | signOutEverywhere() | DELETE /sessions | Result<SignOutEverywhereResult> | | getRpProfile() | GET /rp/profile | Result<RpProfile> | | accountReauth(body) | POST /account/reauth | Result<undefined> — 204 | | setAccountPassword(body) | POST /account/password | Result<undefined> — 204 | | accountPasskeyRegisterBegin() | POST /account/passkey/register/begin | Result<WebAuthnBeginResponse> | | accountPasskeyRegisterFinish(body) | POST /account/passkey/register/finish | Result<undefined> — 204 | | accountGoogleLinkStart() | GET /account/oauth/google/start | Result<{ authorizationUrl }> |

getRpProfile() is the Tier-2 read. GET /rp/profile returns the scope-projected global profile: identityId and scopes are always present, and every other field (displayName, handle, avatarRef, locale, timezone, country) is null unless the session's granted scopes permit it — profile:basic for the first three, profile:region for the rest. The endpoint is authenticated by the relying-party ceremony, so the caller must pass X-Verdify-Client-Id itself through ctx.headers; this client does not add it.

The account* methods need step-up. accountReauth() re-proves the current credential and sets a vfy_su cookie with a 5-minute TTL; without it in a forwarded cookie header, setAccountPassword(), the account passkey pair and accountGoogleLinkStart() answer 401 with step_up_required.

Identity client — sdk.identity.*

| Method | Endpoint | Resolves to | |---|---|---| | getIdentity(id) | GET /users/{id} | Result<Identity> | | listCredentials(id) | GET /users/{id}/credentials | Result<CredentialList> | | revokeCredential(id, credentialId) | DELETE /users/{id}/credentials/{credentialId} | Result<undefined> | | checkHandleAvailability(handle) | GET /handles/availability | Result<HandleAvailability> | | claimHandle(id, body) | POST /users/{id}/handle | Result<Handle> | | getProfile(id) | GET /users/{id}/profile | Result<GlobalProfile> | | updateProfile(id, body) | PATCH /users/{id}/profile | Result<GlobalProfile> | | deactivateSelf(id, body?) | POST /users/{id}/deactivate | Result<DeactivateResult> | | requestSelfDeletion(id) | POST /users/{id}/delete | Result<DeleteRequestResult> | | cancelSelfDeletion(id) | DELETE /users/{id}/delete | Result<DeleteRequestResult> | | getLegalAcceptance(id) | GET /users/{id}/legal-acceptance | Result<LegalAcceptance> |

requestSelfDeletion() starts a cancellable grace window and blocks a new sign-in immediately, but erases nothing. It revokes every other live session immediately, the same as deactivation — but deliberately preserves the requesting session, since that is the caller's own route back to cancelSelfDeletion() (ADR-0078; preservation is not a lifetime extension — that session remains subject to its own ordinary idle/absolute expiry). It resolves to { identityId, status: "pending_deletion", scheduledAt }, where scheduledAt is when the window ends. It is step-up gated: without a vfy_su cookie in the forwarded cookie header it answers 401 with step_up_required, and 409 (VERDIFY-IDENTITY-409-006, or 409-008 concurrent_modification on a racing write) if the identity is not active.

cancelSelfDeletion() restores pending_deletion → active during that window and resolves to { identityId, status: "active" } with no scheduledAt. It deliberately requires no step-up — the higher bar applies to starting the destructive path, not to stopping it. It answers 409 (VERDIFY-IDENTITY-409-007) when there is nothing to cancel, including after the window has elapsed. Neither method takes a request body.

getLegalAcceptance() reads account-level Terms of Service / Privacy Notice acceptance evidence. It is never RP-scoped, and it is not a marketing-consent or RP-disclosure grant. status is one of current, stale (evidence exists but predates a configured version) or unrecorded. unrecorded is the honest state for an identity that activated before this evidence existed — it is not backfilled or fabricated, and the four evidence fields (termsVersion, termsAcceptedAt, privacyVersion, privacyAcceptedAt) are then absent rather than guessed, so treat them as optional. currentTermsVersion and currentPrivacyVersion are always present, and are what status was computed against. Self only: it answers 404 for an identity that is not the caller, with no existence leak.

Webhooks

verifyWebhookSignature(rawBody, signatureHeader, secret, opts?) verifies a Verdify webhook — HMAC-SHA256 over "<t>.<rawBody>", constant-time compared, plus a replay window (opts.toleranceSeconds, default 300). It returns the same Result shape, with { timestamp, verified: true } on success.

Pass the exact raw request body bytes. Re-serialized parsed JSON will not verify.

Options

createVerdifyClient({
  /** Auth service base URL, including the version path prefix. Required. */
  authBaseUrl: string,
  /** Identity service base URL, including the version path prefix. Required. */
  identityBaseUrl: string,
  /** Custom fetch (testing, or a runtime wrapper). Defaults to globalThis.fetch. */
  fetch?: typeof fetch,
  /** Max retries for idempotent GETs. Default: 2. */
  maxRetries?: number,
})

maxRetries applies to GET only, on a transport error or a 502/503/504. POSTs are never retried — replaying a login or a token rotation re-spends credential-attempt and rate-limit budget for a request the server may already have processed. 429 is passed through unretried; Retry-After handling is not implemented.

The retrying fetch dispatches fetch(url, init), not fetch(request). In a custom wrapper, url is a string and init.headers is a plain Record<string, string> — not a Headers, so init.headers.get is not a function.

Exported types

VerdifyClient, VerdifyClientOptions, AuthClientOptions, IdentityClientOptions, LoginResult, LoginMfaRequiredResult, RequestContext, Result, Ok, Err, ErrorEnvelope, and the full generated schema maps as AuthSchemas and IdentitySchemas.

Not covered by this client

These auth.v1 paths exist in the vendored spec and the generated types, but have no client method — call them directly, or use the right package:

  • Password reset: POST /password-reset, POST /password-reset/{id}/complete. The complete step also answers 202 with the same MfaRequired body that login() handles, and the grant it mints is redeemable through completeMfaChallenge() like any other.
  • Recovery: POST /recovery, /recovery/{id}/factors, /recovery/{id}/complete, /recovery/codes, and POST /account/recovery/codes.
  • TOTP enrolment: /account/totp, /account/totp/enroll, /account/totp/confirm.
  • The non-password step-up proofs: /account/reauth/passkey/begin, /account/reauth/google/start.
  • The Tier-2 ceremony — /rp/sign-up/email, /rp/verify-email, /rp/login, /rp/token, /rp/mfa/challenge — which belongs to @verdify/auth-browser, not here. /rp/profile is the exception: it is a server-side scoped read and is exposed as getRpProfile().

Status

0.12.0. Generated from verdify-contracts at ref ca727cd — the pin lives in codegen.config.ts (CONTRACTS_REF), and pnpm run verify:contracts-sync diffs the vendored specs against it in CI.

0.12.0 adds getLegalAcceptance() (ADR-0080), reading an identity's Terms of Service / Privacy Notice acceptance evidence and whether it is current. Additive only — no existing method changed.

0.11.0 adds requestSelfDeletion() and cancelSelfDeletion() (ADR-0076), the self-service half of account deletion. Additive only — no existing method changed.

0.10.0 adds completeMfaChallenge(), so the Tier-1 MFA flow is now completable entirely through this package; the hand-rolled fetch() that earlier releases documented as the workaround is no longer needed. Nothing else changed — login() is untouched.

The behaviour change to know about since 0.2.0: as of 0.9.0, login() returns LoginResult rather than Result<SessionInfo>, and an MFA-required 202 is reported as ok: false. Code written against an earlier release treated that 202 as a completed login. See the MFA section above and ADR-0071.

This package ships only dist/ and this README. The generated-vs-handwritten split decision is ADR-0001, and ADR-0032 / ADR-0071 are referenced throughout — all of them live in the source repository, which is private, so they are not resolvable links from here.