@colixsystems/directory-client
v0.12.0
Published
Typed, scoped identity-plane client for the AppStudio directory API: current principal, users, groups, memberships, and invites.
Readme
@colixsystems/directory-client
Typed, scoped identity-plane client for the AppStudio directory API: the current principal, end-users, groups, group memberships, and invites. It is the identity-plane sibling of @colixsystems/datastore-client, @colixsystems/assets-client, and @colixsystems/payments-client. It is a standalone fetch-based client you instantiate yourself with createDirectoryClient({ baseUrl, getToken, getTenantId, getRequestHeaders }).
Two surfaces, one package. This client serves two callers:
- External / server-side integrations instantiate it directly with an API key and call the methods below.
- The widget runtime — the Player and exported Expo app instantiate the same package and inject it into
WidgetContext.directory. Widgets never import this package; they call the SDK hooks from@colixsystems/widget-sdk(useDirectory,useUsers,useGroups,useUser, …), which readctx.directory. Both surfaces speak the identical snake_case REST contract.
Status
v0.12.0 — pre-publish. Not yet published to npm. Adds users.active({ within?, group_id?, limit?, offset? }): the users seen online, today, or in the last 7 or 30 days, most recent first (needs the users.activity capability). users.list passes joined_since and sort: "newest" through for an admin's "who joined" list. Additive.
v0.11.0 — Adds the presence namespace: presence.online(tableId, recordIds) answers, for each of 1 to 100 records the caller is a member of, on a table whose presence setting is on, which of its members have the app open now and show their online status — ids (at most 100) and a count, never a timestamp. A member holds their own read grant on the record, given by a person; a grant from a template or one inherited from a parent row does not count. Callers must be live, active people of the workspace (FORBIDDEN otherwise, including preview and store-review accounts); a table that has not opted in answers PRESENCE_NOT_ENABLED; setShowOnline is rate-limited per user. presence.me() and presence.setShowOnline(bool) read and set the caller's own "Show when I'm online" switch. All three need only the caller's app-user session (no scope header) and answer only while the workspace records user activity; a caller who hides their own status sees nobody. Additive.
v0.10.0 — Adds users.stats({ days?, group_id? }): workspace-level user totals for an app's own admins (active / deactivated users, new users per day or week, the workspace's groups, users in no group, empty groups, and invite figures when the caller may list invites) — never a list of people. Adds users.activity({ days?, group_id? }), users.inactive({ inactive_days?, group_id?, limit?, offset? }) and users.remind(id, { title, body }) for workspaces that record user activity: online now, active users, never active, platforms, active share per group, the users not seen recently with when they were last reminded, and a reminder to one of them. These three need the users.activity capability. Days follow the workspace's time zone. Additive.
v0.9.0 — users.list(query) takes ids (an array of 1 to 100 user ids), sent as one comma-separated param, to resolve the names behind ids a caller already holds. Additive.
v0.8.0 — pre-publish. Not yet published to npm. Adds users.update(id, { name?, email? }): change a user's name, email, or both. Resolves { id, name, email }. An email change notifies the user at their previous address. Additive.
v0.7.0 — pre-publish. Not yet published to npm. Withdraws 0.6.0's send-later invitations: users.invite(body) takes no send_invitation and always emails the invitation, invite rows carry no sent_at, and not_sent is no longer a status. invites.resend(id) re-sends a pending invite, as before 0.6.0.
v0.6.0 — added send-later invitations (send_invitation, not_sent, sent_at). Withdrawn in 0.7.0.
v0.5.0 — invites.list(query) takes two more optional params: q (case-insensitive substring match on the invite's name or email) and app_user_id (only the invites reserving that user id; a non-UUID value is rejected 400 INVALID_APP_USER_ID). status gains open: every invite that is neither accepted nor revoked, i.e. pending or expired. A re-invite keeps the reserved id, so { app_user_id, status: "open" } is the one invite that still reserves it. Every invites.list() row and the invites.resend() result now carry app_user_id — the id the invitee's user account will have once they accept, so a caller holding the id stored in a USER column can resolve the person behind it before they join. Additive; no existing field changed shape.
v0.4.0 — adds users.sendPasswordReset(userId): mail an end-user a password-reset link.
v0.3.0 — adds the optional group_id param to users.list(query) (sc-2964): list only the members of one app-user group (a groups.list() row id) — a cross-tenant / non-existent id returns an empty page. Additive; no existing field changed shape.
v0.2.0 — adds the bankid namespace (REQ-BANKID-AUTH): link / unlink a BankID identity to the signed-in app-user, backing the SDK useBankIdLink() hook.
snake_case, no transform
The wire format is snake_case in both directions (REQ-GEN-09), and so is this client. Request bodies are sent verbatim ({ name, email, group_ids }) and response objects are returned verbatim ({ id, name, email, group_ids, is_active, created_at, … }). There is no camelCase ↔ snake_case mapping. The only caller-facing camelCase is the JS method names (listMine, addMember, …) and the factory option names.
List endpoints return the { data, meta } envelope (meta = { total, limit, offset }) verbatim — they are never unwrapped to a bare array. Query-string params are snake_case (limit, offset, q, is_active, role, group_id, status). Pass users.list({ group_id }) (the id of a groups.list() row) to list only that group's members — a group id from another tenant or a non-existent one returns an empty page, never a cross-tenant member.
The X-Widget-Scopes capability header
The user / group / invite admin endpoints sit behind a per-widget capability gate. This SDK does not mint that token and knows nothing about the mint endpoint. Instead, the host supplies getRequestHeaders({ namespace, operation }); the client calls it once per gated request and merges whatever headers it returns (e.g. { 'X-Widget-Scopes': '…' }) onto the outgoing request. me(), groups.listMine() and the presence methods need only the authenticated principal, so they are issued without calling getRequestHeaders.
namespace is one of 'users' | 'groups' | 'invites'; operation is the method name ('list', 'create', 'deactivate', …).
Public API
import {
createDirectoryClient,
DirectoryError,
NotFoundError,
ForbiddenError,
ValidationError,
RateLimitedError,
ServerError,
} from "@colixsystems/directory-client";
const client = createDirectoryClient({
baseUrl: "https://api.appstudio.io",
getToken: () => "Bearer ...", // bare tokens are auto-prefixed with "Bearer "
getTenantId: () => "tenant_abc",
getRequestHeaders: ({ namespace, operation }) =>
({ "X-Widget-Scopes": mintScopeToken(namespace, operation) }), // optional
// fetchImpl defaults to globalThis.fetch
});
const me = await client.me(); // GET /auth/app/me
const { data: users, meta } = await client.users.list({ q: "ann", role: "USER" });
const { data: engMembers } = await client.users.list({ group_id: "grp_eng" }); // members of one group
const user = await client.users.get("user_123");
await client.users.invite({ name: "Ann", email: "[email protected]", group_ids: ["g1"] });
await client.users.deactivate("user_123"); // -> { id, is_active: false }
await client.users.reactivate("user_123"); // -> { id, is_active: true }
const { data: groups } = await client.groups.list({ q: "eng" });
const group = await client.groups.create({ name: "Engineering" });
await client.groups.addMember(group.id, "user_123");
await client.groups.removeMember(group.id, "user_123");
await client.groups.remove(group.id);
const { data: myGroups } = await client.groups.listMine();
const { data: invites } = await client.invites.list({ status: "pending", q: "ann" });
// Who is behind an id read from a USER column, before that person has joined?
const { data: [invitee] } = await client.invites.list({ app_user_id: record.assignee.id, status: "open", limit: 1 });
await client.invites.resend("invite_1");
await client.invites.revoke("invite_1");
// BankID account linking (REQ-BANKID-AUTH) — self-service, JWT-gated (no scope).
const { linked, available } = await client.bankid.status(); // GET .../bankid/link
const order = await client.bankid.startLink(); // POST .../bankid/start {intent:link}
const poll = await client.bankid.collect(order.order_ref); // GET .../bankid/collect/{ref}
await client.bankid.cancel(order.order_ref); // POST .../bankid/cancel/{ref}
await client.bankid.unlink(); // DELETE .../bankid/linkSurface
| Method | HTTP | Scope header? | Returns |
| --- | --- | --- | --- |
| me() | GET /auth/app/me | no | { id, name, email, group_ids } |
| users.list(query?) | GET /app/users | users | Page<AppUser> |
| users.get(id) | GET /app/users/{id} | users | AppUser |
| users.stats(query?) | GET /app/users/stats | users | UserStats |
| users.activity(query?) | GET /app/users/activity | users | UserActivity |
| users.inactive(query?) | GET /app/users/activity/inactive | users | Page<InactiveUser> |
| users.active(query?) | GET /app/users/activity/active | users | Page<ActiveUser> |
| users.remind(id, { title, body }) | POST /app/users/activity/reminders | users | { user_id, last_reminded_at } |
| users.invite(body) | POST /app/invites | invites | AppUserInvite |
| users.deactivate(id) | POST /app/users/{id}/deactivate | users | { id, is_active } |
| users.reactivate(id) | POST /app/users/{id}/reactivate | users | { id, is_active } |
| users.sendPasswordReset(id) | POST /app/users/{id}/password-reset | users | { sent, email_masked } |
| users.update(id, body) | PATCH /app/users/{id} | users | { id, name, email } |
| groups.list(query?) | GET /app/groups | groups | Page<AppUserGroup> |
| groups.create(body) | POST /app/groups | groups | AppUserGroup |
| groups.remove(id) | DELETE /app/groups/{id} | groups | void |
| groups.addMember(groupId, userId) | PUT /app/groups/{groupId}/members/{userId} | groups | void |
| groups.removeMember(groupId, userId) | DELETE /app/groups/{groupId}/members/{userId} | groups | void |
| groups.listMine() | GET /app/groups/mine | no | Page<AppUserGroup> |
| invites.list(query?) | GET /app/invites | invites | Page<AppUserInvite> |
| invites.revoke(id) | DELETE /app/invites/{id} | invites | void |
| invites.resend(id) | POST /app/invites/{id}/resend | invites | AppUserInvite |
| bankid.status() | GET /auth/app/idp/bankid/link | no | { linked, available } |
| bankid.startLink() | POST /auth/app/idp/bankid/start | no | { order_ref, auto_start_token, qr, status } |
| bankid.collect(orderRef) | GET /auth/app/idp/bankid/collect/{ref} | no | { status, qr?, message?, linked? } |
| bankid.cancel(orderRef) | POST /auth/app/idp/bankid/cancel/{ref} | no | { status: "cancelled" } |
| bankid.unlink() | DELETE /auth/app/idp/bankid/link | no | { linked: false } |
| presence.online(tableId, recordIds) | GET /app/users/presence | no | { data: [{ record_id, online_user_ids, online_count }] } |
| presence.me() | GET /auth/app/account/presence | no | { show_online } |
| presence.setShowOnline(showOnline) | PUT /auth/app/account/presence | no | { show_online } |
Changing a user's name or email
users.update(id, { name?, email? }) changes either field or both; a body with neither is refused. It needs the users.write capability, like deactivate. That capability therefore covers changing a user's sign-in email, which is a sensitive power: grant it only to the people who administer the app. When the email changes, the user is told at their previous address, so a change they did not expect never goes unnoticed.
A refusal rejects with the error's details.code naming the reason:
| details.code | Status | Meaning |
| --- | --- | --- |
| INVALID_UPDATE | 400 | Neither name nor email was sent |
| INVALID_NAME | 400 | The name is empty or too long |
| INVALID_EMAIL | 400 | The email is not a valid address |
| EMAIL_TAKEN | 409 | The address is not available |
| EMAIL_RESERVED | 409 | A reserved address |
| SCIM_MANAGED_NAME / SCIM_MANAGED_EMAIL | 409 | The profile is synced from an identity provider; change it there |
| SSO_LINKED_EMAIL | 409 | The user signs in through an identity provider; change the email there |
| SERVICE_ACCOUNT_PROTECTED | 409 | A service account's email cannot be changed |
| SEED_USER_PROTECTED / REVIEW_USER_PROTECTED | 409 | The app's preview or store-review account: its email address cannot be changed |
| OWN_EMAIL_CHANGE_REFUSED | 409 | The signed-in user cannot change their own email here; they use their account screen |
Every refusal from EMAIL_TAKEN down, except the SCIM_MANAGED_* pair, concerns the email only: a rename sent as { name } alone still succeeds.
An unknown or other-workspace id rejects with NotFoundError. A 409 has no dedicated subclass: it is a DirectoryError with status === 409, so branch on details.code.
Email changes are rate-limited per caller. A 429 rejects with RateLimitedError (code === "RATE_LIMITED", status === 429). update is a PATCH, so the client makes one attempt and never retries it; wait before calling again.
Note: there is no
POST /app/users(create) and noPUT /app/users/{id}/PUT /app/groups/{id}— adding a user happens throughusers.invite(...)(or the studio-only integration-user route, which is not part of this client).
Transport
| Concern | Behaviour |
| --- | --- |
| Auth header | authorization from getToken(); a bare token is prefixed with Bearer |
| Tenant header | x-tenant-id from getTenantId() |
| Scope header | merged from getRequestHeaders({ namespace, operation }) on gated requests |
| Retries | idempotent GETs retried 3× with exponential backoff (200/400/800 ms) |
| Timeouts | 10 s default, configurable per call |
| Error model | typed DirectoryError hierarchy (errorFromResponse maps status → subclass) |
| Platform | browser and React Native (uses fetch + AbortController) |
Dependencies
None. The client uses only platform fetch and AbortController, available in modern browsers, Node 18+, and React Native.
