@happyvertical/smrt-profiles
Central identity system with multi-auth (Nostr/OIDC/API keys/magic links), relationships, controlled metadata, and audit logging.
Overview
The @happyvertical/smrt-profiles package provides central identity management with
STI-based profiles (Person, Organization, Bot), multi-auth support, bidirectional relationships,
controlled metadata, and audit logging.
Key Features
- STI Profile Types: Profile base with Bot, Organization, Person subclasses, plus custom types via ProfileType
- Controlled Metadata: EAV pattern via ProfileMetafield (controlled vocabulary with validation schema) and ProfileMetadata
- Bidirectional Relationships: Auto-creates reciprocal inverse. Context profiles for tertiary relationships. Temporal tracking via ProfileRelationshipTerm
- Multi-Provider Auth: OIDC (Keycloak/Google/GitHub), Nostr (encrypted keypairs, NIP-05), API keys (SHA-256 hashed, scoped), Magic link tokens
- Email-Based Linking: Same verified email = same profile across providers
- Audit Logging: Action/resource trail with source tracking (web/cli/ci/webhook/mcp), onBehalfOfId for CI pass-through
- Optional Tenancy: Profiles can be global or tenant-scoped
Architecture
┌─────────────────────────────────────────────────────┐ │ SMRT Profiles System │ ├─────────────────────────────────────────────────────┤ │ │ │ Core Models │ │ • Profile (STI base -> Bot, Organization, Person) │ │ • ProfileType (classification lookup table) │ │ • ProfileMetadata + ProfileMetafield (controlled) │ │ • ProfileRelationship (auto-creates reciprocal) │ │ • ProfileRelationshipType (reciprocal flag) │ │ • ProfileRelationshipTerm (start/end dates) │ │ │ │ Authentication Models │ │ • OidcIdentity (issuer + subject, Keycloak/Google) │ │ • NostrIdentity (AES-256-GCM encrypted keypairs) │ │ • ApiKey (SHA-256 hashed, scope + expiry) │ │ • MagicLinkToken (one-time passwordless auth) │ │ │ │ Security & Compliance │ │ • AuditLog (action/resource, source tracking) │ │ • resolveIdentity() (multi-auth resolution) │ │ • NIP-05 address generation (Nostr) │ │ │ │ Auth Functions │ │ • createProfileFromOidc / createProfileFromNostr │ │ • createMagicLinkService / createNip05Handler │ │ • Nostr crypto (sign, verify, bech32 encode/decode) │ └─────────────────────────────────────────────────────┘
Installation
Using pnpm (recommended)
pnpm add @happyvertical/smrt-profilesUsing npm
npm install @happyvertical/smrt-profilesUsing bun
bun add @happyvertical/smrt-profilesPeer Dependencies
@happyvertical/smrt-tenancy is a peer dependency for tenant scoping.
Also depends on @noble/curves and bech32 for Nostr cryptography.
pnpm add @happyvertical/smrt-tenancyDatabase Setup
Requires database configuration (SQLite, PostgreSQL, etc.):
import { smrt } from '@happyvertical/smrt-core';
await smrt.initialize({
db: { type: 'sqlite', url: 'profiles.db' }
});Quick Start (5 Minutes)
1. Create Profile Type
import {
ProfileCollection,
ProfileTypeCollection
} from '@happyvertical/smrt-profiles';
// Create profile type
const typeCollection = await ProfileTypeCollection.create({ db: {...} });
const humanType = await typeCollection.create({ name: 'Human' });
await humanType.save();
console.log(humanType.slug); // 'human'2. Create Profile
// Create profile
const profileCollection = await ProfileCollection.create({ db: {...} });
const person = await profileCollection.create({
typeId: humanType.id,
name: 'Alice Johnson',
email: 'alice@example.com',
description: 'Software engineer'
});
await person.save();3. Add Metadata
import { ProfileMetafieldCollection } from '@happyvertical/smrt-profiles';
// Define metafield with validation
const fieldCollection = await ProfileMetafieldCollection.create({ db: {...} });
const location = await fieldCollection.create({
name: 'Location',
validation: { type: 'string', maxLength: 100 }
});
await location.save();
// Add metadata to profile
await person.addMetadata('location', 'San Francisco, CA');
const metadata = await person.getMetadata();
// { location: 'San Francisco, CA' }4. Create Relationships
import {
ProfileRelationshipTypeCollection
} from '@happyvertical/smrt-profiles';
// Create relationship type (reciprocal)
const relTypeCollection = await ProfileRelationshipTypeCollection.create({ db: {...} });
const friendType = await relTypeCollection.create({
name: 'Friend',
reciprocal: true // Auto-creates inverse
});
await friendType.save();
// Create relationship (automatically creates reciprocal)
const bob = await profileCollection.get({ email: 'bob@example.com' });
await person.addRelationship(bob, 'friend');
// Query relationships
const friends = await person.getRelatedProfiles('friend');
console.log(friends.length); // 1 (Bob)Core Concepts
1. Profile Type System
ProfileType is a lookup table that classifies profiles (Person, Organization, Robot, Team, etc.).
// Create multiple types
const personType = await typeCollection.create({ name: 'Person' });
const orgType = await typeCollection.create({ name: 'Organization' });
const teamType = await typeCollection.create({ name: 'Team' });
// Use type when creating profiles
const employee = await profileCollection.create({
typeId: personType.id,
name: 'Jane Doe'
});
// Get type slug
const slug = await employee.getTypeSlug(); // 'person'
// Set type by slug
await employee.setTypeBySlug('organization');2. Metadata System (Controlled EAV)
Flexible key-value storage with controlled vocabulary and validation.
Architecture
- ProfileMetafield: Defines allowed keys (controlled vocabulary)
- ProfileMetadata: Stores actual values
- Validation: Each metafield can define validation rules
// Define metafield with validation
const phone = await fieldCollection.create({
name: 'Phone Number',
validation: {
type: 'string',
pattern: '^\+?[0-9]{10,15}$',
message: 'Must be valid phone number'
}
});
await phone.save();
// Add metadata (validated before save)
await person.addMetadata('phone-number', '+14155551234');
// Get all metadata
const metadata = await person.getMetadata();
// { 'phone-number': '+14155551234', location: 'SF' }
// Update multiple
await person.updateMetadata({
location: 'New York',
'job-title': 'Senior Engineer'
});
// Remove metadata
await person.removeMetadata('location');Validation Types
| Type | Properties | Example |
|---|---|---|
string | pattern, minLength, maxLength | Email, phone, URL |
number | min, max | Age, salary, score |
boolean | - | Active, verified |
date | - | Start date, birthdate |
json | - | Complex objects |
3. Relationship System
ProfileRelationship connects two profiles with a relationship type. Relationships can be directional or reciprocal.
Reciprocal Relationships
// Create reciprocal type
const spouseType = await relTypeCollection.create({
name: 'Spouse',
reciprocal: true
});
await spouseType.save();
// Add relationship (auto-creates inverse)
await alice.addRelationship(bob, 'spouse');
// Both profiles now have the relationship
const aliceSpouses = await alice.getRelatedProfiles('spouse'); // [Bob]
const bobSpouses = await bob.getRelatedProfiles('spouse'); // [Alice]Directional Relationships
// Create directional type
const managerType = await relTypeCollection.create({
name: 'Manager',
reciprocal: false
});
await managerType.save();
// Add one-way relationship
await employee.addRelationship(manager, 'manager');
// Only employee has manager
const managers = await employee.getRelatedProfiles('manager'); // [Manager]
const reports = await manager.getRelatedProfiles('manager'); // []Context Profiles
Optional tertiary profile for modeling complex relationships.
// Colleagues through a company
const company = await profileCollection.create({
typeId: orgType.id,
name: 'Acme Corp'
});
await company.save();
await alice.addRelationship(bob, 'colleague', company);
// Query with context
const colleagues = await alice.getRelatedProfiles('colleague');
// Can filter by context in advanced queries4. Temporal Relationships (Terms)
ProfileRelationshipTerm tracks time-based periods for relationships (employment history, memberships, etc.).
// Add relationship with term
const employeeRel = await employee.addRelationship(company, 'works-at');
await employeeRel.addTerm(new Date('2023-01-15'), null); // Currently employed
// End current employment
await employeeRel.endCurrentTerm(new Date('2024-06-30'));
// Add new employment period
await employeeRel.addTerm(new Date('2024-07-01'), null);
// Query history
const terms = await employeeRel.getTerms();
// [
// { startDate: 2023-01-15, endDate: 2024-06-30 },
// { startDate: 2024-07-01, endDate: null }
// ]
// Get current active term
const activeTerm = await employeeRel.getActiveTerm();Authentication
OIDC Integration
Support for any OpenID Connect provider (Google, GitHub, Keycloak, custom) with email-based account linking.
import {
createProfileFromOidc,
resolveIdentity
} from '@happyvertical/smrt-profiles';
// On OIDC login callback
const oidcProfile = await createProfileFromOidc({
issuer: 'https://accounts.google.com',
subject: 'google-user-123',
email: 'user@example.com',
name: 'John Doe'
});
// Creates Profile + OidcIdentity, links by email if verifiedEmail-Based Account Linking
- Same verified email = same profile across providers
- Security: only links when
email_verified: true - Multiple OIDC identities per profile supported
Authentication Resolution
import { resolveIdentity } from '@happyvertical/smrt-profiles';
// Resolves profile from any auth method
const result = await resolveIdentity({
oidcSession: event.locals.session,
apiKey: event.request.headers.get('X-API-Key'),
db: event.locals.db
});
// Returns: { profile, source } | nullResolution Priority
- API key header (highest priority for programmatic access)
- OIDC session (web users)
- Nostr authentication (NIP-42 signed events)
- Actor header (CI pass-through)
- None (anonymous)
API Keys
Secure API keys for programmatic access with scopes and expiration.
import { ApiKey } from '@happyvertical/smrt-profiles';
// Generate API key (plaintext returned once only!)
const { key, apiKey } = await ApiKey.generate({
profileId: profile.id,
scope: 'read:profiles',
expiresAt: new Date('2025-12-31')
});
console.log(key); // plaintext - store now, never returned again
console.log(apiKey.keyPrefix); // visible identifier for managementAPI Key Security
- SHA-256 hashed storage (plaintext returned once only on generate)
keyPrefixstored for identification- Scope-based access control with expiry
Nostr Identity
Decentralized identity with custodial keypair management.
import {
generateNostrKeypair,
createProfileFromNostr,
NostrIdentity
} from '@happyvertical/smrt-profiles';
// Generate keypair (requires SERVER_MASTER_SECRET env var)
const keypair = generateNostrKeypair();
// Create profile with Nostr identity
const nostr = new NostrIdentity({
profileId: profile.id,
pubkey: keypair.pubkey,
});
await nostr.save();
// NIP-05 address handler
import { createNip05Handler } from '@happyvertical/smrt-profiles';
const nip05 = createNip05Handler(config);
// Magic link service
import { createMagicLinkService } from '@happyvertical/smrt-profiles';
const magicLink = createMagicLinkService(config);Tutorials
Tutorial 1: Building an Employee Directory
Create an employee directory with organizational structure, metadata, and relationship tracking.
Step 1: Create Profile Types
const personType = await typeCollection.create({ name: 'Person' });
const orgType = await typeCollection.create({ name: 'Organization' });
const deptType = await typeCollection.create({ name: 'Department' });
await Promise.all([personType.save(), orgType.save(), deptType.save()]);Step 2: Create Organization and Departments
const company = await profileCollection.create({
typeId: orgType.id,
name: 'Acme Corporation',
description: 'Technology company'
});
await company.save();
const engineering = await profileCollection.create({
typeId: deptType.id,
name: 'Engineering'
});
await engineering.save();
await engineering.addRelationship(company, 'part-of');Step 3: Create Employees with Metadata
// Define metadata fields
const jobTitleField = await fieldCollection.create({
name: 'Job Title',
validation: { type: 'string', maxLength: 100 }
});
const startDateField = await fieldCollection.create({
name: 'Start Date',
validation: { type: 'date' }
});
await Promise.all([jobTitleField.save(), startDateField.save()]);
// Create employee
const employee = await profileCollection.create({
typeId: personType.id,
name: 'Alice Johnson',
email: 'alice@acme.com'
});
await employee.save();
// Add metadata
await employee.addMetadata('job-title', 'Senior Software Engineer');
await employee.addMetadata('start-date', new Date('2023-01-15'));Step 4: Create Relationships with Terms
// Employee works at company
const employmentRel = await employee.addRelationship(company, 'works-at');
await employmentRel.addTerm(new Date('2023-01-15'), null); // Current
// Employee in department
await employee.addRelationship(engineering, 'member-of');
// Colleague relationship with context
const bob = await profileCollection.create({
typeId: personType.id,
name: 'Bob Smith',
email: 'bob@acme.com'
});
await bob.save();
await employee.addRelationship(bob, 'colleague', company);Step 5: Query and Analytics
// Get all employees
const employees = await profileCollection.list({
where: { typeId: personType.id }
});
// Get employee's colleagues
const colleagues = await employee.getRelatedProfiles('colleague');
// Get employment history
const workRel = await employee.getRelationships({ slug: 'works-at' });
const terms = await workRel[0].getTerms();Tutorial 2: OIDC-Based Multi-Tenant SaaS
Implement OIDC authentication with email-based account linking across tenants.
Step 1: Configure OIDC Providers
// Configure in your auth provider (e.g., Auth.js, Keycloak)
const providers = [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET
})
];Step 2: Handle OIDC Callback
// In your OIDC callback handler
export async function handleOidcCallback(oidcClaims, provider) {
const { profile, oidcIdentity, created } = await createProfileFromOidc(
oidcClaims,
provider,
{ db: getTenantDb() }
);
// Store tenant ID in metadata
if (created) {
await profile.addMetadata('tenant-id', getCurrentTenantId());
}
// Create session
const session = await createSession({
profileId: profile.id,
provider: oidcIdentity.provider
});
return session;
}Step 3: Authentication Middleware
// In hooks.server.ts or middleware
export async function handle({ event, resolve }) {
const { profile, source } = await resolveIdentity({
oidcSession: await getSession(event.cookies),
apiKey: event.request.headers.get('X-API-Key'),
db: event.locals.db
});
if (profile) {
event.locals.profile = profile;
event.locals.authSource = source;
// Get tenant from metadata
const metadata = await profile.getMetadata();
event.locals.tenantId = metadata['tenant-id'];
}
return resolve(event);
}Tutorial 3: Social Network with Reciprocal Relationships
Build a social network with friends, followers, and custom relationship types.
// Create relationship types
const friendType = await relTypeCollection.create({
name: 'Friend',
reciprocal: true
});
const followerType = await relTypeCollection.create({
name: 'Follower',
reciprocal: false
});
await Promise.all([friendType.save(), followerType.save()]);
// Add friends (auto-reciprocal)
await alice.addRelationship(bob, 'friend');
// Both have the relationship
const aliceFriends = await alice.getRelatedProfiles('friend'); // [Bob]
const bobFriends = await bob.getRelatedProfiles('friend'); // [Alice]
// Add follower (one-way)
await alice.addRelationship(charlie, 'follower');
// Query friend network at depth 2
const friendsOfFriends = await alice.getRelationshipNetwork({
slug: 'friend',
depth: 2
});
// Analytics
const followerCount = (await alice.getRelatedProfiles('follower')).length;
const mutualFriends = await findMutualFriends(alice, bob);Examples
Example 1: Healthcare Provider Network
// Profile types
const physicianType = await typeCollection.create({ name: 'Physician' });
const patientType = await typeCollection.create({ name: 'Patient' });
const clinicType = await typeCollection.create({ name: 'Clinic' });
// Create profiles
const drSmith = await profileCollection.create({
typeId: physicianType.id,
name: 'Dr. Sarah Smith',
email: 'sarah.smith@clinic.com'
});
const clinic = await profileCollection.create({
typeId: clinicType.id,
name: 'Downtown Medical Clinic'
});
// Metadata
await drSmith.addMetadata('specialty', 'Cardiology');
await drSmith.addMetadata('license-number', 'CA12345');
// Relationships
await drSmith.addRelationship(clinic, 'works-at');
// Referrals with context
const drJones = await profileCollection.create({
typeId: physicianType.id,
name: 'Dr. Michael Jones'
});
await drSmith.addRelationship(drJones, 'refers-to', clinic);
// Audit logging
await drSmith.recordAction({
action: 'patient_viewed',
resourceType: 'Patient',
resourceId: 'patient-123',
source: 'web',
metadata: { reason: 'Follow-up appointment' }
});Example 2: Educational Institution
// Profile types
const studentType = await typeCollection.create({ name: 'Student' });
const teacherType = await typeCollection.create({ name: 'Teacher' });
const courseType = await typeCollection.create({ name: 'Course' });
// Create profiles
const student = await profileCollection.create({
typeId: studentType.id,
name: 'Emma Wilson',
email: 'emma.wilson@university.edu'
});
const course = await profileCollection.create({
typeId: courseType.id,
name: 'Computer Science 101'
});
// Enrollment with term
const enrollmentRel = await student.addRelationship(course, 'enrolled-in');
await enrollmentRel.addTerm(
new Date('2024-09-01'),
new Date('2024-12-15')
);
// Metadata
await student.addMetadata('grade-level', 'Freshman');
await student.addMetadata('gpa', 3.8);
// Query courses
const courses = await student.getRelatedProfiles('enrolled-in');
// Get enrollment history
const enrollments = await student.getRelationships({ slug: 'enrolled-in' });
const history = await Promise.all(
enrollments.map(async (rel) => ({
course: await rel.getToProfile(),
terms: await rel.getTerms()
}))
);Integration with Other Modules
smrt-users
The profiles package provides identity and profile management that complements smrt-users:
- smrt-users: Authentication, sessions, permissions
- smrt-profiles: Profile data, relationships, metadata
// User logs in via OIDC (handled by smrt-users)
// Profile is created or retrieved via smrt-profiles
const { profile } = await resolveIdentity(authContext);
// Store profile reference in user session
event.locals.profile = profile;
// Use profile for authorization
const tenantId = (await profile.getMetadata())['tenant-id'];
const membership = await getUserTenantMembership(profile.id, tenantId);smrt-core
- All models extend SmrtObject for database persistence
- Automatic API generation (REST, CLI, MCP)
- AI-powered methods (
generateBio(),matches())
Multi-Tenancy Pattern
// Tenant A database
const profilesA = await ProfileCollection.create({
db: { type: 'postgres', url: DATABASE_URL_TENANT_A }
});
// Tenant B database
const profilesB = await ProfileCollection.create({
db: { type: 'postgres', url: DATABASE_URL_TENANT_B }
});
// Complete isolation at database levelBest Practices
1. Profile Design
- Use descriptive names for profile types (Person vs User, Team vs Group)
- Normalize emails to lowercase for reliable lookups
- Always provide profile descriptions for context
- Don't mix profile types unnecessarily
2. Metadata Management
- Define metafields (vocabulary) upfront before adding metadata
- Use appropriate validation types and constraints
- Namespace keys with dot notation (contact.phone, social.github)
- Use batch operations for efficiency
// Good - namespaced
await profile.updateMetadata({
'contact.phone': '+14155551234',
'contact.email': 'user@example.com',
'social.github': 'username',
'social.linkedin': 'username'
});
// Bad - no namespace
await profile.updateMetadata({
phone: '+14155551234',
email: 'user@example.com',
github: 'username'
});3. Relationships
- Choose relationship types that accurately represent connections
- Use
reciprocal: truefor symmetric relationships (friend, spouse) - Add context profiles when relationships depend on intermediary
- Always add terms for temporal relationships (employment, membership)
4. Authentication
- Only link OIDC accounts with
email_verified: true - Implement regular API key rotation and revocation
- Use specific scopes rather than
*permission - Combine OIDC with additional verification for sensitive operations
5. Security
- Record all sensitive actions in audit logs
- Never store plaintext API keys (handled automatically)
- Secure SERVER_MASTER_SECRET for Nostr decryption
- Implement rate limiting at middleware level
6. Performance
- Use batch operations for bulk imports/updates
- Use pagination (
limitandoffset) for large result sets - Limit relationship network traversal depth (default 2-3)
- Consider caching frequently accessed metadata
Troubleshooting
Profile type not found
Solution: Create profile types before creating profiles
// Wrong order
const profile = await profileCollection.create({
typeId: 'person', // Error: type doesn't exist
name: 'John'
});
// Correct order
const personType = await typeCollection.create({ name: 'Person' });
await personType.save();
const profile = await profileCollection.create({
typeId: personType.id, // Works
name: 'John'
});OIDC identity not linking to profile
Solution: Ensure email_verified is true
// Won't link (security)
const claims = {
email: 'user@example.com',
email_verified: false // ❌
};
// Will link
const claims = {
email: 'user@example.com',
email_verified: true // ✅
};Reciprocal relationship not creating inverse
Solution: Set reciprocal: true on relationship type
// Wrong - no reciprocal
const friendType = await relTypeCollection.create({
name: 'Friend',
reciprocal: false // ❌
});
// Correct
const friendType = await relTypeCollection.create({
name: 'Friend',
reciprocal: true // ✅
});Metadata validation failing
Solution: Check validation rules and value format
// Define validation
const phoneField = await fieldCollection.create({
name: 'Phone',
validation: {
type: 'string',
pattern: '^\+?[0-9]{10,15}$'
}
});
// Will fail
await profile.addMetadata('phone', '123'); // Too short
// Will succeed
await profile.addMetadata('phone', '+14155551234');Multi-tenant data leaking
Solution: Pass correct database URL for each tenant
// Wrong - shared database
const collection = await ProfileCollection.create({ db: sharedDb });
// Correct - tenant-specific
const tenantACollection = await ProfileCollection.create({
db: { type: 'postgres', url: TENANT_A_URL }
});
const tenantBCollection = await ProfileCollection.create({
db: { type: 'postgres', url: TENANT_B_URL }
});API Reference
Profile Model
| Method | Returns | Description |
|---|---|---|
addMetadata(slug, value) | Promise<void> | Add single metadata value |
getMetadata() | Promise<object> | Get all metadata as key-value object |
updateMetadata(obj) | Promise<void> | Update multiple metadata values |
removeMetadata(slug) | Promise<void> | Remove metadata by slug |
addRelationship(to, slug, context?) | Promise<ProfileRelationship> | Create relationship |
getRelationships(options?) | Promise<ProfileRelationship[]> | Get relationships with filtering |
getRelatedProfiles(slug?) | Promise<Profile[]> | Get related profiles |
generateApiKey(options) | Promise<ApiKey> | Generate new API key |
getActiveApiKeys() | Promise<ApiKey[]> | List non-expired, non-revoked keys |
getOidcIdentities() | Promise<OidcIdentity[]> | List linked OIDC identities |
recordAction(options) | Promise<AuditLog> | Log action for audit trail |
generateBio() | Promise<string> | Generate professional bio using AI |
ProfileRelationship Model
| Method | Returns | Description |
|---|---|---|
addTerm(start, end?) | Promise<ProfileRelationshipTerm> | Add time-based period |
endCurrentTerm(endDate) | Promise<void> | Close active term |
getActiveTerm() | Promise<{ profile, source } | null> | Get current active term |
getTerms() | Promise<ProfileRelationshipTerm[]> | Get all historical terms |
getTypeSlug() | Promise<string> | Get relationship type slug |
Auth Functions
| Function | Description |
|---|---|
resolveIdentity(context) | Resolve profile from any auth method |
createProfileFromOidc(claims) | Create profile + OIDC identity in one call |
createProfileFromNostr(options) | Create profile + Nostr identity in one call |
createMagicLinkService(config) | Factory for magic link auth service |
createNip05Handler(config) | Factory for NIP-05 address handler |
generateNostrKeypair() | Generate new Nostr keypair |
ApiKey.generate(options) | Generate API key (plaintext returned once) |