@mantequilla-soft/butrauth-client
v0.5.0
Published
Node.js client for Butter Auth — OAuth 2.0 + Hive identity bridge
Readme
@mantequilla-soft/butrauth-client
Tiny Node.js client for Butter Auth — OAuth 2.0 Authorization Code + PKCE, RS256 access tokens, Hive identity bridge.
Zero runtime dependencies. Node ≥ 18 (uses global fetch and node:crypto).
TL;DR
What this is. A small Node library so your server does not have to hand-roll an OAuth client. It builds the redirect URL, swaps the code for a token, and verifies tokens offline.
Why use it rather than raw fetch. Two of the fiddly parts are done for you and are easy to get subtly wrong by hand: PKCE (generating the verifier and challenge correctly) and token verification (checking an RS256 signature against the right key, rejecting the wrong issuer, and refusing a token with no expiry). Verification is local, so it costs no network call.
Where it runs. Your backend, never the browser — it uses your client secret.
sequenceDiagram
participant A as Your backend<br/>(this SDK)
participant U as User's browser
participant B as Butter Auth
A->>A: createAuthUrl()<br/><i>makes PKCE verifier + challenge</i>
A->>U: redirect them
U->>B: signs in, links or creates account
B->>U: back to you with ?code=
U->>A: hands you the code
A->>B: exchangeCode(code, verifier)
B->>A: access_token + username
A->>A: verifyAccessToken(token)<br/><i>offline: RS256 + issuer + expiry</i>Requirements
- Node 18 or newer — the SDK uses global
fetchandnode:crypto. - Zero runtime dependencies.
- A registered app:
client_id,client_secret, and aredirect_urithat matches exactly what you registered.
Install
npm install @mantequilla-soft/butrauth-clientUsage
import { ButrAuthClient } from '@mantequilla-soft/butrauth-client'
const butr = new ButrAuthClient({
baseUrl: 'https://butrauth.com',
clientId: process.env.BUTRAUTH_CLIENT_ID,
clientSecret: process.env.BUTRAUTH_CLIENT_SECRET
})
// 1. Build the redirect URL (in your /login handler)
const { url, codeVerifier, state } = butr.createAuthRequest({
redirectUri: 'https://my-app.com/callback'
})
// → store `codeVerifier` in an httpOnly cookie or server session
// → res.redirect(url)
// 2. Exchange the code (in your /callback handler)
const tokens = await butr.exchangeCode({
code: req.query.code,
redirectUri: 'https://my-app.com/callback',
codeVerifier // from the cookie/session
})
// → { accessToken, tokenType, expiresIn, refreshToken, refreshExpiresIn,
// username, incubation, handle }
//
// ⚠️ `username` is NULL when `incubation` is true — the person has signed in and
// picked a public name but has no Hive account yet. Every new signup starts
// that way, so this is the common case, not an edge one. See "Users with no
// Hive account" below.
// 3. Verify the token whenever you need to authenticate a request
const claims = await butr.verifyAccessToken(tokens.accessToken)
// → { userId, hiveUsername, clientId, issuedAt, expiresAt, incubation, handle }
// 4. Access tokens last one hour. When one expires, refresh instead of sending
// the user back through the authorize flow. Store the NEW refresh token.
const renewed = await butr.refreshAccessToken(storedRefreshToken)The full HTTP contract is documented in ../INTEGRATION.md.
A runnable Express example lives in example.js.
API
new ButrAuthClient({ baseUrl, clientId, clientSecret })
client.createAuthRequest({ redirectUri, scope?, state?, invite?, referrer? })
Returns { url, codeVerifier, state }. The caller must persist codeVerifier
until exchangeCode runs. invite and referrer hand a referral to the signup
screen; see Invite links below.
client.exchangeCode({ code, redirectUri, codeVerifier })
Returns { accessToken, tokenType, expiresIn, refreshToken, refreshExpiresIn,
username }. Throws ButrAuthError on any failure.
client.refreshAccessToken(refreshToken)
Exchanges a refresh token for a fresh access token (RFC 6749 §6), so a session
outlives the one-hour access token without another authorize round-trip. Returns
the same shape as exchangeCode.
Refresh tokens are rotated: the token you pass in is spent, and the
refreshToken on the response is the only one that will work next time — always
persist it, replacing the old value. Replaying a spent token is treated as a
stolen credential and revokes the entire rotation chain, so the user has to log
in again. Store refresh tokens like passwords: server-side, never in the browser.
client.verifyAccessToken(token)
Verifies the RS256 JWT against Butter Auth's public key (cached for one hour) and
returns the decoded claims. Throws ButrAuthError on bad signature, expiry,
wrong audience, or wrong token type.
client.getPublicKey()
Returns the RSA public key (PEM). Mostly internal — call verifyAccessToken
instead.
client.requireAuth({ optional?, requireHiveAccount? })
Returns Express-style middleware. Reads Authorization: Bearer <token>,
verifies the JWT, and attaches the claims to req.butrAuth.
import express from 'express'
const app = express()
// Protect a route — 401 if the token is missing/invalid.
app.get('/me', butr.requireAuth(), (req, res) => {
res.json(req.butrAuth) // { userId, hiveUsername, clientId, incubation, handle, ... }
})
// Optional: lets anonymous requests through with req.butrAuth = null.
app.get('/feed', butr.requireAuth({ optional: true }), (req, res) => {
res.json({ user: req.butrAuth, posts: [] })
})
// Mount this on EVERY route that signs or broadcasts. 403 (not 401) for someone
// with no Hive account: they are correctly authenticated, they just have no
// account to act as yet.
app.post('/api/broadcast', butr.requireAuth({ requireHiveAccount: true }), handler)client.resolveHandles(handles)
Resolve incubation handles to their current identity, auto-chunked at 100.
Returns [{ handle, userId, status, hiveUsername }] — order is not guaranteed,
so key off handle.
client.reportIncubationProgress(userId, percent)
Tell Butter Auth how far one of your users is towards whatever you ask of them, so their app owner sees a progress bar in the activation queue. Opaque and advisory: it grants nothing and approves nobody.
client.getIncubationStatus(accessToken)
The caller's own incubation state — handle, approval, whether they can create an account yet — for rendering a "you're nearly there" prompt.
client.getReferrer(username)
Who referred this user. Pass a Hive username or a warm-up handle — they are interchangeable, and after graduation the same person answers to both, so a referral recorded under the handle is still found under the account.
const { referredBy } = await butrauth.getReferrer('alice')
// { username: 'alice', referredBy: 'bob', referredAt: '2026-09-14T…' }referredBy is null both for someone who named nobody and for a username
nobody here has ever used. That is deliberate: neither is a person you can pay.
⚠️ The referrer is a name the user typed, not a verified identity. Butter Auth checks it has the shape of a Hive name, refuses a self-referral, and stores only the first answer ever given — it does not check the account exists. Confirm it before you pay it.
client.getReferrers(usernames)
getReferrer for many users at once, auto-chunked at 100. Settling referral
credit means asking about a page of users, and the single-name call is one HTTP
request each. Every requested name comes back, in order, so you can zip the
results against your input instead of diffing two lists.
Referral links
Referrals are recorded when a user names themselves — picking a warm-up
handle or creating an account — on whichever path they take. To credit a link,
put a referrer on the authorize URL you send them to:
https://butrauth.com/authorize?client_id=…&screen_hint=signup&referrer=aliceThe naming screen then shows that name already filled in and locked, so the
person who followed the link cannot quietly change or clear the credit. 3Speak
does this from a ?ref=alice link on any of its own URLs.
Invite links
An app owner can activate trusted people (a business, a big creator) as referrers on butrauth's Referrals tab. Each referrer gets an invite link; a new user who signs up through it gets a real Hive account on day one instead of the warm-up, up to the referrer's monthly limit and the providers' budget. When either runs out they join through the warm-up, still credited.
Set your app's invite link format on the same tab, e.g.
https://yourapp.com/invite/{code}, so the links point at your site (the host
must be one your redirect URIs already use). Then:
- Your invite page reads the code and shows who invited them:
client.getInvite(code)returns{ valid, referrer, fastTrack, app }. Checkapp.clientId === yourClientId. - Keep the code until they sign up (a cookie or localStorage), and pass it on:
client.createAuthRequest({ redirectUri, invite: code }). butrauth binds it after sign-in and, if the link can fast-track them, goes straight to account creation. Sendscreen_hint=signupas usual for a new user. - A screen for your referrers:
client.getReferralLinks(account)lists the links your app gave that Hive account, withurl,limit,usedThisMonth,leftThisMonth,resetsAtand totals, andclient.getReferralPeople(account, link.id)lists who came in. Both are confidential (server side) and take the account YOUR session verified, so they also work for users who signed in with a wallet rather than butrauth. They only ever return your own app's links.
Users with no Hive account
Butter Auth can hold an identity that has signed in, picked a public name, and has no Hive account yet. The point is cost: a free account spends a real, money-backed creation token and providers cap at roughly 25 a day, so the token should follow evidence that someone will stay.
Every new signup starts this way. It is not an opt-in, and there is no per-app switch — so if your integration ignores this, it is broken for every new user, not for a rare few.
Such a token carries:
{ username: null, incubation: true, handle: 'newbie-name' }That null is deliberate, and it is the safety property. An integration written
before this existed reads username, fails its own Hive-username check, and
broadcasts nothing.
Never substitute handle for hiveUsername. A handle is a display
pseudonym for an account that does not exist; signing an operation authored by
it produces a post from an account that is not there. Keep it in its own
variable, well away from wherever you store the signed-in Hive account.
Three things to do:
- Guard every signing or broadcasting route with
requireAuth({ requireHiveAccount: true }). - Store your content rows against
userId, never the handle — a handle changes while incubating and again at graduation. Resolve for display withresolveHandles(). - Give them somewhere to put content in the meantime. That is what
@mantequilla-soft/incubation-clientis for: off-chain posts, replies, votes and follows, replayed under a real account at graduation.
The full contract is in ../INTEGRATION.md §3b.
TypeScript
Type definitions ship in the package. No @types/... install needed.
import { ButrAuthClient, type AccessTokenClaims } from '@mantequilla-soft/butrauth-client'Broadcasting on the user's behalf
Butter Auth does not broadcast for you. The user grants posting authority to your
service account on chain during the auth flow, and your backend uses its own
posting WIF to sign and broadcast operations. See
../INTEGRATION.md.
