@affectively/auth
v5.0.0
Published
Shared authentication and cryptographic utilities for AFFECTIVELY
Downloads
43
Readme
@affectively/auth (Aegis Auth)
Decentralized Identity, Capabilities & Fine-Grained Access Control
A zero-dependency authentication and authorization library built on Web Crypto API. Implements UCAN (User Controlled Authorization Networks) with XPath-like node selection for surgical access control over your data tree, alongside custodial transaction-signing contracts for world-facing services.
Architectural Note: Why is auth separate from encryption?
In the Affectively ecosystem, we separate Integrity & Identity (this package) from Confidentiality (@affectively/zk-encryption).
- Use
@affectively/authwhen you need to answer "Who are you and what can you do?" (UCAN tokens, DIDs, ECDSA Signatures, XPath Data Authorization).- Use
@affectively/zk-encryptionwhen you need to answer "Who can read this data?" (Zero-Knowledge E2EE, ECIES, AES). Keeping these separate ensures edge workers that only need to route UCAN traffic aren't forced to load heavy payload-encryption libraries.
Parent
Child
What Lives Here
- Data Authorization Engine: Fine-grained access control with priority-based rules (
access.ts) - XPath Node Selection: Query complex JSON data trees using XPath (
xpath.ts) - Firebase-Style Rules Engine: Evaluate declarative string-based rules against data trees (
rules.ts) - Deep UCAN Capabilities: Full UCAN issuance, attenuation, and delegation chains (
token.ts,delegation.ts) - Revocation-Aware Checks: Token/device revocation primitives (
ucanAuth.ts) - Custodial Signer Contract: Canonical contract types for action-scoped custodial signing (
custodialSigner.ts)
Custodial Signer Contract
src/custodialSigner.ts is the canonical Aegis contract for action-scoped
custodial signing across server and worker runtimes. It defines:
- Action allowlist names
- Typed payload contracts per action
- Execute/health/signer metadata response shapes
- Shared error-code surface for fail-closed callers
World Reuse Pattern: Use this package as the single contract source, then pair it with:
- Cloud Run signer service implementation in
apps/custodial-signer - Shared typed client in
shared-utils/src/crypto/custodial-signer - Runtime fail-closed callers (workers/server) that only send allowlisted actions
Data Authorization Engine Features
- Zero Dependencies - Uses only Web Crypto API
- DID Support - Generate and manage
did:keyidentifiers - UCAN Tokens - Create, parse, verify, and delegate capabilities
- XPath Selection - Target specific nodes in your data tree
- Access Control - Fine-grained per-user, per-node, per-operation rules
- Firebase-Style Rules - Declarative security rules
- Sandboxing - Designate collaborative areas
Table of Contents
- Installation
- Quick Start
- Real-World CMS Example
- XPath Node Selection
- Access Control
- Firebase-Style Rules
- Identity & UCAN Tokens
- API Reference
Installation
npm install @affectively/auth
# or
bun add @affectively/authQuick Start
import { generateIdentity, AccessControl } from '@affectively/auth';
// Create identity
const author = await generateIdentity({ displayName: 'Jane Author' });
// Set up access control
const ac = new AccessControl();
ac.grant(author.did, '//users/jane/**', ['read', 'write', 'delete']);
ac.createSandbox('//drafts/**');
// Check access
ac.check(author.did, '/users/jane/profile', 'write', data);
// => { allowed: true }Real-World CMS Example
A complete content management system with pages, posts, media, embeds, and users:
const cmsData = {
// ===================
// PAGES
// ===================
pages: {
'home': {
id: 'home',
title: 'Welcome to Our Site',
slug: '/',
status: 'published',
template: 'homepage',
meta: {
description: 'The best site on the internet',
ogImage: '/media/og-home.jpg',
robots: 'index,follow',
},
sections: [
{
type: 'hero',
heading: 'Hello World',
subheading: 'We build amazing things',
backgroundImage: '/media/hero-bg.jpg',
cta: { text: 'Learn More', url: '/about' },
},
{
type: 'features',
items: [
{ icon: 'rocket', title: 'Fast', description: 'Lightning quick' },
{ icon: 'shield', title: 'Secure', description: 'Bank-level security' },
],
},
],
author: 'did:key:alice',
createdAt: '2024-01-15T10:00:00Z',
updatedAt: '2024-01-20T14:30:00Z',
},
'about': {
id: 'about',
title: 'About Us',
slug: '/about',
status: 'published',
template: 'default',
body: '<p>We are a team of passionate developers...</p>',
sidebar: {
widgets: ['team-members', 'contact-form'],
},
author: 'did:key:alice',
},
},
// ===================
// BLOG POSTS
// ===================
posts: {
'hello-world': {
id: 'hello-world',
title: 'Hello World: Our First Post',
slug: '/blog/hello-world',
status: 'published',
excerpt: 'Welcome to our new blog...',
body: `
<p>We're excited to launch our new blog!</p>
<p>Stay tuned for more updates.</p>
`,
// Featured image
featuredImage: {
url: '/media/posts/hello-world-hero.jpg',
alt: 'Hello World banner',
width: 1200,
height: 630,
caption: 'Photo by Jane Doe',
},
// Image gallery (multiple images)
gallery: [
{ url: '/media/posts/gallery-1.jpg', alt: 'Team meeting' },
{ url: '/media/posts/gallery-2.jpg', alt: 'Office space' },
{ url: '/media/posts/gallery-3.jpg', alt: 'Product demo' },
],
categories: ['announcements', 'company'],
tags: ['launch', 'blog', 'news'],
author: 'did:key:alice',
coAuthors: ['did:key:bob'],
publishedAt: '2024-01-15T10:00:00Z',
// SEO metadata
meta: {
title: 'Hello World - Our Blog',
description: 'Read our first blog post...',
canonical: 'https://example.com/blog/hello-world',
},
// Comments section
comments: {
enabled: true,
moderation: 'auto',
items: [
{
id: 'comment-1',
author: 'did:key:visitor1',
authorName: 'John Visitor',
body: 'Great post!',
status: 'approved',
createdAt: '2024-01-16T08:00:00Z',
},
],
},
},
'product-launch': {
id: 'product-launch',
title: 'Announcing Our New Product',
slug: '/blog/product-launch',
status: 'published',
body: '<p>Today we announce...</p>',
// Embedded content (inflated oEmbeds)
embeds: [
{
type: 'youtube',
url: 'https://youtube.com/watch?v=abc123',
oembed: {
title: 'Product Demo Video',
thumbnail_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
html: '<iframe src="https://youtube.com/embed/abc123" allowfullscreen></iframe>',
width: 560,
height: 315,
provider_name: 'YouTube',
},
},
{
type: 'twitter',
url: 'https://twitter.com/user/status/123456',
oembed: {
html: '<blockquote class="twitter-tweet">...</blockquote>',
author_name: '@user',
provider_name: 'Twitter',
},
},
{
type: 'spotify',
url: 'https://open.spotify.com/track/xyz',
oembed: {
title: 'Launch Day Playlist',
html: '<iframe src="https://open.spotify.com/embed/track/xyz"></iframe>',
provider_name: 'Spotify',
},
},
],
author: 'did:key:bob',
},
},
// ===================
// MEDIA LIBRARY
// ===================
media: {
'hero-bg.jpg': {
id: 'hero-bg.jpg',
filename: 'hero-bg.jpg',
url: '/uploads/hero-bg.jpg',
mimeType: 'image/jpeg',
size: 245000,
width: 1920,
height: 1080,
alt: 'Hero background',
folder: 'backgrounds',
uploadedBy: 'did:key:alice',
uploadedAt: '2024-01-10T09:00:00Z',
// Responsive image variants
variants: {
thumbnail: { url: '/uploads/hero-bg-thumb.jpg', width: 150, height: 84 },
medium: { url: '/uploads/hero-bg-medium.jpg', width: 800, height: 450 },
large: { url: '/uploads/hero-bg-large.jpg', width: 1200, height: 675 },
},
},
'document.pdf': {
id: 'document.pdf',
filename: 'annual-report-2024.pdf',
url: '/uploads/annual-report-2024.pdf',
mimeType: 'application/pdf',
size: 1500000,
folder: 'documents',
uploadedBy: 'did:key:alice',
visibility: 'private',
allowedUsers: ['did:key:alice', 'did:key:bob'],
},
},
// ===================
// USERS
// ===================
users: {
'did:key:alice': {
did: 'did:key:alice',
displayName: 'Alice Smith',
email: '[email protected]',
avatar: '/media/avatars/alice.jpg',
role: 'admin',
bio: 'Founder and CEO',
social: { twitter: '@alicesmith', linkedin: 'alicesmith' },
preferences: {
theme: 'dark',
notifications: { email: true, push: false },
},
},
'did:key:bob': {
did: 'did:key:bob',
displayName: 'Bob Jones',
role: 'editor',
},
'did:key:charlie': {
did: 'did:key:charlie',
displayName: 'Charlie Brown',
role: 'author',
},
},
// ===================
// NAVIGATION
// ===================
navigation: {
main: {
id: 'main',
items: [
{ label: 'Home', url: '/' },
{ label: 'About', url: '/about' },
{ label: 'Products', url: '/products', children: [
{ label: 'Product A', url: '/products/a' },
{ label: 'Product B', url: '/products/b' },
]},
{ label: 'Blog', url: '/blog' },
],
},
},
// ===================
// SETTINGS
// ===================
settings: {
site: { name: 'My Site', tagline: 'Building the future' },
seo: { defaultTitle: 'My Site', titleTemplate: '%s | My Site' },
integrations: {
mailchimp: { apiKey: '***', listId: 'abc123' },
stripe: { publicKey: 'pk_***' },
},
},
// ===================
// FORMS
// ===================
forms: {
contact: {
id: 'contact',
name: 'Contact Form',
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{ name: 'message', type: 'textarea', required: true },
],
submissions: [
{ id: 'sub-1', data: { name: 'Jane', email: '[email protected]', message: 'Hi!' }, status: 'unread' },
],
},
},
};Access Control for the CMS
import { AccessControl } from '@affectively/auth';
const ac = new AccessControl();
// =====================
// PUBLIC ACCESS
// =====================
// Published pages and posts
ac.grantPublic('//pages/*[status="published"]', 'read');
ac.grantPublic('//posts/*[status="published"]', 'read');
// Public media (not private)
ac.grantPublic('//media/*[visibility!="private"]', 'read');
// Navigation and site settings
ac.grantPublic('//navigation/**', 'read');
ac.grantPublic('//settings/site', 'read');
ac.grantPublic('//settings/seo', 'read');
// =====================
// AUTHENTICATED USERS
// =====================
// Users can edit their own profile
ac.grant('did:key:alice', '//users/did:key:alice/**', ['read', 'write']);
ac.grant('did:key:bob', '//users/did:key:bob/**', ['read', 'write']);
// Anyone can submit forms
ac.grant('*', '//forms/*/submissions', 'write', { constraints: { requireAuth: true } });
// =====================
// AUTHORS
// =====================
// Authors can create/edit their own posts
ac.grant('did:key:charlie', '//posts/*[author="did:key:charlie"]/**', ['read', 'write']);
// Authors can read all posts (for reference)
ac.grant('did:key:charlie', '//posts/**', 'read');
// Authors can upload media
ac.grant('did:key:charlie', '//media/**', ['read', 'write']);
// Authors manage comments on their posts
ac.grant('did:key:charlie', '//posts/*[author="did:key:charlie"]/comments/**', ['read', 'write', 'delete']);
// =====================
// EDITORS
// =====================
// Editors can edit post CONTENT (title, body, excerpt, gallery, embeds)
ac.grant('did:key:bob', '//posts/**/body', ['read', 'write']);
ac.grant('did:key:bob', '//posts/**/title', ['read', 'write']);
ac.grant('did:key:bob', '//posts/**/excerpt', ['read', 'write']);
ac.grant('did:key:bob', '//posts/**/gallery/**', ['read', 'write']);
ac.grant('did:key:bob', '//posts/**/embeds/**', ['read', 'write']);
ac.grant('did:key:bob', '//posts/**/featuredImage/**', ['read', 'write']);
// Editors can manage all comments
ac.grant('did:key:bob', '//posts/**/comments/**', ['read', 'write', 'delete']);
// Editors can edit pages
ac.grant('did:key:bob', '//pages/**', ['read', 'write']);
// Editors can manage media
ac.grant('did:key:bob', '//media/**', ['read', 'write', 'delete']);
// Editors CANNOT change ownership or status (higher priority deny)
ac.deny('did:key:bob', '//posts/*/author', 'write', { priority: 50 });
ac.deny('did:key:bob', '//posts/*/status', 'write', { priority: 50 });
// =====================
// ADMINS
// =====================
// Full access
ac.grant('did:key:alice', '//**', '*');
// Even admins can't delete the homepage
ac.deny('*', '//pages/home', 'delete', { priority: 100 });
// =====================
// SENSITIVE DATA
// =====================
// API keys are admin-only
ac.deny('*', '//settings/integrations/**', '*');
ac.grant('did:key:alice', '//settings/integrations/**', '*', { priority: 50 });
// Form submissions are editor+ only
ac.deny('*', '//forms/*/submissions/**', 'read');
ac.grant('did:key:bob', '//forms/*/submissions/**', ['read', 'write']);XPath Queries for the CMS
import { select, getLeaves, getBranches } from '@affectively/auth';
// Get all published posts
select('//posts/*[status="published"]', cmsData);
// Get all images in galleries
select('//gallery/*', cmsData);
// Get all oEmbed data
select('//embeds/*/oembed', cmsData);
// Get all YouTube embeds specifically
select('//embeds/*[type="youtube"]', cmsData);
// Get all URLs in the entire CMS
select('//url', cmsData);
// Get all user-editable text
select('//title', cmsData);
select('//body', cmsData);
select('//excerpt', cmsData);
// Get all images (featured + gallery + variants)
select('//featuredImage', cmsData);
select('//gallery/*', cmsData);
select('//variants/*', cmsData);
// Get media by uploader
select('//media/*[uploadedBy="did:key:alice"]', cmsData);
// Get pending comments
select('//comments/items/*[status="pending"]', cmsData);
// Get all form submissions
select('//forms/*/submissions/*', cmsData);
// Get all leaf values (for search indexing)
getLeaves(cmsData);
// Get all objects/sections (for editing)
getBranches(cmsData);XPath Node Selection
Select specific nodes in your data tree using XPath-inspired expressions.
Axes
| Syntax | Description | Example |
|--------|-------------|---------|
| / | Direct children | /posts/hello-world |
| // | Any depth (descendant) | //email |
| * | Any single segment | /users/*/profile |
| ** | Any path (recursive) | //settings/** |
Node Type Functions
| Function | Matches | Use Case |
|----------|---------|----------|
| leaf() | Strings, numbers, booleans | All content values |
| branch() | Objects | All sections/containers |
| array() | Arrays | All lists/galleries |
| text() | Strings only | Text content |
| node() | Everything | All nodes |
// All text in a post
select('/posts/hello-world//text()', data);
// All arrays (galleries, navigation items)
select('//array()', data);
// All sections (objects) on homepage
select('/pages/home/sections//branch()', data);Predicates
| Syntax | Description | Example |
|--------|-------------|---------|
| [prop="value"] | Equals | /*[status="published"] |
| [prop!="value"] | Not equals | /*[role!="guest"] |
| [prop>value] | Greater than | /*[price>100] |
| [prop] | Property exists | /*[featuredImage] |
| [0] | First item | /items[0] |
| [-1] | Last item | /items[-1] |
| [contains(p,"x")] | Contains | /*[contains(tags,"featured")] |
| [startsWith(p,"x")] | Starts with | /*[startsWith(slug,"/blog")] |
| [matches(p,"re")] | Regex | /*[matches(email,"@company\\.com")] |
// Posts by author
select('//posts/*[author="did:key:alice"]', data);
// Posts with galleries
select('//posts/*[gallery]', data);
// Large files
select('//media/*[size>1000000]', data);
// Posts in category
select('//posts/*[contains(categories,"news")]', data);Access Control
Operations
| Operation | Use For |
|-----------|---------|
| read | Viewing, fetching |
| write | Creating, updating |
| delete | Removing |
| admin | Administrative actions |
| * | All operations |
Methods
const ac = new AccessControl();
// Grant access
ac.grant(did, '//path/**', ['read', 'write'], { priority: 10, expiresIn: 86400000 });
ac.grantPublic('//path/**', 'read');
// Deny access
ac.deny(did, '//path/**', ['write', 'delete'], { priority: 100 });
ac.denyPublic('//admin/**', '*');
// Check access
const result = ac.check(did, '/users/alice', 'write', data);
// => { allowed: true, reason: 'Granted by rule: //users/alice/**' }
// Get accessible nodes
const nodes = ac.getAccessibleNodes(did, 'read', data);
// Patterns
ac.createSandbox('//drafts/**'); // Public read/write
ac.createPublicReadOnly('//docs/**'); // Public read, no write
ac.createUserOwned(did, '//users/me/**'); // Full control
// Expire rules
ac.grant(did, '//premium/**', 'read', { expiresIn: 7 * 24 * 60 * 60 * 1000 });
// Export/import
const json = ac.exportRules();
ac.importRules(json);Priority
Higher priority rules are checked first. Default: grant=0, deny=100.
// Priority 100: Deny everyone
ac.deny('*', '//admin/**', '*', { priority: 100 });
// Priority 50: Allow admin user
ac.grant(adminDID, '//admin/**', '*', { priority: 50 });Firebase-Style Rules
import { parseRules, evaluateRules } from '@affectively/auth';
const rules = parseRules({
rules: {
pages: {
'$pageId': {
'.read': 'resource.status === "published"',
'.write': 'auth.role === "admin" || auth.role === "editor"',
}
},
posts: {
'$postId': {
'.read': 'resource.status === "published" || auth.did === resource.author',
'.write': 'auth.did === resource.author || auth.role === "editor"',
comments: {
'.read': true,
'.write': 'auth !== null',
}
}
},
settings: {
site: { '.read': true, '.write': 'auth.role === "admin"' },
integrations: {
'.read': 'auth.role === "admin"',
'.write': 'auth.role === "admin"',
},
},
}
});
const result = evaluateRules(rules, '/posts/hello-world', 'read', {
auth: { did: 'did:key:visitor', role: 'user' },
resource: { status: 'published', author: 'did:key:alice' }
});
// => { allowed: true }Expression Syntax
// Boolean
'.read': true
// Comparisons
'.read': 'auth.did === $userId'
'.read': 'resource.count > 10'
// Boolean operators
'.read': 'auth !== null && auth.verified'
'.read': 'auth.role === "admin" || auth.role === "editor"'
// Methods
'.read': 'auth.capabilities.includes("read")'
'.read': 'resource.tags.includes("public")'
'.read': 'auth.email.endsWith("@company.com")'Identity & UCAN Tokens
Generate Identity
import { generateIdentity, sign, verify } from '@affectively/auth';
const alice = await generateIdentity({
algorithm: 'ES256',
displayName: 'Alice',
includeEncryptionKey: true,
});
console.log(alice.did); // did:key:z6Mkf...
// Sign data
const sig = await sign(alice, new TextEncoder().encode('Hello'));
// Verify
const valid = await verify(alice.signingKey.publicKey, sig, data);UCAN Tokens
import { createUCAN, verifyUCAN, delegateCapabilities } from '@affectively/auth';
// Create token
const token = await createUCAN(
alice,
bobDID,
[
{ can: 'file/read', with: 'storage://bucket/*' },
{ can: 'file/write', with: 'storage://bucket/uploads/*' },
],
{ expirationSeconds: 3600 }
);
// Verify
const result = await verifyUCAN(token, alice.signingKey.publicKey, {
audience: bobDID,
requiredCapabilities: [{ can: 'file/read', with: '*' }]
});
// Delegate (attenuate)
const childToken = await delegateCapabilities(
token,
bob,
charlieDID,
[{ can: 'file/read', with: 'storage://bucket/docs/*' }]
);API Reference
Identity
generateIdentity(options?)- Create identitysign(identity, data)- Sign dataverify(publicKey, signature, data)- Verify signaturederiveDID(publicKey)- Derive DID from key
UCAN
createUCAN(issuer, audience, capabilities, options?)- Create tokenverifyUCAN(token, publicKey, options?)- Verify tokenparseUCAN(token)- Parse without verificationdelegateCapabilities(parent, issuer, audience, caps, options?)- Delegate
XPath
select(expression, data)- Select nodescompile(expression)- Compile selectorgetLeaves(data)- All terminal valuesgetBranches(data)- All objectsgetValue(data, path)- Get valuepathExists(data, path)- Check existence
Access Control
ac.grant(subject, selector, operations, options?)- Grantac.deny(subject, selector, operations?, options?)- Denyac.check(subject, path, operation, data?)- Checkac.getAccessibleNodes(subject, operation, data)- List accessibleac.createSandbox(selector)- Public sandboxac.createPublicReadOnly(selector)- Read-only areaac.createUserOwned(subject, selector)- User area
Firebase Rules
parseRules(json)- Parse rulesevaluateRules(rules, path, operation, context)- Evaluaterules()- Builder
Storage
MemoryKeyStorage,MemoryIdentityStorage- VolatileIndexedDBKeyStorage,IndexedDBIdentityStorage- PersistentcreateStorage()- Auto-detect best option
License
MIT
Related
- @affectively/aeon - Distributed sync
- @affectively/zk-encryption - Encryption
- UCAN Spec
