@happyvertical/smrt-profiles

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

v0.29.34IdentityAuthESM

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: Profile base with Bot, Organization, Person subclasses, plus extensible types via ProfileType
  • Controlled Metadata: ProfileMetafield (validated controlled vocabulary) + ProfileMetadata per-profile values. Profile.metadata is a @oneToMany('ProfileMetadata') relationship.
  • ProfileAsset (new): dedicated owned-asset join in profile_assets with relationship and sortOrder. Replaces generic AssetAssociation for profile-owned assets; AssetAssociation remains for generic/provenance links only.
  • Bidirectional Relationships: auto-creates reciprocal inverse, optional contextProfileId for tertiary, ProfileRelationshipTerm for 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), onBehalfOfId for 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)

bash
pnpm add @happyvertical/smrt-profiles

Using npm

bash
npm install @happyvertical/smrt-profiles

Peer 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

typescript
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)

typescript
// 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)

typescript
// 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

typescript
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

typescript
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.

typescript
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

ModelPattern
NostrIdentityEncrypted keypair (AES-256-GCM). Requires SERVER_MASTER_SECRET env var for decryption. NIP-05 address generation supported.
OidcIdentityMultiple issuers (Keycloak/Google/GitHub). Lookup by issuer + subject pair. findOrCreate() for first login. Same subject from different issuers = different identities.
ApiKeySHA-256 hashed. Plaintext returned once only on generate(). keyPrefix for identification. Scope-based with expiry.
MagicLinkTokenOne-time token with expiry for passwordless auth.

resolveIdentity() Dispatcher

typescript
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

typescript
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 later

Audit 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).

typescript
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_SECRET required for Nostr private-key decryption — centralized key management
  • API key plaintext returned once: ApiKey.generate() returns it exactly one time; only keyPrefix is 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; reserve AssetAssociation for generic/provenance links

Best Practices

  • Define ProfileMetafield upfront — 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() — include onBehalfOfId for CI
  • Implement regular API key rotation; use specific scopes rather than *