@verevoir/access
v2.1.1
Published
Identity resolution, policy evaluation, and workflow state machines
Downloads
339
Readme
@verevoir/access
Identity resolution, policy evaluation, role storage, API keys, and workflow state machines. Zero runtime dependencies. Works with or without Verevoir.
npm install @verevoir/accessThis package is identity + authorization, not authentication. It consumes tokens that someone else issued (Google, Apple, your OIDC provider, your own JWT) and tells you who the request is, what they can do, and whether a workflow transition is allowed. It doesn't handle login flows.
Quick example
import {
definePolicy,
hasRole,
isOwner,
or,
can,
defineWorkflow,
} from '@verevoir/access';
import { createGoogleAuthAdapter } from '@verevoir/access/google';
// Identity — bridge to your IdP
const auth = createGoogleAuthAdapter({
allowedClientIds: [process.env.GOOGLE_CLIENT_ID!],
});
// Policy — who can do what
const policy = definePolicy({
rules: [
{ role: 'admin', actions: ['create', 'read', 'update', 'delete'] },
{ role: 'editor', actions: ['create', 'read', 'update'] },
{ role: 'author', actions: ['create', 'read', 'update'], scope: 'own' },
{ role: 'viewer', actions: ['read'] },
],
});
// Workflow — content lifecycle
const publishing = defineWorkflow({
name: 'publishing',
states: ['draft', 'review', 'published', 'archived'],
initial: 'draft',
transitions: [
{ from: 'draft', to: 'review', guard: or(hasRole('author'), hasRole('editor')) },
{ from: 'review', to: 'published', guard: hasRole('editor') },
{ from: 'published', to: 'archived', guard: hasRole('admin') },
{ from: 'review', to: 'draft' },
],
});
// Evaluate
const identity = await auth.resolve(token);
can(policy, identity, 'update', { ownerId: identity.id }); // true | false
publishing.canTransition('draft', 'review', identity); // true | false
publishing.availableTransitions('review', identity); // [Transition, ...]Auth adapters (subpath exports)
The core package is zero-dep and provider-agnostic. Each adapter lives behind a subpath so you opt in explicitly:
| Import | What it does |
|---|---|
| @verevoir/access/google | createGoogleAuthAdapter — verifies Google ID tokens via google-auth-library, supports allowedClientIds and optional hostedDomain. |
| @verevoir/access/apple | createAppleAuthAdapter — verifies Apple ID tokens. Verifier-agnostic (bring your own jose/jwt-decode), so your bundle stays minimal. |
| @verevoir/access/oidc | createOIDCAuthAdapter — generic OIDC adapter for any compliant provider (Okta, Azure AD, Auth0, Keycloak, Authentik, Zitadel). Handles JWKS + verification. |
| @verevoir/access/test-accounts | createTestAuthAdapter — dev-only lookup-table adapter. Import path is the safety flag; flags in code review. |
| @verevoir/access/role-store | createRoleStore — persistent userId → roles[] mapping over any StorageAdapter-shaped store. Includes a close-the-backdoor seed flow. |
| @verevoir/access/api-keys | createApiKeyAuthAdapter + createApiKeyStore — dual-secret API keys with zero-downtime rotation. identity.id is namespaced apikey:<clientId> so API identities never get confused with user ids. |
Each subpath has its own peer deps documented in its README section — the main package brings in nothing.
Core API
Identity
| Export | Description |
|---|---|
| Identity | { id, roles, groups?, metadata? } — the standard shape every adapter resolves to. |
| AuthAdapter | { resolve(token): Promise<Identity \| null> }. |
| defineAuthAdapter({ resolve }) | Wraps a resolve function into an AuthAdapter. For cases where the built-in adapters don't fit. |
| ANONYMOUS, isAnonymous(identity) | Frozen viewer identity and structural check. |
Policy
| Export | Description |
|---|---|
| definePolicy({ rules }) | Creates a policy from role→action rules. Deny-by-default. |
| can(policy, identity, action, context?) | Evaluates a single permission. Returns boolean. |
| PolicyRule | { role, actions, scope?: 'all' \| 'own' }. |
Guards
| Export | Description |
|---|---|
| hasRole(...roles) | Passes if identity has any of the roles. |
| isOwner(ownerField?) | Passes if identity.id matches the context owner. |
| and(...guards), or(...guards), not(guard) | Boolean composition. |
Workflow
| Export | Description |
|---|---|
| defineWorkflow({ name, states, initial, transitions }) | Creates a stateless workflow evaluator. |
| workflow.canTransition(from, to, identity, context?) | Returns boolean. |
| workflow.availableTransitions(state, identity, context?) | Returns the list of allowed transitions from the current state. |
Role store
When roles live outside the IdP — e.g. an in-app admin UI for elevating users — createRoleStore persists the map over any storage adapter:
import { createRoleStore } from '@verevoir/access/role-store';
const roles = createRoleStore({
storage,
seedAdmin: { userId: process.env.SEED_ADMIN_ID!, roles: ['admin'] },
});
const userRoles = await roles.getRoles(identity.id);
await roles.setRoles('[email protected]', ['editor']);
await roles.listAssignments();The seedAdmin option handles the bootstrap-and-close pattern: the first login matching SEED_ADMIN_ID creates the initial admin assignment; once any assignment exists, the env is ignored on subsequent boots. Break-glass is automatic — empty the store and the env reopens.
API keys
createApiKeyAuthAdapter handles clientId:secret Basic-auth-style credentials with zero-downtime rotation (each key supports a primary + secondary secret). Identities get identity.id = 'apikey:<clientId>' so consumer auth flows can branch explicitly rather than treating API keys as unknown users:
import { createApiKeyAuthAdapter, isApiKeyIdentity } from '@verevoir/access/api-keys';
const apiAuth = createApiKeyAuthAdapter({ store: keyStore });
const identity = await apiAuth.resolve(authHeaderToken);
if (isApiKeyIdentity(identity)) {
const accountId = identity.metadata.accountId; // the tenant this key belongs to
}Design decisions
- Identity, not authentication. Login flows (redirects, cookies, CSRF) stay in your framework layer. This package turns a verified token into a workable identity object.
- Roles come from the IdP by default. The role-store is there when you need in-app role management, not as the starting point.
- Policy as code. Role→action mappings are TypeScript, not configuration. Change them by changing the code; diff them in version control.
- Workflows are stateless.
defineWorkflowevaluates transitions. The caller stores the current state on their document. - Zero-dep core. Subpath adapters bring their own peers; the main package's import doesn't pull anything in.
Docs
License
MIT
