Identity & access — reference
The exhaustive surface for identity, app contexts, the scope model, access profiles, roles, and credentials: every method, its parameters, validation rules, limits, the response envelope, error codes, and an honest Notes & limits section. For concepts see explanation.md; for runnable guides see how-to.md.
For the canonical, always-current request and response field shapes, use the generated API reference (rendered from the OpenAPI specification). This page is the durable map and the honest edges — not a regenerated copy of the raw endpoint schemas.
Version note. The generic identity-entity surface (
/v1/entities/{namespace},/v1/namespaces) shipped in 0.35.0, replacing the dedicated/v1/orgsand/v1/clientsendpoints in a clean break — there is no compatibility shim. A client older than0.35cannot reach this surface; upgrade before authoring against this page.
Client construction
import { VectrosClient } from '@vectros-ai/sdk';
const client = new VectrosClient({ token, environment });
| Field | Meaning |
|---|---|
token | The bearer credential: sk_*, ssk_*, or st_*. |
environment | The API base URL, e.g. https://api.vectros.ai (production) or https://api.staging.vectros.ai (staging). |
Identity and access methods live under two sub-clients: client.identity.* (users,
identity entities, namespaces) and client.auth.* (contexts, roles, access profiles,
scoped keys, token minting, the identity-binding check, and the cross-context principal
lookup).
Response envelope
List, lookup, and version-history methods return the standard page envelope:
{ data: T[], nextCursor: string | null }
Drain by feeding nextCursor back as the next call's startFrom until it is null. The
identity-binding check (ping) and token minting are not enveloped — they return a plain
object.
Identity plane — client.identity.*
Two dimensions on this plane: the fixed user surface, and the generic identity
entity surface, addressed by namespace. org and client are two reserved
namespace names — they carry no dedicated endpoints or fields of their own; register
them and create, read, and list entities in them exactly as you would in any namespace
you register yourself.
Users
| Method | Purpose |
|---|---|
createUser | Create (idempotent by externalId). |
getUser | Fetch one by Vectros id. |
updateUser | Full-replace update (PUT) of the body. |
deleteUser | Delete by id. |
listUsers | List, filterable by externalId; enveloped. |
getUserVersions | Version history; enveloped. |
| Field | Type | Notes |
|---|---|---|
externalId | string | Your id; create is idempotent on it. Capped at 256 chars; permissive about characters. |
id | string | Vectros-assigned UUID; returned on create/get/list. |
email | string | Users carry email, not name. |
type | enum | HUMAN (default) or SERVICE. |
payload | object | Free-form Record<string, unknown> attribute bag, round-tripped as-is. |
status | string | ACTIVE on create directly; PENDING | ACTIVE | SUSPENDED over the lifecycle. PENDING is server-managed — you cannot set it directly (below). |
schemaId | string | Optional governing schema. When set, payload is validated + lookup-indexed against it, and a token carrying users:s:<schemaType> (the type the schema declares) can reveal that type's sensitive fields on this user — the same mechanism as entities:s:<namespace>. |
Inviting a sub-user — client.auth.*
An alternate creation path that binds an access profile in the same call, so the invitee
arrives already permissioned. Lives under client.auth.* (not client.identity.*) because
it's driven by the access-profile shape.
| Method | Purpose |
|---|---|
createInvite | Create a PENDING user and bind an access profile to it, by email. Idempotent on (contextId, email) — a repeat invite rotates the token and resends. Requires users:c; if the pair already has an outstanding invitation in the SAME context, also requires users:r and users:u (the resend path), else 409. An email that already resolves to a member of a DIFFERENT context in the same tenant grants/attaches access there instead: if that member is ACTIVE, this mints no token — nothing is mutated — so it needs only users:r on top of the baseline users:c (not users:u) — except when that member's credential can't authenticate through the target context's own registered issuer, in which case this falls back to a normal independent invitation (its own token/accept link) instead, see below; if that member is still PENDING elsewhere, this rotates its token exactly like the resend path, so it needs users:r+users:u together, same as resendInvite. |
resendInvite | Rotate the token and extend expiry on an outstanding invitation, identified by (contextId, email); re-delivers if sendEmail. Invalidates any previously issued accept link. Does not change the bound permissions. Requires users:c, users:r, and users:u together. |
updateUser | Also the accept path: a PUT carrying status: 'ACTIVE', inviteToken, externalSubject, and emailVerifiedAttestation: true moves a PENDING user to ACTIVE. Calling this yourself does not grant the invitee sign-in anywhere (not the Vectros dashboards, not token exchange) — see Inviting a sub-user by email for what externalSubject actually does here and the recommended alternative when the invitee needs to sign in themselves. |
Every users:c/users:r/users:u requirement named above has an equally sufficient alternative,
letter-for-letter: the member-lifecycle capability plus the matching unqualified profiles:<verb>
grant(s), for a credential scoped to a single app context. profiles:c is the baseline for BOTH
methods (it's what gates entry to createInvite at all, including every case named above, and
resendInvite requires it explicitly) — never omit it. On top of that baseline, grant only the
additional letters the specific case actually names: profiles:r alone for the ACTIVE-attach case;
profiles:r+profiles:u for a resend or a PENDING-attach. See the member-lifecycle row in the
granted_capabilities table.
CreateInviteRequest fields:
| Field | Type | Notes |
|---|---|---|
email | string | Required. Rejected with 409 if it already belongs to an active/suspended member of contextId, or already has a PENDING invitation under contextId without the caller holding users:r+users:u to resend it. An email already resolving to an ACTIVE or PENDING member under a DIFFERENT context in this same tenant grants/attaches access to contextId instead (or SUSPENDED stays a hard block) — see above — provided the caller holds the scope that grant/attach itself needs (users:r for ACTIVE, users:r+users:u for PENDING); without it, this also 409s. An email that already has an identity in your OTHER tenant (test vs. live) is not a collision — it gets a second, independent membership there. |
contextId | string | Required. Must reference an existing app context in the tenant your calling credential is bound to — the invitation lands in that same tenant; the invitee's access profile is bound to it. |
accessProfile | { roleId } | { scopes } | Required. Exactly one of the two, same XOR as a standalone access profile. A roleId naming no role in the context is rejected with 400. The resolved scope (the role's, or the inline scopes) is checked against the caller's own scope and rejected with 403 if it exceeds it — you cannot invite someone into more than you hold. |
acceptUrl | string | Required when sendEmail is true (the default). Your https landing page; the token is appended as a t query parameter. IP-literal hosts are rejected. |
sendEmail | boolean | Default true. false skips delivery and returns inviteToken + acceptLink in the response instead, for you to deliver through your own provider. |
fromName | string | Optional display name for the invitation email's From header. Default Vectros; max 100 chars, no newlines. |
ttlSeconds | integer | Optional. Default 604800 (7 days); must be between 3600 (1h) and 2592000 (30 days). |
firstName | string | Optional; personalizes the email greeting only — not stored or returned. |
lastName | string | Optional; accepted but currently unused (not stored, returned, or applied to the greeting). |
CreateInviteResponse fields: userId (stable across accept), inviteExpiresAt (ISO-8601;
accept attempts after this are rejected), emailSent (true only when sendEmail was true and
delivery succeeded), and — only when sendEmail was false — inviteToken and acceptLink.
Treat inviteToken as a credential: holding it is sufficient to accept the invitation.
Inviting an email that already resolves to a member elsewhere in the SAME tenant — including the
tenant owner's own email — grants access to contextId rather than 409ing, unless the email is
already a member of contextId itself (still a collision), SUSPENDED (a deliberate lockout — the
new context is never attached silently; reactivate the user explicitly first), or the caller lacks
the scope the grant/attach itself needs (below — in which case it's a 409 too). An ACTIVE member
with no existing access to contextId gets it immediately (201, no email — emailSent is false,
inviteToken/acceptLink are absent since there's nothing to accept); this requires users:r
alongside users:c (the response names their existing userId) — without users:r, 409. A
member whose original invitation is still PENDING has contextId's access attached to that same
outstanding invitation and its token rotated, requiring users:r+users:u, same as an ordinary
same-context resend — without either, 409. One exception for
an ACTIVE member: if their credential can't actually authenticate through contextId's own
registered issuer (their bound externalSubject is proven exclusively from a different trusted
issuer than the one contextId uses), attaching silently would be a dead end — a normal, independent
invitation is created instead (its own userId and token/accept link), exactly as if the email had
no existing identity in the tenant. An email that already has an identity in your OTHER tenant is
never one of these cases — createInvite always succeeds there with a second, independent membership.
While an invitation is outstanding: email cannot be changed on the PENDING user by a
plain update — delete and re-invite to redirect it. Deleting a PENDING user also removes its
bound access profile. Once accepted (PENDING → ACTIVE), email is editable again and
externalSubject is treated as immutable.
Identity entities — /v1/entities/{namespace}
One CRUD-plus-lookup surface for every entity in every entity-backed namespace, including
org and client. The namespace is a path parameter, not a body field — it is
immutable per entity and set once, at create.
| Method | Purpose |
|---|---|
createEntity({ namespace, body }) | Create (idempotent by (namespace, externalId)). ?upsert=true overwrites an existing match instead of returning it unchanged. |
getEntity({ namespace, id }) | Fetch one by Vectros id. |
updateEntity({ namespace, id, body }) | Full-replace update (PUT). |
deleteEntity({ namespace, id }) | Delete by id. |
listEntities({ namespace, userId?, externalId?, scope?, type?, field?, value? | from?+to? | prefix?, order?, startFrom?, limit? }) | List, and — via the type/field/… params — the schema-field lookup surface (non-sensitive fields); enveloped. |
lookupEntities({ namespace, body }) | Body-based lookup for a sensitive schema field (the value travels off the URL); enveloped. |
getEntityVersions({ namespace, id }) | Version history; enveloped. |
Fields (EntityRequest, on create/update):
| Field | Type | Notes |
|---|---|---|
externalId | string | Required on create. Your id, unique within the namespace; create is idempotent on it. |
name | string | Human-readable name. |
status | enum | ACTIVE (default) or SUSPENDED. A label you set and read back: SUSPENDED records your own intent to retire the entity, and the platform does not enforce it — a suspended entity is still readable, updatable and referenceable. Enforce it in your own application if you need it to have an effect. Sent case-insensitively and stored uppercase; any other value is rejected with 400, on create and update. |
payload | object | Free-form attribute bag. On update, replaces the stored payload in full (not merged); omit to leave it unchanged. |
schemaId | string | Optional governing schema (validates + lookup-indexes payload). Must belong to your account. |
scopes | string[] | Parent ownership edges, each <namespace>:<value> — at most two, each in a namespace different from the entity's own. On update, an explicit scopes replaces the full parent set; omit it to leave ownership unchanged. The entity's own reference is accepted-and-ignored (so you can round-trip a GET's scopes value unchanged); any other value in the entity's own namespace is rejected — a parent always crosses namespaces. For a scoped credential, the update is authorized against a single scope clause that must cover every namespace the change touches — including a parent you are removing (dropping a label is authorized exactly like adding one). Because PUT is a complete declaration, re-sending an unchanged parent that sits in a namespace your granting clause doesn't cover makes the whole PUT a 403 naming that namespace; with no PATCH on the identity plane, the remedy is to send only the labels your clause covers. |
Fields (EntityResponse, on read):
| Field | Type | Notes |
|---|---|---|
created | boolean | Create-response only: true for a new entity, false for an idempotent-return or upsert-overwrite. |
id | string | Vectros-assigned UUID. |
namespace | string | The entity's namespace (present on responses; not a request field). |
externalId | string | As supplied at create. |
name | string | |
status | string | ACTIVE | SUSPENDED. |
scopes | string[] | The entity's effective scopes: its own reference (<namespace>:<id>) followed by its parent edges, e.g. ["team:<id>", "org:<id>"]. |
payload | object | Sensitive fields masked ([redacted]) unless the token carries entities:s:<namespace>. |
schemaId / schemaVersion | string / number | The governing schema and the version in effect when last written. |
contextId | string? | The app context the entity's namespace is registered under (null for a tenant-wide namespace) — an echo of the stored row, not a request field. Mirrors NamespaceResponse's contextId above. |
createdAt | string | ISO-8601 UTC. |
Namespaces — /v1/namespaces
Declares whether a scope:<namespace> value is a free-form string (the default) or must
resolve to a real identity entity. org and client are ordinary namespace registrations —
reserved names, not built-ins — and, like every namespace, require an explicit registration
before use. There is no special case for them on this surface.
| Method | Purpose |
|---|---|
registerNamespace(body) | Register a namespace. Requires specificityRank. Root sk_*; or the CLI bootstrap's provisioning capability, confined to its own context; or the CLI bootstrap's separate tenant-wide namespace-provisioning capability, which may register a TENANT-WIDE (no ?contextId=) namespace only — never a named context, including its own. |
getNamespace({ namespace }) | Fetch one. Open to any credential, confined to the credential's own registration (see Placement, below). |
listNamespaces | List; enveloped. Open to any credential, confined the same way. |
updateNamespace({ namespace, body }) | Update entityBacked / defaultSchemaId / specificityRank / the membership fields below. Root sk_* only. |
deleteNamespace({ namespace }) | Delete. Root sk_* only. |
| Field | Type | Notes |
|---|---|---|
namespace | string | 2–32 chars, lowercase-first grammar (^[a-z][a-z0-9_-]{1,31}$). Immutable once registered. The ten hard-reserved names below can never be registered; org/client are reserved but ARE registerable — see below. |
entityBacked | boolean | Default false. true ⇒ every scope:<namespace> value platform-wide must resolve to a real entity in this namespace (fail-closed existence check on create); false ⇒ free-form string, grammar-only — and note "grammar-only" is not "unconstrained": see the value row below. |
(the <value> half) | string | 1–128 chars, ^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$ — a letter or digit first, then letters, digits, _ or -. Applies wherever a scope value is written or filtered: the scopes array, ?scope=, an access profile's identityOverrides, and a scoped token's identity. Deliberately excludes : (a value becomes part of a storage key whose parser splits on colons) and $/{/} (a stored value is substituted into a scope clause and re-parsed, so a placeholder-shaped value would be read back as a matcher). Unlike the namespace, a value may be mixed-case and may start with a digit — entity ids are UUIDs. |
contextId | string? | Placement, fixed once set. Omit (or don't supply ?contextId= on create) for the default — a tenant-wide registration, shared by every app context. Pass ?contextId= on POST /v1/namespaces to make the registration context-owned: it belongs entirely to that one app context, invisible from every other context — two contexts can each register their own team namespace with no collision. A namespace cannot be re-homed after registration; its entities' partition is part of their key. The response always carries contextId (null for a tenant-wide registration); there is no separate placement field. |
defaultSchemaId | string | Optional default governing schema for entities created in this namespace. |
specificityRank | integer | Required on create; account-unique. An integer position in your account's specificity order, used to break a tie when a caller holds two scope dimensions at once during basedOn schema resolution — the higher-ranked namespace's variant wins. Optional on update (omit to leave unchanged). Leave headroom between values (10, 20, 30, …) so a later namespace can slot in between without renumbering. |
membershipRecordType / membershipTargetField | string / string | Optional pair, all-or-nothing: which record type and field name together hold the grant for this namespace's membership (see Membership backing, below). |
membershipLevelField / membershipLevels | string / string[] | Optional; a closed set of tier labels (e.g. admin/viewer) for level-qualified membership grants. |
membershipContextId | string | Required alongside membership fields only on a tenant-wide registration — which app context holds the grant records. A context-owned registration's grants live in its own context by construction and need no separate field. |
reserved | boolean | (response only) true when this registration was provisioned by the platform rather than authored by a caller; false for a namespace you registered yourself. |
createdAt | string | (response only). |
?contextId= propagates to every /v1/entities/{namespace} operation, not only namespace
registration — GET (by-id, every list/lookup mode), PUT, DELETE, GET .../versions, and
POST .../lookup all take it. It is required for a context-owned namespace and rejected
for a tenant-wide one; a context-confined credential may only name its own context. Naming a
different context explicitly is refused with 403 before the lookup ever runs — it never
reaches a sibling context's entities to get a 404 for them. Within its own context (named or
omitted), an entity that doesn't exist there answers 404, same as anywhere else. Reading a
namespace's own registration is confined the same way: a credential may read only its own
context's registration, or the tenant-wide one — never a sibling context's.
Writing a namespace registration — create, update, or delete — always requires a root API key, regardless of placement, UNLESS the caller holds one of two owner-only, non-grantable CLI bootstrap capabilities, neither of which extends to update or delete:
- The bootstrap's provisioning capability may additionally create a registration confined to its own bound app context — never a context it isn't bound to.
- The bootstrap's separate tenant-wide namespace-provisioning capability may additionally
create a tenant-wide registration (omitting
?contextId=) — and only that: naming any context explicitly, including its own, is refused exactly as for a caller without it. The two capabilities are independent — holding one confers none of the other's reach — and a bootstrap token typically carries both together.
Membership backing. A registration may declare where its membership lives, independent of
where its entities live: membershipRecordType/membershipTargetField name the record type and
field that, together, hold the grant. Declaring a membership backing grants nothing by itself — a
role's data_scope must opt in explicitly with ${{ member.scope.<namespace> }} (any level) or
${{ member.scope.<namespace>:<level> }} (one level only); authoring a level the namespace hasn't
declared is rejected at write time. Resolution happens once per request, never at mint time, so
a revoked membership takes effect on the caller's very next request rather than waiting out a
token's lifetime.
org and client must be registered explicitly, like any other namespace. Register both
once, with a root key, before creating an org/client entity — POST /v1/entities/org/client
returns 400 ("not entity-backed") until you do:
POST /v1/namespaces {"namespace": "org", "entityBacked": true, "specificityRank": 1000}
POST /v1/namespaces {"namespace": "client", "entityBacked": true, "specificityRank": 2000}
A one-time step per account. Once registered, both behave identically to any other namespace —
they appear in GET /v1/namespaces, and no request/response shape changes. The retired
/v1/orgs and /v1/clients routes remain retired.
Reserved namespace names — never registerable, because they collide with a fixed
schema-reference surface or an /v1/entities/{namespace}/... sub-path: record,
document, entity, user, self, tenant, context, scope, versions, lookup.
org/client are namespace names too, but unlike the ten above they ARE registerable — see
above.
Referential integrity. A namespace cannot be deleted, or updated from
entityBacked: true to false, while entities still exist in it — both reject with 409.
CLI equivalents
vectros identity create --type <user|namespace> --external-id <id>
[--name <n>] [--email <e>] [--service] [--scope <ns:value>...] [--metadata <json>]
vectros identity list --type <user|namespace> [--external-id <id>] [--limit <n>]
vectros identity get --type <user|namespace> --id <vectrosId>
vectros identity delete --type <user|namespace> --id <vectrosId>
--type takes user (the fixed principal surface) or any namespace — org/client
(reserved names, registered like any other) or one you've registered. --name and --email are mutually dimension-specific
(--email only for user; --name only for an entity namespace). --service only
applies to user; --scope <namespace:value> (repeatable, ≤2) only applies to an entity
namespace. There is no CLI command for the namespace registry itself — register a
namespace via the SDK or API with a root key (see how-to.md); the CLI's
identity command only creates/reads/lists/deletes entities inside a namespace that
already exists.
App contexts — client.auth.*
The isolation partition. contextId must match ^[a-z][a-z0-9-]{2,30}$ (starts with a
lowercase letter; lowercase letters, digits, hyphens; 3–31 chars total).
| Method | Purpose |
|---|---|
createAppContext | Create (idempotent by contextId). name is required; companyName is optional. Root sk_* only. |
getAppContext | Fetch one by contextId. |
updateAppContext | Update name / description / companyName / identityProjectionClaims (the path supplies contextId; the body's contextId is required by the schema but ignored — it is immutable). |
listAppContexts | List; enveloped. |
deleteAppContext | Confirm-gated async cascade — see below. Root sk_* only. |
companyName is distinct from name: name is the app's own fixed identity, companyName is the
deploying organization's own display name, used to personalize platform-sent correspondence (e.g.
sub-user invitation emails) with the deployer's own branding instead of a generic app name. Optional
on every request; absent on any context that predates its introduction.
identityProjectionClaims (0.43.0). Declares which of your tenant's captured identity-provider
claim names (see capturedClaims under Trusted issuers, below) get projected, read-only, onto
access profiles in this context. Filled in once per profile, the first time a sign-in for that
principal can supply a value — usually at profile-creation, but for an invited member not until they
actually accept the invite and sign in for the first time (there's nothing to project before that).
Once filled, a profile's projection does not update again even if this declaration or the underlying
identity data changes later — changing this declaration only affects profiles that haven't been
filled yet. Requires a root API key or your platform provisioning credential — an ordinary
app-contexts:u-scoped token can update name/description/companyName but cannot set this field;
attempting to returns 403. Projected values surface as a read-only identityProjection object on
AccessProfileResponse (see Access profiles, below) — absent when your context declares no
projection, when none of the declared names have a captured value for that principal yet, or before
that principal's first successful sign-in. Omit to leave unchanged; send an empty list to disable
future projection.
⚠️ Declaring a name here is itself the access-control decision — identityProjection on the read
side carries no additional gate. Unlike email on the same AccessProfileResponse (gated behind
your token also holding users:r), every projected field is returned to any caller who can read the
profile at all — an ordinary profiles:r grant, no users:r needed. If you declare a PII-shaped
claim name (e.g. "email", "phone_number"), every profiles:r holder in this context sees it.
Declare only names you intend everyone with roster-read access to see.
Root-only lifecycle (API). On the API, creating and deleting an app context require a
root sk_* key. A scoped key or token (ssk_* / st_*) cannot create or tear down a
context — not even one carrying the wildcard * scope. (Get / update / list are reachable
with appropriate scope.) Account owners additionally have a self-service path that does
not involve the root key: the dashboard's Contexts page and vectros context destroy both
perform the same confirm-gated teardown through an owner-gated server-side route, so no
teardown-capable credential is ever held by the browser or stored by the CLI.
Delete contract. deleteAppContext({ contextId, confirm }) requires confirm to equal
contextId:
- Without a matching
confirm→ 400, and nothing is touched (the rejected delete is a no-op; child roles/profiles still exist). - With a matching
confirm→ accepted (202); the context flipsactive → purgingimmediately and drains all of its records, documents, folders, schemas, roles, and profiles in the background, reachingdeletedwhen the drain completes.
Reserved context. Certain context ids are reserved by the platform and cannot be created
by a tenant — vectros-admin (backs the hosted admin surfaces) and default (the base
context auto-provisioned for every tenant).
Errors. Malformed contextId → 400 with a developer-friendly message. Get on a
well-formed but never-created contextId → 404 (not 500). Cross-tenant probes collapse to
404.
CLI equivalents
vectros context create <contextId> [--name <n>]
vectros context list
vectros context get <contextId>
vectros context destroy <contextId> [--force] [--tenant test|live]
(destroy is the same confirm-gated async cascade as the API delete: interactively it asks
you to re-type the context id, and --force skips that prompt — required when stdin is not
a terminal. It authenticates as the account owner, so it works without the root key.)
The scope model
Scope grammar
An allowed action is resource:ops[:qualifier]:
resource— one of the data-plane resourcesrecords,schemas,search,documents,folders,inference,entities(below, qualified by namespace), or a control-plane resource:users(your tenant's own user identities —users:c,users:r,users:u,users:d; the developer portal's scope editor also offersusers:crudas a one-click shorthand for all four),profiles(access profiles —profiles:c,profiles:r,profiles:u,profiles:d),scripts(scripts:c,scripts:r,scripts:d, and the execute letterscripts:x— there is noscripts:u, since a stored version is immutable) ortriggers(triggers:c,triggers:r,triggers:u,triggers:d).userstakes no qualifier onc/r/u/d— a qualifier there is rejected — but does on the fifth op letter,s(below).profiles:c/u/daccept an optional qualifier confining WHICH principal the grant may act on — a literalusr_<id>, or the bareselfsentinel.selfmatches only when the target of the request is the credential's own bound principal; it never widens a grant, and it is a plain literal in the qualifier position, not a${{ }}template. A bare, unqualified entry (e.g.profiles:cru) is unchanged and stays broad — it remains the context-admin grant.profiles:rdoes not accept a qualifier:GET/list routes are unaffected by this grammar. Example:{"allowed_actions": ["profiles:u:self"], "data_scope": {}}lets a credential update only its own access profile in a context, never another principal's.ops— any combination of the lettersccreate,rread,uupdate,ddelete. E.g.records:r,records:cru,documents:crud. Two more letters are accepted on any resource but have an effect only where a resource defines one:sreveals sensitive fields (records:rs:patient) andxexecutes a stored script (scripts:x, orscripts:x:<name>for one script name; seePOST /v1/scripts/executein the data-model reference).qualifier— optional tail that narrows (e.g.records:r:intake_form= read only theintake_formrecord type;entities:cru:org= create/read/update entities in theorgnamespace only). It never widens.
scripts:c is one trust tier, and it cannot be narrowed. Unlike profiles, it accepts
no qualifier on c/r/d — scripts:c:<name> is rejected at authoring, because pushing a
script has no per-name routing that would make a name-scoped grant do anything but sit inert
(the x execute letter is the one op that DOES take a script-name qualifier — see above — and
is a separate, narrower axis from create). A credential holding bare scripts:c may push a
new version under any script name in the context, including the name behind an existing
version:"latest" trigger rule — whose code then runs under that rule's own stored grant
the next time it fires, not the pusher's. There is no way to grant a narrower form of
scripts:c today. Treat it the way you would a deploy credential, and grant it no more widely
than you would one.
entities follows the same resource:ops:<qualifier> grammar as records, with the
qualifier naming a namespace rather than a record type — entities:c:team grants
create on team entities only. A fifth op letter, s, reveals sensitive payload fields —
entities:s:<namespace> for entities, and users:s:<schemaType> for users that carry a
governing schema (schemaId) — see data-model
reference for the general s op.
What does NOT grant access (author the letter form instead):
| Form | Effect at runtime |
|---|---|
records:r, search:r, entities:r:org, … | ✓ Grants the named operations. |
* (the single literal wildcard) | ✓ Grants everything — the shape root keys carry. Reserve it. |
A coarse verb (read, write, delete) | ✗ Grants nothing. |
resource:* (operations-wildcard) | ✗ Grants nothing at runtime. Author explicit c/r/u/d letters. |
The operations-wildcard
resource:*does not grant access at the runtime enforcement layer. Always author the explicit letter form. The only wildcard that grants anything is the bare literal*.
Managing the namespace registry itself (POST/PUT/DELETE /v1/namespaces) is not a
grantable scope at all — it is root-key-only regardless of any scope a token carries;
namespaces:* is not a recognized resource.
granted_capabilities
A scope clause — in a role, an access profile, or an invitation — accepts an optional
granted_capabilities array alongside allowed_actions and data_scope. A capability names a
bounded effect that reaches across a partition boundary, which the resource:ops verb grammar
above cannot express. Six names are recognized in this release, and the list is closed and
versioned with the API: a later release may accept further names, and a name this release does
not accept is rejected when you author the clause, not stored inert.
| Capability | Effect |
|---|---|
member-lifecycle | Create and remove identities in your tenant — and, for a still-pending invitation, resend it (rotate its token, extend its expiry) or attach an existing member to an additional app context. Bounded by the app contexts the credential can reach — an ordinary grant where app contexts separate your apps, but not where you have modelled your own customers as separate app contexts. Adding someone who already has an identity resolves them by email/externalId; it does not let the credential look an identity up on its own. |
forensic-read | Read the access log across every context in your tenant, by the credential that performed each read. Bounded by the tenant, not the credential's app context — crossing contexts is the point. Confers nothing on its own: the endpoint still requires the access-log:r action, so the effective grant is access-log:r plus this name. Grant it to an audit or support role. |
context-directory-read | Read your tenant's app context directory (which contexts exist, their roles and access profiles, and which contexts a given principal is in) across every context, not just the credential's own — see GET /v1/principals/{id}/profiles below for its concrete effect. Bounded by the tenant. Confers no data-plane reach at all (records, documents, folders, schemas, entities) and no write. Grant it only to an admin or support role. |
delegate-mint | Mint a scoped API key (ssk_*) bound to a different principal than your own via POST /v1/admin/keys/scoped — see Scoped key lifecycle below. Bounded by the tenant: the credential it lets you mint literally is another principal, wherever that identity is later used. The target must still already be a member of your own app context, and the minted key's effective scope can never exceed your own — this capability lifts only who the minted key may be, not what it may do. Grant it to an operator or automation role that provisions credentials on other members' behalf. |
delegate-principal-stamp | When creating a record, stamp a userId belonging to someone else in your tenant instead of only your own. Without it, a userId on create must match the credential's own identity or the request is rejected. Confers nothing by itself: the same data_scope that already authorizes the write must also explicitly name userId (a clause silent on userId still denies), so you keep exact, per-value control over which principals may be named — but the capability itself doesn't have to live on that same clause; declaring it anywhere on the role (including a separately-composed roleIds entry) is enough, only the write's own verb+userId admission must be co-located. Confers no ability to act as that person — only to label a record as theirs, which becomes usable once they authenticate with their own credential. Bounded by the same app context and record type your data_scope already confines the write to — unlike four of the other five, it is not tenant-wide. Applies to record creation only; an update can never reassign userId, with or without this capability. Grant it to an admin role that provisions membership-shaped records on behalf of known users. |
trigger-control-plane-grant | Let a trigger rule's own grant (its scopes/composed role) name a control-plane resource — keys, profiles, users, app-contexts, access-log, or scripts, any op — or the bare wildcard *, which otherwise names every resource. Without it, POST/PUT /v1/triggers refuses such a grant outright. Bounded by the tenant: a trigger's grant executes unattended on every matching record write for as long as the rule exists, which is a materially different exposure from a human exercising the same scope once. Grant it only to a role that deliberately author control-plane-reaching triggers. |
Three rules govern the field, all fail-closed:
- An absent or empty list grants no capability. Absence is silence, not a wildcard.
- An unrecognized name denies that whole clause rather than being ignored — a typo fails loudly at the first request instead of quietly downgrading the grant to its surviving verbs.
- A
"*"inallowed_actionsconfers zero capabilities."*"is not a wildcard in this list, and is rejected as a name; name each capability explicitly. Where a capability acts on individual records, the clause'sdata_scopenarrows it too —delegate-principal-stampis the one name in this release where that narrowing genuinely applies (per the exactuserIdvalues the clause names); the other five are unaffected bydata_scope.
On self-signup — and this cuts one way, not all six: a role carrying forensic-read,
context-directory-read, delegate-mint, or trigger-control-plane-grant is refused when a
self-signup policy creates a profile, so no new self-signup can be created against such a role.
member-lifecycle and delegate-principal-stamp are the deliberate exceptions — both are bounded
by app context rather than the tenant, so a self-signup role may carry either; granting
member-lifecycle to your default self-signup role is the intended way to let a founder sign up
and then invite their own team, and delegate-principal-stamp lets that same founder provision
membership-shaped records for the people they invite. For the four refused names, the check runs
at profile-creation time rather than continuously — adding one of them to a role your users can
already sign up to grants it to those existing members at their next token. Treat re-scoping
such a role as the authoring decision it is.
assignable_roles
(0.43.0+) A scope clause — in a role, an access profile, or a scoped-key scope — accepts an
optional assignable_roles array: a roleId allow-list restricting which named roles that clause
may compose into a delegated access profile (roleIds composition). It is orthogonal to
allowed_actions/data_scope — it narrows which roles the clause's authority may hand out, not how
much data it reaches. Capped at 20 entries. Absent or null means no roleId restriction, which is
the default and matches how every clause authored before this release behaves.
You opt in by adding the field to a clause that grants role- or profile-creating authority — which
is profiles:c: roles live under an app context (/v1/app-contexts/{contextId}/roles) and are
governed by the same scope, so there is no separate roles resource to grant.
⚠️ Adoption is one-way for that clause, and the order matters. Once a clause carries
assignable_roles, it can compose only the roles the list names — so name every roleId the restricted clause still needs before you add the field, not after. "Unchanged behavior" holds for non-adopters only.
One further condition catches a common shape: a composed role that itself grants role-composing
authority — profiles, users, keys or triggers with c, u or d, or a wildcard * — must
also carry an assignable_roles no broader than yours. users:c is "can invite team members", so
this is not exotic. Without it, composing such a role would hand the delegate unrestricted composing
power one hop later, which is exactly the escalation the field exists to close. An ordinary role
that grants no composing authority of its own needs no change, and composing one keeps working
exactly as it does today.
dataScope
A map from an ownership dimension to the list of allowed values. userId is its own fixed
dimension; every other dimension is a namespace, keyed scope:<namespace>:
{ "dataScope": { "scope:client": ["client_abc", null] } }
| Key | Meaning |
|---|---|
userId | Confine to rows owned by the listed users. |
scope:<namespace> | Confine to rows whose scopes include one of the listed <namespace>:<value> entries — scope:org, scope:client, or any namespace you registered. |
- Enforced as a server-side filter below any caller-supplied filter; cannot be widened by the caller.
- Multiple values in one dimension's list → union (OR). Multiple dimensions → intersection (AND).
- Strict by default. A scoped credential must include the matching filter on every list
and search call (
?userId=, or?scope=<namespace>:<value>for a namespace dimension), or the request is rejected (e.g. "scope:org is required by token scope"). nullsentinel. Include JSONnullin a dimension's value list to additively grant access to tenant-level rows (no value for that dimension). Opt-in only — never implicit.?scope=takes one entry per query — filtering on two namespace dimensions at once in a single call isn't expressible;?userId=plus one?scope=is. Repeated?scope=may land later.- Reads vs. writes on an unnamed dimension. A dimension
dataScopesays nothing about doesn't narrow reads — but it also grants no way to write into it. To place data in a given dimension, that dimension must be named indataScope, either explicitly or via the"*"default-dimension form (a role clause's{"*": [...]}entry — see Role fields above).
The bootstrap scope gate (data-plane allowlist)
The CLI / blueprint bootstrap flow mints scoped keys only for the data plane. The allowlist is exactly:
records, schemas, search, documents, folders, inference, entities
entities grants ordinary per-namespace identity-entity CRUD — creating/reading/updating/
deleting org/client/or any namespace you registered — gated by the same
dataScope/ownership rules as records. What it does not grant is any authority over
the namespace registry itself: registering a new namespace (POST/PUT/
DELETE /v1/namespaces) is root-key-only regardless of any scope a token carries, and
namespaces is not a resource the bootstrap loader will ever mint (see above).
Any other resource — the control plane keys, profiles, app-contexts, users,
billing, admin, namespaces, or any unrecognized resource — and the literal * are
hard-rejected: the bootstrap mints nothing and exits non-zero. There is no override
flag; control-plane scoped keys are created deliberately in the developer portal.
Access profiles — client.auth.*
The per-principal, per-context permission binding.
| Method | Purpose |
|---|---|
createAccessProfile | Create/bind (idempotent by (context, principalId)). |
getAccessProfile | Fetch one by (contextId, principalId). |
updateAccessProfile | Update scopes/role/status/overrides. |
deleteAccessProfile | Remove the binding. |
listAccessProfiles | List a context's profiles; enveloped. |
listProfilesForPrincipal | Cross-context: every context a principal is bound to; enveloped. |
Profile fields
| Field | Meaning |
|---|---|
principalId | The bound principal: usr_<userId> or key_<keyId>. Must start with usr_ or key_; structural characters like : are rejected with 400. |
scopes | Inline clauses, each { allowed_actions: string[] } (snake_case on the wire). XOR with roleId/roleIds. |
roleId | Reference to a single reusable role. XOR with scopes/roleIds. Must name a role that exists in the context — a roleId with no matching role is rejected with 400 naming it, on create, update/upsert, and when a scoped-key mint resolves the bound profile's role. (Previously accepted and stored, leaving a profile that could never authenticate — that gap is closed.) Deprecated as of 0.41.0 in favor of roleIds — still accepted, but sending both in the same request is 400. |
roleIds | (0.41.0+) Reference to one or more reusable roles, composed additively: the effective grant is each named role's own clauses, concatenated in the order listed — never merged, so each clause keeps meaning exactly what its own author wrote. Every id must name a role in the same app context, and no id may repeat. XOR with scopes; deprecated-but-compatible with roleId (sending both is 400). Reads always return roleIds, and return roleId too only when exactly one role composes. |
status | active or suspended. Suspending denies access without deletion — and (0.43.0+) it stops NEW credentials being issued against the profile immediately, on both issuance paths: createScopedKey is refused 409, and the token exchange is refused with its usual uniform 403 invalid_grant. Both read this field live. Credentials issued BEFORE the suspension are a separate matter and unchanged: they may keep working for up to five minutes while the access-profile cache expires, and an st_* keeps its own one-hour lifetime regardless. So suspension is immediate containment against new credentials and eventually-consistent against existing ones; to stop a specific credential now, revoke that credential. |
assumable | (0.41.0+) Only valid alongside inline scopes — the entitlement grant POST /v1/auth/token/assume reads, same grammar and 403 authoring-subset rule as Role.assumable (below). Setting it on a roleId/roleIds-composed profile is rejected outright with 400 (it would never be read); author the grant on the referenced role(s) instead. |
identityOverrides | Ownership values stamped onto what the principal touches — each a bare entity-id string keyed by scope:<namespace> (e.g. {"scope:org":"<entity-id>"}), reserved (scope:org, scope:client) or one you registered, up to two total. The value is authorized like scopes/roleId — and this applies even to an update whose body carries only identityOverrides: a scoped credential may override only to a scope:<namespace> value it itself holds (403 otherwise); a root key's override value must reference an entity that exists (400 naming the value otherwise). userId and the tenant id are sacred and rejected with 400. The same authorization also runs on the value being replaced: changing or clearing an entry — including wiping identityOverrides via an empty map — is 403 unless the caller holds the value it would overwrite, and deleteAccessProfile is refused the same way if the profile's identityOverrides holds a value the caller doesn't hold. Unaffected: giving your own identity to a profile with none, editing or deleting a profile whose identity is already yours, omitting identityOverrides entirely, and root API keys (exempt throughout). |
identityProjection | (0.43.0, READ-ONLY.) IdP-asserted identity fields projected onto this profile, per the app context's identityProjectionClaims declaration (see App contexts, above). Filled in once, the first time a sign-in for this principal can supply a value — usually at profile-creation, but for an invited member not until they actually accept and sign in for the first time. Once filled, it does not update again even if the declaration or the underlying identity data changes later. Absent when the context declares no projection, when none of the declared names have a captured value for this principal yet, or before this principal's first successful sign-in. Cannot be set or changed via createAccessProfile/updateAccessProfile — sending it in either request body is silently ignored; the stored value is untouched. |
XOR enforcement. A profile carries exactly one of inline scopes or a role reference
(roleId/roleIds). Updating to set one clears the other (empty-string / empty-array
sentinels). On create, the unset half is absent or an empty sentinel. roleId is a
deprecated-but-compatible derived mirror of roleIds as of 0.41.0, not an independent third
option: send either, never both (400), and a read always returns roleIds, returning roleId
too only when exactly one role composes.
Idempotency. A repeat create for an existing (context, principalId) returns the
existing profile unchanged.
Cross-context lookup — narrowed for another principal. listProfilesForPrincipal({ principalId }) (GET /v1/principals/{id}/profiles) returns every profile for that principal
across all contexts when you're looking up your own principal, or when the calling
credential holds the context-directory-read capability (see Granted
capabilities, above) — same as before. Holding profiles:r alone is
not enough to enumerate a different principal's access across contexts: a context-confined
credential looking up another principal instead sees only that principal's profile in the
credential's own context, at most one result. If you rely on profiles:r alone to enumerate
another principal's access across contexts, grant context-directory-read to the calling role as
well. A principal with no visible profiles returns an empty array (200), not 404. A
malformed principalId (e.g. containing :) → 400.
CLI equivalents (vectros access)
vectros access grant --principal <usr_|key_> --context <c> (--role <r> [--role <r2> ...] | --actions <csv>)
vectros access revoke --principal <usr_|key_> --context <c>
vectros access list (--context <c> | --principal <usr_|key_>)
vectros access get --principal <usr_|key_> --context <c>
vectros access explain --principal <usr_|key_> --context <c>
--role and --actions are mutually exclusive (exactly one option, but --role is
repeatable — 2+ compose additively, sending roleIds to the platform, same semantics as
the wire field above). --actions mints a single-clause inline profile. access list
requires exactly one of --context (a context's members) or --principal (a principal's
contexts). Bind identityOverrides with
--identity-overrides '{"scope:org":"...","scope:group":"..."}'.
Roles — client.auth.*
Reusable, context-scoped, identity-agnostic permission shapes.
| Method | Purpose |
|---|---|
createRole | Create (idempotent by roleId). |
getRole | Fetch by (contextId, roleId). |
updateRole | Update name / scopes. |
deleteRole | Delete — blocked with 409 while a profile still references the role. |
listRoles | List a context's roles; enveloped. |
Role fields
| Field | Meaning |
|---|---|
roleId | The stable role handle within the context. |
name | Human-readable name. |
description | Optional. |
scopes | One or more clauses, each { allowed_actions: string[] }. Roles may be multi-clause — any clause that matches grants access. |
assignableRoles | (0.43.0+) Optional roleId allow-list on a clause, restricting which named roles that clause may compose into a delegated access profile. See assignable_roles above for the adoption order and the composing-authority condition. |
assumable | (0.41.0+) Optional map naming which values, per scope:<namespace>, a holder of this role may assume via POST /v1/auth/token/assume (below): {"scope:org": ["org_engineering", "org_sales"]}. The principal (userId) can never be named. Omitting it grants no assumption of anything. A non-root credential authoring an assumable grant broader than its own live composed reach gets 403. Also directly authorable on an inline-scopes AccessProfile (same 403 rule); setting it alongside roleId/roleIds is rejected outright with 400 (it would never be read — the role-reference path sources the grant from each referenced role's own assumable, not the profile's) — author the grant on the referenced Role(s) instead. |
Role clauses accept a placeholder grammar in data_scope, in addition to literal values:
| Placeholder | Resolves to |
|---|---|
${{ self.userId }} | The acting principal's own userId. |
${{ self.scope.<namespace> }} (e.g. ${{ self.scope.org }}) | The acting principal's own value in that namespace. |
${{ any }} | Any value present in that dimension — deliberately not a row with no value there. Combine with null in the same list to also match owner-less rows. |
${{ under.self.userId }} | A value whose immediate parent is the principal's own userId. One level only, not a full ancestor walk. |
${{ under.self.scope.<namespace> }} | A value whose immediate parent is the principal's own value in that namespace — e.g. lets a credential confined to an org act on the clients under it without naming each client at mint time. |
A dimension key of "*" (e.g. {"*": ["${{ any }}", null]}) states a default rule for
every dimension the clause doesn't name explicitly; a dimension named directly in the same
clause always takes precedence over the "*" default. A null data-scope sentinel
additively grants tenant-level (owner-less) records. Any ${{ ... }} spelling other than
the forms above is rejected at authoring time with a 400 — it is never silently stored
as a literal that matches nothing. These placeholder forms are authored through blueprints.
Referential integrity. Deleting a role referenced by a profile is rejected with 409 — remove or re-point the profile first. Deleting an unreferenced role succeeds; the platform does not cascade.
assumable's own value grammar (0.41.0+) is deliberately narrower than data_scope's
above: a plain literal, ${{ under.self.userId }}, or ${{ member.scope.<namespace>[:level] }}
— never ${{ under.self.scope.<namespace> }} (it resolves against the caller's current value
for a namespace /assume can itself change, so what it admitted would depend on what was last
assumed — that form stays valid in data_scope, where it's re-derived per write), a bare
${{ self.<dim> }}, or ${{ any }}, all rejected at authoring time.
CLI equivalents (vectros role)
vectros role create --context <c> --role-id <id> --name <n> --actions <csv> [--description <d>]
vectros role list --context <c>
vectros role get --context <c> --role-id <id>
vectros role delete --context <c> --role-id <id>
The CLI role create authors single-clause roles from --actions; assumable has no
role create flag today — author it via a blueprint's roleAssumable: block (see
clients/blueprints.md) or a direct API call. role get shows a
role's assumable grant when one is set.
Assuming an identity — POST /v1/auth/token/assume
Re-mints the presented st_* scoped token with exactly one identity.<namespace> value
changed — for a caller whose ROLE explicitly grants assuming more than one value in that
namespace (an invited hr-admin, or a multi-org case-handler) and needs to change which value
new writes are placed under.
| Request | {"scope:<namespace>": "<value>", ...} — one or more namespaces, canonical scope:<namespace> form, e.g. {"scope:org": "orgB"}. Each value must be a plain literal, never a ${{ ... }} placeholder. Naming more than one namespace requires a SINGLE one of your roles to grant all of them together — the combination is never assembled across two roles, because no role author would have vouched for it. |
| Credential | st_* only — a root API key or scoped API key (ssk_*) gets 403; neither needs this. |
| Entitlement check | Each requested value must be explicitly granted by the caller's role(s) assumable field for that namespace — a point check against the value requested, deliberately separate from what data_scope permits reading or writing. Holding broad data_scope reach in a namespace does not by itself grant assuming any value in it. Checked live, against the caller's roles as they are right now, not a copy captured when the presented token was minted. |
403 | The requested value isn't granted by any role backing the presented token. |
409 | The credential's basis changed since it was minted (the access profile or a role it composes from has since been edited, suspended, or deleted). Re-authenticate for a current token, then retry. |
| Chaining | A token produced by /assume can never assume again (403) — every assume starts from the token you exchanged for. Keep your original token if you need to switch more than once. |
exp | Identical to the presented token's — this call can never extend a session's life. |
| Clause survival | Every clause whose reach does not depend on the namespace you're changing is preserved verbatim. A clause depends on a namespace when it filters on it, or when one of its values resolves against your own value for it (${{ self.scope.<ns> }}, ${{ under.self.scope.<ns> }} — which can sit under a different key than the one changing). A role that doesn't authorize the new value loses its clauses touching that namespace, even ones scoped to the value you already held. |
| Errors | Ordinary {"message": ...} shape — not the OAuth envelope POST /v1/auth/token/exchange uses. |
A fresh, independently-revocable jti is stamped on every call, and the token also carries a
root_jti revocation-lineage claim, so revoking the presented token closes the one token assumed
from it directly — consistent with chaining being capped at depth one (above): there is no deeper
chain for a revocation to need to reach.
CLI equivalent
vectros keyring/vectros join don't wrap this endpoint directly today — it's reachable via
the SDK's client.auth.assumeToken(...) (or POST /v1/auth/token/assume directly), and
packages/react's token cache wraps it as setPartnerApiTokenAssumer + a third argument to
getVectrosApiToken.
Trusted issuers — v1/auth/issuers
RFC 8693 OAuth token exchange: register a third-party IdP once, then let its users trade its
JWTs for Vectros st_* tokens without a Vectros credential of their own. See
explanation.md for the
concept and clients/blueprints.md for the blueprint-authored
path.
Version note. Shipped in 0.39.0. 0.40.0 confined every operation on this surface to the caller's own app context and added the token-exchange
context_iddisambiguator. 0.41.0 addedupdateIssuer(see Updating a registration, below) and issuer suspension — and, elsewhere on this page, added access-profileroleIdscomposition and thePOST /v1/auth/token/assume+Role.assumablesurface (see Assuming an identity, above). See the SDK's ownCHANGELOG.md(shipped in the@vectros-ai/sdkpackage) for the exact before/after. 0.45.0 made a registration withoutrestrictedToDomainstart aspending_verificationand addedverifyIssuer(see Proving control of an issuer, below); this page describes current behavior.
Issuer registration — client.auth.*
| Method | Purpose |
|---|---|
registerIssuer | Register (idempotent by issuerId). Root sk_* or the CLI bootstrap's provisioning capability only — never an ordinary grantable scope. |
getIssuer | Fetch one by issuerId. Same gate as register. |
listIssuers | List the tenant's issuers (or, for a context-confined caller, only its own context's); enveloped. |
updateIssuer | Update the mutable (non-trust-anchor) field set (subClaim, emailClaim, userinfoUri, status, selfSignupPolicies, capturedClaims, restrictedToDomain) — subClaim is identity-determining and is refused once the issuer has a bound user, and restrictedToDomain has rules of its own; see Updating a registration, below. Same gate as register. |
verifyIssuer | Prove control of a pending_verification registration, activating it (0.45.0+). Same gate as register. See Proving control of an issuer, below. |
deleteIssuer | Deregister. Refused with 409 if the issuer has ever bound a user. |
Fields (IssuerRequest, on register):
| Field | Type | Notes |
|---|---|---|
issuerId | string | Required. Your slug for this registration within your tenant: 3-31 chars, lowercase letter first, then lowercase letters/digits/hyphens. Immutable once registered; the idempotency key. |
issuer | string | Required. The IdP's iss claim value, exactly as its tokens carry it. |
jwksUri | string | Required. The IdP's remote JWKS endpoint — where Vectros fetches the public keys it verifies signatures against. Must use the https:// scheme — an http:// endpoint is refused with 400, since a plaintext fetch lets an on-path attacker substitute the signing keys this platform trusts for the issuer. |
audience | string | Required. The aud claim a presented subject_token must carry. Must be globally unique in combination with issuer, across every tenant — use a distinct audience per environment/context sharing one IdP account. |
contextId | string | Required. Which app context an exchanged token targets; must already exist. A provisioning-capability credential may name only the context it's bound to (403 otherwise); a root key may name any of its contexts. |
subClaim | string | Optional. The claim carrying the subject identifier. Defaults to sub. |
emailClaim | string | Optional. The claim carrying the subject's email, used for first-login invite matching. Defaults to email. |
userinfoUri | string | Optional. The IdP's OIDC userinfo endpoint. Presented tokens are access tokens, which don't carry email under OIDC unless the IdP was specifically configured to add it — if emailClaim misses on the presented token and userinfoUri is configured, Vectros falls back to calling this endpoint (presented token as bearer credential) and reads emailClaim from its JSON response. Omit to leave the fallback disabled. Must use the https:// scheme (0.44.0) — an http:// endpoint is refused with 400, since the request carries your presented token as a bearer credential and a plaintext fetch lets an on-path attacker both harvest it and control the response this platform trusts back. Unlike the trust-anchor fields, this is a plain safe field: fix a legacy http:// value with a single PUT {"userinfoUri": "https://…"} — that works even on a registration with bound users, no context migration or operator step needed. |
selfSignupPolicies | array | Optional. {signup_type, role_id} pairs — see Self-signup policies, below. Omit to leave self-signup disabled (the default). |
capturedClaims | array of string | Optional (0.43.0). Additional OIDC claim names — beyond emailClaim, which keeps its own dedicated field — to capture from this issuer's tokens on every successful token exchange and store as your tenant's golden identity-provider-asserted copy for the signed-in user. Not a fixed set: name whatever claims this IdP actually asserts (standard, e.g. name/phone_number/address, or your IdP's own custom claims). Each claim is read from the verified token first, falling back to userinfoUri (if configured) only for names still missing after that. Omit entirely to capture nothing beyond email (the default). |
restrictedToDomain | string | Optional. A domain your account has already verified. Scopes this registration's (issuer, audience) uniqueness to that domain, so the registration is active at once instead of pending_verification. It routes only tokens whose hd claim equals the domain, so it can only serve users of a domain you own — it proves control of the domain, not of the issuer itself. A PUT may change it or clear it (""); clearing it — making the registration unrestricted — is refused unless the registration has itself proven control of the unrestricted pair (it was registered without a domain and verified). |
Fields (IssuerResponse, on read): every field above, plus:
| Field | Type | Notes |
|---|---|---|
status | string | active | suspended | pending_verification. A registration without restrictedToDomain is pending_verification on create (0.45.0+) and accepts no tokens until verifyIssuer succeeds; it can't be changed by PUT (a request that names any other status is refused with 400). A suspended issuer stays registered but its tokens are no longer accepted for exchange, identically to an unregistered issuer — a token-exchange caller can't distinguish the two. A domain-scoped registration reads active on create; set an active one to suspended (and back) via updateIssuer. Suspending doesn't revoke any already-bound user's existing access directly — it stops future exchanges through this issuer from succeeding. |
verificationClaim | string | Present only while pending_verification. The token claim your IdP must stamp verificationNonce into: https://vectros.ai/claims/issuer_challenge. Fixed by the platform, never chosen by the registrant. |
verificationNonce | string | Present only while pending_verification. The one-time value that claim must carry. Not a credential — it proves nothing unless it arrives inside a token signed by the issuer's own keys. |
verificationExpiresAt | string | Present only while pending_verification. ISO-8601 UTC; 7 days after registration. |
createdAt | string | ISO-8601 UTC. |
created | boolean | Create-response only: true for a new registration, false for an idempotent echo. The HTTP status mirrors it — 201 vs 200. Absent on reads. |
Proving control of an issuer — verifyIssuer (0.45.0+)
POST /v1/auth/issuers/{issuerId}/verify with { "token": "<jwt>" } activates a
pending_verification registration. The call succeeds only if all of these hold:
- The registration is
pending_verificationand its challenge has not expired (7 days). - The issuer's OpenID Connect discovery document (
<issuer>/.well-known/openid-configuration) is reachable overhttps://, names exactly the registeredissuer, and publishes ajwks_urithat is exactly the registeredjwksUri. The comparison is exact — no case, slash or query normalization. - The token verifies against that key set with the registration's
issuerandaudience(signature, expiry,iss,aud). - The token carries the claim
https://vectros.ai/claims/issuer_challengeas a string equal to the registration'sverificationNonce. - No other registration already holds the
(issuer, audience)pair (including a registration that predates the pair claim and is active without one), the registration's app context is not being torn down, and no different registration is already active in that context.
The token is discarded after the check — never stored, logged, or used to sign anyone in. Any failure
leaves the registration pending_verification and gives back any pair claim it took, and returns 400 with a message
about your own registration or token; a registration that isn't yours, or doesn't exist, returns 404; a
registration changed or removed while it was being verified returns 409 (this request activated nothing;
read the registration back — another verification may already have activated it — otherwise retry). The request
body is limited to 8 KB.
Same gate as register: a root sk_* or the CLI bootstrap's provisioning capability.
Uniqueness and limits:
(issuer, audience)is globally unique, across every tenant, and is claimed when a registration becomesactive. A registration withoutrestrictedToDomainclaims nothing while it ispending_verification— registering never reveals whether another tenant holds the pair. The claim is taken byverifyIssuer, so a pair another registration already holds is refused there with 400, regardless ofissuerId. A registration scoped withrestrictedToDomainclaims its(issuer, audience, domain)triple when it is created.- A tenant may hold at most 50 issuer registrations at once (0.44.0). Registering past that
cap is rejected with 400; deregister an unused issuer to free a slot. Bounds how many
globally-unique
(issuer, audience)pairs one tenant account can hold at a time — does not, by itself, prove anyone controls an issuer (see the note onjwksUriabove and the tracked follow-up on issuer-control verification). - An
issuerIdcan be permanently retired (0.44.0). If a Vectros operator ever force-releases a registration that had bound users (the platform's response to a squatted(issuer, audience)pair — see Delete, below, for the ordinary "has anyone bound" guard this bypasses), that exactissuerIdcan never be registered again under the same tenant — 400, naming the reason. This exists so a registration's bound users can never be silently re-pointed at a new trust anchor by whoever registers next under the same slug; register under a differentissuerIdinstead. - The idempotent echo doesn't cross a context boundary. Re-registering with the same
issuerIdnormally echoes the existing registration back (created: false). If a context-confined caller'sissuerIdhappens to collide with a registration it doesn't own (a sibling context's), that collision now fails with 400 instead of echoing the other context'sjwksUri/audienceback to a caller that shouldn't see them. - One active issuer per app context. Registering a second issuer against a context that
already has an active one is rejected with 400 — deregister the existing one first to
replace it (only possible while it has no bound users — see Delete, below, for what to do
once it does). The reverse — one issuer serving several contexts, each via its own registration
row and a distinct
audience— is supported and unaffected. - Delete is refused if the issuer has ever bound a user (self-signup or invite-then-exchange,
through this issuer) — 409, unconditionally of the affected users'
status(suspending them first does not lift the refusal). There is no self-service, same-context replacement for a bound registration: the context's one-active-issuer claim above is released only when THIS registration is deleted, so a second issuerId targeting the samecontextIdis refused for the identical reason, no matter what you name it. Real options: suspend this issuer (stops new exchanges immediately) and register a replacement under a different app context with a new(issuer, audience)pair (a distinct context — this context's existing users/roles/access profiles are not reachable from it). A Vectros operator can force-release a bound registration instead — but this permanently retires itsissuerIdand leaves its bound users unable to ever exchange through it again; their existing accounts are lost either way, so this buys nothing over the different-context option above beyond staying in the same app context. (The(issuer, audience)pair itself does become claimable again — just never under thisissuerId, for this tenant, again.) An issuer that has never completed a successful exchange can always be deregistered. - Cross-context confinement (0.40.0). A credential confined to one app context (the
bootstrap token) sees, registers into, and deregisters only issuers in its own context; naming
or listing a sibling context's issuer behaves exactly as if it doesn't exist (404 on
get/delete/update, silently absent from list). A root API key is unaffected.
@vectros-ai/cli0.16.0+ re-mints the bootstrap credential, pinned to a blueprint's own context, when itsissuers:block targets a non-defaultcontext — see clients/blueprints.md. - An ordinary bootstrap token with no explicit context binding resolves to
defaultspecifically, not "every context".getIssuer/listIssuers(andupdateIssuer/deleteIssuer) called with such a token only ever see issuers registered under thedefaultapp context — a 404 (get/update/delete) or an empty list result for any issuer registered under a different context is expected, not a bug, and re-minting the bootstrap credential pinned to that other context (as above) is what's needed to reach it.
Self-signup policies. Each selfSignupPolicies entry is {signup_type, role_id} — a
caller-nameable slug (same grammar as a role id) paired with the role a brand-new,
no-invite-required first-time exchange binds to. No entry may ever resolve to a role carrying
elevated (provisioning, wildcard, or a tenant-management resource — keys, profiles, users,
app-contexts, access-log) scope: checked, best-effort, when you register the policy (skipped
if the role doesn't exist yet), and unconditionally re-checked, unskippable, at the moment a
caller actually signs up. See
explanation.md
for why a caller-supplied signup_type carries no escalation risk on its own.
Claim capture (0.43.0). capturedClaims names any additional OIDC claims — beyond email — you
want Vectros to capture from this issuer's tokens, re-captured on every successful exchange for that
subject so it stays current with what the IdP asserts. There's deliberately no fixed vocabulary:
name whatever your IdP actually provides. Captured values are never returned by any read API (there's
no users:r-equivalent surface for them, by design) — the sanctioned way an app reads them is a
future, separately-declared, read-only projection onto its own membership roster, not a direct read
of the golden copy itself.
Scoping a registration to a verified domain — restrictedToDomain (0.44.0)
restrictedToDomain is an optional field on registerIssuer that ties a registration to a domain your
account has already verified. It is the alternative to proving control of the issuer (above): a
domain-scoped registration is active as soon as it is created, but it only ever serves that domain's users.
- The domain must be verified for your account first. Verification is done by your account owner in the developer portal under Domains — the same place webhook domains are verified — not with the API credential that makes the register call. Naming a domain that isn't verified is refused with 400. The value is trimmed and compared in lowercase.
- It claims its own triple at register. The registration claims
(issuer, audience, domain), not the bare(issuer, audience)pair, so an unrelated registration on the same pair never blocks you: the same(issuer, audience)can be registered once per verified domain, and beside a registration that has no domain. Registering a triple another registration already holds is refused with 400 — use a distinctaudienceper environment or context sharing one IdP account. Deleting the registration frees its triple. - It routes only matching tokens. At token exchange, a domain-scoped registration is considered only when
the presented token carries an
hdclaim equal to the domain (compared case-insensitively) — the OpenID Connect hosted-domain convention, which Google emits natively for Workspace accounts; other IdPs need a claims-mapping rule that emits a claim literally namedhd. A token with nohdclaim never matches it, and when both a domain-scoped and a domain-less registration match a token's audience, the domain-scoped one wins. - It proves control of the domain, not of the issuer. That is why it can be active at once, and why it cannot serve anyone outside the domain.
Changing it later is an updateIssuer — see Updating a registration, below.
Updating a registration — updateIssuer (0.41.0)
updateIssuer(issuerId, body) is a partial update over a deliberately narrow field set: a
field omitted from body is left unchanged.
Updatable ("safe") fields — emailClaim, userinfoUri, status, selfSignupPolicies,
capturedClaims. None of these carries retroactive blast radius on an already-bound user in the
sense of a trust-anchor change; they only take effect at the next first-login/exchange (claim
mapping, the userinfo fallback endpoint, self-signup eligibility, which additional claims get
captured) or change whether the issuer's tokens are currently honored at all (status).
capturedClaims is the exception worth calling out explicitly: each successful exchange
re-captures against the list as CURRENTLY configured, so narrowing it — not just clearing it to []
— drops a no-longer-listed claim from the stored copy on that user's next exchange, not just stops
adding to it. Pass an empty list to stop capturing anything beyond email; pass a narrower non-empty
list to intentionally retire specific claims from future captures (and, on each affected user's next
login, from what's already stored).
restrictedToDomain is updatable, with rules of its own (see Scoping a registration to a verified
domain, above). It changes only which tokens are routed to the registration from then on — never an
already-bound user's trust anchor or identity. A new non-blank value must be a domain your account has
verified and whose (issuer, audience, domain) triple no other registration holds — 400 otherwise.
An empty string ("") clears it, making the registration domain-less; that is refused with 400 unless the
registration holds the unrestricted (issuer, audience) pair — that is, unless it was registered without a
domain (and, from 0.45.0, verified). A registration created scoped to a domain never held that pair, so to
move it to an unrestricted registration, register a new issuer without restrictedToDomain and verify it. A
registration still pending_verification can't be re-scoped at all — verify it, or delete it and register
again. Supplying the current value back is a no-op.
subClaim is updatable, but is NOT safe in the sense above — it IS identity-determining. It
names which verified JWT claim POST /v1/auth/token/exchange reads as the federated subject before
composing the internal identity key (issuerId + that claim's value). Changing it on an issuer that
already has bound users would silently re-identify — or, under self-signup, orphan — every one of
them. A subClaim change is therefore refused with 400 once any user has ever bound through the
issuer, the same "has anyone bound" check deleteIssuer already applies (see NOT updatable,
below, for a related but different immutability shape) — and remains freely tunable before an
issuer's first real login. Supplying the current value back is a no-op, not a "change," even on a
bound issuer.
NOT updatable — issuer, jwksUri, audience (the cryptographic trust anchor: together they
decide which signing keys, and therefore which tokens, this platform accepts) and contextId (the
routing pin an exchanged token targets). Supplying one of these four in the request body with a
value that differs from the current registration is rejected with 400, naming the field —
it is never silently ignored, so a client can't believe a trust-anchor change took effect when it
didn't. Supplying the current value back (e.g. a client that reads the full object with getIssuer
and PUTs it back unchanged) is a no-op, not an error. To actually rotate a trust anchor, delete and
re-register the issuer under the same issuerId — which is itself refused with 409 while any
user is bound through it, so a live issuer's trust can't be silently swapped out from under its
users.
Confinement, gating, and the uniform-404 behavior on a sibling-context issuer are identical to
getIssuer/deleteIssuer (see Cross-context confinement, above). A selfSignupPolicies update
runs the same best-effort elevated-role check as registration — an entry may never target a role
carrying provisioning or wildcard scope.
Token exchange — POST /v1/auth/token/exchange
RFC 8693 OAuth 2.0 token exchange. Unauthenticated — no Vectros credential of any kind; the
subject_token itself is the credential. Uses the OAuth-standard error envelope
({"error": ..., "error_description": ...}, RFC 6749 §5.2), not this API's usual
{"message": ...} shape — its client is generic OAuth tooling, not the Vectros SDK.
TokenExchangeRequest fields:
| Field | Type | Notes |
|---|---|---|
grant_type | string | Required. Must be exactly urn:ietf:params:oauth:grant-type:token-exchange. |
subject_token | string | Required. The IdP-issued JWT to exchange. |
subject_token_type | string | Required. urn:ietf:params:oauth:token-type:jwt or urn:ietf:params:oauth:token-type:id_token. |
requested_token_type | string | Optional. Accepted and ignored — this contract mints exactly one token shape. |
invite_token | string | Optional. The inv_* token from a sub-user invite email, for a first-time subject completing that invite. If present, it's the only binding path tried — a failed invite never falls through to self-signup. |
signup_type | string | Optional. Selects a selfSignupPolicies entry for a first-time subject with no invite token. Omit when the issuer declares exactly one policy (the unambiguous default); required to pick among several. Ignored entirely for a caller with an existing identity or a presented invite_token. |
context_id | string | Optional (0.40.0). Selects which app context to target when the matched issuer is registered against more than one, each via its own audience. Omit when your token's aud claim matches only one registered context — the common case, unaffected by this field's existence. A mismatch (naming a context this issuer isn't registered against) is refused identically to an unrecognized issuer. |
TokenExchangeResponse fields: access_token (an st_* token), issued_token_type
(always urn:ietf:params:oauth:token-type:access_token), token_type (always Bearer),
expires_in (seconds — the same 3600s default/cap as every other st_*).
Errors specific to this endpoint:
| Code | When |
|---|---|
| 400 | Malformed body; missing subject_token; unparseable JWT; unsupported subject_token_type or grant_type; missing iss/aud claims; more than 8 candidate aud values on the token. |
| 401 | subject_token failed verification — bad signature, expired, iss/aud mismatch, or the registered JWKS couldn't be fetched. Deliberately generic; these causes are not distinguished in the response. |
| 403 | The token verified, but its subject resolves to no existing Vectros user, matches no pending invite, and either no self-signup policy is configured or signup_type matched none of the configured ones. |
| 404 | The iss/aud pair names no registered issuer (or, with context_id set, no registration in that context) — indistinguishable from a genuinely unregistered issuer. |
CLI / blueprint path
There is no standalone vectros issuer CLI command. Register an issuer through the SDK/API
directly with a root key, or declare it in a blueprint's top-level issuers: block (see
clients/blueprints.md) and let vectros bootstrap apply
it — the loader calls registerIssuer under the bootstrap credential's provisioning capability,
re-minting it pinned to the blueprint's own context when needed (0.40.0 confinement,
@vectros-ai/cli 0.16.0+).
Credentials
Types
| Prefix | Lifetime | Scope | Use |
|---|---|---|---|
sk_live_* / sk_test_* | Permanent (revoke to retire) | Wildcard within its tenant | Server-to-server from your own backend. |
ssk_live_* / ssk_test_* | Permanent (revoke to retire) | Bound to a profile; identity-bearing | Agents, bots, long-running workers; audit attribution. |
st_* | 1h default / 1h max | Embedded in the token | Front-end-safe per-session credentials. |
The raw secret of an sk_*/ssk_* is shown once at creation and never re-readable; the
platform stores only a hash.
Scoped key lifecycle — client.auth.*
| Method | Purpose |
|---|---|
createScopedKey | Mint an ssk_* for a principal that already has a profile in the context; raw secret returned once. The profile must be active and the user must not be SUSPENDED — either is refused 409 (0.43.0+), for every credential including a root key. A PENDING user is fine. Minting bound to your own principal needs no special authorization. Minting bound to a different principal additionally requires the calling credential's role to carry delegate-mint (see Granted capabilities, above) — 403 without it. A root key is unaffected by the delegate-mint requirement and may mint against any principal. |
getScopedKey | Metadata for one key (no secret). |
revokeScopedKey | Soft-delete; stops working within ~5 minutes (authorizer cache). |
listScopedKeys | The tenant's keys; enveloped (single page — no cursor input on this endpoint). |
createScopedKey fields: keyName, tenantId, contextId, userId (the bare principal
user id), optional label. A re-issue of an existing (tenant, context, principal, keyName)
tuple returns the key without the secret (the platform never re-discloses) — rotate to
get a fresh secret.
CLI equivalents (vectros key)
vectros key issue --principal <p> --context <c> [--name <n>] [--label <l>] [--format human|raw|env|json]
vectros key list [--principal <p>] [--context <c>]
vectros key get <keyId>
vectros key revoke <keyId>
vectros key rotate --principal <p> --context <c> [--name <n>] [--format …]
key rotate has no dedicated endpoint — it revokes the matching active key and mints a fresh
one. There is no in-place rotation.
Token minting — client.auth.mintToken. Root sk_* only.
const { token, expiresAt } = await client.auth.mintToken({
scope: { allowedActions: string[], dataScope?: {...}, identity?: {...} },
userId?: string, // mint on behalf of a real user in the tenant
contextId?: string, // target an existing app context (defaults to `default`)
expiresInSeconds?: number, // default 3600, max 3600
});
Minting a scoped token is a root-key operation end to end — a scoped key or token
(ssk_*/st_*) cannot call this endpoint at all, so there is no confined credential for
contextId to let escape its own context.
| Field | Meaning |
|---|---|
scope.allowedActions | Required; array of resource:ops[:qualifier] strings. Malformed entries → 400 at mint. |
scope.dataScope | Optional; { userId: [...] } and/or { "scope:<namespace>": [...] } entries — the data the token may read/write. |
scope.identity | Optional; { userId: "..." } and/or { "scope:<namespace>": "..." } — ownership values stamped onto resources the token creates. |
userId | Optional; must reference a real user in the caller's tenant (unknown id → 400 naming the field). |
contextId | Optional; the app context the minted token operates in. Omit to inherit default. Must reference an existing context — an unrecognized value returns a uniform 404. Combine with an omitted userId/scope.identity to mint a token that creates ownerless (unattributed) resources in that context — e.g. the shared lineage base for a new record type (see basedOn in the data model reference), which every other owner's schema of that type then points back to. |
expiresInSeconds | Optional; default 3600 (1h), capped at 3600 (1h) — a larger value is rejected with a 400. |
Returns { token: "st_…", expiresAt: <unix-seconds> }. Tokens cannot be revoked in flight —
expiry is the only lever; mint short.
resolvedScope (0.43.0+). POST /v1/auth/token, POST /v1/auth/token/exchange and
POST /v1/auth/token/assume each return an additional resolvedScope object alongside the
minted / exchanged / re-minted token:
resolvedScope: { allowedActions: string[], identity: Record<string, string> }
It is the same plaintext data baked into the token's own (compressed) scope claim, resolved
server-side — so you no longer decode the token to read what it actually carries.
allowedActions is the union of allowed_actions across every clause the token holds (["*"]
for a wildcard-scoped credential); identity is the token's own identity claim, keyed by the
same public dimension names you send on mint (userId, scope:<namespace>). Purely additive —
every existing response field is unchanged.
Identity-binding check — client.auth.ping
Returns the authenticated principal's identity: status, tenantId, environment,
principalType (root_key | scoped_key | token), and principalKeyId. For a
scoped_key, allowedActions is present, and dataScope.scopes reports its resolved
scope:<namespace> bindings if any. For a token, tokenExpiresAt is present. An invalid
credential is denied at the edge with 403.
Error codes
| Code | When |
|---|---|
| 400 | Malformed contextId / principalId / scope token / namespace name; an unrecognized ${{ ... }} placeholder spelling in a role's data_scope; a scope value with punctuation outside letters/digits/_/-, or not starting with a letter or digit; userId/ownership id not a real row in the tenant; a roleId that doesn't reference an existing role, on profile create/update/upsert, an invite's accessProfile, or a scoped-key mint bound to it; a scopes entry naming an entity's own namespace (a parent must cross namespaces); a scope:<namespace> value that doesn't resolve to a real entity in an entity-backed namespace — validated on update as well as create, naming the missing parent; identityOverrides.userId (sacred field); context delete without a matching confirm; more than two scopes/parent dimensions; a namespace register/update omitting specificityRank, or reusing one already taken in the account; a malformed invite email, a non-https or IP-literal acceptUrl, or a ttlSeconds outside 1h–30d; a plain update attempting to change email on a PENDING user; a status outside ACTIVE/SUSPENDED on user or entity update as well as create (0.43.0+ — the update paths previously stored the value verbatim), including the empty string; status: PENDING on PUT /v1/users/{id} (0.43.0+), which is server-managed and rejected even when it is the value the user already holds — omit status when updating a pending user's other fields. |
| 403 | Invalid or unauthorized credential; a scoped action the credential's scope does not permit; a create or update whose resulting ownership placement no single scope clause of the credential permits — including attributing an item to another user without a clause constraining the userId dimension, and setting a scopes/identityOverrides value the scoped credential does not itself hold; changing, clearing, or deleting an identityOverrides value the caller doesn't itself hold; minting a scoped key bound to a different principal without the calling credential holding delegate-mint (minting bound to your own principal is unaffected); a granted_capabilities entry naming an unrecognized capability (denies the whole clause) or naming "*" (never a wildcard in this list); an invite's resolved accessProfile (its roleId's scopes, or its inline scopes) exceeding the caller's own scope; updating or deleting a namespace without a root key, or registering one without a root key or one of the CLI bootstrap's two independent namespace-provisioning capabilities; reading OR writing a namespace registration, or reading, creating, updating, or deleting an entity, with ?contextId= naming a context other than a context-confined caller's own — refused before the lookup ever runs, so a confined caller can never reach a sibling context's rows to get a 404 for them instead. Messages are uniform on purpose — they do not reveal which check failed. |
| 404 | Get on a non-existent (or cross-tenant) context/identity/namespace; list/create under a non-existent parent context; resendInvite on an (contextId, email) pair with no outstanding invitation; an entity or namespace registration that genuinely doesn't exist under the context actually used for the lookup (naming a different context than a confined caller's own is a 403, not this — see above). createScopedKey — naming a userId that doesn't exist anywhere in the tenant and naming one that exists but has no access profile in the named context now return the same 404 (the access-profile message, which stays actionable: "create one via POST /v1/app-contexts/{contextId}/profiles first") — a context-confined credential can no longer tell the two apart. Cross-tenant probes collapse to 404. |
| 409 | createScopedKey against an access profile that exists but is suspended, or for a user whose own status is SUSPENDED (0.43.0+, two distinct messages naming which). The remedy is to reactivate whichever the message names — the profile via PUT /v1/app-contexts/{contextId}/profiles/{principalId} with status: active (needs profiles:u), or the user via PUT /v1/users/{id} with status: ACTIVE — rather than to change your credential. Both refusals prevent ISSUANCE only: suspending the profile also stops credentials already bound to it within ~5 minutes, while suspending the user stops nothing that already exists (a user's status is never consulted when a credential authenticates), so revoke those keys explicitly if that is the containment you need. this is deliberately not a 403, since every 403 on that endpoint means your own credential is insufficient. Delete a role still referenced by a profile; delete a namespace, or flip it from entityBacked: true to false, while entities still exist in it; createInvite on an email already active/suspended in contextId, already invited under contextId without the caller holding users:r+users:u to resend it, or already resolving to a SUSPENDED member of a DIFFERENT app context in this SAME tenant (a deliberate lockout — never silently attached) — to a scoped credential (ssk_*/st_*) all these causes now return the same, undifferentiated 409. A root API key still receives the structured email_already_associated body for the cross-context-SUSPENDED cause (it can already list its own users directly, so the distinction discloses nothing new to it); if you branch on that error code, do so on a root key, or treat any 409 from this endpoint as "address unavailable within this tenant". An email resolving to an ACTIVE or PENDING member of a different app context in this SAME tenant grants/attaches access to contextId instead of 409ing — see Inviting a sub-user above — unless the caller lacks the scope that grant/attach itself needs (users:r for ACTIVE, users:r+users:u for PENDING), in which case it's a 409 too, same undifferentiated shape to a scoped credential. An email that already has an identity in your OTHER tenant is not one of these causes — createInvite succeeds and creates a second, independent membership. resendInvite has no 409 of its own — a non-matching (contextId, email) is a 404, not a collision. |
A scoped credential lacking permission for a list/search filter required by its dataScope
is rejected (strict scope) with a message naming the required dimension.
Notes & limits
What this surface does not do, stated plainly:
- No identity PATCH. Users and identity entities update via full-replace PUT only — there is no partial-update on the identity plane.
- No reparenting beyond
scopes. An entity's parents are set through itsscopesarray (full replace on update); there is no separate move/reparent operation, and a parent edge always names another entity's<namespace>:<id>— orgs and clients (and any namespace) are referenced by id, never nested through a path. - At most two
scopesentries per entity, and per record/document/folder. Same cap as the record ownership model always had; a value in an entity's own namespace is rejected as a "parent." ?scope=is single-valued. One<namespace>:<value>filter per list/search call; pair it with?userId=for a second dimension, but two namespace dimensions can't be filtered simultaneously in one call.- Single scope clause on
mintToken. The token-mint endpoint serializes one(allowedActions, dataScope)clause per request. Multi-clause shapes are expressed through a role (referenced by a profile) or a blueprint, not minted directly as a single multi-clause token. - Single-clause access-profile create. The profile-create API accepts one inline clause; reach for a multi-clause role for compound shapes.
identityOverridesisscope:<namespace>only, capped at two.userIdand the tenant id are sacred and rejected — a profile cannot forge another user's identity.resource:*grants nothing at runtime. Author explicitc/r/u/dletters. Only the bare literal*grants everything (and that is the root-key shape).sis an advanced op letter, beyondc/r/u/d. A fifth op,s, grants reveal of a type's sensitive fields (e.g.entities:s:orgreveals sensitiveorgpayload fields;customer:rssimilarly for a record type). It is a per-resource capability, not part of the standard CRUD set — the blueprint/bootstrap scope gate accepts onlyc/r/u/d, so packs are authored with those;sis minted deliberately where sensitive-field reveal is intended.- The namespace registry is never scope-mintable. Registering, updating, or deleting a
namespace always requires a root key — there is no
namespaces:c(or similar) scope to grant a lesser credential that ability. - No in-place key rotation. Rotation is revoke-then-reissue; a key's raw secret is shown once and never re-readable.
- Revocation is not instant. A revoked
sk_*/ssk_*keeps working until the edge authorizer cache expires — up to about five minutes.st_*tokens cannot be revoked at all; they expire on their lifetime. Plan offboarding with this window in mind. listScopedKeysis a single page — the endpoint takes no cursor input; filter client-side by context/principal.- Cross-tenant existence is unobservable. Probing for another tenant's id returns the same uniform 404 as a non-existent id; scope-mismatch failures return the same generic shape.
- This was a clean break, not an additive migration (0.35.0). The old
/v1/orgs,/v1/clients,orgId/clientIdfields,orgs:<verb>/clients:<verb>scopes, and the${{ self.orgId }}/${{ self.clientId }}placeholders are gone outright — there is no compatibility shim and no deprecation window. Every removed form fails loudly (a compile error against a generated SDK, or a400naming its replacement), never silently.
Where to go next
- explanation.md — the concepts: contexts as the isolation moat, the identity plane, namespaces, the scope model, and the three credential types.
- how-to.md — runnable guides for every method above.
- The generated API reference (rendered from the OpenAPI specification) — canonical, always-current request/response field shapes.
- The blueprint walkthroughs — end-to-end builds that wire contexts, profiles, roles, and scoped keys together.