@happyvertical/smrt-social

Social media account management with OAuth and post scheduling across YouTube, Threads, X, Bluesky, and Facebook.

v0.29.34OAuthPost SchedulingMulti-PlatformOptional Tenancy

Overview

smrt-social manages social media connections and publishing across multiple platforms. It handles OAuth credential storage (CSRF + PKCE), post creation and scheduling, and per-post analytics tracking. The platform enum is hardcoded — extending it requires code changes.

Installation

bash
npm install @happyvertical/smrt-social

Quick Start

typescript
import { SocialAccount, SocialPost, OAuthState } from '@happyvertical/smrt-social';

// Connect a social account. Prefer storing credentials in smrt-secrets
// via setCredentials() rather than the deprecated plaintext token columns.
const account = new SocialAccount({
  name: 'Bentley News YouTube',
  platform: 'youtube',
  platformUsername: 'Bentley News',
  tokenExpiresAt: new Date('2026-06-01'),
  defaultHashtags: ['news', 'local'],
  linkBehavior: 'description',
  publishMode: 'dry_run',          // safety default; gate 'public' behind publicPublishingAllowed
});
await account.save();

// Store the OAuth payload in smrt-secrets (sets credentialSecretId on the account)
await account.setCredentials({
  accessToken: '...',
  refreshToken: '...',
});

// Check readiness before publishing
if (account.isReady) {
  const post = new SocialPost({
    socialAccountId: account.id,
    title: 'Breaking News from Bentley',
    description: 'Latest updates from the town council meeting.',
    hashtags: ['news', 'local', 'bentley'],
    linkUrl: 'https://example.com/article',
    scheduledAt: new Date('2026-03-05T18:00:00Z'),
    status: 'scheduled',
  });
  await post.save();
}

// OAuth flow: create state, redirect user, verify callback
const state = new OAuthState({
  platform: 'youtube',
  state: OAuthState.generateState(),
  codeVerifier: OAuthState.generateCodeVerifier(),
  redirectUri: 'https://app.example.com/oauth/callback',
  scopes: ['youtube.upload', 'youtube.readonly'],
});
await state.save();
// On callback: state.verifyState(callbackState)

Core Models

SocialAccount (STI)

typescript
class SocialAccount extends SmrtObject {
  name: string
  platform: 'youtube' | 'threads' | 'x' | 'bluesky' | 'facebook'
  platformUserId: string | null
  platformUsername: string | null
  platformUrl: string | null
  accessToken: string | null        // deprecated: prefer credentialSecretId/setCredentials()
  refreshToken: string | null       // deprecated
  credentialSecretId: string | null // smrt-secrets reference for the credential payload
  accessTokenSecretName: string | null
  refreshTokenSecretName: string | null
  tokenExpiresAt: Date | null
  isActive: boolean
  status: 'connected' | 'disconnected' | 'expired' | 'missing_permissions' | 'error'
  defaultHashtags: string[]
  scopes: string[]
  linkBehavior: 'description' | 'inline' | 'attachment' | 'reply' | 'none'
  publishMode: 'dry_run' | 'stage_remote' | 'private_or_scheduled' | 'public'
  publicPublishingAllowed: boolean  // latch required before publishMode 'public' takes effect
  errorMessage: string | null

  get isReady(): boolean             // active + connected + credentials + no missing perms + not expired + publish latch
  get isTokenExpired(): boolean      // 5-minute buffer before expiry
  get hasCredentials(): boolean      // any usable credential reference present
  get needsAttention(): boolean
  get effectivePublishMode(): PublishMode  // downgrades 'public' to 'dry_run' until the latch is set
  async setCredentials(credentials: Record<string, unknown>, options?): Promise<void>
}

SocialPost

typescript
class SocialPost extends SmrtObject {
  socialAccountId: string | null
  postType: 'text' | 'link' | 'image' | 'video'
  title: string | null
  description: string
  hashtags: string[]
  linkUrl: string | null
  platformPostId: string | null   // id assigned by the platform after publish
  scheduledAt: Date | null
  publishedAt: Date | null
  status: 'draft' | 'pending_approval' | 'approved' | 'scheduled'
        | 'publishing' | 'dry_run' | 'staged' | 'published'
        | 'failed' | 'cancelled'
  errorMessage: string | null
  analytics: PostAnalytics        // object: views, impressions, likes, comments, shares, clicks, raw, lastUpdated
  analyticsLastSyncedAt: Date | null

  get isEditable(): boolean       // true when draft, pending_approval, or failed
  get isScheduled(): boolean
  get isPublished(): boolean
  get fullText(): string          // description + formatted hashtags
}

OAuthState (STI)

typescript
class OAuthState extends SmrtObject {
  platform: 'youtube' | 'threads' | 'x' | 'bluesky' | 'facebook'
  state: string                // CSRF token
  codeVerifier: string | null  // PKCE code verifier
  redirectUri: string
  scopes: string[]
  expiresAt: Date              // defaults to now + 10 minutes (10-minute TTL)

  get isExpired(): boolean
  get isValid(): boolean       // not expired and state is set
  verifyState(callbackState: string): boolean
  static generateState(): string
  static generateCodeVerifier(): string
  static async generateCodeChallenge(verifier: string): Promise<string>  // S256
}

Best Practices

DOs

  • Check account.isReady before attempting to publish
  • Use OAuthState.generateState() for CSRF protection
  • Use PKCE (generateCodeVerifier/generateCodeChallenge) for OAuth flows
  • Implement a job runner to trigger publishing at scheduledAt time
  • Clean up expired OAuthState records (10-minute TTL)

DON'Ts

  • Don't assume auto-publishing (scheduledAt is metadata only -- app must trigger)
  • Don't expect analytics to auto-populate (must sync from platform APIs)
  • Don't write OAuth tokens into the deprecated plaintext accessToken/refreshToken columns -- use setCredentials() (smrt-secrets)
  • Don't enable publishMode: 'public' without also setting publicPublishingAllowed (the latch gates real public publishing)
  • Don't extend the platform enum without code changes (hardcoded list)

Related Modules