Add LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit

- Connections page: admins set up each platform's developer app (keys stored
  encrypted), connect a client's account via OAuth, choose company Page /
  business location, check or disconnect
- Adapters per platform with sign-in, identity, targets and publishing;
  token refresh with "reconnect needed" when a sign-in expires
- Publishing and Content library: "Post to" lists the client's connected
  platforms with their own fields and optional per-platform caption
- Queue validates drafts per platform and publishes through the adapters;
  Facebook/Instagram unchanged
- Signed, expiring media links (/m/...) for platforms that fetch media by URL
- Agent: list_connections tool; schedule tools accept all platforms + options
- Offline test suite with simulated platform APIs (15 tests)
- docs/platforms.md: setup, approvals per platform, what is and isn't tested

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-20 08:19:33 -04:00
parent 3cbc76e7f1
commit f24e6ada4b
26 changed files with 1886 additions and 41 deletions

View File

@ -26,6 +26,20 @@ APP_DOMAIN=app.quorlasocial.com
# Let's Encrypt sends certificate notices here
ACME_EMAIL=support@quorlasocial.com
# ---------- Other platforms (LinkedIn, Google, TikTok, Pinterest, Threads, Reddit) ----------
# App keys are normally entered in the dashboard (Connections → Set up) and stored encrypted in
# data/connections.json, using DASHBOARD_SECRET as the key (or QUORLA_SECRET_KEY if set).
# Changing that secret means every platform must be reconnected.
# Public address of this install, used for sign-in redirects and media links (TikTok photos, Threads,
# Google Business Profile). Defaults to https://APP_DOMAIN when APP_DOMAIN is set.
PUBLIC_URL=
# LinkedIn API version (YYYYMM). LinkedIn retires versions after about a year.
LINKEDIN_API_VERSION=202606
# true while the Pinterest app only has Trial access (Pins go to Pinterest's sandbox)
PINTEREST_SANDBOX=false
# Reddit requires a descriptive User-Agent: web:<app id>:<version> (by /u/<reddit username>)
REDDIT_USER_AGENT=web:com.quorlasocial.app:1.0 (by /u/YOUR_REDDIT_USERNAME)
# ---------- Dashboard ----------
DASHBOARD_PORT=4310
# 127.0.0.1 behind nginx (recommended); 0.0.0.0 to listen on all interfaces

View File

@ -32,6 +32,7 @@ You run many client accounts at once. Every client is different: never reuse one
- DMs: `direct_messages` reads; `send_direct_message` only replies to someone who messaged first (Meta's 24-hour window), with approval.
- Research: `competitor_lookup` (any public business/creator IG account), `audience_demographics`, `instagram_mentions`.
- Every write is logged to `data/publish-log.jsonl`.
- **Other platforms** (LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit): an admin connects each client's account in the dashboard (Connections). Call `list_connections` first; only plan posts for platforms that are connected, and pass each platform's required fields in `options` (e.g. YouTube `title`, Pinterest `boardId`, TikTok `privacy`, Reddit `subreddit` + `title`). Write a platform-specific `caption` in `options` when the main caption doesn't fit (LinkedIn: professional; Threads: ≤500 chars; GBP: local offer + button). Same approval rules apply. Reddit: only where the subreddit's rules allow it; never disguise promotion.
## Reports & branding
- Every report we hand over (audits, weekly reports) is exported as a Metatroncube-branded PDF: `node tools/report-pdf/build.mjs <file.md>` (first time: `cd tools/report-pdf && npm install`). Logos and styles live in `branding/`.

View File

@ -98,11 +98,11 @@ export function archiveItem(id, by = "team") {
}
/** Turn an approved item into a DRAFT post in the queue (still needs approval there to publish). */
export function scheduleItem(id, { platforms = ["instagram", "facebook"], localTime, caption, fbCaption, collaborators = [], type }, by = "team") {
export function scheduleItem(id, { platforms = ["instagram", "facebook"], localTime, caption, fbCaption, collaborators = [], options = {}, type }, by = "team") {
const i = getItem(id);
if (!["approved", "scheduled"].includes(i.status)) throw err("Only approved work can be scheduled. Review it first.");
const post = sched.createPost({
client: i.client, platforms, type: type || i.format, media: i.files, caption: caption ?? i.captionIdea, fbCaption, collaborators, localTime,
client: i.client, platforms, type: type || i.format, media: i.files, caption: caption ?? i.captionIdea, fbCaption, collaborators, options, localTime,
createdBy: by, notes: `From library item ${i.id} "${i.title}" by ${i.uploadedBy}`,
});
mutate(id, (x) => { x.status = "scheduled"; x.postIds.push(post.id); x.history.push({ at: now(), by, action: `scheduled ${post.localTime}`, postId: post.id }); });

View File

@ -0,0 +1,63 @@
// Google Business Profile: "updates" (local posts) on a business location, with an optional button.
// Access to the Business Profile APIs must be requested from Google (approval form) before calls work.
import { request, isVideo } from "./http.js";
import { googleAuth, googleSetup } from "./google.js";
import { publicMediaUrl } from "./media.js";
const CTA = [["", "No button"], ["LEARN_MORE", "Learn more"], ["BOOK", "Book"], ["ORDER", "Order online"], ["SHOP", "Buy"], ["SIGN_UP", "Sign up"], ["CALL", "Call now"]];
export default {
id: "gbp",
name: "Google Business Profile",
color: "#4285f4",
setup: googleSetup("Business Profile API access is approved separately: request it with Google's “GBP API access” form for the same project.", [
"APIs & Services → Library → enable “My Business Account Management API”, “My Business Business Information API” and “Google My Business API”.",
"Request Business Profile API access (developers.google.com/my-business → Request access). Calls fail with a quota of 0 until Google approves.",
]),
auth: googleAuth(["openid", "email", "https://www.googleapis.com/auth/business.manage"]),
formats: { TEXT: true, IMAGE: true },
limits: { caption: 1500 },
targetLabel: "Business location",
fields: [
{ key: "cta", label: "Button", type: "select", options: CTA, default: "" },
{ key: "ctaUrl", label: "Button link", type: "text", hint: "Needed for every button except Call now. e.g. booking page." },
],
async identify(tokens) {
const { data } = await request("Google", "https://openidconnect.googleapis.com/v1/userinfo", { token: tokens.accessToken });
return { id: data.sub, name: data.email || data.name, username: null, avatar: data.picture || null, url: null };
},
async targets(tokens) {
const out = [];
const { data } = await request("Google Business Profile", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", { token: tokens.accessToken });
for (const acc of data.accounts || []) {
let pageToken;
do {
const r = await request("Google Business Profile", `https://mybusinessbusinessinformation.googleapis.com/v1/${acc.name}/locations`, {
token: tokens.accessToken, query: { readMask: "name,title,storefrontAddress", pageSize: 100, pageToken },
});
for (const loc of r.data.locations || []) {
const addr = loc.storefrontAddress ? [loc.storefrontAddress.addressLines?.[0], loc.storefrontAddress.locality].filter(Boolean).join(", ") : "";
out.push({ id: `${acc.name}/${loc.name}`, name: addr ? `${loc.title} · ${addr}` : loc.title });
}
pageToken = r.data.nextPageToken;
} while (pageToken);
}
return out;
},
async publish({ tokens, conn, post, opts, caption }) {
const location = conn.target?.id;
if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first.");
const body = { languageCode: "en", summary: String(caption || "").slice(0, 1500), topicType: "STANDARD" };
if (opts.cta) {
if (opts.cta !== "CALL" && !opts.ctaUrl) throw new Error("Google Business Profile: add a link for the button.");
body.callToAction = { actionType: opts.cta, ...(opts.cta !== "CALL" ? { url: opts.ctaUrl } : {}) };
}
const img = (post.media || []).find((m) => !isVideo(m));
if (img) body.media = [{ mediaFormat: "PHOTO", sourceUrl: /^https?:/.test(img) ? img : publicMediaUrl(img) }];
const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${location}/localPosts`, { token: tokens.accessToken, method: "POST", json: body });
return { id: data.name, permalink: data.searchUrl || null, state: data.state };
},
};

View File

@ -0,0 +1,20 @@
// Shared Google sign-in for YouTube and Google Business Profile (one Google Cloud OAuth client covers both).
export const googleAuth = (scopes) => ({
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
scopes,
pkce: true,
extraAuthParams: { access_type: "offline", prompt: "consent", include_granted_scopes: "true" },
});
export const googleSetup = (product, extraSteps = []) => ({
console: "https://console.cloud.google.com/apis/credentials",
steps: [
"In Google Cloud Console create (or pick) a project for Quorla.",
...extraSteps,
"APIs & Services → OAuth consent screen: External, app name Quorla, your support email, and add the scopes Quorla asks for.",
"Credentials → Create credentials → OAuth client ID → Web application. Add the Redirect URI shown below.",
`Copy the Client ID and Client secret here. YouTube and Google Business Profile can share the same client: enter it under each.`,
],
note: `Until Google verifies the consent screen, add each client's Google account as a Test user (max 100). ${product}`,
});

View File

@ -0,0 +1,48 @@
// Small HTTP helpers shared by the platform adapters.
import fs from "node:fs";
import path from "node:path";
export class PlatformError extends Error {
constructor(platform, message, { status, body } = {}) {
super(`${platform}: ${message}`);
this.platform = platform; this.status = status; this.body = body;
}
}
/** fetch → JSON, throwing a readable PlatformError on HTTP or API errors. */
export async function request(platform, url, { method = "GET", headers = {}, json, form, body, token, query } = {}) {
const u = new URL(url);
for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
const h = { Accept: "application/json", ...headers };
if (token) h.Authorization = `Bearer ${token}`;
let payload = body;
if (json !== undefined) { h["Content-Type"] = "application/json"; payload = JSON.stringify(json); }
if (form !== undefined) { h["Content-Type"] = "application/x-www-form-urlencoded"; payload = new URLSearchParams(Object.entries(form).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)])).toString(); }
const res = await fetch(u, { method, headers: h, body: payload });
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
const apiError = data && typeof data === "object" && (data.error?.message || data.error_description || (typeof data.error === "string" && data.error) || data.message && !res.ok && data.message);
if (!res.ok || (data && typeof data === "object" && data.error && data.error.code && data.error.code !== "ok")) {
throw new PlatformError(platform, apiError || `${res.status} ${res.statusText}`, { status: res.status, body: data });
}
return { data, headers: res.headers, status: res.status };
}
export const fileName = (p) => path.basename(String(p)).replace(/^\d+-[a-z0-9]{4}-/i, "");
export const isUrl = (s) => /^https?:\/\//i.test(String(s || ""));
export const isVideo = (s) => /\.(mp4|mov|m4v|webm)$/i.test(String(s || "").split("?")[0]);
export const mimeOf = (p) => ({ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".mp4": "video/mp4", ".mov": "video/quicktime", ".m4v": "video/mp4", ".webm": "video/webm" })[path.extname(String(p).split("?")[0]).toLowerCase()] || "application/octet-stream";
/** Bytes of a local file or a URL. */
export async function bytesOf(src) {
if (isUrl(src)) {
const r = await fetch(src);
if (!r.ok) throw new Error(`Couldn't download ${src}: ${r.status}`);
return Buffer.from(await r.arrayBuffer());
}
return fs.readFileSync(src);
}
export const sizeOf = async (src) => (isUrl(src) ? (await bytesOf(src)).length : fs.statSync(src).size);
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

View File

@ -0,0 +1,136 @@
// Platform registry: everything beyond Facebook/Instagram (which use the Meta system user).
// Each client connects its own account per platform (OAuth); tokens live encrypted in data/connections.json.
import linkedin from "./linkedin.js";
import youtube from "./youtube.js";
import gbp from "./gbp.js";
import tiktok from "./tiktok.js";
import pinterest from "./pinterest.js";
import threads from "./threads.js";
import reddit from "./reddit.js";
import * as vault from "./vault.js";
import * as oauth from "./oauth.js";
export const ADAPTERS = { linkedin, gbp, youtube, tiktok, pinterest, threads, reddit };
export const PLATFORM_IDS = Object.keys(ADAPTERS);
export { vault };
const err = (m, status = 400) => Object.assign(new Error(m), { status });
const FORMAT_WORD = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
export function adapter(id) {
const a = ADAPTERS[id];
if (!a) throw err(`Unknown platform: ${id}`, 404);
return a;
}
/** Public description of every platform for the Connections page (no secrets). */
export function describePlatforms() {
return PLATFORM_IDS.map((id) => {
const a = ADAPTERS[id];
const app = vault.appCredentials(id);
return {
id, name: a.name, color: a.color, setup: a.setup, formats: Object.keys(a.formats), fields: a.fields || [],
targetLabel: a.targetLabel || null, hasTargets: Boolean(a.targets), limits: a.limits || {},
defaultScopes: a.auth.scopes,
app: app ? { configured: true, source: app.source, clientId: app.clientId, scopes: app.scopes || [], updatedAt: app.updatedAt || null } : { configured: false },
};
});
}
// ---------- sign-in ----------
export function startConnect({ platform, client, redirectUri, user }) {
const a = adapter(platform);
const app = vault.appCredentials(platform);
if (!app) throw err(`${a.name} isn't set up yet. An admin adds the app's client ID and secret first.`);
const p = a.auth.pkce ? oauth.pkce() : {};
const state = vault.createState({ platform, client, redirectUri, verifier: p.verifier, userId: user.id, by: user.name || user.email });
return oauth.authorizeUrl(a, app, { redirectUri, state, challenge: p.challenge });
}
export async function finishConnect({ platform, code, state, user }) {
const s = vault.takeState(state);
if (s.platform !== platform) throw err("This sign-in doesn't match the platform. Start again.");
if (s.userId !== user.id) throw err("Finish the sign-in in the same Quorla session that started it.");
const a = adapter(platform);
const app = vault.appCredentials(platform);
const tokens = await oauth.exchangeCode(a, app, { code, redirectUri: s.redirectUri, verifier: s.verifier });
const account = await a.identify(tokens);
const conn = vault.saveConnection({ client: s.client, platform, account, tokens, scopes: (tokens.scope || "").split(/[\s,]+/).filter(Boolean), by: s.by });
// Pick the only target automatically (e.g. one business location).
if (a.targets) {
try {
const t = await a.targets(tokens, account);
if (t.length === 1) vault.updateConnection(conn.id, { target: t[0] });
} catch { /* chosen later on the Connections page */ }
}
return { ...vault.getConnection(conn.id), client: s.client };
}
/** Fresh tokens for a connection, refreshing (and saving) them when they expire within 5 minutes. */
export async function tokensFor(connId) {
const conn = vault.getConnection(connId, { withTokens: true });
if (!conn.tokens) throw err(`${adapter(conn.platform).name}: the stored sign-in can't be read (the server secret changed). Reconnect it.`);
const soon = conn.tokens.expiresAt && new Date(conn.tokens.expiresAt) - Date.now() < 5 * 60000;
if (!soon) return { conn, tokens: conn.tokens };
try {
const tokens = await oauth.refreshTokens(adapter(conn.platform), vault.appCredentials(conn.platform), conn.tokens);
vault.updateConnection(conn.id, { tokens, status: "connected", lastError: null });
return { conn, tokens };
} catch (e) {
vault.updateConnection(conn.id, { status: "reconnect", lastError: e.message });
throw err(`${adapter(conn.platform).name}: the sign-in expired. Reconnect it on the Connections page. (${e.message})`);
}
}
export async function targetsFor(connId) {
const { conn, tokens } = await tokensFor(connId);
const a = adapter(conn.platform);
return a.targets ? a.targets(tokens, conn.account) : [];
}
export async function setTarget(connId, targetId) {
const list = await targetsFor(connId);
const t = list.find((x) => x.id === targetId);
if (!t) throw err("That option isn't available for this account any more.");
return vault.updateConnection(connId, { target: t });
}
/** Re-read the account (name, followers) to check the connection still works. */
export async function checkConnection(connId) {
const { conn, tokens } = await tokensFor(connId);
try {
const account = await adapter(conn.platform).identify(tokens);
return vault.updateConnection(connId, { account, status: "connected", lastError: null });
} catch (e) {
vault.updateConnection(connId, { status: "error", lastError: e.message });
throw e;
}
}
// ---------- posting ----------
/** Problems that stop a post going to this platform (checked when the draft is saved). */
export function validate(platform, client, post) {
const a = adapter(platform);
const conn = vault.connectionFor(client, platform);
if (!conn) throw err(`${a.name} isn't connected for this client. Connect it on the Connections page.`);
if (!a.formats[post.type]) throw err(`${a.name} doesn't take ${FORMAT_WORD[post.type] || post.type.toLowerCase()} posts. It takes: ${Object.keys(a.formats).map((f) => FORMAT_WORD[f]).join(", ")}.`);
if (a.targetLabel && a.targets && !conn.target && platform !== "linkedin") throw err(`${a.name}: choose the ${a.targetLabel.toLowerCase()} on the Connections page first.`);
const opts = post.options?.[platform] || {};
for (const f of a.fields || []) {
if (f.onlyFor && !f.onlyFor.includes(post.type)) continue;
if (f.required && (opts[f.key] === undefined || opts[f.key] === "")) throw err(`${a.name}: ${f.label.toLowerCase()} is required.`);
if (f.max && String(opts[f.key] || "").length > f.max) throw err(`${a.name}: ${f.label.toLowerCase()} is longer than ${f.max} characters.`);
}
const cap = a.limits?.caption;
const caption = opts.caption ?? post.caption ?? "";
if (cap && caption.length > cap) throw err(`${a.name}: the caption is ${caption.length} characters; the limit is ${cap}. Shorten it or add a ${a.name} caption.`);
}
export async function publish(platform, client, post) {
const a = adapter(platform);
const c = vault.connectionFor(client, platform);
if (!c) throw err(`${a.name} isn't connected for this client.`);
const { conn, tokens } = await tokensFor(c.id);
const opts = { ...Object.fromEntries((a.fields || []).filter((f) => f.default !== undefined).map((f) => [f.key, f.default])), ...(post.options?.[platform] || {}) };
return a.publish({ tokens, conn, post, opts, caption: opts.caption ?? post.caption ?? "" });
}

View File

@ -0,0 +1,113 @@
// LinkedIn: post to a person's profile or to a company Page they administer.
// Company Pages need the Community Management API product on the LinkedIn app (approval required).
import { request, bytesOf, isVideo, sizeOf } from "./http.js";
const API = "https://api.linkedin.com";
const version = () => process.env.LINKEDIN_API_VERSION || "202606";
const rest = (token) => ({ token, headers: { "LinkedIn-Version": version(), "X-Restli-Protocol-Version": "2.0.0" } });
// LinkedIn's "little text" format: reserved characters must be escaped; hashtags use a template.
export function littleText(s) {
const text = String(s || "");
const esc = (t) => t.replace(/[\\|{}@[\]()<>#*_~]/g, (c) => `\\${c}`);
const re = /(^|\s)#([\p{L}\p{N}_]+)/gu;
let out = "", last = 0, m;
while ((m = re.exec(text))) {
out += esc(text.slice(last, m.index)) + m[1] + `{hashtag|\\#|${m[2]}}`;
last = m.index + m[0].length;
}
return out + esc(text.slice(last));
}
async function uploadImage(token, owner, src) {
const { data } = await request("LinkedIn", `${API}/rest/images?action=initializeUpload`, { ...rest(token), method: "POST", json: { initializeUploadRequest: { owner } } });
const { uploadUrl, image } = data.value;
const put = await fetch(uploadUrl, { method: "PUT", headers: { Authorization: `Bearer ${token}` }, body: await bytesOf(src) });
if (!put.ok) throw new Error(`LinkedIn: image upload failed (${put.status}).`);
return image;
}
async function uploadVideo(token, owner, src) {
const bytes = await bytesOf(src);
const { data } = await request("LinkedIn", `${API}/rest/videos?action=initializeUpload`, {
...rest(token), method: "POST",
json: { initializeUploadRequest: { owner, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: false } },
});
const { video, uploadInstructions, uploadToken } = data.value;
const etags = [];
for (const part of uploadInstructions) {
const r = await fetch(part.uploadUrl, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: bytes.subarray(part.firstByte, part.lastByte + 1) });
if (!r.ok) throw new Error(`LinkedIn: video upload failed (${r.status}).`);
etags.push(r.headers.get("etag"));
}
await request("LinkedIn", `${API}/rest/videos?action=finalizeUpload`, {
...rest(token), method: "POST", json: { finalizeUploadRequest: { video, uploadToken: uploadToken || "", uploadedPartIds: etags } },
});
return video;
}
export default {
id: "linkedin",
name: "LinkedIn",
color: "#0a66c2",
setup: {
console: "https://www.linkedin.com/developers/apps",
steps: [
"Create an app at linkedin.com/developers/apps and link it to your company's LinkedIn Page (the Page admin must verify it).",
"Products tab: add “Sign In with LinkedIn using OpenID Connect” and “Share on LinkedIn” (instant).",
"For posting to clients' company Pages, also request “Community Management API” (LinkedIn reviews it; your company Page must be verified).",
"Auth tab: add the Redirect URL shown below, then copy the Client ID and Client Secret here.",
],
note: "Until Community Management API is approved, set Scopes to “openid profile w_member_social” so you can post to personal profiles only.",
},
auth: {
authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization",
tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken",
scopes: ["openid", "profile", "w_member_social", "r_organization_admin", "w_organization_social", "r_organization_social"],
},
formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true },
limits: { caption: 3000, images: 20 },
targetLabel: "Post as",
async identify(tokens) {
const { data } = await request("LinkedIn", `${API}/v2/userinfo`, { token: tokens.accessToken });
return { id: data.sub, name: data.name, username: null, avatar: data.picture || null, url: null };
},
/** The person, plus every company Page they administer (needs Community Management API). */
async targets(tokens, account) {
const out = [{ id: `urn:li:person:${account.id}`, name: `${account.name} (personal profile)` }];
try {
const { data } = await request("LinkedIn", `${API}/rest/organizationAcls`, { ...rest(tokens.accessToken), query: { q: "roleAssignee", role: "ADMINISTRATOR", state: "APPROVED" } });
for (const e of data.elements || []) {
const orgId = String(e.organization).split(":").pop();
let name = `Company ${orgId}`;
try { name = (await request("LinkedIn", `${API}/rest/organizations/${orgId}`, rest(tokens.accessToken))).data.localizedName || name; } catch { /* keep id */ }
out.push({ id: e.organization, name: `${name} (company Page)` });
}
} catch { /* no organization scopes: personal profile only */ }
return out;
},
async publish({ tokens, conn, post, caption }) {
const author = conn.target?.id || `urn:li:person:${conn.account.id}`;
const body = {
author, commentary: littleText(caption), visibility: "PUBLIC", lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: false,
distribution: { feedDistribution: "MAIN_FEED", targetEntities: [], thirdPartyDistributionChannels: [] },
};
const media = post.media || [];
if (post.type === "REELS" || (media.length === 1 && isVideo(media[0]))) {
if ((await sizeOf(media[0])) > 5 * 1024 ** 3) throw new Error("LinkedIn: videos must be under 5 GB.");
body.content = { media: { id: await uploadVideo(tokens.accessToken, author, media[0]) } };
} else if (media.length > 1) {
const images = [];
for (const m of media.slice(0, 20)) images.push({ id: await uploadImage(tokens.accessToken, author, m) });
body.content = { multiImage: { images } };
} else if (media.length === 1) {
body.content = { media: { id: await uploadImage(tokens.accessToken, author, media[0]) } };
}
const { headers } = await request("LinkedIn", `${API}/rest/posts`, { ...rest(tokens.accessToken), method: "POST", json: body });
const id = headers.get("x-restli-id") || headers.get("x-linkedin-id");
return { id, permalink: id ? `https://www.linkedin.com/feed/update/${id}/` : null };
},
};

View File

@ -0,0 +1,50 @@
// Short-lived signed public links to uploaded media, for platforms that fetch media from a URL
// (TikTok photos, Threads, Google Business Profile). Served by the dashboard at /m/<token>/<file>.
// Only files under data/library and data/schedule/media can be linked, and links expire.
import path from "node:path";
import crypto from "node:crypto";
import { PROJECT_ROOT, loadEnv } from "../env.js";
loadEnv();
const DATA = path.join(PROJECT_ROOT, "data");
const ROOTS = [path.join(DATA, "library"), path.join(DATA, "schedule", "media")].map((p) => path.resolve(p));
const err = (m, status = 400) => Object.assign(new Error(m), { status });
function secret() {
const s = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
if (!s) throw err("Set DASHBOARD_SECRET in .env to create media links.", 500);
return crypto.createHash("sha256").update(`quorla-media:${s}`).digest();
}
/** The public HTTPS address of this install, e.g. https://app.quorlasocial.com */
export function publicBaseUrl() {
const u = process.env.PUBLIC_URL || (process.env.APP_DOMAIN ? `https://${process.env.APP_DOMAIN}` : "");
return u.replace(/\/$/, "");
}
const allowed = (f) => ROOTS.some((r) => f === r || f.startsWith(r + path.sep));
/** Signed URL for a local media file, valid for `hours`. */
export function publicMediaUrl(filePath, { hours = 6 } = {}) {
const base = publicBaseUrl();
if (!base) throw err("This platform fetches media from a public link. Set PUBLIC_URL (e.g. https://app.quorlasocial.com) in .env.");
const f = path.resolve(filePath);
if (!allowed(f)) throw err("Only files from the content library or Publishing uploads can be shared with platforms.");
const rel = path.relative(DATA, f).split(path.sep).join("/");
const body = Buffer.from(JSON.stringify({ p: rel, e: Date.now() + hours * 3600000 })).toString("base64url");
const sig = crypto.createHmac("sha256", secret()).update(body).digest("base64url");
return `${base}/m/${body}.${sig}/${encodeURIComponent(path.basename(f).replace(/^\d+-[a-z0-9]{4}-/i, ""))}`;
}
/** Verify a token from /m/<token>/… and return the absolute file path, or null. */
export function resolveMediaToken(token) {
const [body, sig] = String(token || "").split(".");
if (!body || !sig) return null;
const expected = crypto.createHmac("sha256", secret()).update(body).digest("base64url");
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
let p;
try { p = JSON.parse(Buffer.from(body, "base64url").toString()); } catch { return null; }
if (!p?.p || p.e < Date.now()) return null;
const f = path.resolve(DATA, p.p);
return allowed(f) ? f : null;
}

View File

@ -0,0 +1,69 @@
// Generic OAuth 2.0 authorization-code flow used by every platform adapter:
// build the sign-in URL (with PKCE where the platform supports it), exchange the code, refresh tokens.
import crypto from "node:crypto";
import { request } from "./http.js";
/** PKCE verifier + challenge. TikTok's desktop flavour uses hex; everyone else uses base64url (RFC 7636). */
export function pkce(style = "S256") {
const verifier = crypto.randomBytes(48).toString("base64url");
const hash = crypto.createHash("sha256").update(verifier);
const challenge = style === "hex" ? hash.digest("hex") : hash.digest("base64url");
return { verifier, challenge };
}
export function authorizeUrl(adapter, app, { redirectUri, state, challenge }) {
const a = adapter.auth;
const u = new URL(a.authorizeUrl);
u.searchParams.set(a.clientIdParam || "client_id", app.clientId);
u.searchParams.set("redirect_uri", redirectUri);
u.searchParams.set("response_type", "code");
u.searchParams.set("scope", (app.scopes?.length ? app.scopes : a.scopes).join(a.scopeSeparator || " "));
u.searchParams.set("state", state);
if (a.pkce && challenge) { u.searchParams.set("code_challenge", challenge); u.searchParams.set("code_challenge_method", "S256"); }
for (const [k, v] of Object.entries(a.extraAuthParams || {})) u.searchParams.set(k, v);
return u.toString();
}
/** Normalise a token response into { accessToken, refreshToken, expiresAt, refreshExpiresAt, raw-ish extras }. */
export function normaliseTokens(t, prev = {}) {
const now = Date.now();
return {
accessToken: t.access_token,
refreshToken: t.refresh_token || prev.refreshToken || null,
tokenType: t.token_type || "bearer",
expiresAt: t.expires_in ? new Date(now + Number(t.expires_in) * 1000).toISOString() : prev.expiresAt || null,
refreshExpiresAt: t.refresh_expires_in || t.refresh_token_expires_in
? new Date(now + Number(t.refresh_expires_in || t.refresh_token_expires_in) * 1000).toISOString()
: prev.refreshExpiresAt || null,
userId: t.user_id ? String(t.user_id) : t.open_id || prev.userId || null,
scope: t.scope || prev.scope || null,
};
}
async function tokenRequest(adapter, app, form) {
const a = adapter.auth;
const headers = {};
const body = { ...form };
if (a.clientAuth === "basic") headers.Authorization = `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}`;
else { body[a.clientIdParam || "client_id"] = app.clientId; body.client_secret = app.clientSecret; }
if (a.tokenHeaders) Object.assign(headers, a.tokenHeaders);
const { data } = await request(adapter.name, a.tokenUrl, { method: "POST", headers, form: body });
if (!data?.access_token) throw new Error(`${adapter.name}: no access token in the response.`);
return data;
}
export async function exchangeCode(adapter, app, { code, redirectUri, verifier }) {
const raw = await tokenRequest(adapter, app, {
grant_type: "authorization_code", code, redirect_uri: redirectUri, ...(adapter.auth.pkce && verifier ? { code_verifier: verifier } : {}),
});
let tokens = normaliseTokens(raw);
if (adapter.afterExchange) tokens = await adapter.afterExchange(tokens, app);
return tokens;
}
export async function refreshTokens(adapter, app, prev) {
if (adapter.refresh) return adapter.refresh(prev, app); // platforms with their own refresh scheme (Threads)
if (!prev.refreshToken) throw new Error(`${adapter.name}: the sign-in expired and can't be renewed automatically. Reconnect it.`);
const raw = await tokenRequest(adapter, app, { grant_type: "refresh_token", refresh_token: prev.refreshToken });
return normaliseTokens(raw, prev);
}

View File

@ -0,0 +1,87 @@
// Pinterest API v5: image, multi-image (carousel) and video Pins on a chosen board.
// Trial-access apps can only create Pins in Pinterest's sandbox: set PINTEREST_SANDBOX=true until upgraded.
import { request, bytesOf, mimeOf, isVideo, sleep } from "./http.js";
const api = () => (process.env.PINTEREST_SANDBOX === "true" ? "https://api-sandbox.pinterest.com/v5" : "https://api.pinterest.com/v5");
async function uploadVideo(token, src) {
const { data } = await request("Pinterest", `${api()}/media`, { token, method: "POST", json: { media_type: "video" } });
const form = new FormData();
for (const [k, v] of Object.entries(data.upload_parameters || {})) form.set(k, v);
form.set("file", new Blob([await bytesOf(src)], { type: mimeOf(src) }), "video.mp4");
const up = await fetch(data.upload_url, { method: "POST", body: form });
if (!up.ok) throw new Error(`Pinterest: video upload failed (${up.status}).`);
for (let i = 0; i < 60; i++) {
const s = (await request("Pinterest", `${api()}/media/${data.media_id}`, { token })).data;
if (s.status === "succeeded") return data.media_id;
if (s.status === "failed") throw new Error("Pinterest couldn't process the video.");
await sleep(5000);
}
throw new Error("Pinterest is still processing the video after 5 minutes.");
}
export default {
id: "pinterest",
name: "Pinterest",
color: "#e60023",
setup: {
console: "https://developers.pinterest.com/apps/",
steps: [
"Use a Pinterest Business account. Go to developers.pinterest.com → My apps → Connect app, and fill in the app details (quorlasocial.com, privacy policy).",
"Add the Redirect URI shown below in the app's settings.",
"Copy the App ID and App secret key here.",
"Request Standard access (Pinterest asks for a short demo video of the Connect and Pin flow).",
],
note: "With Trial access, Pins can only be created in Pinterest's sandbox. Set PINTEREST_SANDBOX=true in .env until Standard access is approved.",
},
auth: {
authorizeUrl: "https://www.pinterest.com/oauth/",
tokenUrl: "https://api.pinterest.com/v5/oauth/token",
clientAuth: "basic",
scopes: ["boards:read", "pins:read", "pins:write", "user_accounts:read"],
scopeSeparator: ",",
},
formats: { IMAGE: true, CAROUSEL: true, REELS: true },
limits: { caption: 500, title: 100, images: 5 },
fields: [
{ key: "boardId", label: "Board", type: "select", optionsFrom: "targets", required: true },
{ key: "title", label: "Pin title", type: "text", max: 100 },
{ key: "link", label: "Destination link", type: "text", hint: "Where the Pin leads, e.g. the product or booking page." },
],
async identify(tokens) {
const { data } = await request("Pinterest", `${api()}/user_account`, { token: tokens.accessToken });
return { id: data.id || data.username, name: data.business_name || data.username, username: data.username, avatar: data.profile_image || null, url: `https://www.pinterest.com/${data.username}/`, followers: data.follower_count ?? null };
},
async targets(tokens) {
const out = [];
let bookmark;
do {
const { data } = await request("Pinterest", `${api()}/boards`, { token: tokens.accessToken, query: { page_size: 100, bookmark } });
for (const b of data.items || []) out.push({ id: b.id, name: b.name });
bookmark = data.bookmark;
} while (bookmark);
return out;
},
async publish({ tokens, post, opts, caption }) {
if (!opts.boardId) throw new Error("Pinterest: pick a board.");
const media = post.media || [];
let media_source;
if (post.type === "REELS" || isVideo(media[0])) {
media_source = { source_type: "video_id", media_id: await uploadVideo(tokens.accessToken, media[0]), cover_image_key_frame_time: 1 };
} else if (media.length > 1) {
const items = [];
for (const m of media.slice(0, 5)) items.push({ content_type: mimeOf(m), data: (await bytesOf(m)).toString("base64") });
media_source = { source_type: "multiple_image_base64", items };
} else {
media_source = { source_type: "image_base64", content_type: mimeOf(media[0]), data: (await bytesOf(media[0])).toString("base64") };
}
const { data } = await request("Pinterest", `${api()}/pins`, {
token: tokens.accessToken, method: "POST",
json: { board_id: opts.boardId, title: opts.title ? String(opts.title).slice(0, 100) : undefined, description: String(caption || "").slice(0, 500), link: opts.link || undefined, media_source },
});
return { id: data.id, permalink: `https://www.pinterest.com/pin/${data.id}/` };
},
};

View File

@ -0,0 +1,53 @@
// Reddit: text and link posts to a subreddit. Reddit communities often remove brand self-promotion:
// check each subreddit's rules, and prefer genuine participation over ads.
import { request } from "./http.js";
const UA = () => process.env.REDDIT_USER_AGENT || "web:com.quorlasocial.app:1.0 (by /u/quorla)";
export default {
id: "reddit",
name: "Reddit",
color: "#ff4500",
setup: {
console: "https://www.reddit.com/prefs/apps",
steps: [
"Signed in as the agency's Reddit account, open reddit.com/prefs/apps → “create another app…” → type “web app”.",
"Set the redirect uri to the address shown below.",
"Copy the client ID (under the app name) and the secret here.",
"Commercial use of Reddit's API needs Reddit's approval: register via Reddit's Data API request form before using it for clients.",
],
note: "Set REDDIT_USER_AGENT in .env to something like web:com.quorlasocial.app:1.0 (by /u/yourusername), as Reddit requires.",
},
auth: {
authorizeUrl: "https://www.reddit.com/api/v1/authorize",
tokenUrl: "https://www.reddit.com/api/v1/access_token",
clientAuth: "basic",
scopes: ["identity", "submit", "read", "mysubreddits", "flair"],
extraAuthParams: { duration: "permanent" },
get tokenHeaders() { return { "User-Agent": UA() }; },
},
formats: { TEXT: true },
limits: { caption: 40000, title: 300 },
fields: [
{ key: "subreddit", label: "Subreddit", type: "text", required: true, hint: "Without r/, e.g. waterloo. Read the subreddit's rules first." },
{ key: "title", label: "Post title", type: "text", required: true, max: 300 },
{ key: "link", label: "Link (optional)", type: "text", hint: "Makes it a link post; the caption is then not used." },
{ key: "flairId", label: "Flair ID (optional)", type: "text", hint: "Some subreddits require a flair." },
],
async identify(tokens) {
const { data } = await request("Reddit", "https://oauth.reddit.com/api/v1/me", { token: tokens.accessToken, headers: { "User-Agent": UA() } });
return { id: data.id, name: `u/${data.name}`, username: data.name, avatar: (data.icon_img || "").split("?")[0] || null, url: `https://www.reddit.com/user/${data.name}`, followers: data.total_karma ?? null };
},
async publish({ tokens, opts, caption }) {
const sr = String(opts.subreddit || "").replace(/^\/?r\//i, "").trim();
if (!sr || !opts.title) throw new Error("Reddit: add the subreddit and a title.");
const form = { sr, title: String(opts.title).slice(0, 300), api_type: "json", resubmit: "true", ...(opts.flairId ? { flair_id: opts.flairId } : {}) };
if (opts.link) Object.assign(form, { kind: "link", url: opts.link }); else Object.assign(form, { kind: "self", text: caption || "" });
const { data } = await request("Reddit", "https://oauth.reddit.com/api/submit", { token: tokens.accessToken, method: "POST", headers: { "User-Agent": UA() }, form });
const errors = data?.json?.errors || [];
if (errors.length) throw new Error(`Reddit: ${errors.map((e) => e.slice(1).join(" ")).join("; ")}`);
return { id: data.json.data.name || data.json.data.id, permalink: data.json.data.url };
},
};

View File

@ -0,0 +1,93 @@
// Threads (Meta): text, image, video and carousel posts. Media is fetched by Threads from a public link.
// Uses its own Threads app credentials (Meta app with the Threads use case), separate from the Meta system user.
import { request, isVideo, sleep } from "./http.js";
import { publicMediaUrl } from "./media.js";
const G = "https://graph.threads.net";
const url = (m) => (/^https?:/.test(m) ? m : publicMediaUrl(m));
async function waitFinished(token, id) {
for (let i = 0; i < 60; i++) {
const { data } = await request("Threads", `${G}/v1.0/${id}`, { query: { fields: "status,error_message", access_token: token } });
if (data.status === "FINISHED") return;
if (data.status === "ERROR" || data.status === "EXPIRED") throw new Error(`Threads couldn't process the media: ${data.error_message || data.status}`);
await sleep(5000);
}
throw new Error("Threads is still processing the media after 5 minutes.");
}
async function container(token, uid, params) {
const { data } = await request("Threads", `${G}/v1.0/${uid}/threads`, { method: "POST", query: { ...params, access_token: token } });
return data.id;
}
export default {
id: "threads",
name: "Threads",
color: "#000000",
setup: {
console: "https://developers.facebook.com/apps/",
steps: [
"In Meta for Developers, create an app with the “Access the Threads API” use case (it can be a separate app from the Facebook/Instagram one).",
"Use case → Settings: add the Redirect URI shown below as a Redirect Callback URL (also fill Uninstall and Delete callback URLs with your site).",
"Add permissions threads_basic, threads_content_publish, threads_manage_replies, threads_read_replies, threads_manage_insights.",
"App settings → Basic: copy the Threads App ID and Threads App secret here.",
],
note: "Until Meta approves the app, only Threads accounts added under App roles → Roles (as Threads testers, who accept the invite in Threads) can connect.",
},
auth: {
authorizeUrl: "https://threads.net/oauth/authorize",
tokenUrl: `${G}/oauth/access_token`,
scopes: ["threads_basic", "threads_content_publish", "threads_manage_replies", "threads_read_replies", "threads_manage_insights"],
scopeSeparator: ",",
},
formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true },
limits: { caption: 500, images: 20 },
/** Swap the 1-hour token for a 60-day one. */
async afterExchange(tokens, app) {
const { data } = await request("Threads", `${G}/access_token`, { query: { grant_type: "th_exchange_token", client_secret: app.clientSecret, access_token: tokens.accessToken } });
return { ...tokens, accessToken: data.access_token, expiresAt: new Date(Date.now() + Number(data.expires_in || 5184000) * 1000).toISOString(), refreshToken: null };
},
/** Long-lived Threads tokens are refreshed with themselves (any time after 24 h, before expiry). */
async refresh(prev) {
const { data } = await request("Threads", `${G}/refresh_access_token`, { query: { grant_type: "th_refresh_token", access_token: prev.accessToken } });
return { ...prev, accessToken: data.access_token, expiresAt: new Date(Date.now() + Number(data.expires_in || 5184000) * 1000).toISOString() };
},
async identify(tokens) {
const { data } = await request("Threads", `${G}/v1.0/me`, { query: { fields: "id,username,name,threads_profile_picture_url", access_token: tokens.accessToken } });
let followers = null;
try {
const ins = await request("Threads", `${G}/v1.0/${data.id}/threads_insights`, { query: { metric: "followers_count", access_token: tokens.accessToken } });
followers = ins.data.data?.[0]?.total_value?.value ?? null;
} catch { /* insights permission not granted */ }
return { id: data.id, name: data.name || data.username, username: data.username, avatar: data.threads_profile_picture_url || null, url: `https://www.threads.net/@${data.username}`, followers };
},
async publish({ tokens, conn, post, caption }) {
const token = tokens.accessToken, uid = conn.account.id;
const text = String(caption || "").slice(0, 500);
const media = post.media || [];
let id;
if (post.type === "TEXT" || !media.length) {
id = await container(token, uid, { media_type: "TEXT", text });
} else if (media.length > 1) {
const children = [];
for (const m of media.slice(0, 20)) {
const c = await container(token, uid, isVideo(m) ? { media_type: "VIDEO", video_url: url(m), is_carousel_item: true } : { media_type: "IMAGE", image_url: url(m), is_carousel_item: true });
await waitFinished(token, c);
children.push(c);
}
id = await container(token, uid, { media_type: "CAROUSEL", children: children.join(","), text });
} else {
const m = media[0];
id = await container(token, uid, isVideo(m) ? { media_type: "VIDEO", video_url: url(m), text } : { media_type: "IMAGE", image_url: url(m), text });
}
await waitFinished(token, id);
const { data } = await request("Threads", `${G}/v1.0/${uid}/threads_publish`, { method: "POST", query: { creation_id: id, access_token: token } });
let permalink = null;
try { permalink = (await request("Threads", `${G}/v1.0/${data.id}`, { query: { fields: "permalink", access_token: token } })).data.permalink; } catch { /* optional */ }
return { id: data.id, permalink };
},
};

View File

@ -0,0 +1,108 @@
// TikTok Content Posting API: direct-post videos (uploaded in chunks) and photo posts (pulled from a URL).
// Until TikTok audits the app, posts can only be private (SELF_ONLY) and the account must be set to private.
import { request, bytesOf, isVideo, sleep } from "./http.js";
import { publicMediaUrl } from "./media.js";
const API = "https://open.tiktokapis.com/v2";
const PRIVACY = [["PUBLIC_TO_EVERYONE", "Everyone"], ["MUTUAL_FOLLOW_FRIENDS", "Friends"], ["FOLLOWER_OF_CREATOR", "Followers"], ["SELF_ONLY", "Only me"]];
/** TikTok chunk rules: whole file if ≤ 64 MB, else 10 MB chunks with the remainder in the last one. */
export function chunkPlan(size) {
if (size <= 64 * 1024 * 1024) return { chunkSize: size, count: 1 };
const chunkSize = 10 * 1024 * 1024;
return { chunkSize, count: Math.floor(size / chunkSize) };
}
async function waitForPost(token, publishId) {
for (let i = 0; i < 60; i++) {
const { data } = await request("TikTok", `${API}/post/publish/status/fetch/`, { token, method: "POST", json: { publish_id: publishId } });
const s = data.data?.status;
if (s === "PUBLISH_COMPLETE") return data.data;
if (s === "FAILED") throw new Error(`TikTok rejected the post: ${data.data?.fail_reason || "unknown reason"}`);
await sleep(5000);
}
return { status: "PROCESSING" }; // TikTok keeps processing; it appears on the profile when done
}
export default {
id: "tiktok",
name: "TikTok",
color: "#010101",
setup: {
console: "https://developers.tiktok.com/apps",
steps: [
"Create an app at developers.tiktok.com (Manage apps → Connect an app). Add a website URL, terms and privacy policy links (quorlasocial.com).",
"Add products “Login Kit” and “Content Posting API”; in Content Posting API turn on Direct Post.",
"Login Kit → Redirect URI: add the address shown below.",
"For photo posts: URL properties → verify the domain of your Quorla address (e.g. app.quorlasocial.com).",
"Copy the Client key and Client secret here, then submit the app for review.",
],
note: "Before TikTok approves the app, posts are private (“Only me”) and only work for accounts added as Sandbox target users.",
},
auth: {
authorizeUrl: "https://www.tiktok.com/v2/auth/authorize/",
tokenUrl: `${API}/oauth/token/`,
clientIdParam: "client_key",
scopes: ["user.info.basic", "user.info.profile", "user.info.stats", "video.publish", "video.upload"],
scopeSeparator: ",",
},
formats: { REELS: true, IMAGE: true, CAROUSEL: true },
limits: { caption: 2200, images: 35 },
fields: [
{ key: "privacy", label: "Who can view", type: "select", options: PRIVACY, required: true, hint: "TikTok requires you to choose. Unaudited apps can only use “Only me”." },
{ key: "allowComments", label: "Allow comments", type: "checkbox", default: true },
{ key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] },
{ key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] },
{ key: "title", label: "Photo post title", type: "text", max: 90, onlyFor: ["IMAGE", "CAROUSEL"], hint: "Optional, up to 90 characters. The caption becomes the description." },
],
async identify(tokens) {
const { data } = await request("TikTok", `${API}/user/info/`, { token: tokens.accessToken, query: { fields: "open_id,avatar_url,display_name,username,follower_count,profile_deep_link" } });
const u = data.data?.user || {};
return { id: u.open_id || tokens.userId, name: u.display_name, username: u.username || null, avatar: u.avatar_url || null, url: u.username ? `https://www.tiktok.com/@${u.username}` : u.profile_deep_link || null, followers: u.follower_count ?? null };
},
async publish({ tokens, conn, post, opts, caption }) {
const token = tokens.accessToken;
if (!opts.privacy) throw new Error("TikTok: choose who can view the post.");
const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {};
if (creator.privacy_level_options && !creator.privacy_level_options.includes(opts.privacy)) {
throw new Error(`TikTok: this account can't post as “${opts.privacy}”. Allowed: ${creator.privacy_level_options.join(", ")}.`);
}
const media = post.media || [];
let publishId;
if (post.type === "REELS" || isVideo(media[0])) {
const bytes = await bytesOf(media[0]);
const plan = chunkPlan(bytes.length);
const { data } = await request("TikTok", `${API}/post/publish/video/init/`, {
token, method: "POST",
json: {
post_info: { title: String(caption || "").slice(0, 2200), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, disable_duet: !opts.allowDuet, disable_stitch: !opts.allowStitch },
source_info: { source: "FILE_UPLOAD", video_size: bytes.length, chunk_size: plan.chunkSize, total_chunk_count: plan.count },
},
});
publishId = data.data.publish_id;
for (let i = 0; i < plan.count; i++) {
const start = i * plan.chunkSize;
const end = i === plan.count - 1 ? bytes.length : start + plan.chunkSize;
const r = await fetch(data.data.upload_url, { method: "PUT", headers: { "Content-Type": "video/mp4", "Content-Range": `bytes ${start}-${end - 1}/${bytes.length}` }, body: bytes.subarray(start, end) });
if (!r.ok) throw new Error(`TikTok: video upload failed on part ${i + 1} (${r.status}).`);
}
} else {
const urls = media.slice(0, 35).map((m) => (/^https?:/.test(m) ? m : publicMediaUrl(m)));
const { data } = await request("TikTok", `${API}/post/publish/content/init/`, {
token, method: "POST",
json: {
post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, auto_add_music: true },
source_info: { source: "PULL_FROM_URL", photo_cover_index: 0, photo_images: urls },
post_mode: "DIRECT_POST", media_type: "PHOTO",
},
});
publishId = data.data.publish_id;
}
const done = await waitForPost(token, publishId);
const postId = done.publicaly_available_post_id?.[0];
const user = conn.account?.username;
return { id: postId ? String(postId) : publishId, publishId, status: done.status, permalink: postId && user ? `https://www.tiktok.com/@${user}/video/${postId}` : null };
},
};

View File

@ -0,0 +1,152 @@
// Encrypted store for platform app credentials (client ID/secret per platform) and each client's
// connected accounts (OAuth tokens). Lives in data/connections.json; secrets are AES-256-GCM encrypted
// with a key derived from QUORLA_SECRET_KEY (or DASHBOARD_SECRET). Tokens never leave the server.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { PROJECT_ROOT, loadEnv } from "../env.js";
loadEnv();
const FILE = process.env.QUORLA_CONNECTIONS_FILE || path.join(PROJECT_ROOT, "data", "connections.json"); // override for tests
const err = (message, status = 400) => Object.assign(new Error(message), { status });
const now = () => new Date().toISOString();
function key() {
const secret = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
if (!secret) throw err("Set DASHBOARD_SECRET (or QUORLA_SECRET_KEY) in .env before connecting platforms: it encrypts the stored tokens.", 500);
return crypto.createHash("sha256").update(`quorla-vault:${secret}`).digest();
}
export function seal(value) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv("aes-256-gcm", key(), iv);
const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${body.toString("base64url")}`;
}
export function open(sealed) {
const [v, iv, tag, body] = String(sealed || "").split(".");
if (v !== "v1") throw err("Unreadable secret.", 500);
const d = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return JSON.parse(Buffer.concat([d.update(Buffer.from(body, "base64url")), d.final()]).toString("utf8"));
}
function read() {
try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { apps: {}, connections: [] }; }
}
function write(db) {
fs.mkdirSync(path.dirname(FILE), { recursive: true });
const tmp = `${FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(db, null, 2), { mode: 0o600 });
fs.renameSync(tmp, FILE);
}
// ---------- platform apps (client ID / secret) ----------
/** App credentials for a platform: from the vault, else from .env (<PLATFORM>_CLIENT_ID / _CLIENT_SECRET). */
export function appCredentials(platform) {
const rec = read().apps[platform];
if (rec) {
try { return { ...open(rec.sealed), source: "settings", updatedAt: rec.updatedAt }; } catch { /* key changed */ }
}
const P = platform.toUpperCase();
const clientId = process.env[`${P}_CLIENT_ID`], clientSecret = process.env[`${P}_CLIENT_SECRET`];
return clientId ? { clientId, clientSecret, source: "env" } : null;
}
/** Save a platform's app credentials. Leave the secret empty to keep the stored one. `scopes` overrides the defaults. */
export function saveApp(platform, { clientId, clientSecret, scopes }, by) {
if (!String(clientId || "").trim()) throw err("Enter the app's client ID (or client key).");
const db = read();
const prev = db.apps[platform] ? (() => { try { return open(db.apps[platform].sealed); } catch { return {}; } })() : {};
const secret = String(clientSecret || "").trim() || prev.clientSecret;
if (!secret) throw err("Enter the app's client secret.");
const scopeList = String(scopes ?? "").split(/[\s,]+/).filter(Boolean);
db.apps[platform] = { sealed: seal({ clientId: String(clientId).trim(), clientSecret: secret, scopes: scopeList }), updatedAt: now(), updatedBy: by };
write(db);
}
export function removeApp(platform) {
const db = read(); delete db.apps[platform]; write(db);
}
// ---------- connected accounts ----------
const publicConn = (c) => {
const { sealed, ...rest } = c;
return rest;
};
export function listConnections({ client, platform } = {}) {
return read().connections.filter((c) => (!client || c.client === client) && (!platform || c.platform === platform)).map(publicConn);
}
export function getConnection(id, { withTokens = false } = {}) {
const c = read().connections.find((x) => x.id === id);
if (!c) throw err("Connection not found.", 404);
if (!withTokens) return publicConn(c);
let tokens = null;
try { tokens = open(c.sealed); } catch { /* key changed: reconnect */ }
return { ...publicConn(c), tokens };
}
/** The client's connection for a platform (one account per platform per client). */
export function connectionFor(client, platform, opts) {
const c = read().connections.find((x) => x.client === client && x.platform === platform);
return c ? getConnection(c.id, opts) : null;
}
/** Create or replace the client's connection for a platform. */
export function saveConnection({ client, platform, account, tokens, scopes, by }) {
const db = read();
const existing = db.connections.find((x) => x.client === client && x.platform === platform);
const rec = {
id: existing?.id || crypto.randomUUID().slice(0, 8),
client, platform,
account, // { id, name, username, url, avatar, followers }
target: existing && existing.account?.id === account?.id ? existing.target || null : null,
scopes: scopes || [],
expiresAt: tokens.expiresAt || null,
refreshExpiresAt: tokens.refreshExpiresAt || null,
status: "connected", lastError: null,
connectedAt: now(), connectedBy: by, updatedAt: now(),
sealed: seal(tokens),
};
db.connections = db.connections.filter((x) => x !== existing);
db.connections.push(rec);
write(db);
return publicConn(rec);
}
export function updateConnection(id, patch) {
const db = read();
const c = db.connections.find((x) => x.id === id);
if (!c) throw err("Connection not found.", 404);
if (patch.tokens) { c.sealed = seal(patch.tokens); c.expiresAt = patch.tokens.expiresAt || null; c.refreshExpiresAt = patch.tokens.refreshExpiresAt || c.refreshExpiresAt || null; }
for (const k of ["target", "status", "lastError", "account"]) if (k in patch) c[k] = patch[k];
c.updatedAt = now();
write(db);
return publicConn(c);
}
export function removeConnection(id) {
const db = read();
const before = db.connections.length;
db.connections = db.connections.filter((x) => x.id !== id);
if (db.connections.length === before) throw err("Connection not found.", 404);
write(db);
}
// ---------- pending OAuth sign-ins (state → who/what, PKCE verifier) ----------
const pending = new Map();
export function createState(data) {
const state = crypto.randomBytes(24).toString("base64url");
pending.set(state, { ...data, at: Date.now() });
for (const [k, v] of pending) if (Date.now() - v.at > 15 * 60000) pending.delete(k);
return state;
}
export function takeState(state) {
const v = pending.get(String(state || ""));
pending.delete(String(state || ""));
if (!v || Date.now() - v.at > 15 * 60000) throw err("This sign-in link expired or was already used. Start again from Connections.");
return v;
}

View File

@ -0,0 +1,57 @@
// YouTube: upload videos (vertical videos under 3 minutes show as Shorts).
// Unverified Google apps can only upload as Private until Google audits the app.
import { request, bytesOf, mimeOf, isVideo } from "./http.js";
import { googleAuth, googleSetup } from "./google.js";
const API = "https://www.googleapis.com/youtube/v3";
export default {
id: "youtube",
name: "YouTube",
color: "#ff0000",
setup: googleSetup("YouTube API audit (Google's form) lifts the private-only upload limit and the low daily quota.", [
"APIs & Services → Library → enable “YouTube Data API v3”.",
]),
auth: googleAuth(["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/youtube.readonly"]),
formats: { REELS: true },
limits: { caption: 5000, title: 100 },
fields: [
{ key: "title", label: "Video title", type: "text", required: true, max: 100, hint: "Shown on YouTube. Up to 100 characters. The caption becomes the description." },
{ key: "privacy", label: "Visibility", type: "select", options: [["public", "Public"], ["unlisted", "Unlisted"], ["private", "Private"]], default: "public" },
{ key: "madeForKids", label: "Made for kids?", type: "select", options: [["no", "No"], ["yes", "Yes"]], default: "no", hint: "Required by YouTube (COPPA)." },
],
async identify(tokens) {
const { data } = await request("YouTube", `${API}/channels`, { token: tokens.accessToken, query: { part: "snippet,statistics", mine: "true" } });
const ch = data.items?.[0];
if (!ch) throw new Error("YouTube: this Google account has no YouTube channel. Create one first, or sign in with the brand account.");
return {
id: ch.id, name: ch.snippet.title, username: ch.snippet.customUrl || null,
avatar: ch.snippet.thumbnails?.default?.url || null, url: `https://www.youtube.com/channel/${ch.id}`,
followers: Number(ch.statistics?.subscriberCount ?? 0) || null,
};
},
async publish({ tokens, post, opts, caption }) {
const src = (post.media || [])[0];
if (!src || !isVideo(src)) throw new Error("YouTube needs one video file.");
const title = String(opts.title || "").trim();
if (!title) throw new Error("YouTube needs a video title.");
const bytes = await bytesOf(src);
const meta = {
snippet: { title: title.slice(0, 100), description: String(caption || "").slice(0, 5000), categoryId: "22" },
status: { privacyStatus: opts.privacy || "public", selfDeclaredMadeForKids: opts.madeForKids === "yes" },
};
const init = await fetch("https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", {
method: "POST",
headers: { Authorization: `Bearer ${tokens.accessToken}`, "Content-Type": "application/json; charset=UTF-8", "X-Upload-Content-Type": mimeOf(src), "X-Upload-Content-Length": String(bytes.length) },
body: JSON.stringify(meta),
});
if (!init.ok) throw new Error(`YouTube: couldn't start the upload (${init.status}): ${(await init.text()).slice(0, 300)}`);
const location = init.headers.get("location");
const up = await fetch(location, { method: "PUT", headers: { "Content-Type": mimeOf(src), "Content-Length": String(bytes.length) }, body: bytes });
const video = await up.json().catch(() => ({}));
if (!up.ok || !video.id) throw new Error(`YouTube: upload failed (${up.status}): ${video.error?.message || ""}`);
return { id: video.id, permalink: `https://www.youtube.com/shorts/${video.id}`, privacy: video.status?.privacyStatus };
},
};

View File

@ -6,6 +6,10 @@ import path from "node:path";
import crypto from "node:crypto";
import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js";
import * as extra from "./platforms/index.js";
const META_PLATFORMS = ["instagram", "facebook"];
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
const DIR = path.join(PROJECT_ROOT, "data", "schedule");
const FILE = path.join(DIR, "queue.json");
@ -77,24 +81,27 @@ export function getPost(id) {
* Add a post to the queue as a DRAFT. Nothing is published until a person approves it.
* localTime is in the client's time zone, e.g. "2026-10-20 19:00".
*/
export function createPost({ client, platforms = ["instagram"], type = "IMAGE", media = [], caption = "", fbCaption, collaborators = [], localTime, createdBy = "agent", notes } = {}) {
export function createPost({ client, platforms = ["instagram"], type = "IMAGE", media = [], caption = "", fbCaption, collaborators = [], options = {}, localTime, createdBy = "agent", notes } = {}) {
const c = clientInfo(client);
if (!TYPES.includes(type)) throw err(`Type must be one of ${TYPES.join(", ")}.`);
platforms = [...new Set(platforms)].filter((p) => p === "instagram" || p === "facebook");
if (!platforms.length) throw err("Pick Instagram, Facebook or both.");
// Instagram-only clients (no Facebook Page): drop Facebook when Instagram is also picked.
if (!c.pageId && c.igId) {
if (!platforms.includes("instagram")) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`);
platforms = ["instagram"];
platforms = [...new Set(platforms)].filter((p) => ALL_PLATFORMS.includes(p));
if (!platforms.length) throw err("Pick at least one platform.");
// Instagram-only clients (no Facebook Page): drop Facebook when something else is also picked.
if (!c.pageId && c.igId && platforms.includes("facebook")) {
if (platforms.length === 1) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`);
platforms = platforms.filter((p) => p !== "facebook");
}
if (platforms.some((p) => META_PLATFORMS.includes(p)) && !c.pageId && !c.igId) throw err(`${c.name} isn't linked to a Facebook Page or Instagram account, so it can't post there.`);
if (type === "TEXT" && platforms.includes("instagram")) throw err("Instagram needs an image or video. Use Facebook only for text posts.");
if (type !== "TEXT" && !media.length) throw err("Add at least one image or video (file path or public URL).");
options = Object.fromEntries(Object.entries(options || {}).filter(([k]) => platforms.includes(k)));
for (const p of platforms) if (!META_PLATFORMS.includes(p)) extra.validate(p, c.slug, { type, caption, media, options });
for (const m of media) if (!/^https?:\/\//.test(m) && !fs.existsSync(m)) throw err(`File not found: ${m}`);
const publishAt = zonedToUtc(localTime, c.timezone);
if (publishAt < Date.now() - 60000) throw err(`That time has already passed in ${c.timezone}.`);
const post = {
id: crypto.randomUUID().slice(0, 8), client: c.slug, clientName: c.name, platforms, type, media, caption,
fbCaption: fbCaption || null, collaborators, timezone: c.timezone, localTime: formatInTz(publishAt, c.timezone),
fbCaption: fbCaption || null, collaborators, options, timezone: c.timezone, localTime: formatInTz(publishAt, c.timezone),
publishAt: publishAt.toISOString(), status: "draft", createdBy, createdAt: new Date().toISOString(), notes: notes || null,
results: {}, errors: {},
};
@ -106,7 +113,8 @@ export function updatePost(id, patch, by = "team") {
const items = read(); const p = items.find((x) => x.id === id);
if (!p) throw err("Post not found.", 404);
if (!["draft", "failed"].includes(p.status)) throw err("Only drafts or failed posts can be edited. Cancel it first.");
for (const k of ["caption", "fbCaption", "media", "platforms", "collaborators", "type", "notes"]) if (patch[k] !== undefined) p[k] = patch[k];
for (const k of ["caption", "fbCaption", "media", "platforms", "collaborators", "type", "notes", "options"]) if (patch[k] !== undefined) p[k] = patch[k];
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
write(items); return p;
@ -143,13 +151,16 @@ export async function cancelPost(id, by = "team") {
}
async function publishNow(p) {
const a = await accountFor(p.client);
let a = null;
for (const platform of p.platforms) {
if (p.results[platform]?.id && !p.results[platform]?.cancelled) continue; // already done (e.g. FB scheduled natively)
try {
if (META_PLATFORMS.includes(platform)) a ||= await accountFor(p.client);
p.results[platform] = platform === "instagram"
? await meta.igPublishAny(a, { type: p.type, media: p.media, caption: p.caption, collaborators: p.collaborators })
: await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media });
: platform === "facebook"
? await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media })
: await extra.publish(platform, p.client, p);
delete p.errors[platform];
log({ action: "published", platform, post: p.id, client: p.client, result: p.results[platform] });
} catch (e) { p.errors[platform] = e.message; log({ action: "failed", platform, post: p.id, client: p.client, error: e.message }); }

View File

@ -10,6 +10,7 @@ import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js";
import * as sched from "./schedule.js";
import * as lib from "./library.js";
import * as extra from "./platforms/index.js";
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
@ -139,11 +140,25 @@ server.tool("audience_demographics", "Follower demographics (top cities, countri
// ---------- Scheduling (queue → human approval → auto-publish at the client's local time) ----------
const slug = z.string().describe("Client folder slug, e.g. maison-de-treats");
server.tool("schedule_post", "Add a post to the content queue as a DRAFT (never publishes). localTime is in the CLIENT's time zone. media = local file paths or public URLs.",
{ client: slug, platforms: z.array(z.enum(["instagram", "facebook"])).min(1), type: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]),
const platformList = z.array(z.enum(sched.ALL_PLATFORMS)).min(1);
const options = z.record(z.string(), z.record(z.string(), z.any())).default({})
.describe("Per-platform settings, e.g. {\"youtube\":{\"title\":\"…\",\"privacy\":\"public\"},\"pinterest\":{\"boardId\":\"…\"},\"linkedin\":{\"caption\":\"…\"}}. Any platform can take its own \"caption\". See list_connections for each platform's fields.");
server.tool("list_connections", "Which platforms beyond Facebook/Instagram (LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit) are connected for a client, the account, and the per-post fields each platform needs.",
{ client: slug },
wrap(async ({ client }) => {
const conns = extra.vault.listConnections({ client });
return extra.describePlatforms().map((p) => {
const c = conns.find((x) => x.platform === p.id);
return { platform: p.id, name: p.name, connected: Boolean(c), status: c?.status || null, account: c?.account?.name || null, target: c?.target?.name || null, formats: p.formats, fields: p.fields.map((f) => ({ key: f.key, label: f.label, required: Boolean(f.required), options: f.options?.map((o) => o[0]), onlyFor: f.onlyFor })), captionLimit: p.limits.caption || null };
});
}));
server.tool("schedule_post", "Add a post to the content queue as a DRAFT (never publishes). localTime is in the CLIENT's time zone. media = local file paths or public URLs. Platforms other than instagram/facebook must be connected for the client first (list_connections).",
{ client: slug, platforms: platformList, type: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]), options,
media: z.array(z.string()).default([]), caption: z.string().default(""), fb_caption: z.string().optional(),
collaborators: z.array(z.string()).default([]).describe("Instagram usernames to invite as Collab"), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"), notes: z.string().optional() },
wrap(async (a) => sched.createPost({ client: a.client, platforms: a.platforms, type: a.type, media: a.media, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, localTime: a.local_time, notes: a.notes, createdBy: "agent" })));
wrap(async (a) => sched.createPost({ client: a.client, platforms: a.platforms, type: a.type, media: a.media, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options, localTime: a.local_time, notes: a.notes, createdBy: "agent" })));
server.tool("list_scheduled", "The content queue (drafts, approved, published, failed), optionally for one client.",
{ client: z.string().optional(), status: z.enum(["draft", "approved", "publishing", "published", "partial", "failed", "cancelled"]).optional() },
@ -165,8 +180,8 @@ server.tool("content_library", "Finished work the team uploaded for a client (de
wrap(async ({ client, status }) => lib.listItems({ client, status }).map((i) => ({ id: i.id, client: i.client, title: i.title, format: i.format, files: i.files, captionIdea: i.captionIdea, notes: i.notes, forDate: i.forDate, taskRef: i.taskRef, status: i.status, version: i.version, uploadedBy: i.uploadedBy, reviewNote: i.reviewNote, updatedAt: i.updatedAt, postIds: i.postIds }))));
server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT post in the queue (never publishes; a person still approves the post). local_time is in the client's time zone. Leave caption empty to use the uploader's caption idea, but normally write a finished caption.",
{ id: z.string(), platforms: z.array(z.enum(["instagram", "facebook"])).min(1).default(["instagram", "facebook"]), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"),
caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]) },
wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators }, "agent")));
{ id: z.string(), platforms: platformList.default(["instagram", "facebook"]), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"),
caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options },
wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent")));
await server.connect(new StdioServerTransport());

View File

@ -0,0 +1,301 @@
// Offline tests for the platform adapters: every platform API is simulated by a fake fetch that records
// what Quorla sends. Run: node --test connector/test/ (no network, no real accounts touched)
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-test-"));
process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json");
process.env.DASHBOARD_SECRET = "test-secret-not-real";
process.env.PUBLIC_URL = "https://app.example.test";
const { PROJECT_ROOT } = await import("../src/env.js");
const P = await import("../src/platforms/index.js");
const { vault } = P;
const media = await import("../src/platforms/media.js");
const { littleText } = await import("../src/platforms/linkedin.js");
const { chunkPlan } = await import("../src/platforms/tiktok.js");
// Test media inside data/library (the only folders media links may expose).
const LIB = path.join(PROJECT_ROOT, "data", "library", "_quorla_test");
const IMG = path.join(LIB, "1700000000000-abcd-photo.jpg");
const IMG2 = path.join(LIB, "1700000000001-abcd-photo2.png");
const VID = path.join(LIB, "1700000000002-abcd-clip.mp4");
// ---------- fake fetch ----------
let calls = [];
let routes = [];
const realFetch = globalThis.fetch;
function route(method, match, reply) { routes.push({ method, match, reply }); }
function reset() { calls = []; routes = []; }
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
async function fakeFetch(input, init = {}) {
const url = String(input instanceof URL ? input : input?.url || input);
const method = (init.method || "GET").toUpperCase();
let body = init.body;
if (body instanceof URLSearchParams) body = body.toString();
calls.push({ method, url, headers: init.headers || {}, body });
const r = routes.find((x) => x.method === method && (typeof x.match === "string" ? url.startsWith(x.match) : x.match.test(url)));
if (!r) return json({ error: { message: `no fake route for ${method} ${url}` } }, 599);
return typeof r.reply === "function" ? r.reply({ url, method, body, headers: init.headers || {} }) : r.reply.clone();
}
const sent = (re) => calls.filter((c) => re.test(c.url));
const bodyJson = (c) => JSON.parse(c.body);
const form = (c) => Object.fromEntries(new URLSearchParams(c.body));
const user = { id: "u1", name: "Tester" };
async function connect(platform, client, { tokenReply, identify }) {
vault.saveApp(platform, { clientId: `${platform}-id`, clientSecret: `${platform}-secret` }, "test");
const url = new URL(P.startConnect({ platform, client, redirectUri: `https://app.example.test/oauth/${platform}/callback`, user }));
const state = url.searchParams.get("state");
route("POST", P.adapter(platform).auth.tokenUrl, tokenReply || json({ access_token: `${platform}-at`, refresh_token: `${platform}-rt`, expires_in: 3600, scope: "a b" }));
for (const [m, match, reply] of identify) route(m, match, reply);
const conn = await P.finishConnect({ platform, code: "the-code", state, user });
return { url, conn };
}
before(() => {
fs.mkdirSync(LIB, { recursive: true });
fs.writeFileSync(IMG, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]));
fs.writeFileSync(IMG2, Buffer.from([0x89, 0x50, 0x4e, 0x47, 4, 5]));
fs.writeFileSync(VID, Buffer.alloc(1024, 7));
globalThis.fetch = fakeFetch;
});
after(() => {
globalThis.fetch = realFetch;
fs.rmSync(LIB, { recursive: true, force: true });
fs.rmSync(TMP, { recursive: true, force: true });
});
// ---------- vault ----------
test("vault: secrets are encrypted at rest and never listed", () => {
vault.saveApp("linkedin", { clientId: "cid", clientSecret: "super-secret-value" }, "t");
const raw = fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8");
assert.ok(!raw.includes("super-secret-value") && !raw.includes("cid"), "app secret must not be stored in plain text");
assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value");
vault.saveApp("linkedin", { clientId: "cid2", clientSecret: "" }, "t");
assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value", "empty secret keeps the stored one");
const c = vault.saveConnection({ client: "acme", platform: "linkedin", account: { id: "1", name: "A" }, tokens: { accessToken: "tok-123" }, by: "t" });
assert.ok(!JSON.stringify(vault.listConnections()).includes("tok-123"));
assert.ok(!fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8").includes("tok-123"));
assert.equal(vault.getConnection(c.id, { withTokens: true }).tokens.accessToken, "tok-123");
vault.removeConnection(c.id);
});
test("vault: a sign-in state works once and only for the same platform/user", () => {
const s = vault.createState({ platform: "x", userId: "u1" });
assert.equal(vault.takeState(s).platform, "x");
assert.throws(() => vault.takeState(s), /expired or was already used/);
});
// ---------- media links ----------
test("media links: signed, expiring, limited to library/uploads", () => {
const u = media.publicMediaUrl(IMG);
assert.match(u, /^https:\/\/app\.example\.test\/m\/[^/]+\/photo\.jpg$/);
const token = u.split("/m/")[1].split("/")[0];
assert.equal(media.resolveMediaToken(token), path.resolve(IMG));
assert.equal(media.resolveMediaToken(token.slice(0, -2) + "xx"), null, "tampered signature rejected");
assert.throws(() => media.publicMediaUrl(path.join(PROJECT_ROOT, ".env")), /Only files from the content library/);
const old = media.publicMediaUrl(IMG, { hours: -1 }).split("/m/")[1].split("/")[0];
assert.equal(media.resolveMediaToken(old), null, "expired link rejected");
});
// ---------- helpers ----------
test("LinkedIn little-text escaping keeps hashtags as hashtags", () => {
assert.equal(littleText("Hi (team) #Waterloo_Eats, see [x]!"), "Hi \\(team\\) {hashtag|\\#|Waterloo_Eats}, see \\[x\\]!");
assert.equal(littleText("C# 100%"), "C\\# 100%");
});
test("TikTok chunk plan follows TikTok's rules", () => {
assert.deepEqual(chunkPlan(3 * 1024 * 1024), { chunkSize: 3 * 1024 * 1024, count: 1 });
const big = 150 * 1024 * 1024;
const p = chunkPlan(big);
assert.equal(p.chunkSize, 10 * 1024 * 1024);
assert.equal(p.count, 15);
assert.ok(big - (p.count - 1) * p.chunkSize <= 128 * 1024 * 1024, "last chunk stays under 128 MB");
});
// ---------- sign-in URLs ----------
test("sign-in URLs carry the right client id, scopes and PKCE per platform", () => {
for (const id of P.PLATFORM_IDS) vault.saveApp(id, { clientId: `${id}-id`, clientSecret: "s" }, "t");
const u = (id) => new URL(P.startConnect({ platform: id, client: "acme", redirectUri: `https://app.example.test/oauth/${id}/callback`, user }));
const tt = u("tiktok");
assert.equal(tt.searchParams.get("client_key"), "tiktok-id");
assert.match(tt.searchParams.get("scope"), /video\.publish,video\.upload/);
const yt = u("youtube");
assert.equal(yt.searchParams.get("access_type"), "offline");
assert.equal(yt.searchParams.get("code_challenge_method"), "S256");
assert.ok(yt.searchParams.get("code_challenge"));
assert.equal(u("reddit").searchParams.get("duration"), "permanent");
assert.equal(u("linkedin").searchParams.get("redirect_uri"), "https://app.example.test/oauth/linkedin/callback");
vault.saveApp("linkedin", { clientId: "x", clientSecret: "s", scopes: "openid profile w_member_social" }, "t");
assert.equal(u("linkedin").searchParams.get("scope"), "openid profile w_member_social", "scope override is used");
});
// ---------- each platform: connect + publish ----------
test("LinkedIn: connect, list company Pages, post image to a company Page", async () => {
reset();
const { conn } = await connect("linkedin", "acme", { identify: [
["GET", "https://api.linkedin.com/v2/userinfo", json({ sub: "abc", name: "Pat Lee", picture: "https://p" })],
["GET", "https://api.linkedin.com/rest/organizationAcls", json({ elements: [{ organization: "urn:li:organization:42" }] })],
["GET", "https://api.linkedin.com/rest/organizations/42", json({ localizedName: "Acme Co" })],
] });
assert.equal(conn.account.name, "Pat Lee");
const targets = await P.targetsFor(conn.id);
assert.deepEqual(targets.map((t) => t.id), ["urn:li:person:abc", "urn:li:organization:42"]);
await P.setTarget(conn.id, "urn:li:organization:42");
route("POST", "https://api.linkedin.com/rest/images?action=initializeUpload", json({ value: { uploadUrl: "https://upload.linkedin.test/1", image: "urn:li:image:9" } }));
route("PUT", "https://upload.linkedin.test/1", new Response("", { status: 201 }));
route("POST", "https://api.linkedin.com/rest/posts", new Response("", { status: 201, headers: { "x-restli-id": "urn:li:share:77" } }));
const r = await P.publish("linkedin", "acme", { type: "IMAGE", media: [IMG], caption: "New menu #Waterloo" });
assert.equal(r.id, "urn:li:share:77");
const post = bodyJson(sent(/rest\/posts/)[0]);
assert.equal(post.author, "urn:li:organization:42");
assert.equal(post.content.media.id, "urn:li:image:9");
assert.match(post.commentary, /\{hashtag\|\\#\|Waterloo\}/);
assert.ok(sent(/rest\/posts/)[0].headers["LinkedIn-Version"]);
});
test("YouTube: connect a channel, upload a Short with title and privacy", async () => {
reset();
const { conn } = await connect("youtube", "acme", { identify: [
["GET", "https://www.googleapis.com/youtube/v3/channels", json({ items: [{ id: "UC1", snippet: { title: "Acme TV", customUrl: "@acmetv", thumbnails: { default: { url: "t" } } }, statistics: { subscriberCount: "1200" } }] })],
] });
assert.equal(conn.account.followers, 1200);
route("POST", "https://www.googleapis.com/upload/youtube/v3/videos", new Response("", { status: 200, headers: { location: "https://upload.youtube.test/s1" } }));
route("PUT", "https://upload.youtube.test/s1", json({ id: "vid1", status: { privacyStatus: "unlisted" } }));
P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: { youtube: { title: "Behind the scenes" } } });
assert.throws(() => P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /video title is required/);
assert.throws(() => P.validate("youtube", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: { youtube: { title: "t" } } }), /doesn't take image posts/);
const r = await P.publish("youtube", "acme", { type: "REELS", media: [VID], caption: "Desc", options: { youtube: { title: "Behind the scenes", privacy: "unlisted" } } });
assert.equal(r.permalink, "https://www.youtube.com/shorts/vid1");
const meta = JSON.parse(sent(/upload\/youtube/)[0].body);
assert.equal(meta.snippet.title, "Behind the scenes");
assert.equal(meta.status.privacyStatus, "unlisted");
assert.equal(meta.status.selfDeclaredMadeForKids, false);
});
test("Google Business Profile: auto-picks the only location, posts with a button and photo link", async () => {
reset();
const { conn } = await connect("gbp", "acme", { identify: [
["GET", "https://openidconnect.googleapis.com/v1/userinfo", json({ sub: "g1", email: "owner@acme.test" })],
["GET", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", json({ accounts: [{ name: "accounts/1" }] })],
["GET", "https://mybusinessbusinessinformation.googleapis.com/v1/accounts/1/locations", json({ locations: [{ name: "locations/5", title: "Acme Bakery", storefrontAddress: { addressLines: ["1 King St"], locality: "Waterloo" } }] })],
] });
assert.equal(vault.getConnection(conn.id).target.id, "accounts/1/locations/5");
route("POST", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/localPosts", json({ name: "accounts/1/locations/5/localPosts/9", searchUrl: "https://g.test/p" }));
assert.throws(() => P.validate("gbp", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /doesn't take video/);
await assert.rejects(P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER" } } }), /add a link for the button/);
const r = await P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER", ctaUrl: "https://acme.test/order" } } });
assert.equal(r.permalink, "https://g.test/p");
const b = bodyJson(sent(/localPosts/).at(-1));
assert.deepEqual(b.callToAction, { actionType: "ORDER", url: "https://acme.test/order" });
assert.match(b.media[0].sourceUrl, /^https:\/\/app\.example\.test\/m\//);
});
test("TikTok: connect, privacy is enforced, video uploads and photo posts pull from signed links", async () => {
reset();
const { conn } = await connect("tiktok", "acme", {
tokenReply: json({ access_token: "tt-at", refresh_token: "tt-rt", expires_in: 86400, open_id: "op1", scope: "video.publish" }),
identify: [["GET", "https://open.tiktokapis.com/v2/user/info/", json({ data: { user: { open_id: "op1", display_name: "Acme", username: "acme", follower_count: 50 } }, error: { code: "ok" } })]],
});
assert.equal(conn.account.username, "acme");
assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /who can view is required/);
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } }));
await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE" } } }), /can't post as/);
route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ data: { publish_id: "pub1", upload_url: "https://upload.tiktok.test/u1" }, error: { code: "ok" } }));
route("PUT", "https://upload.tiktok.test/u1", new Response("", { status: 201 }));
route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "PUBLISH_COMPLETE", publicaly_available_post_id: [7001] }, error: { code: "ok" } }));
const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Our kitchen", options: { tiktok: { privacy: "SELF_ONLY" } } });
assert.equal(r.permalink, "https://www.tiktok.com/@acme/video/7001");
const init = bodyJson(sent(/video\/init/)[0]);
assert.equal(init.source_info.total_chunk_count, 1);
assert.equal(init.post_info.privacy_level, "SELF_ONLY");
assert.equal(sent(/upload\.tiktok/)[0].headers["Content-Range"], "bytes 0-1023/1024");
route("POST", "https://open.tiktokapis.com/v2/post/publish/content/init/", json({ data: { publish_id: "pub2" }, error: { code: "ok" } }));
await P.publish("tiktok", "acme", { type: "CAROUSEL", media: [IMG, IMG2], caption: "Menu", options: { tiktok: { privacy: "SELF_ONLY", title: "New menu" } } });
const photo = bodyJson(sent(/content\/init/)[0]);
assert.equal(photo.media_type, "PHOTO");
assert.equal(photo.source_info.photo_images.length, 2);
assert.ok(photo.source_info.photo_images.every((u) => u.startsWith("https://app.example.test/m/")));
});
test("Pinterest: boards, base64 image Pin to the chosen board", async () => {
reset();
const { conn } = await connect("pinterest", "acme", { identify: [
["GET", "https://api.pinterest.com/v5/user_account", json({ username: "acmepins", business_name: "Acme", follower_count: 300 })],
["GET", "https://api.pinterest.com/v5/boards", json({ items: [{ id: "b1", name: "Cakes" }, { id: "b2", name: "Bread" }] })],
] });
assert.equal(calls.find((c) => c.url.startsWith("https://api.pinterest.com/v5/oauth/token")).headers.Authorization.startsWith("Basic "), true, "Pinterest uses Basic auth for tokens");
assert.equal(vault.getConnection(conn.id).target, null, "two boards: nothing auto-picked");
assert.throws(() => P.validate("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: {} }), /board is required/);
route("POST", "https://api.pinterest.com/v5/pins", json({ id: "pin9" }));
const r = await P.publish("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "Birthday cakes", options: { pinterest: { boardId: "b1", title: "Cakes", link: "https://acme.test" } } });
assert.equal(r.permalink, "https://www.pinterest.com/pin/pin9/");
const pin = bodyJson(sent(/v5\/pins/)[0]);
assert.equal(pin.board_id, "b1");
assert.equal(pin.media_source.source_type, "image_base64");
assert.equal(pin.media_source.data, fs.readFileSync(IMG).toString("base64"));
});
test("Threads: long-lived token exchange, text and image posts", async () => {
reset();
route("GET", "https://graph.threads.net/access_token", json({ access_token: "th-long", expires_in: 5184000 }));
const { conn } = await connect("threads", "acme", {
tokenReply: json({ access_token: "th-short", user_id: 555 }),
identify: [
["GET", "https://graph.threads.net/v1.0/me", json({ id: "555", username: "acme" })],
["GET", "https://graph.threads.net/v1.0/555/threads_insights", json({ data: [{ total_value: { value: 88 } }] })],
],
});
assert.equal(vault.getConnection(conn.id, { withTokens: true }).tokens.accessToken, "th-long");
assert.equal(conn.account.followers, 88);
route("POST", "https://graph.threads.net/v1.0/555/threads?", json({ id: "c1" }));
route("GET", "https://graph.threads.net/v1.0/c1", json({ status: "FINISHED" }));
route("POST", "https://graph.threads.net/v1.0/555/threads_publish", json({ id: "t1" }));
route("GET", "https://graph.threads.net/v1.0/t1", json({ permalink: "https://threads.net/@acme/post/t1" }));
const r = await P.publish("threads", "acme", { type: "IMAGE", media: [IMG], caption: "Hello" });
assert.equal(r.permalink, "https://threads.net/@acme/post/t1");
const c = new URL(sent(/555\/threads\?/)[0].url);
assert.equal(c.searchParams.get("media_type"), "IMAGE");
assert.match(c.searchParams.get("image_url"), /^https:\/\/app\.example\.test\/m\//);
assert.throws(() => P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: {} }), /limit is 500/);
P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: { threads: { caption: "short" } } });
});
test("Reddit: text post needs subreddit and title, sends User-Agent", async () => {
reset();
await connect("reddit", "acme", { identify: [["GET", "https://oauth.reddit.com/api/v1/me", json({ id: "r1", name: "acme_bakery", total_karma: 12 })]] });
assert.throws(() => P.validate("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "waterloo" } } }), /post title is required/);
route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [], data: { name: "t3_abc", url: "https://reddit.com/r/waterloo/abc" } } }));
const r = await P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "We're open late", options: { reddit: { subreddit: "r/waterloo", title: "Late hours" } } });
assert.equal(r.permalink, "https://reddit.com/r/waterloo/abc");
const sub = sent(/api\/submit/)[0];
assert.deepEqual({ sr: form(sub).sr, kind: form(sub).kind, text: form(sub).text }, { sr: "waterloo", kind: "self", text: "We're open late" });
assert.ok(sub.headers["User-Agent"]);
route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [["SUBREDDIT_NOTALLOWED", "you aren't allowed to post there.", "sr"]] } }));
routes = routes.filter((x, i, a) => !(x.match === "https://oauth.reddit.com/api/submit" && a.findIndex((y) => y.match === x.match) === i));
await assert.rejects(P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "x", title: "t" } } }), /aren't allowed to post/);
});
test("expired tokens are refreshed and saved; failed refresh asks to reconnect", async () => {
reset();
const c = vault.connectionFor("acme", "youtube");
vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "yt-rt", expiresAt: new Date(Date.now() - 1000).toISOString() } });
route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "new-at", expires_in: 3600 }));
const { tokens } = await P.tokensFor(c.id);
assert.equal(tokens.accessToken, "new-at");
assert.equal(tokens.refreshToken, "yt-rt", "refresh token kept when the platform doesn't send a new one");
assert.equal(form(sent(/oauth2\.googleapis/)[0]).grant_type, "refresh_token");
reset();
vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "bad", expiresAt: new Date(Date.now() - 1000).toISOString() } });
route("POST", "https://oauth2.googleapis.com/token", json({ error: "invalid_grant", error_description: "Token has been revoked." }, 400));
await assert.rejects(P.tokensFor(c.id), /Reconnect it/);
assert.equal(vault.getConnection(c.id).status, "reconnect");
});
test("posting to an unconnected platform is refused clearly", () => {
assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/);
});

View File

@ -13,6 +13,8 @@ import * as docs from "./docs.js";
import * as meta from "../../connector/src/meta.js";
import * as sched from "../../connector/src/schedule.js";
import * as lib from "../../connector/src/library.js";
import * as extra from "../../connector/src/platforms/index.js";
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
import { DATA_DIR as _DD } from "./config.js";
const app = express();
@ -47,6 +49,31 @@ app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency:
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
// Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from.
app.get("/m/:token/:name", (req, res) => {
const f = resolveMediaToken(req.params.token);
if (!f || !fs.existsSync(f)) return res.status(404).send("Not found");
res.setHeader("Cache-Control", "private, max-age=3600");
res.sendFile(f);
});
// OAuth: the platform sends the person back here after they approve Quorla.
const baseUrl = (req) => publicBaseUrl() || `${req.protocol}://${req.get("host")}`;
const redirectUri = (req, platform) => `${baseUrl(req)}/oauth/${platform}/callback`;
app.get("/oauth/:platform/callback", async (req, res) => {
const back = (q) => res.redirect(`/connections?${new URLSearchParams(q)}`);
const user = currentUser(req);
if (!user) return back({ error: "Your Quorla session ended. Sign in and connect again." });
if (req.query.error) return back({ error: `${req.params.platform}: ${String(req.query.error_description || req.query.error).slice(0, 300)}` });
try {
const conn = await extra.finishConnect({ platform: req.params.platform, code: String(req.query.code || ""), state: String(req.query.state || ""), user });
back({ client: conn.client, connected: req.params.platform });
} catch (e) {
console.error("[oauth]", req.params.platform, e.message);
back({ error: String(e.message).slice(0, 300) });
}
});
// ---------- everything below needs a session ----------
app.use("/api", requireAuth);
@ -58,6 +85,24 @@ app.post("/api/me/password", h(async (req, res) => {
return { user };
}));
// ---------- platforms beyond Facebook/Instagram ----------
app.get("/api/platforms", h(async (req) => ({
platforms: extra.describePlatforms().map((p) => ({ ...p, redirectUri: redirectUri(req, p.id), app: req.user.role === "admin" ? p.app : { configured: p.app.configured } })),
publicUrl: baseUrl(req), mediaLinks: Boolean(publicBaseUrl()),
})));
app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; }));
app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; }));
app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) })));
app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
const client = String(req.query.client || "");
if (!clients.getClient(client)) throw Object.assign(new Error("Pick a client first."), { status: 400 });
return { url: extra.startConnect({ platform: req.params.platform, client, redirectUri: redirectUri(req, req.params.platform), user: req.user }) };
}));
app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) })));
app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) })));
app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) })));
app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; }));
// ---------- team members (admins) ----------
app.get("/api/users", requireAdmin, h(async () => ({ users: users.listUsers() })));
app.post("/api/users", requireAdmin, h(async (req) => users.createUser(req.body || {}, req.user.name || req.user.email)));

View File

@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp } from "lucide-react";
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react";
import { api } from "./api.js";
import { cx, Spinner } from "./ui.jsx";
import Login from "./pages/Login.jsx";
@ -12,6 +12,7 @@ import Settings from "./pages/Settings.jsx";
import Publishing from "./pages/Publishing.jsx";
import Inbox from "./pages/Inbox.jsx";
import Library from "./pages/Library.jsx";
import Connections from "./pages/Connections.jsx";
const AuthCtx = createContext(null);
export const useAuth = () => useContext(AuthCtx);
@ -21,6 +22,7 @@ const NAV = [
{ to: "/library", label: "Content library", icon: FolderUp },
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
{ to: "/connections", label: "Connections", icon: Plug },
{ to: "/team", label: "Team", icon: Users2 },
{ to: "/runs", label: "AI runs", icon: Sparkles },
{ to: "/settings", label: "Settings", icon: Settings2 },
@ -117,6 +119,7 @@ export default function App() {
<Route path="/library" element={<Library />} />
<Route path="/publishing" element={<Publishing />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/connections" element={<Connections />} />
<Route path="/team" element={<Team />} />
<Route path="/runs" element={<Runs />} />
<Route path="/runs/:id" element={<Runs />} />

View File

@ -0,0 +1,128 @@
// "Post to" picker shared by New post (Publishing) and Schedule (Content library):
// Instagram/Facebook from the Meta link, plus every platform connected for the client, each with its own fields.
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "./api.js";
import { cx, Field } from "./ui.jsx";
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
/** Platforms the client can post to: [{ id, name, meta, formats, fields, limits, conn }] */
export function useClientPlatforms(client) {
const [state, setState] = useState({ loading: true, list: [] });
useEffect(() => {
if (!client) { setState({ loading: false, list: [] }); return; }
let live = true;
setState((s) => ({ ...s, loading: true }));
Promise.all([api(`/api/clients/${client}`), api("/api/platforms"), api(`/api/connections?client=${encodeURIComponent(client)}`)])
.then(([c, p, k]) => {
if (!live) return;
const info = c.client;
const list = [];
if (info.igId) list.push({ id: "instagram", name: "Instagram", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY"], fields: [], limits: { caption: 2200 } });
if (info.pageId) list.push({ id: "facebook", name: "Facebook", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"], fields: [], limits: {} });
for (const pl of p.platforms) {
const conn = k.connections.find((x) => x.platform === pl.id && x.status === "connected");
if (conn) list.push({ ...pl, conn });
}
setState({ loading: false, list });
})
.catch(() => live && setState({ loading: false, list: [] }));
return () => { live = false; };
}, [client]);
return state;
}
/** Default selection for a client: its Meta platforms. */
export const defaultPlatforms = (list) => list.filter((p) => p.meta).map((p) => p.id);
function TargetSelect({ conn, value, onChange, id }) {
const [opts, setOpts] = useState(null);
useEffect(() => { api(`/api/connections/${conn.id}/targets`).then((r) => setOpts(r.targets)).catch(() => setOpts([])); }, [conn.id]);
return (
<select id={id} className="input" value={value || ""} onChange={(e) => onChange(e.target.value)}>
<option value="" disabled>{opts === null ? "Loading…" : opts.length ? "Choose…" : "None found"}</option>
{(opts || []).map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
);
}
function PlatformFields({ platform, type, options, setOption, caption }) {
const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type));
const own = options.caption;
const limit = platform.limits?.caption;
const used = (own ?? caption ?? "").length;
return (
<div className="rounded-lg border border-line p-3 space-y-3">
<div className="text-[12.5px] font-semibold flex items-center gap-2">
<span className="size-2.5 rounded-full" style={{ background: platform.color }} aria-hidden="true" />{platform.name}
{platform.conn?.target && <span className="font-normal text-muted">· {platform.conn.target.name}</span>}
</div>
<div className="grid grid-cols-2 gap-3">
{fields.map((f) => {
const id = `opt-${platform.id}-${f.key}`;
const v = options[f.key] ?? f.default ?? "";
if (f.type === "checkbox") {
return <label key={f.key} htmlFor={id} className="flex items-center gap-2 text-[13px] col-span-1"><input id={id} type="checkbox" checked={Boolean(v)} onChange={(e) => setOption(f.key, e.target.checked)} />{f.label}</label>;
}
return (
<div key={f.key} className={cx(f.type === "text" && "col-span-2")}>
<Field label={`${f.label}${f.required ? "" : " (optional)"}`} hint={f.hint}>
{f.type === "select" && f.optionsFrom === "targets" ? <TargetSelect id={id} conn={platform.conn} value={v} onChange={(x) => setOption(f.key, x)} />
: f.type === "select" ? (
<select id={id} className="input" value={v} onChange={(e) => setOption(f.key, e.target.value)}>
{!f.default && f.default !== "" && <option value="" disabled>Choose…</option>}
{f.options.map(([val, label]) => <option key={val} value={val}>{label}</option>)}
</select>
) : <input id={id} className="input" value={v} maxLength={f.max} onChange={(e) => setOption(f.key, e.target.value)} />}
</Field>
</div>
);
})}
</div>
<Field label={`${platform.name} caption (optional)`} hint={`Leave empty to use the main caption.${limit ? ` ${used}/${limit} characters.` : ""}`}>
<textarea id={`opt-${platform.id}-caption`} className={cx("input h-16 py-2", limit && used > limit && "border-bad")} value={own ?? ""} onChange={(e) => setOption("caption", e.target.value || undefined)} />
</Field>
</div>
);
}
/**
* value: { platforms: string[], options: { [platform]: {...} } }, onChange(next)
* type: the post format (IMAGE, REELS, CAROUSEL, STORY, TEXT)
*/
export default function PlatformPicker({ client, type, caption, value, onChange, available }) {
const { loading, list } = available;
const byId = useMemo(() => Object.fromEntries(list.map((p) => [p.id, p])), [list]);
const toggle = (id) => onChange({ ...value, platforms: value.platforms.includes(id) ? value.platforms.filter((x) => x !== id) : [...value.platforms, id] });
const setOption = (pid) => (k, v) => onChange({ ...value, options: { ...value.options, [pid]: { ...(value.options[pid] || {}), [k]: v } } });
if (loading) return <div className="text-[13px] text-muted">Loading platforms…</div>;
if (!list.length) return <div className="text-[13px] text-muted">This client has no connected accounts yet. <Link className="text-brand-deep underline" to={`/connections?client=${client}`}>Connect one</Link>.</div>;
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-x-4 gap-y-2 text-[13px]">
{list.map((p) => {
const ok = p.formats.includes(type);
return (
<label key={p.id} className={cx("flex items-center gap-1.5", !ok && "text-faint")} title={ok ? undefined : `${p.name} doesn't take ${FORMAT_LABEL[type]} posts`}>
<input type="checkbox" id={`plat-${p.id}`} disabled={!ok} checked={ok && value.platforms.includes(p.id)} onChange={() => toggle(p.id)} /> {p.name}
</label>
);
})}
</div>
{value.platforms.filter((id) => byId[id] && !byId[id].meta && byId[id].formats.includes(type)).map((id) => (
<PlatformFields key={id} platform={byId[id]} type={type} caption={caption} options={value.options[id] || {}} setOption={setOption(id)} />
))}
</div>
);
}
/** Only the platforms that accept this format, with options for those platforms. */
export function selectionFor(value, available, type) {
const ok = new Set(available.list.filter((p) => p.formats.includes(type)).map((p) => p.id));
const platforms = value.platforms.filter((p) => ok.has(p));
const options = Object.fromEntries(platforms.filter((p) => value.options[p]).map((p) => [p, value.options[p]]));
return { platforms, options };
}

View File

@ -0,0 +1,211 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
function CopyText({ text }) {
const [done, setDone] = useState(false);
const copy = () => navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1400); }).catch(() => {});
return (
<div className="flex items-center gap-2 rounded-md border border-line bg-canvas px-2.5 py-1.5">
<code className="font-mono text-[12.5px] break-all flex-1 min-w-0">{text}</code>
<button type="button" onClick={copy} className="p-1 rounded hover:bg-white text-muted shrink-0" aria-label="Copy">{done ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}</button>
</div>
);
}
/** Admin: enter a platform's developer-app credentials (stored encrypted on the server). */
function AppDialog({ platform, onClose, onSaved }) {
const [f, setF] = useState({ clientId: platform.app.clientId || "", clientSecret: "", scopes: (platform.app.scopes || []).join(" ") });
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
const save = async (e) => {
e.preventDefault(); setBusy("save"); setError(null);
try { await api(`/api/platforms/${platform.id}/app`, { method: "PUT", body: f }); onSaved(); } catch (err) { setError(err); setBusy(null); }
};
const remove = async () => {
setBusy("remove"); setError(null);
try { await api(`/api/platforms/${platform.id}/app`, { method: "DELETE" }); onSaved(); } catch (err) { setError(err); setBusy(null); }
};
return (
<Dialog open onClose={onClose} wide title={`Set up ${platform.name}`}
footer={<>
{platform.app.configured && platform.app.source === "settings" && <Button variant="danger" className="mr-auto" loading={busy === "remove"} onClick={remove}>Remove app keys</Button>}
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" type="submit" form="app-form" loading={busy === "save"}>Save</Button>
</>}>
<div className="space-y-4 text-[13.5px]">
<ol className="list-decimal pl-5 space-y-1.5 text-ink-2">
{platform.setup.steps.map((s) => <li key={s}>{s}</li>)}
</ol>
<a href={platform.setup.console} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-brand-deep font-medium">Open the {platform.name} developer console <ExternalLink className="size-3.5" /></a>
<Field label="Redirect URI (paste this into the developer console)"><CopyText text={platform.redirectUri} /></Field>
{platform.setup.note && <div className="rounded-md border border-[#f1dcb8] bg-warn-soft text-warn px-3 py-2 text-[13px]">{platform.setup.note}</div>}
<form id="app-form" onSubmit={save} className="grid grid-cols-2 gap-3">
<Field label={platform.id === "tiktok" ? "Client key" : "Client ID / App ID"}><input id="app-client-id" className="input" value={f.clientId} onChange={set("clientId")} required autoComplete="off" /></Field>
<Field label="Client secret" hint={platform.app.configured ? "Leave empty to keep the stored secret." : "Stored encrypted. Never shown again."}>
<input id="app-client-secret" className="input" type="password" value={f.clientSecret} onChange={set("clientSecret")} required={!platform.app.configured} autoComplete="new-password" />
</Field>
<div className="col-span-2">
<Field label="Scopes (optional)" hint={`Leave empty for the defaults: ${platform.defaultScopes.join(" ")}`}>
<input id="app-scopes" className="input" value={f.scopes} onChange={set("scopes")} placeholder={platform.defaultScopes.join(" ")} />
</Field>
</div>
</form>
{platform.app.source === "env" && <p className="text-muted text-[12.5px]">Currently using keys from the server's .env. Saving here overrides them.</p>}
<ErrorNote error={error} />
</div>
</Dialog>
);
}
function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, reload, setError }) {
const [busy, setBusy] = useState(null);
const [targets, setTargets] = useState(null);
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
const connect = async () => {
setBusy("connect"); setError(null);
try { const { url } = await api(`/api/oauth/${platform.id}/start?client=${encodeURIComponent(client)}`); window.location.assign(url); }
catch (e) { setError(e); setBusy(null); }
};
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); await reload(); } catch (e) { setError(e); } setBusy(null); };
const loadTargets = useCallback(() => {
if (!conn || !platform.targetLabel) return;
api(`/api/connections/${conn.id}/targets`).then((r) => setTargets(r.targets)).catch(() => setTargets([]));
}, [conn, platform.targetLabel]);
useEffect(() => { loadTargets(); }, [loadTargets]);
const status = !platform.app.configured ? { tone: "neutral", text: "Not set up" }
: !conn ? { tone: "neutral", text: "Not connected" }
: conn.status === "connected" ? { tone: "ok", text: "Connected" }
: { tone: "bad", text: conn.status === "reconnect" ? "Reconnect needed" : "Error" };
return (
<div className="panel p-4 flex flex-col gap-3 min-w-0">
<div className="flex items-start gap-3">
<span className="size-9 rounded-lg grid place-items-center text-white font-bold text-[13px] shrink-0" style={{ background: platform.color }} aria-hidden="true">{platform.name[0]}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"><span className="font-semibold">{platform.name}</span><Badge tone={status.tone} dot>{status.text}</Badge></div>
<div className="text-[12px] text-muted mt-0.5">Posts: {platform.formats.map((f) => FORMAT_LABEL[f]).join(", ")}</div>
</div>
</div>
{conn && (
<div className="text-[13px] space-y-1">
<div className="flex items-center gap-2 min-w-0">
{conn.account?.avatar && <img src={conn.account.avatar} alt="" className="size-6 rounded-full shrink-0" />}
<span className="font-medium truncate">{conn.account?.name}</span>
{conn.account?.username && <span className="text-muted truncate">@{conn.account.username}</span>}
</div>
<div className="text-[12px] text-muted">
{conn.account?.followers != null && <>{fmt(conn.account.followers)} {platform.id === "youtube" ? "subscribers" : platform.id === "reddit" ? "karma" : "followers"} · </>}
Connected {timeAgo(conn.connectedAt)} by {conn.connectedBy}
{conn.account?.url && <> · <a className="text-brand-deep" href={conn.account.url} target="_blank" rel="noreferrer">View profile</a></>}
</div>
{conn.lastError && <div className="text-[12px] text-bad">{conn.lastError}</div>}
</div>
)}
{conn && platform.targetLabel && (
<Field label={platform.targetLabel}>
{targets === null ? <div className="text-[12.5px] text-muted">Loading…</div> : (
<select id={`target-${platform.id}`} className="input" value={conn.target?.id || ""} disabled={!isAdmin || busy === "target"}
onChange={(e) => run("target", () => api(`/api/connections/${conn.id}`, { method: "PATCH", body: { targetId: e.target.value } }))}>
<option value="" disabled>{targets.length ? "Choose…" : "Nothing found for this account"}</option>
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
)}
</Field>
)}
{needsMediaLinks && <div className="text-[12px] text-warn">Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).</div>}
<div className="flex flex-wrap gap-2 mt-auto pt-1">
{isAdmin && !platform.app.configured && <Button size="sm" variant="primary" icon={Settings2} onClick={onSetup}>Set up</Button>}
{isAdmin && platform.app.configured && !conn && <Button size="sm" variant="primary" icon={Plug} loading={busy === "connect"} onClick={connect}>Connect</Button>}
{isAdmin && conn && <Button size="sm" icon={Plug} loading={busy === "connect"} onClick={connect}>Reconnect</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={RefreshCw} loading={busy === "check"} onClick={() => run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={Unplug} loading={busy === "remove"} onClick={() => run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect</Button>}
{isAdmin && platform.app.configured && <Button size="sm" variant="ghost" icon={Settings2} onClick={onSetup} aria-label={`${platform.name} app settings`} />}
{!isAdmin && !conn && <span className="text-[12.5px] text-muted">An admin connects this.</span>}
</div>
</div>
);
}
export default function Connections() {
const { isAdmin } = useAuth();
const [params, setParams] = useSearchParams();
const [clients, setClients] = useState(null);
const [platforms, setPlatforms] = useState(null);
const [conns, setConns] = useState([]);
const [error, setError] = useState(null);
const [setup, setSetup] = useState(null);
const client = params.get("client") || "";
const notice = params.get("connected");
const oauthError = params.get("error");
const loadPlatforms = useCallback(() => api("/api/platforms").then(setPlatforms).catch(setError), []);
const loadConns = useCallback(() => (client ? api(`/api/connections?client=${encodeURIComponent(client)}`).then((r) => setConns(r.connections)).catch(setError) : Promise.resolve()), [client]);
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); loadPlatforms(); }, [loadPlatforms]);
useEffect(() => { loadConns(); }, [loadConns]);
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
const current = useMemo(() => clients?.find((c) => c.slug === client), [clients, client]);
const pickClient = (slug) => setParams({ client: slug });
const dismiss = () => setParams({ client });
if (!clients || !platforms) return error ? <ErrorNote error={error} /> : <Spinner />;
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="text-[22px] font-extrabold">Connections</h1>
<p className="text-muted mt-0.5">Each client's accounts on every platform. Connect once; posts then go through Publishing with the usual approval.</p>
</div>
<select id="conn-client" className="input h-9 w-auto" value={client} onChange={(e) => pickClient(e.target.value)} aria-label="Client">
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
</header>
{notice && (
<div className="rounded-md border border-[#cfe6d7] bg-ok-soft text-ok px-3 py-2 text-[13px] flex justify-between gap-3">
<span>{platforms.platforms.find((p) => p.id === notice)?.name || notice} is connected for {current?.name}.</span>
<button type="button" className="underline" onClick={dismiss}>Dismiss</button>
</div>
)}
{oauthError && (
<div className="rounded-md border border-[#f2cdc8] bg-bad-soft text-bad px-3 py-2 text-[13px] flex justify-between gap-3">
<span className="flex gap-2"><CircleAlert className="size-4 shrink-0 mt-0.5" />{oauthError}</span>
<button type="button" className="underline shrink-0" onClick={dismiss}>Dismiss</button>
</div>
)}
<ErrorNote error={error} />
<Panel title="Facebook & Instagram" subtitle="Connected through the agency's Meta system user, not here.">
<div className="px-4 py-3 text-[13px] flex flex-wrap gap-x-6 gap-y-1">
<span><b>Instagram:</b> {current?.igUsername ? `@${current.igUsername}` : <span className="text-muted">not linked</span>}</span>
<span><b>Facebook:</b> {current?.pageId ? "Page linked" : current?.igOnly ? <span className="text-muted">none (Instagram only)</span> : <span className="text-muted">not linked</span>}</span>
{!current?.linked && <span className="text-muted">Link it from the client's page.</span>}
</div>
</Panel>
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
{platforms.platforms.map((p) => (
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} isAdmin={isAdmin}
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} reload={loadConns} setError={setError} />
))}
</div>
{setup && <AppDialog platform={setup} onClose={() => setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />}
</div>
);
}

View File

@ -3,6 +3,7 @@ import { Archive, CalendarPlus, Check, FolderUp, Image as ImageIcon, MessageSqua
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
const TONE = { submitted: "brand", changes: "warn", approved: "ok", scheduled: "neutral", archived: "neutral" };
const LABEL = { submitted: "To review", changes: "Changes requested", approved: "Ready to schedule", scheduled: "Scheduled", archived: "Archived" };
@ -215,32 +216,35 @@ function ReviewDialog({ item, onClose, onDone }) {
}
function ScheduleDialog({ item, clientName, onClose, onDone }) {
const [f, setF] = useState({ platforms: ["instagram", "facebook"], date: item.forDate || "", time: "", caption: item.captionIdea || "", fbCaption: "", collaborators: "" });
const [f, setF] = useState({ date: item.forDate || "", time: "", caption: item.captionIdea || "", fbCaption: "", collaborators: "" });
const available = useClientPlatforms(item.client);
const [sel, setSel] = useState({ platforms: [], options: {} });
useEffect(() => { if (!available.loading) setSel({ platforms: defaultPlatforms(available.list), options: {} }); }, [available]);
const chosen = selectionFor(sel, available, item.format);
const [tz, setTz] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api(`/api/schedule/suggest?client=${item.client}`).then(setTz).catch(() => {}); }, [item.client]);
const toggle = (p) => set("platforms", f.platforms.includes(p) ? f.platforms.filter((x) => x !== p) : [...f.platforms, p]);
const submit = async () => {
setBusy(true); setError(null);
try {
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: f.platforms, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
onDone();
} catch (e) { setError(e); setBusy(false); }
};
return (
<Dialog open onClose={onClose} title={`Schedule for ${clientName}`} wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.date || !f.time || !f.platforms.length} onClick={submit}>Add to Publishing as draft</Button></>}>
<Dialog open onClose={onClose} title={`Schedule for ${clientName}`} wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.date || !f.time || !chosen.platforms.length} onClick={submit}>Add to Publishing as draft</Button></>}>
<p className="text-[12.5px] text-muted">This creates a draft in Publishing. An admin still approves it there before it goes live.</p>
<div className="grid grid-cols-2 gap-4">
<Field label="Post to"><div className="flex gap-4 h-9 items-center text-[13px]">{["instagram", "facebook"].map((p) => <label key={p} className="flex items-center gap-1.5 capitalize"><input type="checkbox" checked={f.platforms.includes(p)} onChange={() => toggle(p)} /> {p}</label>)}</div></Field>
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
<div className="flex gap-2"><input type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} /><input type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} /></div>
</Field>
</div>
<Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field>
{f.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
{f.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
<Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field>
{sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
{sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
<ErrorNote error={error} />
</Dialog>
);

View File

@ -3,9 +3,10 @@ import { CalendarClock, Check, Image as ImageIcon, Plus, Upload, X } from "lucid
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" };
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel"], ["CAROUSEL", "Carousel (2–10)"], ["STORY", "Story"], ["TEXT", "Text (Facebook only)"]];
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel / video"], ["CAROUSEL", "Carousel / multi-image"], ["STORY", "Story (Instagram, Facebook)"], ["TEXT", "Text only (no Instagram)"]];
export default function Publishing() {
const { isAdmin } = useAuth();
@ -104,8 +105,10 @@ function Thumb({ post }) {
}
function Compose({ clients, onClose, onSaved }) {
const linked = clients.filter((c) => c.linked);
const [f, setF] = useState({ client: linked[0]?.slug || "", platforms: ["instagram", "facebook"], type: "IMAGE", media: [], caption: "", fbCaption: "", collaborators: "", date: "", time: "" });
const [f, setF] = useState({ client: clients[0]?.slug || "", type: "IMAGE", media: [], caption: "", fbCaption: "", collaborators: "", date: "", time: "" });
const available = useClientPlatforms(f.client);
const [sel, setSel] = useState({ platforms: [], options: {} });
useEffect(() => { if (!available.loading) setSel({ platforms: defaultPlatforms(available.list), options: {} }); }, [available]);
const [tz, setTz] = useState(null);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
@ -130,22 +133,22 @@ function Compose({ clients, onClose, onSaved }) {
const save = async () => {
setSaving(true); setError(null);
try {
const { platforms, options } = selectionFor(sel, available, f.type);
await api("/api/schedule", { method: "POST", body: {
client: f.client, platforms: f.platforms, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined,
client: f.client, platforms, options, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined,
collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`,
} });
onSaved();
} catch (e) { setError(e); }
setSaving(false);
};
const togglePlat = (p) => set("platforms", f.platforms.includes(p) ? f.platforms.filter((x) => x !== p) : [...f.platforms, p]);
return (
<Dialog open onClose={onClose} title="New post" wide footer={<><Button onClick={onClose}>Close</Button><Button variant="primary" loading={saving} disabled={!f.client || !f.date || !f.time} onClick={save}>Save as draft</Button></>}>
<Dialog open onClose={onClose} title="New post" wide footer={<><Button onClick={onClose}>Close</Button><Button variant="primary" loading={saving} disabled={!f.client || !f.date || !f.time || !selectionFor(sel, available, f.type).platforms.length} onClick={save}>Save as draft</Button></>}>
<div className="grid grid-cols-2 gap-4">
<Field label="Client">
<select id="c-client" className="input w-full" value={f.client} onChange={(e) => set("client", e.target.value)}>
{linked.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
</Field>
<Field label="Format">
@ -153,11 +156,6 @@ function Compose({ clients, onClose, onSaved }) {
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Post to">
<div className="flex gap-4 h-9 items-center text-[13px]">
{["instagram", "facebook"].map((p) => <label key={p} className="flex items-center gap-1.5 capitalize"><input type="checkbox" checked={f.platforms.includes(p)} onChange={() => togglePlat(p)} /> {p}</label>)}
</div>
</Field>
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
<div className="flex gap-2">
<input id="c-date" type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} />
@ -179,12 +177,13 @@ function Compose({ clients, onClose, onSaved }) {
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
</Field>
{f.platforms.includes("facebook") && (
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field>
{sel.platforms.includes("facebook") && (
<Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption.">
<textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} />
</Field>
)}
{f.platforms.includes("instagram") && ["IMAGE", "REELS", "CAROUSEL"].includes(f.type) && (
{sel.platforms.includes("instagram") && ["IMAGE", "REELS", "CAROUSEL"].includes(f.type) && (
<Field label="Collab invites (optional)" hint="Instagram usernames. They must accept the invite for the post to appear on their profile.">
<input id="c-collab" className="input w-full" placeholder="@username, @username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} />
</Field>

64
docs/platforms.md Normal file
View File

@ -0,0 +1,64 @@
# Other platforms: LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit
Facebook and Instagram still come through the agency's Meta system user. Every other platform is connected
**per client** on the **Connections** page: an admin signs in to the client's account once ("Connect"), and
from then on posts to that platform go through Publishing with the same draft → approve → publish flow.
## How it fits together
| Piece | Where |
|---|---|
| Connections page (set up apps, connect/disconnect accounts, choose company Page / location) | `dashboard/web/src/pages/Connections.jsx` |
| "Post to" picker with each platform's fields, used in New post and library Schedule | `dashboard/web/src/PlatformPicker.jsx` |
| API routes, OAuth callback `/oauth/<platform>/callback`, media links `/m/<token>/<file>` | `dashboard/server/index.js` |
| Encrypted store for app keys and tokens (`data/connections.json`, AES-256-GCM) | `connector/src/platforms/vault.js` |
| Sign-in (OAuth 2, PKCE for Google), token refresh | `connector/src/platforms/oauth.js`, `index.js` |
| One adapter per platform | `connector/src/platforms/<platform>.js` |
| Queue integration (validation, publishing) | `connector/src/schedule.js` |
| Agent tools: `list_connections`; `schedule_post` / `schedule_from_library` accept all platforms + `options` | `connector/src/server.js` |
| Offline tests (simulated platform APIs) | `connector/test/platforms.test.mjs` → `node --test connector/test/platforms.test.mjs` |
Tokens and app secrets are encrypted with a key derived from `DASHBOARD_SECRET` (or `QUORLA_SECRET_KEY`).
They are never sent to the browser. If that secret changes, every platform shows "Reconnect needed".
## What each platform needs before it works for real
Every platform requires a developer app owned by the agency, with the Redirect URI shown in its **Set up** dialog
(`https://app.quorlasocial.com/oauth/<platform>/callback`), plus the privacy policy and terms pages on quorlasocial.com.
| Platform | Posts | Developer app | Approval needed for real use | Until approved |
|---|---|---|---|---|
| LinkedIn | text, image, multi-image, video; as a person or company Page | linkedin.com/developers | **Community Management API** (company Pages, analytics) | Personal profiles only: set Scopes to `openid profile w_member_social` |
| Google Business Profile | update with photo and button | Google Cloud (OAuth client) | **Business Profile API access** form + OAuth consent verification | API calls return quota errors; test users only |
| YouTube | video (Shorts when vertical, under 3 min) | Google Cloud (same OAuth client) | **YouTube API audit** + consent verification | Uploads are forced to Private; small daily quota |
| TikTok | video, photo / photo carousel | developers.tiktok.com | **App review** for Content Posting API (Direct Post) | Posts are "Only me"; sandbox users only |
| Pinterest | image, multi-image (2–5), video Pins on a board | developers.pinterest.com | **Standard access** (demo video) | Trial: set `PINTEREST_SANDBOX=true`, Pins go to the sandbox |
| Threads | text, image, video, carousel | Meta for Developers (Threads use case) | **Meta app review** for Threads permissions | Only accounts added as Threads testers |
| Reddit | text or link post to a subreddit | reddit.com/prefs/apps (web app) | Reddit's approval for commercial API use | Works for the agency's own account; check each subreddit's rules |
Platforms that fetch media from a link (TikTok photos, Threads, Google Business Profile) get a signed link to the
file that expires after 6 hours. On the server this uses `PUBLIC_URL`, or `https://APP_DOMAIN` when that's set.
TikTok also needs the domain verified in its developer portal.
## What was tested, and what wasn't
- **Tested offline (15 tests, all passing):** encryption at rest, sign-in links for every platform (client ID
parameter, scopes, PKCE, offline access), the callback state check, token refresh and "reconnect needed",
and each platform's publish requests (LinkedIn image to a company Page with hashtags, YouTube resumable upload,
GBP post with button and photo link, TikTok video chunks and photo pull, Pinterest base64 Pin, Threads long-lived
token and image post, Reddit text post and error handling), plus format and field validation.
- **Tested against the local dashboard (21 checks):** admin-only setup and connect, secrets never returned,
sign-in redirect and callback errors (forged state, cancelled, signed out), media links (served, tampered
refused), drafts to connected and unconnected platforms, per-platform captions, disconnect.
- **Not tested:** real calls to each platform. Each platform needs its developer app and keys first. The request
formats follow each platform's documentation as of this build, but expect small fixes the first time each one
connects and posts. Test with the agency's own accounts before connecting clients.
- **Not built yet:** comments/inbox and analytics for these platforms (only follower counts are shown),
Reddit image posts, Pinterest video cover images, LinkedIn document (PDF) posts, X (paid API).
## Suggested order after merging
1. Deploy (`git pull && docker compose up -d --build`), then Connections → Set up for one platform at a time.
2. Start with **LinkedIn** (personal-profile scopes) and **Google Business Profile**, using Metatroncube's own accounts.
3. Apply for the approvals in the table above. They take days to weeks, which is why they should start early.
4. Connect client accounts once each platform is approved.