@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');
Web links
A profile's social accounts and websites, in display order. Several links per platform are allowed; the same address is kept once. New links take the profile's tenant, and they are removed with the profile.
import { ProfileLinkCollection } from '@happyvertical/smrt-profiles';
await profile.setLinks([
{ platform: 'facebook', url: 'https://facebook.com/mayor' },
{ platform: 'website', url: 'https://mayor.example/' },
{ platform: 'other', url: 'https://blog.example/', label: 'Blog' },
]); // replaces the whole list in one transaction
const links = await profile.getLinks(); // ordered by sortOrder
const profileLinks = await ProfileLinkCollection.create({ db });
await profileLinks.reorder(profile.id!, [links[2].id!, links[0].id!, links[1].id!]);
Platforms: facebook, instagram, x, linkedin, youtube, tiktok,
website, other (PROFILE_LINK_PLATFORMS). Addresses must be http:// or
https:// (normalizeProfileLinkUrl). A link's slug is its id, never its
address or label.
Profile.isPublic (default false) is a public-figure flag: the profile is
publicly known (an elected official, a public body). It only records the fact;
the application decides what it shows.
API
Models
| Export | Description |
|---|---|
Profile | Core identity (STI base for Bot/Organization/Person) |
Bot | STI subclass for automated agents |
Organization | STI subclass for companies/groups |
Person | STI subclass for individuals |
ProfileType | Profile classification lookup table |
ProfileMetadata | Per-profile metadata values |
ProfileMetafield | Controlled vocabulary with validation schema |
ProfileRelationship | Directional link between two profiles |
ProfileRelationshipType | Relationship classification with reciprocal flag |
ProfileRelationshipTerm | Time-bounded relationship periods |
ProfileAsset | Dedicated owned-asset join stored in profile_assets with relationship and sortOrder |
ProfileLink | Ordered web link on a profile (profile_links: platform, url, optional label, sortOrder; cascades with the profile) |
Auth Models
| Export | Description |
|---|---|
OidcIdentity | OIDC provider identity (issuer + subject) |
NostrIdentity | Nostr keypair with AES-256-GCM encryption |
ApiKey | SHA-256 hashed API key with scope and expiry |
MagicLinkToken | One-time passwordless auth token |
AuditLog | Action/resource audit trail with source tracking |
Collections
| Export | Description |
|---|---|
ProfileCollection | CRUD and query for profiles |
ProfileAssetCollection | Direct access to profile_assets rows plus asset helper wrappers |
ProfileTypeCollection | Profile type management, including tenant-safe getAvailableById(id, tenantId) for tenant-owned or global types |
ProfileLinkCollection | listForProfile, replaceForProfile (ordered replace in one transaction), reorder |
ProfileMetadataCollection | Metadata value operations |
ProfileMetafieldCollection | Metafield vocabulary management |
ProfileRelationshipCollection | Relationship queries |
ProfileRelationshipTypeCollection | Relationship type management |
ProfileRelationshipTermCollection | Term period management |
ApiKeyCollection | API key lookup and management |
AuditLogCollection | Audit log queries |
MagicLinkTokenCollection | Magic link token operations |
NostrIdentityCollection | Nostr identity lookup (includes NIP-05) |
OidcIdentityCollection | OIDC 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
| Export | Description |
|---|---|
resolveIdentity | Resolve profile from any auth method |
createProfileFromOidc | Create a Profile + OIDC identity or reuse an exact issuer/subject link; existing email matches fail closed |
normalizeIdentityEmail | Adapter-independent Unicode/whitespace email canonicalizer used by identity keys |
backfillProfileEmailKeys | Transactionally populate normalized keys for migrated Profiles |
PROFILE_EMAIL_KEY_BACKFILL_NAME | Durable readiness-marker name recorded by the Profile email-key backfill |
createProfileFromNostr | Create profile + Nostr identity in one call |
createAuthEvent | Create a Nostr auth event |
verifyAuthEvent | Verify a Nostr auth event signature |
createMagicLinkService | Factory for magic link auth service |
createNip05Handler | Factory for NIP-05 address handler |
Nostr Crypto
| Export | Description |
|---|---|
generateNostrKeypair | Generate new Nostr keypair |
encryptPrivkey / decryptPrivkey | AES-256-GCM key encryption |
deriveEncryptionKey | Derive encryption key from master secret |
getPublicKey | Derive pubkey from privkey |
signEvent / computeEventId | Nostr event signing |
verifyNostrSignature | Verify Nostr signature |
pubkeyToNpub / npubToPubkey | Bech32 pubkey conversion |
privkeyToNsec / nsecToPrivkey | Bech32 privkey conversion |
isValidPubkey / isValidPrivkey | Key validation |
parseNip05Identifier / isValidNip05Identifier | NIP-05 parsing |
Key Types
ProfileOptions, ProfileTypeOptions, ProfileLinkOptions, ProfileLinkPlatform, ProfileLinkInput, 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 cryptographybech32-- Bech32 encoding for Nostr keys- Peer:
@happyvertical/smrt-tenancy
Activity log recipe
AuditLogRecipe declares profiles.audit-log. After authorizing access through
readAuditTrail, pass the returned records to auditActivityEntries, then pass its
DTOs as entries to ActivityList from @happyvertical/smrt-svelte inside AdminShell.
The wrapper binds only its own rows into the shell list and removes them when
replaced or unmounted. Replace entries when the viewer's authorization changes.
Actor labels and record links are optional host callbacks; the projection never
infers access rights or exposes before/after payloads. Invalid dates are omitted.
The Activity feature card can be enabled independently of Messaging. Its recipe suggests an Activity section without generating a model route; the authorized activity binding remains the only UI data source.
The /activity surface resolves the public @happyvertical/smrt-svelte#ActivityList
wrapper inside AdminShell. Its required audit-log provider means host-authorized
readAuditTrail followed by auditActivityEntries; it does not enable generic
AuditLog routes. The JSON seed supplies fictional entries directly to the
wrapper. runtime: both reflects browser rendering plus server-only audit reads;
seed data is never inserted into an audit database or treated as authorized history.