@colixsystems/directory-client
v0.2.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/files-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.2.0 — pre-publish. Not yet published to npm. 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, status).
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() and groups.listMine() 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 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" });
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.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 } |
| 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 } |
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.
