@venturekit-pro/social
v0.0.21
Published
Social-platform publishing adapters + catalog for VentureKit applications
Maintainers
Readme
@venturekit-pro/social
Generic social-platform publishing adapters + tenant catalog for VentureKit applications.
Domain-neutral. This package knows about platforms, posts, media, OAuth credentials, validation, and publishing. It does not know about cadence, fan-out, AI generation, angles, blog↔social linkage, or any other calling-app concept. Apps compose those on top.
What it gives you
- One canonical adapter contract —
SocialAdapterwithvalidate()+publish()and optionalverify()+parseWebhook(). - Four v1 adapters — LinkedIn (UGC Posts), X (v2 Tweets), Facebook (Pages Graph), Instagram (Content Publishing). All factory-constructed, stateless, served from one instance per process.
social_platformscatalog — a database row per supported platform (seeded by the package's migration), plus a thin per-tenanttenant_social_platformstable for enable/disable + author-ref pairing.- Typed errors —
SocialAuthError/SocialRateLimitError/SocialValidationError/SocialPublishErrorso callers can build retry policies that 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.firstCommentand any adapter whoseconstraints.supportsFirstCommentistrue(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.
Wire-up
// In your project's vk.config.ts:
import { getSocialMigrationsDir } from '@venturekit-pro/social';
export default defineVenture({
extraMigrationsDirs: [getSocialMigrationsDir()],
});vk migrate runs vk_social_0001_init.sql alongside your project's own
migrations.
Building the registry
import {
createAdapterRegistry,
createLinkedInAdapter,
createXAdapter,
createFacebookAdapter,
createInstagramAdapter,
} from '@venturekit-pro/social';
export const socialRegistry = createAdapterRegistry([
createLinkedInAdapter(),
createXAdapter(),
createFacebookAdapter(),
createInstagramAdapter(),
]);Validate + publish
import { socialRegistry } from './social';
const adapter = socialRegistry.resolve('linkedin');
const validation = adapter.validate(post);
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
}
}OAuth credentials
The package never reads or writes credentials — the caller fetches the
{accessToken, refreshToken?, expiresAt?, authorRef} pair from its own
secret store (the CMS uses KMS-encrypted-in-DB jsonb on tenants.secrets)
and passes it per call.
authorRef carries the platform-side target id:
| Platform | authorRef example |
|------------|--------------------------------------|
| LinkedIn | urn:li:organization:12345 |
| X | the user/page handle |
| Facebook | page_12345 |
| Instagram | ig_user_17841412345 |
Media staging
The adapters handle image uploads automatically — callers pass a plain
image URL (presigned S3, CDN, etc.) in SocialMedia.url and the
adapter does the rest:
- Facebook — downloads the image in-process and uploads the bytes
via multipart form data (
sourcefield) to/<page-id>/photos. This works even when the URL is a private/presigned link that Meta's servers can't reach (e.g. local dev MinIO, private VPC S3 without a CDN). - LinkedIn — if
media.urlis already a LinkedIn asset URN (urn:li:digitalmediaAsset:…), uses it directly (backward compat for callers that pre-stage). Otherwise, performs LinkedIn's two-step staging automatically:registerUpload→ PUT bytes → asset URN. Images in unsupported formats (e.g. WebP) are converted to JPEG before upload — this requires the optionalsharppeer dependency (pnpm add sharp). - Instagram — passes the image URL to Meta's
/mediaendpoint (Meta fetches it server-side). A publicly reachable URL or CDN is required. - X — the caller stages the upload via X's chunk-upload endpoint
and passes the resulting
media_id_stringasurl.
Notes on byte uploads
- The upload format is decided from the payload's magic number, not
from
SocialMedia.mimeType— stale catalog metadata (a WebP recorded asimage/jpeg) would otherwise skip conversion and be rejected by the vendor.mimeTypeis only used when the format isn't recognised. - Downloads are capped at the platform's
maxMediaSizeBytesand time out after 30s. Oversized payloads raiseSocialValidationError(permanent); transport failures raiseSocialPublishError. - Only
http(s)media URLs are accepted. Because the worker now fetches the URL rather than the vendor, callers that let end users supply arbitrary media URLs must block internal targets (link-local, RFC1918, cloud metadata endpoints) themselves — the package has no way to know what is internal for a given deployment.
Extending with a custom platform
Add a row to social_platforms (any tenant-admin-owned migration of yours)
and register a custom adapter:
const mastodonAdapter: SocialAdapter = {
key: 'mastodon',
displayName: 'Mastodon',
constraints: { /* … */ },
validate(post) { /* … */ },
async publish(post, credentials) { /* … */ },
};
const registry = createAdapterRegistry([
createLinkedInAdapter(),
// …
mastodonAdapter,
]);