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

@auth-craft/client

v0.2.0

Published

Contract-driven typed frontend client for Auth Craft. Methods are generated from @auth-craft/api-contract; tokens are isolated in a Web Worker. Import the root for all groups, or @auth-craft/client/core for a scope-agnostic core you wire per scope.

Downloads

325

Readme

@auth-craft/client

Typed, contract-driven frontend client for Auth Craft.

Every method is generated from @auth-craft/api-contract — the same endpoint declarations the backend routes validate against. So the client's paths, request bodies, query params, path params and response types cannot drift from the server: if a route changes and the contract is updated, your call sites re-type at build time. Tokens are isolated in a Web Worker (a vendored transport, absorbed from FetchGuard), protecting them from XSS.

One scope per app? Import @auth-craft/client/core

  • only the contract groups you need, so other scopes' code stays out of your bundle. The root export below wires every group.

Migrating from the old @auth-craft/frontend-sdk? Jump to Migration.


Install

npm install @auth-craft/client

Bundler note (Vite)

The client runs a Web Worker. With Vite, exclude it from pre-bundling:

// vite.config.ts
export default defineConfig({
  optimizeDeps: { exclude: ['@auth-craft/client'] }
})

If you bundle the SDK to plain JS (e.g. consuming from dist/), pass a workerFactory so the worker URL resolves in your app (see Config).


Quick start

import { createAuthCraftClient } from '@auth-craft/client'

const client = createAuthCraftClient({
  baseUrl: 'https://auth.example.com',
  strategy: 'cookie-auth',          // or 'body-auth'
  allowedDomains: ['auth.example.com']
})

// One-time init — boots the Worker and restores any existing session.
await client.init()

// Log in. Returns a discriminated result: tokens | challenge | mfa | verification.
const r = await client.auth.authenticate({
  body: { identityType: 'email', identityValue: '[email protected]', credentialType: 'password', credentialValue: 'secret' }
})

if (r.ok) {
  // session established → call authenticated endpoints
  const me = await client.auth.me()
}

The call shape

Every generated method takes one argument object with up to three keys — always the same shape, so you never have to remember a per-method signature:

client.<group>.<method>({
  params?: { ...pathParams },   // required only when the path has :params
  body?:   { ...requestBody },  // present only when the endpoint has a body
  query?:  { ...queryParams }   // present only when the endpoint has a query
})
  • params is required when the path contains a segment like :tenantId.
  • body is typed from the contract's request schema (missing/extra fields are a compile error).
  • query is always optional.

Every method returns Promise<Result<T>> from ts-micro-result:

const r = await client.auth.me()
if (r.ok) {
  console.log(r.data.displayName)   // typed from the contract response schema
} else {
  console.error(r.errors[0].code)   // backend error codes, forwarded as-is
}

Config

interface AuthCraftClientConfig {
  /** Base URL of the Auth Craft backend (no trailing slash needed). */
  baseUrl: string
  /** Refresh-token delivery: 'cookie-auth' (httpOnly cookie) or 'body-auth'. */
  strategy: 'cookie-auth' | 'body-auth'
  /** Hosts the Worker may attach tokens to (anti-exfiltration allow-list). */
  allowedDomains: string[]
  /** Refresh this many ms before access-token expiry (proactive). */
  refreshEarlyMs?: number
  /** Headers added to every request (e.g. an edge-gate secret). */
  defaultHeaders?: Record<string, string>
  /** Custom Worker factory — required when consuming the SDK as compiled JS. */
  workerFactory?: () => Worker
  /**
   * Where the token loop runs. 'worker' (default) isolates tokens off-thread;
   * 'inline' runs it on the main thread — no worker to configure, but tokens
   * become reachable by page XSS. See "Transport: worker vs inline" below.
   */
  transport?: 'worker' | 'inline'
  /**
   * Auth scope → sent as the `X-Auth-Context` header on every request.
   * 'system' = admin, 'tenant' = tenant-scoped, omit/'customer' = default.
   */
  context?: 'customer' | 'tenant' | 'system'
  /**
   * Subscribe to session state changes (login, logout, refresh, expiry).
   * Fires after `init()`. Bridge it into app state or trigger auto-logout
   * on `authenticated: false`.
   */
  onAuthStateChanged?: (state: { authenticated: boolean; user?: unknown; expiresAt?: number | null }) => void
}

Transport: worker vs inline

By default the client runs its token/refresh loop in a Web Worker, so the access & refresh tokens live in a separate realm the page's own JavaScript cannot reach. That is the XSS protection: even if a script is injected into your page, it has no reference to the tokens and can only talk to the loop through the same postMessage protocol you do.

Some apps can't or don't want to spawn a worker — no bundler worker support, a strict CSP, SSR/edge constraints, or simply not wanting the Vite optimizeDeps.exclude / workerFactory setup. For those, set transport: 'inline':

const client = await createClientCore({
  baseUrl: 'https://auth.example.com',
  strategy: 'cookie-auth',
  allowedDomains: ['auth.example.com'],
  transport: 'inline', // run the loop on the main thread — no Worker
})

The exact same loop runs, just on the main thread instead of off it. No worker file is loaded, so no bundler worker configuration is needed.

Security trade-off. Inline means the loop — and the tokens it holds — live on the main thread. There is no Worker realm boundary, so page XSS can reach the tokens. Prefer 'inline' only with strategy: 'cookie-auth', where the refresh token is an httpOnly cookie that never enters JavaScript anyway (so the worker was adding little for the refresh token). With strategy: 'body-auth' the refresh token lives in JS, and the Worker is its main XSS defense — keep the default transport: 'worker' there.

transport is ignored when you pass a workerFactory (the factory decides the host).


Method groups

client.<group>.<method>(...). Authenticated unless marked public.

auth

| Method | HTTP | Notes | |---|---|---| | authenticate({ body }) | POST /auth/authenticate | public, login — returns tokens / challenge / mfa / verification | | register({ body }) | POST /auth/register | public — returns { userId, verification? } (does NOT log in) | | completeChallenge({ body }) | POST /auth/complete-challenge | public, login — finishes a 2-step credential challenge | | completeMfaChallenge({ body }) | POST /auth/complete-mfa-challenge | public, login — finishes MFA | | issueMfaChallenge({ body }) | POST /auth/issue-mfa-challenge | public — send an MFA code (SMS/email) | | me() | GET /auth/me | current user profile | | selectTenant({ body }) | POST /auth/select-tenant | switch tenant context (re-issues tokens) | | logout() | POST /auth/logout | clears the session |

refresh is handled automatically by the transport (proactive, before expiry). You don't call it.

password

changePassword({ body }) · forgotPassword({ body }) (public) · resetPassword({ body }) (public)

verification

verifyCode({ body }) (public) · resendVerification({ body }) (public)

session

listSessions({ query? }) · deleteSession({ body }) · revokeToken({ body })

mfa

setupAuthenticator({ body }) · verifyAuthenticator({ body }) · listAuthenticators({ query? }) · removeAuthenticator({ body }) · updateMfaSettings({ body })

MFA verify: the code goes inside data, e.g. verifyAuthenticator({ body: { method: 'totp', data: { code: '123456' } } }).

oauth

oauthState() (public) · oauthCallback({ body }) (public, login) · linkProvider({ body }) · unlinkProvider({ body }) · getProviders()

users

updateProfile({ body })

tenant

All scoped under /tenants/:tenantId/...params.tenantId is required:

getTenantProfile({ params }) · updateTenantProfile({ params, body }) · createInvite({ params, body }) · listInvites({ params, query? }) · acceptInvite({ params, body }) · declineInvite({ params, body }) · revokeInvite({ params, body }) · listMembers({ params, query? }) · updateMember({ params, body }) · removeMember({ params, body })

await client.tenant.listMembers({ params: { tenantId: 'org-123' } })
await client.tenant.createInvite({
  params: { tenantId: 'org-123' },
  body: { inviteeEmail: '[email protected]', roles: ['member'], authPerms: ['tenant.member.view'] }
})

client.api — authenticated requests to your own APIs

The typed groups above only cover Auth Craft's routes. Your app still has its own backend (commerce, console, billing, …) that the same logged-in user must call. client.api is that escape hatch: a raw HTTP transport that rides the same Worker-isolated session — tokens stay out of JS, refresh is shared, and you get a deserialized Result<T> back.

// any URL you build — points at YOUR backend, not the auth server
const r = await client.api.post<{ items: Product[] }>(
  'https://api.example.com/core/products',
  { name: 'Tee', price: 1900 }
)
if (r.ok) r.data.items   // typed by the <T> you supply

await client.api.get<Plan>('https://api.example.com/core/plans/current')
await client.api.delete('https://api.example.com/core/team/members/remove', { userId })
interface ApiTransport {
  get<T = unknown>(url: string, options?): Promise<Result<T>>
  post<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
  put<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
  patch<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
  delete<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
}
  • URLs pass through as-is — build absolute URLs to whichever backend you target. (Unlike the typed groups, api does not prepend baseUrl.)
  • You supply the type <T>api is unaware of your app's contract. For Auth Craft's own routes, prefer the typed groups, which can't drift.
  • Throws if accessed before init().

The escape hatch is deliberately outside the contract: your app's routes are not Auth Craft's concern, so they don't belong in @auth-craft/api-contract. api gives you the authenticated session; you own the routes.


@auth-craft/client/core — per-scope isolation

The root export (createAuthCraftClient) wires every group, so it can't be tree-shaken per scope. A web app is almost always one scope (storefront = customer, console = tenant, admin = system). For those, import the scope-agnostic core and wire only the groups you need — other scopes' code then stays out of your bundle and your dev call surface:

import { createClientCore } from '@auth-craft/client/core'
import { authContract } from '@auth-craft/api-contract/auth'

const core = await createClientCore({
  baseUrl, strategy: 'cookie-auth', allowedDomains, context: 'customer'
})
const auth = core.group(authContract)          // typed, drift-proof
await core.api.get('https://api.example.com/core/products')  // your own backend

core.group(contract) attaches a typed client for one group; core.api, onAuthStateChanged, context, init()/destroy() work exactly as on the full client. A tenant console just wires more groups (@auth-craft/api-contract/tenant).

Verified: a customer app built this way contains zero tenant/mfa/oauth route code. "Not in the bundle" ≠ "not on disk" — the unused group files still live in node_modules/@auth-craft/api-contract, just unreferenced. That's isolation at the call surface, which is the point.

Keeping it enforced (two guards)

  1. Library guard (always on): a test in this package fails if the core (src/core) ever imports a contract group or the api-contract root — so the isolation can't silently rot for downstream apps.

  2. App guard (optional, your linter): stop an app importing a scope it doesn't own.

    ESLint — a helper ships here:

    // app/eslint.config.mjs
    import tseslint from 'typescript-eslint'
    import { authCraftIsolation } from '@auth-craft/client/eslint'
    export default tseslint.config(
      authCraftIsolation({ allow: ['auth', 'password', 'verification', 'mfa', 'oauth', 'session', 'users'] })
    )

    Omit a group (e.g. tenant) and importing it — or the all-groups root @auth-craft/client, or the api-contract root — becomes an error.

    Biome — no shipped preset (Biome config is JSON, not composable), but the same intent is noRestrictedImports in biome.json:

    {
      "linter": { "rules": { "nursery": { "noRestrictedImports": { "level": "error", "options": { "paths": {
        "@auth-craft/client": "Use @auth-craft/client/core + per-group subpaths; the all-groups root can't be tree-shaken.",
        "@auth-craft/api-contract": "Import a focused group (e.g. /auth), not the root.",
        "@auth-craft/api-contract/tenant": "This app's scope does not include 'tenant'."
      } } } } } }
    }

    (Rule placement — nursery vs stable — follows your Biome version.)

    Neither linter? No guard is strictly required: an app that never imports @auth-craft/api-contract/tenant simply doesn't have it; the guards only catch accidental imports.


Login: handling challenge, MFA & verification

A login call (authenticate, completeChallenge, oauthCallback) resolves to a discriminated union — tokens, a credential challenge, an MFA requirement, or an identity-verification requirement. Use the exported type guards to branch:

import {
  isAuthTokens,
  isChallengeRequired,
  isMfaRequired,
  isVerificationRequired,
} from '@auth-craft/api-contract'

const r = await client.auth.authenticate({
  body: { identityType: 'email', identityValue: '[email protected]', credentialType: 'otp' }
})
if (!r.ok) { /* real error */ return }

if (isChallengeRequired(r.data)) {
  // e.g. emailed OTP — collect the code, then:
  const done = await client.auth.completeChallenge({
    body: { challengeId: r.data.challenge.data.challengeId as string, code: userCode }
  })
} else if (isMfaRequired(r.data)) {
  // collect MFA code for one of r.data.mfa.methods, then:
  const done = await client.auth.completeMfaChallenge({
    body: { challengeId: r.data.mfa.challengeId, method: r.data.mfa.methods[0], code: userCode }
  })
} else if (isVerificationRequired(r.data)) {
  // credentials were correct but the login identity (email) is not verified yet.
  // r.data.verification has { challengeId, expiresAt (ISO), identityValue, ... }.
  // Prompt the user to enter the emailed code, then verify it:
  const done = await client.verification.verifyCode({
    body: { challengeId: r.data.verification.challengeId, code: userCode }
  })
  // expired or lost the code? client.verification.resendVerification(...) issues a new one.
} else if (isAuthTokens(r.data)) {
  // session established
}

Scopes (customer / tenant / system)

Set context so the backend reads the right refresh-token cookie / scope:

const adminClient = createAuthCraftClient({ /* ... */, context: 'system' })

This stamps X-Auth-Context: system on every request. (In a deployment with the Cloudflare BFF gateway, the gateway is the source of truth for scope; the header is the local-dev convention.)


Lifecycle

await client.init()      // boot Worker; idempotent enough to call once at startup
client.destroy()         // terminate the Worker and clear in-memory state
client.getTransport()    // advanced: the raw FetchGuard transport handle

createAuthCraftClient(config) returns the client without initializing — call await client.init() yourself. (createClientCore(config) from @auth-craft/client/core initializes for you and resolves a ready core.)


Why it can't drift

| Concern | Guaranteed by | |---|---| | Path / path params | shared contract.path; a CI test diffs every contract path against the live route registry | | HTTP method | same registry diff (method + path) | | Request body / query | InferInput of the contract schema — wrong/missing fields are a compile error | | Response shape | InferOutput of the contract response schema; a CI test fails if a data endpoint lacks one | | Method coverage | methods are generated from the contract group (buildClient iterates its keys) — a new endpoint appears automatically; there is no hand-maintained list to fall behind | | Server reads the same shape | the backend route's ctx.body/query/params are typed from the same contract schema; renaming a field breaks the client call site and the route handler in one tsc run | | Per-method transform bugs | there are none — the envelope→Result<T> step lives once in the transport |


Migration from @auth-craft/frontend-sdk

The old @auth-craft/frontend-sdk was hand-written (per-method paths/types), which let request/response shapes drift from the server. This client supersedes it: the auth methods are now generated from the contract (drift-proof), and raw authenticated HTTP moves to client.api. The package name and factory change, and every auth call uses the uniform { body / params / query } shape.

Setup

// OLD
import { createAuthCraft } from '@auth-craft/frontend-sdk'
const client = createAuthCraft({ baseUrl, strategy, allowedDomains })
await client.init()

// NEW
import { createAuthCraftClient } from '@auth-craft/client'
const client = createAuthCraftClient({ baseUrl, strategy, allowedDomains })
await client.init()

Call-site changes (the important part)

| Old | New | |---|---| | client.auth.authenticate(req) | client.auth.authenticate({ body: req }) | | client.auth.me() | client.auth.me() (unchanged) | | client.password.change(req) | client.password.changePassword({ body: req }) | | client.password.forgot(req) | client.password.forgotPassword({ body: req }) | | client.password.reset(req) | client.password.resetPassword({ body: req }) | | client.verification.verify(req) | client.verification.verifyCode({ body: req }) | | client.verification.resend(req) | client.verification.resendVerification({ body: req }) | | client.session.list(includeRevoked) | client.session.listSessions({ query: { includeRevoked: 'true' } }) | | client.session.delete(req) | client.session.deleteSession({ body: req }) | | client.session.revoke(req) | client.session.revokeToken({ body: req }) | | client.mfa.setup(req) | client.mfa.setupAuthenticator({ body: req }) | | client.mfa.verify({ method, code }) | client.mfa.verifyAuthenticator({ body: { method, data: { code } } }) ⚠️ code in data | | client.mfa.list(opts) | client.mfa.listAuthenticators({ query: opts }) | | client.mfa.delete({ method, id }) | client.mfa.removeAuthenticator({ body: { method, authenticatorId } }) ⚠️ authenticatorId | | client.mfa.updateSettings(req) | client.mfa.updateMfaSettings({ body: req }) | | client.oauth.callback(req) | client.oauth.oauthCallback({ body: req }) | | client.oauth.link(req) | client.oauth.linkProvider({ body: req }) ⚠️ no accessToken field | | client.oauth.unlink(req) | client.oauth.unlinkProvider({ body: req }) | | client.oauth.listProviders() | client.oauth.getProviders() | | (none) | client.oauth.oauthState()new, required for the CSRF redirect flow | | client.user.updateProfile(req) | client.users.updateProfile({ body: req }) | | client.tenant.createInvite(req) | client.tenant.createInvite({ params: { tenantId }, body }) ⚠️ tenantId in path | | client.tenant.listMembers() | client.tenant.listMembers({ params: { tenantId } }) ⚠️ tenantId in path | | client.tenant.getProfile() | client.tenant.getTenantProfile({ params: { tenantId } }) | | client.api.get/post(...) (raw) | client.api.get/post(...)unchanged surface, but on client.api (the old SDK exposed these on the root client) | | client.tenant.checkPendingInvites(req) | not on the typed tenant group (no public auth-route). If your app exposes it on its own API, call it via client.api.post('<your-url>', req). |

Behaviour changes you must be aware of

  • Tenant routes are path-scoped. Every tenant method now requires params.tenantId. The old client called /tenant/... (no id) and 404'd.
  • MFA codes go in data, and the field is authenticatorId (not id).
  • OAuth never accepts accessToken — the backend rejects it (token substitution). Use code or credential. For the redirect/code flow, call oauth.oauthState() first and forward the returned state to the provider.
  • Permissions are string arrays (roles, authPerms), not numeric masks.
  • Login returns a union — branch with isAuthTokens / isChallengeRequired / isMfaRequired / isVerificationRequired (see above). The old client returned ad-hoc shapes per path.
  • refresh is automatic — remove any manual refresh calls.
  • Raw authenticated requests moved to client.api. The old SDK exposed client.get/post/put/patch/delete (and getFetchGuard()) on the root client for hitting your own backend. These now live on client.api with the same signatures (Result<T> in, Result<T> out) — just change client.post(...)client.api.post(...). Auth-route calls should move to the typed groups instead.
  • onAuthStateChanged is unchanged — still a config callback, same { authenticated, user?, expiresAt? } payload; wire your auto-logout there.

Errors

Both old and new return Result<T> from ts-micro-result, so if (r.ok) { r.data } else { r.errors } is unchanged. The new client forwards the backend's error codes verbatim.