@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-tsQuick 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 answers202with the sameMfaRequiredbody thatlogin()handles, and the grant it mints is redeemable throughcompleteMfaChallenge()like any other. - Recovery:
POST /recovery,/recovery/{id}/factors,/recovery/{id}/complete,/recovery/codes, andPOST /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/profileis the exception: it is a server-side scoped read and is exposed asgetRpProfile().
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.
