██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

Access Control Guide

This guide shows how to use @verevoir/access to add identity resolution, role-based access control, and editorial workflows to a Verevoir application.

Installation

npm install @verevoir/access

No runtime dependencies. Works with or without the other Verevoir packages.

Overview

@verevoir/access provides three building blocks:

These are independent concepts. You can use policies without workflows, or workflows without policies. Your application code connects them to storage and the editor.

Step 1: Identity Resolution

An auth adapter maps whatever token your identity provider gives you into an Identity:

import { defineAuthAdapter } from '@verevoir/access';
import type { Identity } from '@verevoir/access';

const auth = defineAuthAdapter({
  resolve: async (token): Promise<Identity | null> => {
    const user = await verifyJwt(token as string);
    if (!user) return null;
    return {
      id: user.sub,           // unique user ID
      roles: user.roles,       // ['admin'], ['editor'], etc.
      groups: user.groups,     // optional — raw IdP groups
      metadata: {              // optional — extra claims
        email: user.email,
        name: user.name,
      },
    };
  },
});

const identity = await auth.resolve(jwtToken);

The library doesn't handle login flows, sessions, or token refresh. It consumes tokens and produces identities. Roles come from your identity provider, not from Verevoir.

Anonymous Identity

When no token is present, use the built-in ANONYMOUS identity instead of returning null. This gives unauthenticated requests a viewer identity that policies can evaluate:

import { defineAuthAdapter, ANONYMOUS, isAnonymous } from '@verevoir/access';

const auth = defineAuthAdapter({
  resolve: async (token) => {
    if (!token) return ANONYMOUS;
    const user = await verifyJwt(token as string);
    if (!user) return ANONYMOUS;
    return { id: user.sub, roles: user.roles };
  },
});

// Every request gets an identity — no null checks needed
const identity = await auth.resolve(token);
policy.can(identity, 'read');  // true — viewer can read

// Detect anonymous users structurally (works after serialization)
if (isAnonymous(identity)) {
  // Show sign-in prompt, hide edit controls, etc.
}

ANONYMOUS is frozen (Object.freeze) to prevent mutation. isAnonymous() compares by id, not by reference, so it works with serialized copies.

Google Auth Adapter

For Google OAuth2 / Workspace authentication, use the subpath export:

import { createGoogleAuthAdapter } from '@verevoir/access/google';
import { OAuth2Client } from 'google-auth-library';

const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);

const auth = createGoogleAuthAdapter({
  client,
  allowedClientIds: [process.env.GOOGLE_CLIENT_ID!],
  hostedDomain: 'yourcompany.com',           // optional — restrict to a Workspace domain
  mapRoles: (payload) =>                      // optional — defaults to ['viewer']
    payload.hd === 'yourcompany.com' ? ['editor'] : ['viewer'],
});

Install the optional peer dependency:

npm install google-auth-library

The adapter:

Test Auth Adapter

For development and testing, use the built-in test accounts adapter:

import { createTestAuthAdapter } from '@verevoir/access/test-accounts';
import type { TestAccount } from '@verevoir/access/test-accounts';

const testAccounts: TestAccount[] = [
  { token: 'admin-token', identity: { id: 'user-admin', roles: ['admin'], metadata: { email: 'admin@example.com' } } },
  { token: 'editor-token', identity: { id: 'user-editor', roles: ['editor'], metadata: { email: 'editor@example.com' } } },
  { token: 'author-token', identity: { id: 'user-author', roles: ['author'] } },
];

const auth = createTestAuthAdapter({ accounts: testAccounts });

The adapter:

The import path is the safety mechanism. import { createTestAuthAdapter } from '@verevoir/access/test-accounts' in a code review is an immediate red flag — no runtime env checks needed. In production, replace the entire file with createGoogleAuthAdapter or a custom defineAuthAdapter.

Role Store

Google (and most IdPs) gives you an identity — sub, email, name — but knows nothing about application-level roles. The mapRoles callback needs to get role data from somewhere. The role store provides persistent user→roles mapping backed by any StorageAdapter.

npm install @verevoir/storage   # optional peer dep — only needed for role-store
import { createRoleStore } from '@verevoir/access/role-store';
import { MemoryAdapter } from '@verevoir/storage';

const storage = new MemoryAdapter();
await storage.connect();

const roleStore = createRoleStore({
  storage,
  seedAdmin: {                    // optional — first-admin seeding
    userId: 'google-114823947',   // the Google `sub` of the first admin
    roles: ['admin'],
  },
});

The store uses a structural type for StorageAdapter (only create, list, update, delete), so any compatible implementation works — no import from @verevoir/storage is required inside @verevoir/access.

Seed admin: On the first getRoles call for the matching userId with an empty store, the seed admin assignment is auto-created. This ensures a fresh deployment isn't locked out. The seed only fires once and only when the store is empty.

API:

Wiring to Google Auth

The mapRoles callback accepts both sync and async functions. Use it to look up roles from the store:

import { createGoogleAuthAdapter } from '@verevoir/access/google';
import { createRoleStore } from '@verevoir/access/role-store';

const roleStore = createRoleStore({ storage, seedAdmin: { userId: 'google-114823947', roles: ['admin'] } });

const auth = createGoogleAuthAdapter({
  client,
  allowedClientIds: [process.env.GOOGLE_CLIENT_ID!],
  mapRoles: async (payload) => {
    if (!payload.sub) return ['viewer'];
    const roles = await roleStore.getRoles(payload.sub);
    return roles.length > 0 ? roles : ['viewer'];
  },
});

This is the full production pattern: Google verifies the token, the role store provides application roles, and the seed admin ensures someone can manage roles on first deploy.

Step 2: Define a Policy

A policy maps roles to permitted actions. Policies are deny-by-default — anything not explicitly allowed is denied.

import { definePolicy } from '@verevoir/access';

const contentPolicy = definePolicy({
  rules: [
    { role: 'admin',  actions: ['create', 'read', 'update', 'delete'] },
    { role: 'editor', actions: ['create', 'read', 'update'] },
    { role: 'author', actions: ['create', 'read'] },
    { role: 'author', actions: ['update', 'delete'], scope: 'own' },
    { role: 'viewer', actions: ['read'] },
  ],
});

Evaluating Access

contentPolicy.can(identity, 'create');
// → true for admin, editor, author; false for viewer

contentPolicy.can(identity, 'update', { ownerId: doc.data.createdBy });
// → true for admin/editor (any doc), true for author (own docs only)

The standalone can function works the same way:

import { can } from '@verevoir/access';

can(contentPolicy, identity, 'delete', { ownerId: 'user-author' });

Scope: Own vs All

Rules default to scope: 'all' — the role can perform the action on any document. Setting scope: 'own' restricts to documents where context.ownerId === identity.id.

A role that needs both unrestricted and restricted actions needs two rules. For example, authors can create and read freely (they can't own a document before it exists), but can only update and delete their own:

{ role: 'author', actions: ['create', 'read'] },              // unrestricted
{ role: 'author', actions: ['update', 'delete'], scope: 'own' } // own only

Tracking Ownership Without Package Changes

No storage interface change is needed. Track ownership in your document data:

// On create
await storage.create('article', { ...formData, createdBy: identity.id });

// On update (preserve the original creator)
await storage.update(id, { ...formData, createdBy: doc.data.createdBy });

// When checking permissions
contentPolicy.can(identity, 'update', {
  ownerId: doc.data.createdBy as string,
});

Step 3: Define a Workflow

Workflows are stateless state machines. You define states and transitions; the library evaluates which transitions are available for a given state and identity.

import { defineWorkflow, hasRole, or } from '@verevoir/access';

const publishing = defineWorkflow({
  name: 'publishing',
  states: ['draft', 'review', 'published', 'archived'],
  initial: 'draft',
  transitions: [
    {
      from: 'draft',
      to: 'review',
      guard: or(hasRole('author'), hasRole('editor'), hasRole('admin')),
    },
    {
      from: 'review',
      to: 'published',
      guard: or(hasRole('editor'), hasRole('admin')),
    },
    {
      from: 'review',
      to: 'draft',
      guard: or(hasRole('author'), hasRole('editor'), hasRole('admin')),
    },
    {
      from: 'published',
      to: 'archived',
      guard: hasRole('admin'),
    },
  ],
});

Querying Available Transitions

// What can this user do from the current state?
const transitions = publishing.availableTransitions('draft', identity);
// Author: [{ from: 'draft', to: 'review', guard: ... }]
// Viewer: []

// Can this specific transition happen?
publishing.canTransition('review', 'published', identity);
// Editor: true, Author: false

Workflows Are Stateless

The workflow object doesn't store the current state. Your document's data holds the state (e.g., data.status), and you pass it to the workflow for evaluation:

const currentStatus = doc.data.status as string;
const available = publishing.availableTransitions(currentStatus, identity);

This means workflow definitions are pure functions — no side effects, no database dependency, fully testable.

Guards

Guards are composable boolean functions used in workflow transitions. Five are built in:

import { hasRole, isOwner, and, or, not } from '@verevoir/access';

hasRole

Passes if the identity has any of the specified roles:

hasRole('admin')                      // single role
hasRole('admin', 'editor')            // any of these roles

isOwner

Passes if identity.id matches a field in the context:

isOwner()                // checks context.ownerId (default)
isOwner('createdBy')     // checks context.createdBy

Combinators

and(hasRole('editor'), isOwner())     // must be editor AND owner
or(hasRole('admin'), hasRole('editor'))  // admin OR editor
not(isOwner())                        // anyone except the owner

Custom Guards

A guard is any function matching this signature:

type Guard = (
  identity: Identity,
  context?: Record<string, unknown>,
) => boolean;

Write your own for domain-specific logic:

const isVerifiedEmail: Guard = (identity) =>
  !!identity.metadata?.emailVerified;

const canPublish = and(hasRole('editor'), isVerifiedEmail);

Connecting Workflows to Auth and the Editor

Workflows and policies work together but remain independent — policies control who can perform CRUD actions; workflows control state transitions within a document.

Workflow-Driven Status Field

The editor's override mechanism lets you replace the default select field with a workflow-aware component:

import type { FieldEditorProps } from '@verevoir/editor';

function StatusField({ value, onChange }: FieldEditorProps<string>) {
  const { identity, workflow } = useUser(); // your app's context
  const current = (value as string) || workflow.initial;
  const transitions = workflow.availableTransitions(current, identity);

  return (
    <div>
      <span>{current}</span>
      {transitions.map((t) => (
        <button key={t.to} onClick={() => onChange(t.to)}>
          → {t.to}
        </button>
      ))}
    </div>
  );
}

// Use via the editor's overrides prop
<BlockEditor
  block={article}
  value={data}
  onChange={setData}
  overrides={{ status: StatusField }}
/>

The status is no longer a free-form select — users can only move to valid workflow states. Different roles see different transition buttons:

RoleFrom draftFrom reviewFrom published
Admin→ review→ published, → draft→ archived
Editor→ review→ published, → draft
Author→ review→ draft
Viewer

The Full Integration Pattern

Here's how auth, policies, and workflows connect in a document editor:

function DocumentEditor({ blockType, documentId }) {
  const { identity, can, workflow } = useUser();
  const [createdBy, setCreatedBy] = useState<string>();

  // Load document — track ownership
  useEffect(() => {
    if (documentId) {
      storage.get(documentId).then((doc) => {
        setCreatedBy(doc.data.createdBy as string);
        actions.onChange(doc.data);
      });
    }
  }, [documentId]);

  // Policy gates the save button
  const isNew = !documentId;
  const canSave = isNew
    ? can('create')
    : can('update', { ownerId: createdBy });

  const handleSave = async () => {
    if (!canSave) return;
    if (documentId) {
      await storage.update(documentId, { ...state.value, createdBy });
    } else {
      await storage.create(blockType, {
        ...state.value,
        createdBy: identity.id,
      });
    }
  };

  // Workflow drives the status field (for articles)
  const overrides = blockType === 'article'
    ? { status: StatusField }
    : undefined;

  return (
    <>
      <button disabled={!canSave}>Save</button>
      <BlockEditor
        block={block}
        value={state.value}
        onChange={actions.onChange}
        overrides={overrides}
      />
    </>
  );
}

React Context for Identity

Wrap your app in a context provider that manages the current identity:

const UserContext = createContext<UserContextValue | null>(null);

function UserProvider({ children }) {
  const [role, setRole] = useState('admin');
  const [identity, setIdentity] = useState(defaultIdentity);

  useEffect(() => {
    auth.resolve(role).then((id) => { if (id) setIdentity(id); });
  }, [role]);

  const can = useCallback(
    (action, context) => contentPolicy.can(identity, action, context),
    [identity],
  );

  return (
    <UserContext.Provider value={{ identity, role, setRole, can, workflow }}>
      {children}
    </UserContext.Provider>
  );
}

In production, replace the mock auth adapter with your real identity provider. The rest of the integration code stays the same.

Workflow Design Patterns

Single Approval

defineWorkflow({
  name: 'approval',
  states: ['pending', 'approved', 'rejected'],
  initial: 'pending',
  transitions: [
    { from: 'pending', to: 'approved', guard: hasRole('approver') },
    { from: 'pending', to: 'rejected', guard: hasRole('approver') },
    { from: 'rejected', to: 'pending' }, // resubmit — open to all
  ],
});

Multi-Stage Pipeline

defineWorkflow({
  name: 'content-pipeline',
  states: ['draft', 'review', 'legal', 'published'],
  initial: 'draft',
  transitions: [
    { from: 'draft', to: 'review', guard: hasRole('author') },
    { from: 'review', to: 'legal', guard: hasRole('editor') },
    { from: 'legal', to: 'published', guard: hasRole('legal') },
    { from: 'review', to: 'draft' },  // send back
    { from: 'legal', to: 'review' },  // send back
  ],
});

Owner-Gated Transitions

defineWorkflow({
  name: 'ownership',
  states: ['active', 'deactivated'],
  initial: 'active',
  transitions: [
    {
      from: 'active',
      to: 'deactivated',
      guard: or(hasRole('admin'), isOwner()),
    },
    {
      from: 'deactivated',
      to: 'active',
      guard: hasRole('admin'),
    },
  ],
});

// Pass ownership context when evaluating
workflow.canTransition('active', 'deactivated', identity, {
  ownerId: doc.data.createdBy,
});

Edge Cases

Working Examples

Both example apps demonstrate the full access control integration:

The Next.js example starts in anonymous (read-only) mode. Sign in with a simulated Google account to get create/edit/delete controls. The React example uses a role switcher dropdown.