@waukeshamakerspace/roster-client
v0.1.0
Published
One HTTP client for Roster, the Waukesha Makerspace person system of record: people, roles, lookup, search and login-time resolve, with the wire quirks (API key header, HTML-at-200 origin, envelope, camel/snake asymmetry) handled once
Maintainers
Readme
@waukeshamakerspace/roster-client
One HTTP client for Roster, the Waukesha Makerspace person system of record.
Four services (gatehouse, journeyman, atlas, keymaster) each grew their own
Roster client. All four sent the same X-API-Key header to the same
/api/people routes, three of them separately normalised the same wire
asymmetry, only one guarded against the origin answering HTML at 200, and only
one had a timeout and retries. This package is that code, once: the wire
mechanics are fixed, and what legitimately differed between the apps (what to
do when Roster cannot answer, retry, timeout, caching) is an option.
Zero runtime dependencies: global fetch on Node 20 or later.
Install
npm install @waukeshamakerspace/roster-clientInside the web-platform monorepo, depend on it with "workspace:*".
Use
import { HttpRosterClient } from '@waukeshamakerspace/roster-client';
const roster = new HttpRosterClient({
apiKey: process.env.ROSTER_API_KEY ?? '',
// baseUrl defaults to https://roster.waukeshamakers.com
});
const roles = await roster.rolesFor(personId); // ['member', 'board']
const person = await roster.getPerson(personId); // { id, displayName, email, roles, ... } or nullCode against the RosterClient interface and inject the implementation, so
tests can hand in the StubRosterClient below.
Methods
Every method sends X-API-Key, checks the content-type before parsing
(the Roster origin serves its admin SPA as HTML with status 200 for unknown
paths), unwraps the { success, data } envelope and treats success: false
as a failure, and normalises Roster's list/detail asymmetry (snake_case
display_name plus role strings on the list route, camelCase displayName
plus role grant objects on the detail route) into the types in types.ts.
| Method | Endpoint | Returns | Empty value under the null posture |
| --- | --- | --- | --- |
| rolesFor(id) | GET /api/people/:id/roles | string[] \| null | null |
| getPerson(id) | GET /api/people/:id | Person \| null (null on 404 in both postures) | null |
| displayNames(ids) | GET /api/people?ids=a,b (100 per request, batched above that) | Map<id, string \| null> (unknown ids map to null) | a map of nulls |
| search(query, { limit, offset, role }) | GET /api/people?search= | { items: PersonListItem[], total } | { items: [], total: 0 } |
| lookup(criteria) | GET /api/people/lookup | LookupMatch[] (bare rows, no emails or roles) | [] |
| resolve(input) | POST /api/people/resolve (needs the roster:resolve scope) | { personId, linkStatus, created } | always throws |
| isActiveMember(id) | derived from rolesFor | boolean | always false (fail closed, never throws) |
Round trips that cannot succeed are skipped: lookup with no usable
criterion (Roster would answer 400), search with an empty query and no
role filter (a role on its own is a legitimate "list the holders"), a
blank id, an empty id list.
Options
new HttpRosterClient({
apiKey, // required; the caller reads its own env var
baseUrl, // default https://roster.waukeshamakers.com; trailing slash stripped; do not append /api
fetchFn, // injectable for tests
timeoutMs, // per attempt, via AbortSignal.timeout; undefined (default) means none
retry, // off by default; {} turns on keymaster's backoff with every default
cache: {
rolesTtlMs, // 0 (default) disables
namesTtlMs, // 0 (default) disables
cacheFailures, // default false; true also caches a failed answer as null for the TTL
},
unavailable, // 'throw' (default) or 'null'; each call can override
rolesNotFound, // 'unavailable' (default, follows the posture) or 'empty' (a 404 on roles is [])
onUnavailable, // called when a failure is swallowed under the null posture; default console.warn
});The posture: unavailable
"Unavailable" is every way of not getting a clean answer: a transport failure
or timeout, a non-2xx status (other than the 404s named above), a non-JSON
body, an unsuccessful envelope, a missing API key. Under 'throw' the method
raises a RosterError (one class, with status, code and cause). Under
'null' it calls onUnavailable and returns its empty value from the table.
The posture is a constructor default with a per-call override, because an
app can need both: gatehouse's ingest path must never 500 on a Roster blip
(lookup and search are soft) while its live admin re-verification must
fail closed (rolesFor and getPerson are hard).
await roster.lookup({ email }, { unavailable: 'null' });
await roster.rolesFor(id, { unavailable: 'throw' });Only a RosterError is ever swallowed; any other error is a bug and
propagates whatever the posture. resolve always throws, and
isActiveMember never does.
Retry
retry: {} turns on the policy keymaster used: 3 attempts, 1 s initial delay
doubling to a 10 s cap, plus or minus 25 percent jitter. It retries an HTTP
5xx or 429 and a network-level failure (fetch rejecting with a TypeError). It
does not retry a 4xx, a non-JSON body, an unsuccessful envelope, or a
per-attempt timeout; pass isRetryable to change that. Every field of
RetryOptions (maxAttempts, initialDelayMs, maxDelayMs,
backoffMultiplier, jitter, isRetryable, onRetry) is optional.
Caches
Both caches are per client instance and keyed by person id. rolesTtlMs
caches rolesFor (and therefore isActiveMember); namesTtlMs caches
displayNames, including the ids Roster did not know. With
cacheFailures: true a failed answer is cached as null for the TTL too,
so a down Roster is not hammered by a burst of writes; that is atlas's
choice, and it means a blip denies for one TTL. clearCache() drops both.
The four apps as worked examples
Each app keeps the posture it has today and keeps reading its own env var.
// gatehouse: ingest reads soft, admin re-verification hard, no timeout today
const roster = new HttpRosterClient({
apiKey: process.env.ROSTER_API_KEY ?? '',
baseUrl: process.env.ROSTER_URL,
});
await roster.lookup(criteria, { unavailable: 'null' });
await roster.search(query, { limit: 10, unavailable: 'null' });
await roster.getPerson(id); // throws on an outage, null on a 404
await roster.rolesFor(id); // throws on an outage: fail closed
// journeyman: names and roles soft, member search hard, names cached 15 min
const roster = new HttpRosterClient({
apiKey: process.env.KEYMASTER_API_KEY ?? '',
baseUrl: process.env.ROSTER_URL,
unavailable: 'null',
cache: { namesTtlMs: 15 * 60_000 },
});
await roster.displayNames(ids);
await roster.rolesFor(id); // null means "could not check": the write path must deny
await roster.search(query, { role: 'member', limit, offset, unavailable: 'throw' });
// atlas: roles soft, 1.5 s timeout, 45 s cache that also remembers failures
const roster = new HttpRosterClient({
apiKey: process.env.ROSTER_API_KEY ?? '',
baseUrl: process.env.ROSTER_URL,
unavailable: 'null',
timeoutMs: 1_500,
rolesNotFound: 'empty',
cache: { rolesTtlMs: 45_000, cacheFailures: true },
});
await roster.isActiveMember(sub); // false on any failure: guards high-stakes writes
// keymaster: resolve at login with retries and a 5 s timeout, always throws
const roster = new HttpRosterClient({
apiKey: config.roster.apiKey,
baseUrl: config.roster.url,
timeoutMs: 5_000,
retry: { onRetry: (error, attempt, delay) => console.warn(`Roster retry ${attempt} after ${delay}ms`, error) },
});
const { personId, linkStatus } = await roster.resolve({ provider, providerUserId, email, emailVerified });Testing against it
StubRosterClient implements RosterClient in memory. Arrange answers with
the given* builders, then assert on the recorded calls (lookupCalls,
searchCalls, getPersonCalls, rolesForCalls, displayNamesCalls,
resolveCalls), for example that Roster was not consulted when a local
mapping already covered an event.
import { RosterError, StubRosterClient } from '@waukeshamakerspace/roster-client';
const roster = new StubRosterClient();
roster.givenEmailMatches('[email protected]', [{ id: 'p1', displayName: 'Ada', wildApricotId: null, keymasterUserId: null }]);
roster.givenPerson('p1', { displayName: 'Ada', email: '[email protected]', roles: ['member'] });
roster.givenRoles('p2', ['board']);
roster.givenSearchMatches('ada', [item]);
roster.givenResolve({ personId: 'p1', linkStatus: 'active', created: false });
roster.givenRolesFail(new RosterError('Roster is down', { status: 503 }));Failures honour the same posture as the real client: under 'throw' (the
default, or new StubRosterClient({ unavailable: 'null' }) to change it) a
given*Fails error is thrown as given; under 'null' the method returns its
empty value. reset() forgets everything.
Consumers that inject their own fetchFn can drive HttpRosterClient
directly; this package's own tests do exactly that.
Types
Person (detail), PersonListItem (list), LookupMatch (lookup's bare
rows), Role (a grant as Roster returns it), LookupCriteria,
SearchOptions, SearchResult, ResolveInput, ResolveResult,
CallOptions, Unavailable, RosterClientOptions, CacheOptions,
RetryOptions. roleNames(value) is the role normaliser (strings, grant
objects, or a mix) for anyone parsing a Roster payload by hand.
