@nexys/user-management-client
v0.0.26
Published
Typed TypeScript client for the Nexys user-management API, generated from the server's oRPC contract (@orpc/client). No React, no DOM.
Readme
@nexys/user-management-client
Typed TypeScript client for the user-management-rs API, built on
@orpc/client. No React. Every API procedure is
a method, calls return Result, nothing throws. The only
browser-dependent surface is the optional passkey ceremony, which is
feature-detected — everything else runs anywhere.
Nothing here is hand-written per endpoint: procedure names, inputs and
outputs come from src/generated/procedures.ts,
which the server generates from its Rust contract
(apps/server/crates/um-server/src/contract). The same contract produces
the OpenAPI document (/openapi.json, docs/openapi.{json,yaml}) and the
Scalar reference at /docs, so the three can't drift. Regenerate after a
server change with:
UPDATE_CONTRACT=1 cargo test -p um-server --lib contract_artifacts_in_syncUsage
import { createClient } from "@nexys/user-management-client";
const um = createClient({
baseUrl: "https://auth.example.com",
onUnauthorized: () => location.assign("/sign-in"),
});
const res = await um.signIn({ email, password });
if (res.ok) {
console.log("welcome", res.data);
} else if (res.error.kind === "network") {
toast("Can't reach the server.");
} else {
// res.error.kind === "http"
toast(res.error.message);
if (res.error.code === "INVALID_EMAIL_OR_PASSWORD") highlightFields();
}Calls go over oRPC: POST <baseUrl>/rpc/<procedure>, with the
procedure's path parameters, query and body merged into one input object
(um.getProject({ projectId }), um.updateProject({ projectId, name })). The
procedure names are the OpenAPI operationIds. Cookies are sent
(credentials: "include").
Procedures without input take no argument; inputs whose fields are all optional may be omitted:
const me = await um.me();
const tenants = await um.myTenants();Errors keep the REST envelope: res.error.code is the server's error
(or code) field, res.error.status the HTTP status.
The throwing @orpc/client client is available as um.orpc for code
that prefers exceptions or wants oRPC integrations (e.g. TanStack Query):
const me = await um.orpc.me(); // throws ORPCError on failureTypes: ProcedureName, ProcedureInput<"getProject">,
ProcedureOutput<"getProject">, and every wire type by name
(MeResponse, TenantMember, …).
Passkeys (WebAuthn)
Passkey register/sign-in is a two-round-trip ceremony (start → the
browser's navigator.credentials → finish) with base64url ⇄
ArrayBuffer wiring in the middle. The passkeys(client) facade does all
of it, so integrating is three lines. Like everything else here it
returns Result and never throws — a browser that can't do WebAuthn, or
a user who cancels the prompt, comes back as { ok: false }.
import { createClient, isConsentChallenge, passkeys } from "@nexys/user-management-client";
const um = createClient({ baseUrl: "https://auth.example.com" });
const pk = passkeys(um);
// Passwordless sign-in (discoverable credentials):
if (pk.supported()) {
const res = await pk.signIn(); // or pk.signIn({ email })
if (res.ok) {
if (isConsentChallenge(res.data)) {
// account owes a blocking consent — complete with acceptConsentChallenge
} else {
// session cookie is set; res.data.user is the signed-in user
}
} else if (res.error.kind === "passkey" && res.error.reason === "cancelled") {
// user dismissed the platform prompt — not an error to shout about
} else {
toast(res.error.message); // http / network / ceremony
}
}
// Register a passkey for the signed-in user (cookie auth):
await pk.register({ name: "MacBook Touch ID" });
// Manage:
const list = await pk.list(); // Result<PasskeyItem[]>
await pk.remove(passkeyId);For autofill UI, mark the username field
autocomplete="username webauthn" and start a conditional request:
if (await passkeyAutofillAvailable()) {
void pk.signIn({ mediation: "conditional" }); // resolves when a passkey is picked
}The raw procedures (passkeyRegisterStart/Finish,
passkeySignInStart/Finish, listPasskeys, deleteOwnPasskey) and
the low-level ceremony helpers (createPasskeyCredential,
getPasskeyAssertion, browserSupportsPasskeys) are there too if you
need to drive the flow yourself.
Testing your app
@nexys/user-management-client/testing fakes the API by procedure name,
over the real oRPC wire format:
import { mockRpc, reply } from "@nexys/user-management-client/testing";
const { client, calls } = mockRpc((procedure, input) =>
procedure === "me" ? reply({ error: "unauthorized" }, 401) : reply({ projects: [] })
);Tests
bun test # hermetic tests against an in-memory oRPC server (+ a stubbed WebAuthn env)
bun run typecheckReleasing
Publishing runs in CI (.github/workflows/publish-client.yml, needs the
NPM_TOKEN repo secret). One vX.Y.Z tag releases the server and every
npm package at the same version, and release.yml commits that version
into the manifests on main:
git tag v0.4.0 && git push origin v0.4.0The workflow typechecks, tests, builds dist/, rewrites the package
entries (main/types/exports) to point at dist/ and runs
npm publish --access public.
Inside this repo the package entries stay on the TS source, so the
workspaces (apps/web's vite build, packages/web, apps/e2e) keep
importing src/ directly — only the published artifact is dist-based
(with a bun export condition so bun consumers still get the source).
