@happyvertical/smrt-profiles
Central identity system with multi-auth (Nostr/OIDC/API keys/magic links), bidirectional 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 via ProfileMetafield / ProfileMetadata, owned-asset joins via the dedicated profile_assets table, and audit logging.
Key Features
- STI Profile Types:
Profilebase withBot,Organization,Personsubclasses, plus extensible types viaProfileType - Controlled Metadata:
ProfileMetafield(validated controlled vocabulary) +ProfileMetadataper-profile values.Profile.metadatais a@oneToMany('ProfileMetadata')relationship. - ProfileAsset (new): dedicated owned-asset join in
profile_assetswithrelationshipandsortOrder. Replaces genericAssetAssociationfor profile-owned assets;AssetAssociationremains for generic/provenance links only. - Bidirectional Relationships: auto-creates reciprocal inverse, optional
contextProfileIdfor tertiary,ProfileRelationshipTermfor start/end dates - Multi-Provider Auth: OIDC (Keycloak/Google/GitHub), Nostr (AES-256-GCM encrypted keypairs, NIP-05), API keys (SHA-256 hashed, scoped, expiry), magic link tokens
- Audit Logging: action/resource trail with
source(web/cli/ci/webhook/mcp),onBehalfOfIdfor CI pass-through identity,allowSuperAdminBypass: true
Tenancy
Profile uses @TenantScoped({ mode: 'optional' }) — a profile
can be tenant-scoped or global. AuditLog is also optional-tenant and explicitly
allows super-admin bypass (allowSuperAdminBypass: true) so platform operators can
audit across tenants. Email is globally unique across all profiles (DB-level constraint), not per-tenant.
Installation
Using pnpm (recommended)
pnpm add @happyvertical/smrt-profilesUsing npm
npm install @happyvertical/smrt-profilesPeer Dependencies
Optional integration with @happyvertical/smrt-tenancy for tenant scoping. Nostr
support requires @noble/curves and bech32 (already declared as
deps). Nostr decryption requires the SERVER_MASTER_SECRET env var.
Quick Start (5 Minutes)
1. Create Profile Type and Profile
import {
ProfileCollection,
ProfileTypeCollection
} from '@happyvertical/smrt-profiles';
const typeCollection = await ProfileTypeCollection.create({ db });
const humanType = await typeCollection.create({ name: 'Human' });
await humanType.save();
const profileCollection = await ProfileCollection.create({ db });
const person = await profileCollection.create({
typeId: humanType.id,
name: 'Alice Johnson',
email: 'alice@example.com', // globally unique
description: 'Software engineer'
});
await person.save();2. Generate a Bio (AI, prompt-overridable)
// Uses the smrtProfiles.profile.generateBio prompt via @happyvertical/smrt-prompts.
// Tenants can override the template/model/params at runtime.
const bio = await person.generateBio();
console.log(bio);
// Check whether a profile matches a description (delegates to is())
const isEngineer = await person.matches('a senior software engineer with TypeScript expertise');3. Owned Assets via ProfileAsset (new)
// profile_assets is the canonical join for profile-owned assets.
// AssetAssociation is reserved for generic / provenance links only.
// addAsset(asset, relationship?, sortOrder?) -- takes an Asset instance.
await person.addAsset(headshotAsset, 'avatar', 0);
const assets = await person.getAssets('avatar'); // relationship filter (string)
await person.removeAsset(headshotAsset.id);
// Reads via the collection (per profile id)
const ownedAssets = await profileCollection.getAssets(person.id, 'avatar');4. Controlled Metadata
import { ProfileMetafieldCollection } from '@happyvertical/smrt-profiles';
const fieldCollection = await ProfileMetafieldCollection.create({ db });
const phone = await fieldCollection.create({
name: 'Phone Number',
validation: { type: 'string', pattern: '^\\+?[0-9]{10,15}$' }
});
await phone.save();
await person.addMetadata('phone-number', '+14155551234');
const metadata = await person.getMetadata();
// { 'phone-number': '+14155551234' }
// Bulk reads/writes
await profileCollection.batchGetMetadata([person.id, bob.id]);
await profileCollection.batchUpdateMetadata([{ profileId: person.id, data: { location: 'SF' } }]);5. Bidirectional Relationships
import { ProfileRelationshipTypeCollection } from '@happyvertical/smrt-profiles';
const relTypeCollection = await ProfileRelationshipTypeCollection.create({ db });
const friendType = await relTypeCollection.create({
name: 'Friend',
reciprocal: true // auto-creates inverse
});
await friendType.save();
await person.addRelationship(bob, 'friend');
const friends = await person.getRelationships({ direction: 'all' });Prompt Registry
Profile.generateBio() is registered with @happyvertical/smrt-prompts so tenants can override the template, model, and params at runtime without forking the package.
import { smrtProfilesGenerateBioPrompt } from '@happyvertical/smrt-profiles';
// key: 'smrtProfiles.profile.generateBio'
// Variables passed to the prompt (PII-conscious surface):
// name, description, profileType, relationshipsSummary (counts only)
//
// Intentionally excluded: email, tenantId, ownerId, raw metadata blobs.See smrt-prompts for the override workflow.
Authentication Methods
| Model | Pattern |
|---|---|
| NostrIdentity | Encrypted keypair (AES-256-GCM). Requires SERVER_MASTER_SECRET env var for decryption.
NIP-05 address generation supported. |
| OidcIdentity | Multiple issuers (Keycloak/Google/GitHub). Lookup by issuer + subject pair. findOrCreate() for first login. Same subject from different issuers = different
identities. |
| ApiKey | SHA-256 hashed. Plaintext returned once only on generate(). keyPrefix for identification. Scope-based with expiry. |
| MagicLinkToken | One-time token with expiry for passwordless auth. |
resolveIdentity() Dispatcher
import { resolveIdentity } from '@happyvertical/smrt-profiles';
// Top-level dispatcher: returns/creates a Profile from
// Nostr signatures, OIDC claims, magic link tokens, or API keys.
const result = await resolveIdentity({
oidcSession: event.locals.session,
apiKey: event.request.headers.get('X-API-Key'),
db: event.locals.db
});
// Returns ResolveIdentityResult: { profile: Profile | null, source, ... }
// source: 'api_key' | 'oidc' | 'nostr' | 'actor' | 'none'Provider-specific Helpers
import {
createProfileFromOidc,
createProfileFromNostr,
ApiKey,
} from '@happyvertical/smrt-profiles';
// First-time OIDC sign-in -- (claims, provider, options)
const { profile, oidcIdentity, created } = await createProfileFromOidc(claims, provider, { db });
// Nostr-authenticated user -- (email, nostrData, options)
const { profile, nostrIdentity } = await createProfileFromNostr(email, nostrData, { db });
// Issue an API key -- plaintext returned ONCE
// generate(profile, { name, scopes?, expiresAt?, db? })
const { key, apiKey } = await ApiKey.generate(profile, {
name: 'CI deploy key',
scopes: ['read:profiles'],
expiresAt: new Date('2026-12-31'),
});
console.log(key); // store now, never returned again
console.log(apiKey.keyPrefix); // visible identifier laterAudit Logging
AuditLog records actions, resource identifiers, and provenance. The source field tracks the entry point (web/cli/ci/webhook/mcp), and onBehalfOfId captures the human identity behind a CI/automation account (pass-through
identity).
await profile.recordAction({
action: 'patient_viewed',
resourceType: 'Patient',
resourceId: 'patient-123',
source: 'web', // web | cli | ci | webhook | mcp
onBehalfOfId: actorProfileId, // CI bots run as a service account, log the human too
metadata: { reason: 'Follow-up appointment' },
});AuditLog sets allowSuperAdminBypass: true so platform admins can read
across tenants for incident response.
Gotchas
SERVER_MASTER_SECRETrequired for Nostr private-key decryption — centralized key management- API key plaintext returned once:
ApiKey.generate()returns it exactly one time; onlykeyPrefixis visible afterwards - OIDC unique per
issuer + subject: same subject from different issuers = different identities - Email globally unique across all profiles (DB-level constraint), not per-tenant
- Use
ProfileAsset(profile_assets) for owned assets; reserveAssetAssociationfor generic/provenance links
Best Practices
- Define
ProfileMetafieldupfront — adding metadata without a backing field skips validation - Namespace metafield slugs with dot notation (
contact.phone,social.github) - Only link OIDC accounts with
email_verified: true— same email across providers is the linking signal - Always record sensitive actions via
recordAction()— includeonBehalfOfIdfor CI - Implement regular API key rotation; use specific scopes rather than
*