Imported from happyvertical/smrt (
packages/profiles/AGENTS.md). Install upstream withnpx skills add happyvertical/smrt --skill profiles. Copyright stays with the author.
@happyvertical/smrt-profiles
Central identity system with multi-auth, relationships, controlled metadata, and audit logging.
Models
- Profile (STI base → Bot, Organization, Person): email (optional; identity uniqueness is arbitrated by
oidc_profile_email_reservations.email_key, not a DB constraint — legacy duplicates are tolerated and fail closed, #2359), readonly indexedemailKeyderived withnormalizeIdentityEmail()for adapter-independent identity lookup,typeIdFK to ProfileType, plus ametadata@oneToMany('ProfileMetadata')relationship for controlled per-profile values. - ProfileAsset: dedicated owned-asset join in
profile_assetswithrelationshipandsortOrder. - ProfileRelationship: bidirectional — creating one auto-creates reciprocal inverse.
contextProfileIdfor tertiary relationships.ProfileRelationshipTermtracks start/end dates. - ProfileMetafield: controlled vocabulary with
validationSchema. ProfileMetadata: per-profile values linked to metafields. - AuditLog: action, resourceType/Id,
source(web/cli/ci/webhook/mcp),onBehalfOfIdfor CI pass-through identity.allowSuperAdminBypass: true.
Auth Methods
| Model | Pattern |
|---|---|
| NostrIdentity | Encrypted keypair (AES-256-GCM). Requires SERVER_MASTER_SECRET env var for decryption. NIP-05 address generation. |
| OidcIdentity | Multiple issuers (Keycloak/Google/GitHub). Lookup by issuer + subject pair. Transactional provisioning derives the readonly nullable unique identityKey and backfills legacy rows. |
| 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. |
Identity Resolution
Auth helpers in src/auth/ build profiles from external identity claims:
resolveIdentity()— top-level dispatcher that returns/creates a Profile from Nostr signatures, OIDC claims, magic link tokens, or API keys.createProfileFromOidc(claims, provider, options)— createsProfile+OidcIdentityfor first-time OIDC sign-in using a transaction-capable root database inoptions.db.ProfileCollection.findUniqueGlobalPersonByEmail(email)— supported verified-identity lookup; fails closed on tenant-scoped, non-Person, or duplicate case-insensitive matches.ProfileCollection.requireCanonicalGlobalPerson(profileId, email?)— validates an application-selected Profile against the same canonical global Person invariant.ProfileCollection.reserveCanonicalIdentityEmail(profileId, email?)— validates and synchronizes the private uniqueoidc_profile_email_reservations.email_keyused as the database arbiter for concurrent external-identity provisioning. Omittingemailuses the Profile's stored address, moving or removing an existing reservation as the canonical Profile changes.createProfileFromNostr(email, nostrData)— createsProfile+NostrIdentityfor Nostr-authenticated users.
Agent (bot) profiles (#2995)
An automated agent is an author, and every authoring seam in the framework
(ChatMessage.senderProfileId, ChatParticipant.profileId, a persona's
actsAsProfileId) is a crossPackageRef to Profile — a native uuid column on
PostgreSQL/DuckDB. A slug-style agent id is therefore not a representable author
(22P02). src/agent-profile.ts is the owning-package API that turns one into
a real Profile so no consumer mints bot profiles itself and no uuid column is
weakened to text:
resolveAgentProfile(profiles, { agentId, tenantId, name? })/resolveAgentProfileId(...)— resolve, creating on first use, thebotProfile an agent authors as. Idempotent and tenant-bound: identity is keyed on(tenantId, slug = agentId, context = AGENT_PROFILE_CONTEXT), matching the model's(tenant_id, slug, context, _meta_type)unique key, so an agent id resolves to a distinct profile per tenant and never collides with a person/organization sharing the slug. Classification is thebotProfileType (AGENT_PROFILE_TYPE_SLUG), created on demand. Parallel first use converges on the natural key rather than on whoever's INSERT won (each attempt re-reads before and after writing), which matters because NULLs are distinct in the(tenant_id, slug, context)unique index, so an untenanted agent is not arbitrated by the database (smrt#2360). Pinned against real PostgreSQL bysrc/__tests__/agent-profile-postgres.test.ts(pnpm --filter @happyvertical/smrt-profiles test:postgres).
First consumer: @happyvertical/smrt-chat's ChatService.
Key Methods
Profile.getAssets()/addAsset()/removeAsset()and the matchingProfileCollectionwrappers — canonical owned asset helpers backed byprofile_assets.Profile.addMetadata(metafieldSlug, value)/Profile.getMetadata()— validates against metafield schema.ProfileCollection.batchGetMetadata()/batchUpdateMetadata()for bulk reads/writes.Profile.getRelationships({ direction: 'from'|'to'|'all' })— direction matters.Profile.getRelationshipsFrom()/getRelationshipsTo()— R10-generated@oneToManyaccessors. ProfileRelationship has two FKs back to Profile, so each@oneToManyannotates its inverse explicitly ({ foreignKey: 'fromProfileId' }/'toProfileId'). Return rawProfileRelationship[]; usegetRelationships()for slug/direction filtering.- AI:
generateBio()(usessmrtProfiles.profile.generateBioprompt via@happyvertical/smrt-prompts),matches(criteria)(delegates tois()).
Prompt Registry
generateBio() is registered with @happyvertical/smrt-prompts so tenants can override template/model/params at runtime:
import { smrtProfilesGenerateBioPrompt } from '@happyvertical/smrt-profiles';
// key: 'smrtProfiles.profile.generateBio'
Gotchas
- SERVER_MASTER_SECRET required for Nostr private key decryption — centralized key management
- API key never returned again:
ApiKey.generate()returns plaintext once; onlykeyPrefixvisible later - OIDC unique per issuer+subject: same subject from different issuers = different identities. Both claims are opaque and case-sensitive; preserve exact whitespace after using trim only to reject blank values.
- OIDC identity mutations are trusted-only: generated REST, MCP, and CLI
surfaces are read-only. Link or update identities through transactional
provisioning APIs so callers cannot rebind issuer/subject authority to an
arbitrary Profile. Deprecated
OidcIdentity.findOrCreate()preserves only transaction-safe exact-link reuse and refuses to create new links;OidcIdentityCollection.linkToProfile()andProfile.linkOidcIdentity()delegate to that same non-creating compatibility path. - Apply schema migrations for identity race keys: run
smrt db:status,smrt db:migrate, thensmrt db:statusbefore deploying. Transactional OIDC provisioning populatesOidcIdentity.identityKeyandoidc_profile_email_reservations; legacy rows reserve an address only after safe validation. Stop/upgrade old Profile writers, then run the public, transactional and idempotentbackfillProfileEmailKeys(db)from one deploy process. Verified-email provisioning and public email-key lookup require the standard_smrt_backfillsreadiness marker and fail closed immediately when it is absent. This keeps table scans in the explicit deploy step; runtime reads use the indexed key and validate only returned candidates. - Public OIDC provisioning requires transactions:
createProfileFromOidc()owns a root transaction or uses a savepoint on an already-bound handle. Root adapters must exposebeginTransaction; transaction-only handles are ambiguous and fail closed. Pass the root database for adapters without nested savepoints, including DuckDB; provisioning fails before durable writes if neither path is safe. Trusted framework packages use the private@happyvertical/smrt-profiles/internal/oidc-provisioningsubpath instead of duplicating adapter probing, locking, or retry policy, and supply both exact issuer/subject and normalized-email lock keys in deterministic order. The coordinator additionally serializes every root-handle statement it owns per database URL on SQLite/DuckDB — shared_smrt_backfillsinitialization, the provisioning transaction, and the post-commit rebind — because those adapters multiplex one native connection and cannot overlap unrelated root transactions safely. Never overlap two statements on one such handle, transaction-bound or not — noPromise.allover reads, not even primary-key rebinds — because DuckDB fails the losing prepared statement or aborts the process outright. It retries bounded PostgreSQL deadlock/serialization failures. New OIDC Profiles use per-profile, non-semantic slugs so duplicate display names never invoke natural-key upsert. Caller-owned transactions never execute_smrt_backfillsDDL; paths that perform canonical email lookup or reservation require the table to already exist or the caller must retry with the root database. 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. - Profile-only OIDC linking fails closed on existing email matches:
the typed canonical scenario contract is
src/testing/oidcProvisioningDecisionMatrix.ts, executed by both Profiles and Users tests; keep public docs pointed at it instead of adding a second behavioral table.createProfileFromOidc()preserves exact issuer/subject reuse, including legacy tenant-scoped and non-Person links, but profiles cannot prove whether a User owns a same-email Profile. New identities therefore never attach to an existing email match through this helper; User/session provisioning still rejects unsafe linked Profiles before creating authentication state. UseUserCollection.getOrCreateFromOidc()for owner-aware verified-email reuse and its supported transaction-bound hooks. A pre-provisioned owned Person remains fail-closed unless the application explicitly usesauthorizeProfileOwnerto select both the canonical Profile and its existing approved sole owner; the users package revalidates those IDs, emails, and identity authority in the provisioning transaction. - Email storage and identity matching differ:
Profile.emailhas an exact-value DB constraint. Identity boundaries query readonly indexedProfile.emailKey, derived by the shared TypeScriptnormalizeIdentityEmail()helper rather than adapter-specific SQL casing or trimming, and deliberately fail closed on duplicate normalized keys. - Manifest objects must remain root-importable: the non-API
OidcProfileEmailReservationmodel and collection are public root exports because generated consumer registration imports every advertised manifest symbol from@happyvertical/smrt-profiles. Release-pack validation imports the actual generated register file from the packed tarball. - Optional tenancy on Profile; AuditLog allows super-admin bypass