Skip to content

@venturekit-pro/social

@venturekit-pro/social provides generic social-platform publishing adapters and a tenant catalog. It is domain-neutral: it knows about platforms, posts, media, OAuth credentials, validation and publishing. It does not know about cadence, fan-out, AI generation, or any other calling-app concept — apps compose those on top.

  • One canonical adapter contract — SocialAdapter with validate() + publish() and optional verify() + parseWebhook().
  • Four v1 adapters — LinkedIn (UGC Posts), X (v2 Tweets), Facebook (Pages Graph), Instagram (Content Publishing). Factory-constructed, stateless, one instance per process.
  • social_platforms catalog — a database row per supported platform (seeded by the package’s migration) plus a thin per-tenant tenant_social_platforms table for enable/disable and author-ref pairing.
  • Typed errors — SocialAuthError, SocialRateLimitError, SocialValidationError, SocialPublishError, so retry policies can match the failure mode.
  • Pre-publish validation — validatePost() enforces per-platform body / hashtag / media constraints and surfaces both errors and warnings.
  • First-comment threads — set SocialPost.firstComment and any adapter whose constraints.supportsFirstComment is true (LinkedIn, X, Instagram) posts it as a companion comment / reply after the main post. Best-effort: a failed comment never unwinds the already-live post.
Terminal window
npm install @venturekit-pro/social
# optional — WebP → JPEG conversion for LinkedIn uploads
npm install sharp

The package ships vk_social_0001_init.sql and declares it via "vk": { "migrations": "src/migrations" }, so vk migrate auto-discovers and applies it for any project that depends on the package. getSocialMigrationsDir() returns the directory for a custom runner.

src/lib/social.ts
import {
createAdapterRegistry,
createLinkedInAdapter,
createXAdapter,
createFacebookAdapter,
createInstagramAdapter,
} from '@venturekit-pro/social';
export const socialRegistry = createAdapterRegistry([
createLinkedInAdapter(), // each factory accepts { baseUrl?, constraints? } overrides
createXAdapter(),
createFacebookAdapter(),
createInstagramAdapter(),
]);
socialRegistry.keys(); // ['linkedin', 'x', 'facebook', 'instagram']
socialRegistry.find('mastodon'); // undefined
socialRegistry.resolve('linkedin'); // throws when unknown

Adapters can also be imported individually from @venturekit-pro/social/adapters/linkedin, /adapters/x, /adapters/facebook and /adapters/instagram (each also exports its *_CONSTRAINTS).

import {
SocialAuthError, SocialRateLimitError, SocialValidationError, SocialPublishError,
type SocialPost, type OAuthCredentials,
} from '@venturekit-pro/social';
import { socialRegistry } from '../lib/social';
const adapter = socialRegistry.resolve('linkedin');
const post: SocialPost = {
body: 'Launch day! …',
media: [{ url: presignedImageUrl, mimeType: 'image/png', altText: 'Dashboard screenshot' }],
link: 'https://app.example.com/blog/launch',
firstComment: 'Read the full story → …',
idempotencyKey: `blog-${blogPost.id}-linkedin`,
};
const validation = adapter.validate(post); // { ok, issues: [{ code, message, severity, … }] }
if (!validation.ok) throw new Error(validation.issues.map((i) => i.message).join('; '));
try {
const result = await adapter.publish(post, credentials);
// result.externalRef, result.publishedUrl?, result.publishedAt
} catch (err) {
if (err instanceof SocialRateLimitError) { // back off using err.retryAfterSeconds
} else if (err instanceof SocialAuthError) { // refresh credentials and retry
} else if (err instanceof SocialValidationError) { // platform rejected the body — show err.message to the editor
} else if (err instanceof SocialPublishError) { // transient or unexpected — retry with backoff
}
}

The package never reads or writes credentials. The caller fetches the OAuthCredentials — { accessToken, refreshToken?, expiresAt?, authorRef } — from its own secret store and passes them per call. authorRef carries the platform-side target:

PlatformauthorRef example
LinkedInurn:li:organization:12345
Xthe user / page handle
Facebookpage_12345
Instagramig_user_17841412345

Callers pass a plain image URL (presigned S3, CDN, …) in SocialMedia.url; the adapters do the rest:

  • Facebook — downloads the image in-process and uploads the bytes as multipart form data to /<page-id>/photos. Works even for private/presigned URLs Meta’s servers cannot reach (local MinIO, private S3).
  • LinkedIn — a urn:li:digitalmediaAsset:… URL is used directly; otherwise the two-step staging (registerUpload → PUT bytes → asset URN) runs automatically. Unsupported formats (e.g. WebP) are converted to JPEG first — requires the optional sharp peer.
  • Instagram — passes the URL to Meta’s /media endpoint (Meta fetches it server-side), so it must be publicly reachable.
  • X — the caller stages the upload via X’s chunk-upload endpoint and passes the resulting media_id_string as url.

The upload format is decided from the payload’s magic number (sniffImageMime), not from SocialMedia.mimeType, which is only a fallback. Downloads are capped at the platform’s maxMediaSizeBytes and time out after 30 s; oversized payloads raise SocialValidationError (permanent), transport failures SocialPublishError. Only http(s) URLs are accepted — and because the worker fetches them, apps that let end users supply media URLs must block internal targets (link-local, RFC 1918, cloud metadata endpoints) themselves.

import { listSocialPlatforms, getSocialPlatformByKey, patchSocialPlatform } from '@venturekit-pro/social';
import { query } from '@venturekit/data';
await listSocialPlatforms(query); // SocialPlatformRow[] — key, displayName, status, meta, …
await getSocialPlatformByKey(query, 'linkedin'); // row or null
await patchSocialPlatform(query, 'x', { status: 'disabled' }); // 'available' | 'deprecated' | 'disabled'

Add a row to social_platforms (createSocialPlatform(query, { key, displayName, … }) or a migration of yours) and register a custom adapter:

import type { SocialAdapter } from '@venturekit-pro/social';
const mastodonAdapter: SocialAdapter = {
key: 'mastodon',
displayName: 'Mastodon',
constraints: { maxBodyChars: 500, minBodyChars: 1, minHashtags: 0, maxHashtags: 10, allowedMimeTypes: ['image/png', 'image/jpeg'], maxMediaCount: 4, allowedAspectRatios: [], maxMediaSizeBytes: 8_000_000, supportsFirstComment: true },
validate(post) { /* … */ },
async publish(post, credentials) { /* … */ },
};
const registry = createAdapterRegistry([createLinkedInAdapter(), mastodonAdapter]);