@happyvertical/smrt-profiles
Comprehensive profile management system with flexible metadata, complex relationships, and multi-provider authentication support (OIDC, Nostr, API keys).
Overview
The @happyvertical/smrt-profiles package enables managing profiles of any type (people,
organizations, robots) with flexible metadata, complex relationships, and integrated authentication
support.
Key Features
- Flexible Profile Types: Define custom types (Person, Organization, Team, Robot)
- Controlled Metadata: EAV pattern with controlled vocabulary and validation
- Complex Relationships: Reciprocal, directional, with temporal terms and context
- Multi-Provider Auth: OIDC (Google, GitHub, Keycloak), Nostr, API keys
- Email-Based Linking: Same verified email = same profile
- Temporal Tracking: Employment history, membership periods
- Audit Logging: Complete action trail for compliance
- Multi-Tenancy: Database-level isolation
Architecture
┌─────────────────────────────────────────────────────────────┐ │ SMRT Profiles System │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Core Models │ │ │ ├──────────────────────────────────────────────────────┤ │ │ │ • Profile (Person, Org, Robot, etc.) │ │ │ │ • ProfileType (type definitions with slugs) │ │ │ │ • ProfileMetadata + ProfileMetafield (controlled) │ │ │ │ • ProfileRelationship + ProfileRelationshipType │ │ │ │ • ProfileRelationshipTerm (temporal tracking) │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Authentication Models │ │ │ ├──────────────────────────────────────────────────────┤ │ │ │ • OidcIdentity (Google, GitHub, Keycloak) │ │ │ │ • NostrIdentity (encrypted keypairs) │ │ │ │ • ApiKey (hashed, scoped, expirable) │ │ │ │ • MagicLinkToken (passwordless) │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Security & Compliance │ │ │ ├──────────────────────────────────────────────────────┤ │ │ │ • AuditLog (all actions tracked) │ │ │ │ • Email verification (OIDC) │ │ │ │ • Master secret encryption (Nostr) │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
Use Cases
- Multi-tenant SaaS platforms with user profiles
- Social networks and collaboration tools
- Employee directories and organizational charts
- Web3 identity systems with Nostr
- Healthcare provider networks
- Educational institutions (students, teachers, courses)
Installation
Using pnpm (recommended)
pnpm add @happyvertical/smrt-profilesUsing npm
npm install @happyvertical/smrt-profilesUsing bun
bun add @happyvertical/smrt-profilesDatabase 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 oidcClaims = {
sub: 'google-user-123',
iss: 'https://accounts.google.com',
email: 'user@example.com',
email_verified: true,
name: 'John Doe'
};
// Create or link profile
const { profile, oidcIdentity, created } = await createProfileFromOidc(
oidcClaims,
'google',
{ db: {...} }
);
if (created) {
console.log('New profile created');
} else {
console.log('Linked to existing profile via email');
}Email-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
// In authentication middleware
const { profile, source } = await resolveIdentity({
oidcSession: event.locals.session,
apiKey: event.request.headers.get('X-API-Key'),
db: event.locals.db
});
if (!profile) {
return error(401, 'Unauthorized');
}
// Store in context
event.locals.profile = profile;
event.locals.authSource = source; // 'oidc', 'api-key', etc.Resolution 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.
// Generate API key for profile
const apiKey = await profile.generateApiKey({
name: 'CI/CD Bot',
scopes: ['read:profiles', 'write:profiles'],
expiresAt: new Date('2025-12-31')
});
console.log(apiKey.key); // 'smrt_1234567890abcdef...'
console.log(apiKey.keyPrefix); // 'smrt_123' (for identification)
// Verify API key
const verified = await ApiKey.verify(apiKey.key);
if (verified?.isValid()) {
const profile = await verified.getProfile();
if (verified.hasScope('read:profiles')) {
// Allow access
}
}
// Revoke key
await apiKey.revoke();
// List active keys
const activeKeys = await profile.getActiveApiKeys();API Key Security
- SHA-256 hashed storage (never plaintext)
- Prefix stored for identification
- Scopes/permissions system
- Expiration and revocation support
- Usage tracking (lastUsedAt)
Nostr Identity
Decentralized identity with custodial keypair management.
import {
generateNostrKeypair,
createMagicLinkToken
} from '@happyvertical/smrt-profiles';
// Generate keypair (encrypted with master secret)
const keypair = await generateNostrKeypair('user@example.com');
// Create magic link token
const token = await createMagicLinkToken(profile, keypair);
// Send email with magic link
await sendEmail({
to: profile.email,
subject: 'Sign in to your account',
body: `Click here: https://app.example.com/auth/magic?${token}`
});
// Verify magic link
const verified = await verifyMagicLinkToken(token);
if (verified) {
// Sign in user
}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 |
Utility Functions
| Function | Returns | Description |
|---|---|---|
createProfileFromOidc(claims, provider, options) | Promise<{ profile, oidcIdentity, created }> | Create or link profile from OIDC |
resolveIdentity(context) | Promise<{ profile, source } | null> | Resolve authentication |
ApiKey.verify(key) | Promise<ApiKey | null> | Verify and validate API key |