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

@cccteam/resource

v0.0.20

Published

Framework-neutral TypeScript client for APIs generated by the ccc resource package

Readme

@cccteam/resource

A framework-neutral TypeScript client for APIs generated by the Go ccc/resource package. It has no Angular, RxJS, or @cccteam/resource-angular dependency: the core is fetch and promises, and a UI framework hooks its own reactivity onto two seams — a pluggable Transport and a subscribable permission Store.

The ccc generator emits zz_gen_api.ts next to the other zz_gen_*.ts files: a descriptor of the API (routes, scopes, keys, operations) plus the shapes only the generator can derive — per-resource Create and Patch interfaces, key tuples, and the Api type. This package interprets the descriptor at runtime, so behavior fixes ship here without regenerating applications.

import { createApi } from './zz_gen_api';

const api = createApi({ baseUrl: '/api' });

// Global resources hang off the root.
const suppliers = await api.suppliers.list({ sort: { field: 'name' }, limit: 100 }); // one page

// Domain-scoped resources exist only on a client bound to a domain; calling one
// without a domain does not compile.
const station = api.domain('ws-alpha');
const orders = await station.workOrders.list({ capabilities: ['Update', 'Delete'] });
await station.workOrderTasks.patch([orders[0].id, 1], { done: true });
await station.scheduleWorkOrder.execute({ workOrderId: orders[0].id, assignedTeamId, dueAt });

// Several operations, one transaction.
await api.batch([station.requisitions.ops.add(values), station.requisitionLines.ops.add(line)]);

// A resource kept off the consolidated handler (consolidated: false) has its own PATCH
// route; its operations remember it, so the same call reaches the right endpoint.
// Operations for two different endpoints cannot commit together and are refused.
await api.batch([api.clients.ops.patch([clientId], { trusted: true })]);

What the types enforce

  • A resource handle carries only the operations the server generated. A read-only view has list and read; a resource whose PATCH handler is suppressed has no create, patch, or remove.
  • Create shapes exclude server-owned fields (output-only, state columns, the tenant key) and a server-generated key; a client-assigned key is required.
  • Patch shapes additionally exclude immutable fields and keys.
  • Keys are named tuples in route order, so a compound key is never a hand-joined string.
  • Filters and sorts are typed against the row's fields. The server's filter grammar is available directly as a string when needed.

Descriptor compatibility

The generator and this package release in either order. A descriptor from a newer generator works with an older client: the generated file writes the descriptor as a value that its createApi hands to the client, TypeScript checks it against the fields the client declares and no others, and the client ignores a field it does not know. A newer client works with an older descriptor: every field added after the package's first release is optional (live is one, tokens another), and an absent field means the API does not serve that feature. An application does nothing about a missing field: the feature is off, api.live.enabled is false for a descriptor without live, a live call is a plain request, and every other part of the client works as before. The absence is announced rather than silent: the first live request against such a descriptor, or a live.start, reaches ClientOptions.warn once (the console by default) with a message saying the request is served plain and naming the regeneration that would serve it live. A browser build is red only when the application itself uses a client feature its generator does not emit yet.

Paging

A list is served one page at a time. list() returns one page of rows — the query's limit, or the resource's declared default (descriptor.page.default) when the query names none. page() returns the page with its neighbors: next and prev follow the server's Link relations exactly as issued, and are absent where no such page exists; total answers a count: true request on a first page from the Total-Count header; reload() repeats the request that produced the page — the first page's parameters, or the cursor a relation handed out — so a table refreshes the page it is on after a write. Every paged request carries an order: the resource's declared @order (descriptor.order), or a sort on the request. The server refuses a paged request with neither (400, naming the resource and the way out), so a page never hides that more rows exist without saying where they are, and every page has the relations that exist.

let page = await station.missions.page({ sort: { field: 'deadline' }, limit: 25, count: true });
console.log(page.total); // every mission the filter admits
while (page.next) {
  page = await page.next(); // the server's own URL, cursor included
}

The resource's declared maximum page size (descriptor.page.max) is the switch every reader of a whole set reads, and the author's deliberate choice. No maximum: the resource is small enough to load, and list({ limit: 'all' }) answers every row in one request, sorted or not — the one order-free list; a picker over such a source reads it whole and resolves the chosen row from the list, so a source with no read route serves it. A maximum: the resource is read one server page at a time and never whole; a picker pages it with Previous and Next by the server's cursors, sorted by its @order or else by the picker's display column, and reads the chosen row by key (a source with a maximum serves a read; the generator refuses one that does not); a lookup that resolves display values for a page of keys asks for one filter=id:in:(…) page over them, served by the key's index. There is no operation that reads every row of a bounded resource: the maximum was declared to keep the whole set out of memory, and nothing in the client walks past it. readMode(descriptor) answers 'whole' or 'paged', and wholeListQuery, pickerSort, keyBatches, and keyLookupQuery are the requests each side makes, shared by every reader.

const hulls = await api.shipClasses.list({ limit: 'all' }); // no maximum: the whole catalog
const hangars = await sector.hangars.page({ count: true }); // a maximum: one page, Previous and Next

A resource whose descriptor lists keys: [] (a @computed or @virtual struct with no @primarykey) is served whole on every request: its one page is the whole list, with no neighbors, and the client sends no limit and no cursor for it, whatever a caller typed past the type, since the server refuses both. It has no read route and no row identity, so its handle offers list alone. The Angular list page over such a resource is described in the @cccteam/resource-angular README ("A list page over a key-less resource").

const orders = await api.standingOrders.list(); // keys: [] — the whole book, in one response

A filter naming a PII field must not travel in a URL: pass sensitiveFilter: true and the list becomes a POST carrying the filter in its body, with paging relations followed the same way.

A page position is a sealed cursor the server issues; a client never assembles one, and a cursor presented with a different filter, sort, limit, or tenant is refused. offset no longer exists. A resource over a declared maximum page size refuses the request (400) rather than clamping it, and refuses limit: 'all'.

A filter may name only the fields the server filters, and the generated field metadata says which: FieldMeta.filterable is 'always' on an indexed table or view field and on a computed resource's allow_filter field, 'withIndexed' on a table or view allow_filter field — accepted only when the same filter also names an 'always' field, since indexes are scarce and one is enough to narrow the rows — and absent on a field a filter may not name. A table draws a filter control from it, so what it offers is what the server answers.

A text field answers startswith, endswith and contains where the schema states a fact for it, and FieldMeta.search lists the matches it answers: a stored LOWER(col) column leading an index answers startswith; a token column under the table's search index answers contains and the anchors its tokenizer declares; a computed resource's allow_filter text field answers all three in memory. A match stands alone whatever filterable says, and counts as the indexed touch a 'withIndexed' companion needs. A resource whose table carries a search index answers the search box, searchBox('nguyen 1987 4321'), rendered *:search:"nguyen 1987 4321": every word must be found in one of the resource's search fields the caller may list (ResourceMeta.searchFields, a child's field under its dotted name, clientContact.displayName), a denied field drops out silently, and a conditional one is searched only where its condition holds. The box is one conjunct of the filter's top-level AND, never inside an OR, at most one per filter, and at most eight words of 64 characters, 200 in all (searchLimits). A field match on a PII field keeps the URL rule above; a box that reaches a PII field travels in the body, so send it with sensitiveFilter: true. The metadata says which: FieldMeta.pii marks a PII field, and ResourceMeta.searchBranches describes each dotted search field (the child resource and field whose List grant governs it, pii when that field is PII). The serializer quotes a value where the grammar would otherwise read part of it (a comma, a pipe, a parenthesis, a quote, a backslash, a leading or trailing space, the empty value) and always quotes the box's text; quoteFilterValue is the rule. matchesSearch is the browser's copy of the rule the server applies in memory, every word found, folded, in one of the values, for a picker narrowing a list it holds whole.

Permissions

api.permissions is the session user's digest cache: one digest per scope (global, or one per domain), loaded on demand and cached for the session.

  • handle.can(permission) and method.can() answer synchronously from the cache and never fetch. granted and conditional both answer true: render the surface and let the server narrow it per row. A scope that has not been loaded answers false.
  • handle.rowCan(row, 'Update' | 'Delete') asks the row first, the digest second: a capability envelope on the row (from capabilities: [...] on the read) decides for that row; without one, the scope's digest decides. rowCan(row, 'Execute', method) answers whether the named RPC method applies to the row, and rowCan(row, 'Create', member) whether the named workflow member resource may be created beneath it (the create-under-parent affordance — the add-button gate for a child list, e.g. lines under a draft requisition).
  • handle.grantedFields(permission) is the digest's field-level enumeration — for Create, the inputs a create form is worth rendering: sorted field names whose dotted target is granted or conditional (a denied field is absent). Undefined means the digest carries no field-level entries for the permission — no field information (denied outright, or a keys-only resource) — so narrow only on a defined answer. Key fields never appear: they are structural, not grant-bearing.
  • api.permissions.loadDigest(domain?) loads a scope; loadDomains() fetches the domains where the user holds any grant; subscribe notifies a UI framework.

Never poll a check endpoint per field. Enforcement stays server-side; everything here is advisory material for what to render.

Live pages

A page that asks for a live list or a live row stays current without polling and without refetching while nothing changed, and a page left and opened again inside a short window is served by the browser with no request. Asking is a page-level decision: list pages and record pages ask; pickers, edit forms, exports, reports, and one-off lookups do not, and a request that did not ask is handled exactly as before.

What a live request opts into. The tab subscribes to changes of that row (the resource and key, and for a domain-scoped resource the domain it was read in, where the server re-checks Read; the row itself is one whatever domain it was read in) or that list (the resource in the handle's domain; filter, sort, and cursor are not part of it, so any change to the resource in the domain refetches the page). The server registers the subscription before it runs the query, and only when the request is permitted; a refused request subscribes nothing. The tab renews its subscriptions every two minutes (at most a thousand in one renewal, the server's limit), the server re-checking each against the permission digest, so a revoked grant ends a subscription at the next renewal and the refetch is refused meanwhile. The answer is cacheable by the browser for five minutes, keyed by a version the client carries in the URL: a seed the tab minted, then the timestamp of the last change seen for that row or list. A change the server publishes reaches the tab through a change feed, one listener per tab over the user's own change set, and the row or list refetches with the change's timestamp as its version, so the browser's cache answers a repeat and never a stale one.

What it costs. One feed listener per tab. One renewal request every two minutes while any live row or list is held. One request per change to a watched row or list, the whole page again for a list change. One unsubscribe when the page leaves (a keepalive request) and one at logout. The server holds no connection and keeps scaling to zero.

What it never does. It caches nothing without the version parameter, so a plain request is never served stale. It never serves one user another's cache: the seed is minted when the feed starts and again at logout. It delivers no change for a row or list the user's permitted request did not subscribe to, and the publisher checks no permission, since the subscription already did. It evaluates no filter on the client: a list change is a refetch, whatever the filter. It writes nothing to the database for versioning, and it does not work offline.

The shape. The generated descriptor says whether the outlet serves it: descriptor.live is the block naming the outlet's live routes (renewRoute, unsubscribeRoute, tokenRoute), present on every browser descriptor and absent elsewhere; without it a live call is plain and no feed can be started. list, page, and read take { live: true }. api.live is the tab's LiveSession: start(feed) fetches the identity the server minted (the token route, GET <prefix>/live/token), starts the feed with it, and refetches every watched row and list by a fresh seed, so a page mounted before the feed ran becomes live; stop() is the logout path (every subscription of the user deleted and the feed's identity revoked with { all: true }, the feed stopped, a fresh seed), run before the session is logged out; watch(subscription, refetch) holds a row or list live and returns the release; handle.subscription(key?) is the subscription a handle's list or row makes. A live request carries X-Subscribe: <tab> and _v=<version>; every request to a live route carries the header too, so each shows the tab on its request log line. The ChangeFeed interface (start, stop, onChange, onResync) and ChangeEvent (row, list, or resource, with at as unix microseconds) are the seam the feed implements; @cccteam/resource-firestore is the Firestore one, and this package depends on no SDK.

import { firestoreChangeFeed } from '@cccteam/resource-firestore';

const api = createApi({ baseUrl: '/api' });
await api.live.start(firestoreChangeFeed()); // once the session is authenticated

// A list page: the first live request subscribes the tab; the watcher refetches on a change.
const station = api.domain('ws-alpha');
let page = await station.workOrders.page({ sort: { field: 'dueAt' }, limit: 25, live: true });
const release = api.live.watch(station.workOrders.subscription(), async () => {
  page = await page.reload(); // the same page, by its cursor, with the change's version
});

// A record page: the row, by its key.
let order = await station.workOrders.read([orderId], { live: true });
const releaseRow = api.live.watch(station.workOrders.subscription([orderId]), async () => {
  order = await station.workOrders.read([orderId], { live: true });
});

release();
releaseRow();
await api.live.stop(); // at logout, before the session is logged out

Everything is refetched by a fresh seed on three occasions and no other: when the live session starts; when the feed's listener recovers after an error; and when the page becomes visible again after being hidden longer than the change document's ten-minute life, since a browser throttles or drops a background page's connection. A quiet stretch is not one of them: the listener keeps its connection and resumes after a drop, so nothing is missed while it runs. LiveOptions on createApi (live: { renewInterval, page, fetch, xsrf }) are the knobs a spec turns; every one has a default.

Feature flags

A feature can ship dark and be turned on per environment, without a release. The server holds a switch per declared flag, and the browser learns which switches are on once, when the session signs in, beside the permission digest; nothing polls, nothing is pushed, and a flip by someone else is seen at the next sign-in. The one exception is the person flipping, whose client reloads the set and the digest once the write succeeds, so their own pages follow at once.

A feature with a resource or a method behind it needs nothing here: while its flag is off the server answers not-found on its routes and leaves it out of the digest, so every surface the digest hides is hidden, and the descriptor entry carries the flag's name as feature for the route guard the Angular binding adds. A gated field is left out of responses and of the digest too, and the generated row type makes it optional; its metadata carries feature as well. The generated Feature union in zz_gen_api.ts names every declared flag, so a misspelled flag fails to compile; the library's own types take a string and the application narrows.

api.features is the enabled set. enabled(name) answers synchronously from the copy and never fetches: a flag the set does not hold is off, and so is every flag before the set has loaded, so a consumer that renders before sign-in fails closed. refresh() loads it from the descriptor's features.route (GET <prefix>/features, answering { enabled: [...] }); a failed load empties the set and rethrows. ensure(name) loads first when the set has not loaded, for a route guard. flags() reads every declared flag with its description and state from the framework's FeatureFlags resource through the client's own handle, and setFeature(name, enabled) flips one through the generated SetFeature method, then reloads the set, the domains, and every cached digest; a refused flip rejects with the server's ApiError and refreshes nothing. canSet() is the digest's Execute entry for the method. clear() forgets the set at logout, and subscribe notifies a UI framework. descriptor.features is optional: a descriptor from a generator that predates feature flags carries none, and the API then serves no flags. served is false, the set stays empty and unloaded, refresh() resolves to nothing without a request, every flag answers off, and flags() and setFeature() refuse before any request, saying the API serves no feature flags. The absence is announced rather than silent: enabled(name) and ensure(name) reach ClientOptions.warn once per flag (the console by default) saying the flag was asked of an API that serves no feature flags and answers off, refresh() says it once for every flag, and each message names the regeneration that would serve them.

await api.features.refresh(); // once the session is authenticated, beside the digest

if (api.features.enabled('debriefs')) {
  // the feature's own code, for a feature with no resource behind it
}

const flags = await api.features.flags(); // every declared flag: name, description, enabled, updatedAt, updatedBy
await api.features.setFeature('debriefs', true); // the set and the digest are current when this resolves

Sign-in

A login page renders from what the server offers, naming no path, no label and no method of its own, so the browser bundle is the same in every environment and a method that is not registered has no button. api.auth is the sign-in client: the one place that knows the wire shape of the session library's login routes.

methods() reads the methods list the generated router serves at GET <prefix>/user/methods (public, before any session, no XSRF token): the methods in the server's order, each with the name it was registered under, the library method behind it (password, google, azure, workos), its kind (password, a form the page posts; redirect, a navigation that sends the browser to the identity provider), its label and its path; and pending, the pending sign-in's status, confirmation and cancel paths, with the sign-in code's send, verification and enrollment paths on a server that sends mail. The answer is kept in api.auth.snapshot, a Store a framework mirrors into its own reactivity, so a page re-renders whenever the list is read again. A listed method of a kind the client does not drive is left out and announced once through the warn hook.

const list = await api.auth.methods();
const password = list.methods.find((method) => method.kind === 'password');
const redirects = list.methods.filter((method) => method.kind === 'redirect');

// The password form posts through the client; the result is the session, a pending step, or
// the server's refusal, resolved rather than thrown so the page renders it in place.
const result = await api.auth.signInWithPassword(password, { username, password: secret });
switch (result.outcome) {
  case 'session':  // the session is established; check it and move on
  case 'pending':  // a step waits: read api.auth.pending(), or go to result.redirectUrl when the server named one
  case 'refused':  // result.code (user_disabled, pending_expired, too_many_attempts, the application's own), result.message, and result.retryAfter when the server named a wait
}

// A redirect method is a navigation, with the page to return to.
location.assign(api.auth.signInUrl(google, '/console/missions'));
// After a refusal, or under "Use a different account": the provider shows its account chooser.
location.assign(api.auth.signInUrl(google, '/console/missions', { selectAccount: true }));

Every step that posts reads the list again first. The list's answer refreshes the XSRF cookie the server's guard checks on a POST, so a login page left open past the cookie's life signs in on the first click rather than being refused for a stale token and succeeding on the second; the re-read also re-syncs the list, and a method the server no longer offers is not posted to (the call throws, and the kept list is the one to render again). The methods request is a page's first request for the same reason: it primes the cookie.

A sign-in waits as a pending step when the identity matches a user that has a password, which must be entered before the two are linked (confirmation), or when the sign-in policy requires the application's MFA step (mfa). A redirect sign-in that waits comes back to the login page with ?pending=<reason>; a password sign-in answers pending. pending() reads the status (waiting with the reason, the email the identity asserted, when it expires and the page it returns to; none; or refused with pending_expired when the sign-in expired or was already used), confirmPending(password) posts the confirmation (the session, a further step, or a refusal: a wrong password answers 401 in the server's words and leaves the step in place), and cancelPending() discards it. A server whose list names no pending routes never holds a sign-in, and these three throw saying so.

A sign-in the policy holds for a second factor waits with the reason mfa, and when the policy accepts the mailed code the status carries emailCode, the step this client drives: the masked address the code goes to (codeEmail, n•••@fleet.example), whether a code stands (codeSent, codeExpiresAt) and whether the account holds no address yet (enrollEmail). sendCode() has the code mailed, replacing one standing, and answers sent with the address and the expiry, or the refusal (409 when the sign-in does not wait for the code or the account holds no address; too_many_attempts with the wait when the address was sent three codes within fifteen minutes); enrollEmail(email) gives the address of an account that holds none and has the code mailed to it, and the address is written on the account, verified, once its code is entered (an address another account holds is answered as sent and that account told by mail, so the screen reveals nothing); verifyCode(code, rememberDevice) enters the code and resolves as a sign-in step does, a wrong code refused with invalid_code and the code standing with one fewer attempt, a code entered while none stands (none sent; the one sent expired, was used or took its last attempt) with code_expired. With rememberDevice the server remembers the browser, and this person's next sign-ins from it skip the code for as long as the server keeps a remembered browser. A server whose pending routes name none of the three sends no mail, and they throw saying so.

A refusal the server answers on a step (401, 403 or 429; 404 or 409 on a confirmation or the code; 400 on an address given for the code) resolves as refused with the server's code and words and never reaches the client's error hook, so nothing sends the browser to the login page it is on and nothing raises a notice. The 429 is the session library's attempt limiter: a name whose failed password attempts reached the limit is refused with the code too_many_attempts and the wait before the next attempt, which the refusal carries as retryAfter, in seconds, read from the body's retryAfter, else from the Retry-After header; a refusal that names no wait has none. Every other failure, a malformed request, a server fault, no response, throws as the client's requests do. The Angular binding's <ccc-login-methods> renders all of this; a page over another framework does the same over these calls.

A password an operator set (a first password, a reset) is temporary: the server refuses every request of the signed-in person with 403 and the code password_change_required until they change it, answering only the session read (which carries passwordChangeRequired: true), the logout and the change. The methods list names the change path as changePassword when the server offers a password method, and changePassword(oldPassword, newPassword) posts there, reading the list again first like every step: changed, after which the page checks the session again (the server regenerated it), or refused with the server's words (the old password did not match, the new one is the old one). A login page that finds the mark on the session read renders the change form before it opens the application.

The server's mailed links all lead to the login page, each with its token in the URL, and the list names the routes they post to: invitation (open, accept, and signUp when the server offers a password method), email (add, verify) and signUp (start, open, complete), each block present only where the server serves it; and usernameIsEmail when the password method declares the username as the person's email address, so the form asks for the address and an account created from a link takes no username of its own. The invitation block also carries standing while the browser holds a live invitation: its email, tenant, expiresAt, usernameIsEmail, methods, accountExists and hasPassword, what opening the link answered, so a login page the browser comes back to without the token (a redirect method refused the sign-in and returned with ?code=) shows the invitation again; it is absent when the browser holds none. Each step below reads the list again first, like every step that posts, and throws when the list names no routes for it.

An invitation (?invitation=<token>) brings a person into the application, or into one of its tenants, with the roles it carries. openInvitation(token) posts the token: the answer describes the invitation (email; tenant, empty for the global surface; expiresAt; signUp, whether an account with a password may be created here; usernameIsEmail; methods, the registration names of the listed directory methods that could admit the address, absent when the server did not say; and accountExists and hasPassword, whether an account already holds the address and signs in with a password, absent when the server did not say), and the browser now carries it, so the sign-in that follows accepts it and the server's hook seats the person: a password sign-in or sign-up by whoever holds the link, a directory sign-in, or a password sign-in by an account that holds an address, only as the invited address (another address is refused with invitation_address, the invitation still standing; an account with no address accepts by the link). signInUrl(method, returnUrl, { loginHint }) names the account the provider should take, which a login page sets to the invited address. acceptInvitation() accepts it for a person already signed in, under the same rule: an account that holds another address resolves refused with 403 and the server's sentence, the invitation still standing. signUpFromInvitation({ password, username? }) creates the invited person's account with a password, the address verified, and signs them in, answering as a sign-in step does. A link that has expired or was already used resolves refused with 404 and the server's words; a taken username or address 409.

A sign-up (?signUp=<token>), where the password method offers it: startSignUp(email) asks for the link, answering sent whatever the address's state (an address an account already holds is pointed at the account by mail, and the screen reveals nothing), or the refusal: not an address (400), a mailbox sent three links within fifteen minutes (429 with too_many_attempts and the wait), sign-up not offered (404). openSignUp(token) answers the address and usernameIsEmail for the form; completeSignUp(token, { password, username? }) creates the account with the address verified and signs the person in.

An address (?verify=<token>): addEmail(email) asks, for the signed-in person, for a confirmation link, answering sent the same way, and nothing is written until verifyEmail(token) posts the token back, which counts for the account that asked and no other: the address lands on the user verified (verified with the address), replacing what they held, and where the username is the address the user is renamed to it. The session read carries email, emailVerified and, while the person holds no address, emailHint, the address a directory asserted at a sign-in, offered as a prefill and trusted for nothing.

User management

Every Auth the server's generated router binds has a user-management surface: its users, their seats (a user holding a role), the roles a seat may take and the invitations standing into it, served as resources beside the application's own, in a global partition and, where the application has a tenant record, a tenant partition under the domain pair, with a set-password method and an unlink-identity method beside them. They are ordinary entries of the descriptor's resources and methods, so the generated handles (api.crewUsers, api.domain(sector).crewSectorSeats), the digest, the capability envelope and the operations body work for them as for everything else. The descriptor's userSurfaces block says which entries make up each surface and what the Auth's sign-in methods give it, so a component is written once and configures itself to the application: auth and label, password (a password method is registered: users are created with an optional password, a password is set for one), usernameIsEmail (that method declares the username as the person's email address, so neither is edited by hand: a verified new address renames the person) and openSignUp (it lets a stranger create an account from a mailed link), the external registrations (each with invited, the directory admits only people created first, and syncsRoles, the directory writes their seats at every sign-in), and the partitions, global first, each naming its users, seats, roles and invitations resources, its setPassword and unlinkIdentity methods where the Auth has them, and its resetFactors method, which forgets the browsers a user asked to be remembered at the sign-in code step. userSurfaces(descriptor) lists them; a descriptor from a generator that predates the surfaces carries none, one that predates the mailed links carries no invitations and neither flag, and one that predates the second factor no resetFactors.

The rows have the same shape on every application (UserRow, SeatRow, RoleRow, UserIdentity, InvitationRow): a user carries id, username, disabled, hasPassword, passwordChangeRequired, createdAt, lastSignInAt, organization, email and emailVerifiedAt (the person's address and when they proved they read it; null while an operator's entry stands unverified, which reserves the address and is trusted for nothing), its identities, the roles held in the partition's scope, the everywhereRoles held in every domain, and rolesSynced when its seats are a directory's; a role carries its name, its kind (global or domain in the global partition, absent in the tenant one) and the description the role file gives it, absent on a server that predates descriptions; an invitation carries id, email, the roles its acceptance grants, invitedBy, createdAt and expiresAt. userPartition(api, surface, partition, domain?) binds one partition: the handles typed over those rows (users.create takes username, an optional password, disabled and email, and the tenant partition's role; users.patch takes username, disabled and email; seats.create takes user and role, the seat's key; invitations.create takes email and roles, and invitations.remove revokes by id; the invitations handle is absent where the descriptor names none), setPassword(user, password), unlinkIdentity(user, identity) and resetFactors(user) posting to the methods' routes, and canCreateUsers(), canSeat(), canUnseat(), canSetPassword(), canUnlinkIdentity(), canResetFactors(), canListInvitations(), canInvite() and canRevokeInvitations(), the digest's answer folded with what the Auth's methods give the partition: no create on an Auth with no way for the new user to sign in, no set-password without a password method, no unlink without a registration, no reset on a descriptor that predates the second factor, nothing about invitations where the partition has none. The tenant partition needs its domain; the global one takes none.

const [crew] = userSurfaces(api.descriptor);
const sector = userPartition(api, crew, crew.partitions[1], 'anvil');
const users = await sector.users.list({ capabilities: ['Update', 'Delete', 'Execute'] });
if (sector.canSeat() && !users[0].rolesSynced) {
  await sector.seats.create({ user: users[0].id, role: 'Harbormaster' });
}
if (sector.canInvite()) {
  // The same request whether an account holds the address or not; the page does not learn which.
  await sector.invitations?.create({ email: '[email protected]', roles: ['Harbormaster'] });
}

An invitation is a mailed link into the partition carrying the roles its acceptance grants: whoever follows it and signs in, by any method, or creates an account with a password, accepts it and is seated with them (see the sign-in section for the login page's side), and the standing invitation is gone from the list; one nobody followed is revoked, or expires. The roles are judged by the role file's grant grammar role by role, so a role granted Create on the invitations resource under a condition on the roles invites into every role but the ones it may not, and the refusal of the one it may not is the server's.

The server keeps the rules no role file should have to state, and answers them as refusals the client's error hook renders: nobody disables, deletes or unseats themselves (403); a user whose seats a directory writes is not seated or unseated by hand (409); an account-level action from the tenant partition on a user also seated outside the tenant (409); the last way in is never unlinked (409); a tenant seat needs the user already in the tenant, or a person the tenant's own directory vouched for (400); where the username is the address, the row's envelope withholds the username and the address from the write. Removing a person's last seat anywhere deletes the account with its identity links, unless a global role keeps it. The Angular binding's <ccc-user-management> renders a partition over these calls.

Tokens

An Auth with machine outlets issues tokens for them: application tokens the signed-in person issues by name, and the grants a command-line client holds after the person approved its device request (RFC 8628: the program prints a code and a link, the person approves on the console, the program polls and receives an access token and a refresh token, one family). The descriptor's tokens block says the console's outlet serves the person's side: route, where the person's live tokens are listed (GET) and an application token is issued (POST); revokeRoute, where one is revoked by id; deviceRoute, where a device request is looked up by the code the program printed (GET, userCode in the query); approveRoute and denyRoute, where it is decided; and outlets, the machine outlets the Auth issues for, each with its name as a token names it and the prefix it is mounted at. The routes are in the form of permissionDigestRoute and userDomainsRoute: a path under the client's baseUrl without the prefix and without a leading slash (user/tokens). The block is optional: a descriptor from a generator that predates tokens, or for an Auth with no machine outlet, carries none, and userTokens(api) is then undefined, so a page says the application issues none instead of asking.

userTokens(api) binds the person's tokens on the client: outlets, the descriptor's list; list(), the live tokens newest first (TokenRow: id, kind, application, access or refresh, name on an application token, clientId on a device grant's two rows, outlet, createdAt, expiresAt, and lastUsedAt once the token was used, never on a refresh token); issue(name, outlet, expiresIn?), answering IssuedToken with token, the secret, this once; revoke(id), which revokes a device grant's pair from either row; and device(userCode), approve(userCode) and deny(userCode), the code passed through as given, which the server reads in any case and spacing. A refusal the server answers with its words resolves as TokenRefusal (outcome: 'refused', the status and the message) rather than throwing, so a page renders it in place, and isTokenRefusal(result) tells it from the result: a name over 200 characters, a lifetime over the maximum or an outlet the application has not (400 on an issue); no live token of the person's by that id (404 on a revoke); no pending device code, the one sentence for an unknown, expired, decided or consumed code (404 on a look-up or a decision). A 401 or 403 is not a refusal: it throws as every request does, so the client's error hook sends the person to sign in.

const tokens = userTokens(api);
if (tokens) {
  const issued = await tokens.issue('nightly export', tokens.outlets[0].name);
  if (!isTokenRefusal(issued)) {
    show(issued.token); // this once; the list never carries it
  }
  const request = await tokens.device('BCDF-GHJK');
  if (!isTokenRefusal(request)) {
    await tokens.approve('BCDF-GHJK'); // the program's next poll receives its tokens
  }
}

The Angular binding's <ccc-tokens> and <ccc-device-approval> render the two pages over these calls.

Transports

fetchTransport() is the default. A framework routes requests through its own HTTP stack by passing a Transport; @cccteam/resource-angular ships httpClientTransport, which rides Angular's HttpClient for its XSRF cookie echo and counts each request as activity. A transport carries the request and judges nothing: it resolves for every HTTP status and rejects only when no response came at all. The client is the one place a response becomes an error. A 4xx or 5xx that a method did not declare as an answer becomes ApiError, which carries the decoded body and, as its message, the server's message field (else HTTP <status>); ClientOptions.onError observes every such error before it is thrown, and a declared answer (accept) resolves instead. ClientOptions.warn, beside it, receives each warning the client raises once per distinct message (console.warn by default): a page asked for something the generated descriptor says the API does not serve and was answered without it, the message naming the regeneration that would serve it. The framework renders those events: the Angular binding returns the browser to the login page on a 401 and raises one global notice for an ApiError nobody caught. A transport also hands back the response headers by lower-cased name; paged lists read Link and Total-Count from them.

Release header

Every request can carry the release the application was built from, so the server can refuse a build it no longer answers. ClientOptions.apiVersion names that release (for example 1.5.0), and the client sends it in the X-Api-Version header (the exported constant apiVersionHeader) on every request. A build that names no release, or names an empty value or dev (the name of a local build), sends nothing and is never refused. The server answers a build from the oldest release it still answers up to its own release, and refuses the rest with status 412 carrying its own release in X-Api-Version, before it reads the request; a request with no header is answered. The client recognizes that 412 before it consults a method's declared answers (accept), so a method that declares 412 never takes the refusal for its own answer. The refusal is thrown as ApiVersionError, a subclass of ApiError that carries serverVersion (the server's release), appVersion (the release this build sent), and serverOlder (true when the server's release is older than this build's), and ClientOptions.onError observes it before it is thrown, as it does every ApiError. Every answer of an outlet names the server's release in the same header, refusals included, and the client keeps the last one named in client.serverRelease, a Store read with get() and watched with subscribe(), undefined until an answer named one: what a release line on the login page and in a footer shows (dev is a server built locally).

When the server's release is older than the build's, as it is after a rollback (an earlier release put back in service) or while a deploy is still shifting traffic from instance to instance, the client retries the request after each pause in ClientOptions.olderServerRetryDelays, whose default is the exported olderServerRetryDelays: 1 s, 3 s, and 6 s, three retries over ten seconds. When every retry is refused, the ApiVersionError is thrown with serverOlder true. A build older than the server is not retried. Releases compare as semantic versions (major, minor, and patch numbers, as in 1.5.0) through the exported compareReleases(a, b): a leading v is ignored, the numeric parts compare as numbers, a prerelease such as 1.5.0-rc.1 is older than its release, and a value that is not a version compares as 0.0.0. The exported releaseVersion(value) answers the release a build names, or undefined for none, an empty value, or dev. The client only recognizes the refusal; what the application does about it is the framework's decision. The Angular binding, @cccteam/resource-angular, hands it to its update service, which reloads onto the server's build or raises a notice; its README says how.

Maintenance answer

A server down for maintenance answers every request with status 503 and the marker header X-Maintenance: 1, and the client tells that answer from an ordinary 503. The deploy pipeline starts a release that interrupts the database behind a maintenance revision of the application, which opens no database and answers every request 503 with a Retry-After header and the marker (the exported constants maintenanceHeader and maintenanceHeaderValue): a navigation gets a maintenance page, and anything else, a request of the client included, gets no body. The client recognizes a 503 carrying the marker before it consults a method's declared answers (accept), so a method that declares 503 never takes it for its own answer, and throws it as MaintenanceError, a subclass of ApiError whose retryAfter is the server's Retry-After in seconds: a whole number of seconds as given, an HTTP date as the seconds from now until it, and undefined when the server sent none the client could read. The exported retryAfterSeconds(value) does that reading and retryAfterHeader names the header. ClientOptions.onError observes the error before it is thrown, as it does every ApiError. A 503 without the marker, from an overloaded or failing server, stays an ordinary ApiError, and the marker on any other status changes nothing. The client only recognizes the answer; what the application shows, and how it checks back, is the framework's decision. The Angular binding, @cccteam/resource-angular, raises one maintenance notice per outage and checks back until the server answers again; its README says how.

Testing

The specs are written against bun:test and run with bun test projects/resource/ (the trailing slash keeps bun off the Angular library beside it); bun run typecheck:resource compiles them with tsc over projects/resource/tsconfig.spec.json, so a @ts-expect-error line in a spec is an assertion that the types refuse a call. bun run test:resource runs both.

@cccteam/resource/testing exports scriptedTransport, a Transport for a spec of code over the client. It records every request in requests and answers each from its script: a fixed response answered to every request, or a responder (request) => TransportResponse that decides per request; with no script, every request is a 200 with no body. The client judges the answers as it judges a server's, so a scripted 409 becomes ApiError or a declared answer, and scripted Link and Total-Count headers position a page.

import { scriptedTransport } from '@cccteam/resource/testing';
import { createApi } from './zz_gen_api';

const transport = scriptedTransport((request) =>
  request.url === '/api/missions?limit=all'
    ? { status: 200, body: [{ id: 'm1', title: 'Anvil run' }] }
    : { status: 404, body: { message: `unscripted ${request.url}` } },
);
const api = createApi({ baseUrl: '/api', transport });

const missions = await api.missions.list({ limit: 'all' });

expect(missions).toEqual([{ id: 'm1', title: 'Anvil run' }]);
expect(transport.requests.map((r) => [r.method, r.url])).toEqual([['GET', '/api/missions?limit=all']]);

Escape hatches

  • api.request(method, path, options) issues any request under baseUrl.
  • api.define(descriptor) builds a typed handle for a resource the generator did not describe, such as one registered manually on the server.