Skip to main content

@happyvertical/smrt-profiles

Central identity system with multi-auth (Nostr/OIDC/API keys/magic links), relationships, controlled metadata, and audit logging.

Installation

pnpm add @happyvertical/smrt-profiles @happyvertical/sql

Usage

import {
backfillProfileEmailKeys,
createProfileFromOidc,
} from '@happyvertical/smrt-profiles';
import { getDatabase } from '@happyvertical/sql';

// Connect after applying schema migrations.
const persistence = { type: 'sqlite' as const, url: 'file:profiles.db' };
// Mark migrated Profile email keys ready before enabling OIDC provisioning.
const db = await getDatabase(persistence);
await backfillProfileEmailKeys(db);

// Provision a new OIDC Profile or reuse its exact issuer/subject link.
const { profile, oidcIdentity, created } = await createProfileFromOidc(
{
iss: 'https://accounts.google.com',
sub: 'abc123',
email: 'olivia@example.com',
email_verified: true,
name: 'Olivia Smith',
},
'google',
{ db },
);

Owned assets

import { AssetCollection } from '@happyvertical/smrt-assets';
import { ProfileCollection } from '@happyvertical/smrt-profiles';

const profiles = await ProfileCollection.create({ db });
const assets = await AssetCollection.create({ db });

const headshot = await assets.create({
name: 'alice-headshot.jpg',
sourceUri: 'file:///tmp/alice-headshot.jpg',
mimeType: 'image/jpeg',
});

await profile.addAsset(headshot, 'avatar');
await profiles.addAsset(profile.id!, headshot, 'gallery', 1);

const avatarAssets = await profile.getAssets('avatar');
const galleryAssets = await profiles.getAssets(profile.id!, 'gallery');

API

Models

ExportDescription
ProfileCore identity (STI base for Bot/Organization/Person)
BotSTI subclass for automated agents
OrganizationSTI subclass for companies/groups
PersonSTI subclass for individuals
ProfileTypeProfile classification lookup table
ProfileMetadataPer-profile metadata values
ProfileMetafieldControlled vocabulary with validation schema
ProfileRelationshipDirectional link between two profiles
ProfileRelationshipTypeRelationship classification with reciprocal flag
ProfileRelationshipTermTime-bounded relationship periods
ProfileAssetDedicated owned-asset join stored in profile_assets with relationship and sortOrder

Auth Models

ExportDescription
OidcIdentityOIDC provider identity (issuer + subject)
NostrIdentityNostr keypair with AES-256-GCM encryption
ApiKeySHA-256 hashed API key with scope and expiry
MagicLinkTokenOne-time passwordless auth token
AuditLogAction/resource audit trail with source tracking

Collections

ExportDescription
ProfileCollectionCRUD and query for profiles
ProfileAssetCollectionDirect access to profile_assets rows plus asset helper wrappers
ProfileTypeCollectionProfile type management
ProfileMetadataCollectionMetadata value operations
ProfileMetafieldCollectionMetafield vocabulary management
ProfileRelationshipCollectionRelationship queries
ProfileRelationshipTypeCollectionRelationship type management
ProfileRelationshipTermCollectionTerm period management
ApiKeyCollectionAPI key lookup and management
AuditLogCollectionAudit log queries
MagicLinkTokenCollectionMagic link token operations
NostrIdentityCollectionNostr identity lookup (includes NIP-05)
OidcIdentityCollectionOIDC identity lookup

ProfileCollection.findUniqueGlobalPersonByEmail(email) is the supported verified-identity lookup. It reads across tenant scopes and returns a Profile only when the case-insensitive email has exactly one match and that row is a global Person; tenant-scoped, non-Person, and duplicate matches throw a CanonicalPersonProfileError. Matching uses readonly, indexed Profile.emailKey, derived with the exported TypeScript normalizeIdentityEmail() helper so Unicode casing and whitespace behave the same on every database adapter. Use requireCanonicalGlobalPerson(profileId, email?) to validate an application-selected Profile against the same invariant. When email is omitted, the helper validates uniqueness using the Profile's current stored email. reserveCanonicalIdentityEmail(profileId, email) additionally claims the normalized address in the private oidc_profile_email_reservations table. Omit email to synchronize the reservation from the Profile's current stored email; changing the address moves the reservation, and clearing it removes the reservation. Legacy simple Person STI discriminators remain valid and are handled by core's normal upgrade path.

OidcIdentity.identityKey stores a nullable, unique issuer/subject natural key for transaction-safe first-login races. The model derives it from issuer and subject on every save, so callers cannot desynchronize it. New links populate it and legacy rows backfill it when reused. Issuer and subject are opaque, case-sensitive OIDC identifiers: surrounding whitespace is preserved and is part of the key; trimming is used only to reject an all-whitespace claim. The generated REST, MCP, and CLI surfaces are read-only because identity mutation is an authentication authority change; trusted callers link identities through the transactional provisioning APIs. The legacy OidcIdentity.findOrCreate() method is deprecated: it transactionally reuses one exact safe issuer/subject link for compatibility but refuses to create a new authentication link. Use createProfileFromOidc() or the users package's owner-aware provisioning API for creation. The deprecated OidcIdentityCollection.linkToProfile() and Profile.linkOidcIdentity() helpers delegate to the same exact-reuse-only path and cannot plant or rebind a link. Existing installations must stop or upgrade legacy Profile writers, run smrt db:status, smrt db:migrate, and then smrt db:status before deploying this version to add the identity keys and private email-reservation table. Then run backfillProfileEmailKeys(db) once from a single deploy process before enabling verified-email provisioning. The transaction-safe backfill is idempotent; canonical email lookup and reservation fail closed with email_key_backfill_required while the standard _smrt_backfills readiness marker is absent. Exact issuer/subject reuse does not depend on email-key readiness because it does not perform email-based linking. The identity-boundary helpers use the readiness guard and indexed key. The general-purpose ProfileCollection.findByEmail() retains its compatible legacy lookup behavior and is not suitable for identity linking. Only the explicit deploy-time backfill scans the Profile table and records readiness; guarded runtime identity lookups use the indexed key and validate returned candidates. createProfileFromOidc() requires a transaction-capable database. Pass the root database, which must expose beginTransaction, and s-m-r-t owns the transaction; an already transaction-bound handle is supported through a savepoint. Paths that perform canonical email lookup or reservation require _smrt_backfills to exist first. Provisioning never attempts tracker DDL on a caller-owned transaction; for those paths, if the table is absent, pass the root database so s-m-r-t can initialize it outside the transaction. Exact issuer/subject reuse skips the email-key readiness-marker lookup, but root coordination still initializes the shared tracker table. Caller-owned exact reuse does not consult the tracker and therefore does not require that table. A handle exposing only transaction() is ambiguous and fails closed. For adapters without nested savepoint support, including DuckDB, pass the root database rather than calling the helper inside an outer transaction. Provisioning fails before durable writes when neither safe path is available. The typed OIDC provisioning decision matrix is the canonical package-by-package behavior contract. It records exact reuse, new identity and resolver outcomes, readiness, retries, adapter support, public errors, and permitted row creation; the Profiles and Users suites execute its applicable rows directly. In particular, Profile-only exact reuse preserves an established legacy link because it creates no User/session authentication state, while UserCollection.getOrCreateFromOidc() remains owner-aware and fail-closed. Use the Users API for verified-email reuse and application reconciliation. Its authorizeProfileOwner option is the only supported exception for a new issuer/subject targeting a pre-provisioned owned Person: the users package atomically verifies the canonical Profile, sole approved owner, matching User email, and non-rebindable identity. Without that explicit application authorization, the owned Profile still returns profile_owned.

Profile and ProfileCollection both expose getAssets(), addAsset(), and removeAsset() helpers backed by profile_assets. Typical relationships are avatar, gallery, and attachment.

Auth Functions

ExportDescription
resolveIdentityResolve profile from any auth method
createProfileFromOidcCreate a Profile + OIDC identity or reuse an exact issuer/subject link; existing email matches fail closed
normalizeIdentityEmailAdapter-independent Unicode/whitespace email canonicalizer used by identity keys
backfillProfileEmailKeysTransactionally populate normalized keys for migrated Profiles
PROFILE_EMAIL_KEY_BACKFILL_NAMEDurable readiness-marker name recorded by the Profile email-key backfill
createProfileFromNostrCreate profile + Nostr identity in one call
createAuthEventCreate a Nostr auth event
verifyAuthEventVerify a Nostr auth event signature
createMagicLinkServiceFactory for magic link auth service
createNip05HandlerFactory for NIP-05 address handler

Nostr Crypto

ExportDescription
generateNostrKeypairGenerate new Nostr keypair
encryptPrivkey / decryptPrivkeyAES-256-GCM key encryption
deriveEncryptionKeyDerive encryption key from master secret
getPublicKeyDerive pubkey from privkey
signEvent / computeEventIdNostr event signing
verifyNostrSignatureVerify Nostr signature
pubkeyToNpub / npubToPubkeyBech32 pubkey conversion
privkeyToNsec / nsecToPrivkeyBech32 privkey conversion
isValidPubkey / isValidPrivkeyKey validation
parseNip05Identifier / isValidNip05IdentifierNIP-05 parsing

Key Types

ProfileOptions, ProfileTypeOptions, ProfileMetadataOptions, ProfileMetafieldOptions, ProfileRelationshipOptions, ProfileRelationshipTypeOptions, ProfileRelationshipTermOptions, OidcIdentityOptions, CanonicalPersonProfileErrorCode, NostrIdentityOptions, ApiKeyOptions, GenerateKeyResult, MagicLinkTokenOptions, GenerateTokenResult, AuditLogOptions, AuditSource, AuthContext, ResolveIdentityResult, InitiateResult, VerifyResult, MagicLinkConfig, MagicLinkService, Nip05HandlerConfig, Nip05HandlerResult, Nip05Request, Nip05Response, NostrEvent, NostrKeypair, EncryptedKey, ValidationSchema, ValidatorFunction, ReciprocalHandler

Dependencies

  • @happyvertical/smrt-core -- ORM base classes
  • @happyvertical/ai -- AI client (SDK)
  • @happyvertical/sql -- Database operations (SDK)
  • @happyvertical/files -- Filesystem utilities (SDK)
  • @happyvertical/logger -- Structured logging (SDK)
  • @happyvertical/utils -- Shared utilities (SDK)
  • @noble/curves -- Nostr cryptography
  • bech32 -- Bech32 encoding for Nostr keys
  • Peer: @happyvertical/smrt-tenancy