@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
listandread; a resource whose PATCH handler is suppressed has nocreate,patch, orremove. Createshapes exclude server-owned fields (output-only, state columns, the tenant key) and a server-generated key; a client-assigned key is required.Patchshapes 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 NextA 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 responseA 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)andmethod.can()answer synchronously from the cache and never fetch.grantedandconditionalboth 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 (fromcapabilities: [...]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, androwCan(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 — forCreate, 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;subscribenotifies 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 outEverything 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 resolvesSign-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 underbaseUrl.api.define(descriptor)builds a typed handle for a resource the generator did not describe, such as one registered manually on the server.
