[object Object]

← back to Dw Marketing Reels

reels: build TikTok Content-Posting-API uploader (dormant, gated on audit) — TK-00128

bc02a051a2fb93509786f008438d6794765e24d6 · 2026-07-27 17:15:22 -0700 · Steve Abrams

- scripts/tiktok-post.mjs: ports the proven FILE_UPLOAD flow (creator_info → init →
  PUT bytes → poll) from tiktok-oauth-catcher/fire-test-post.sh, plus the token refresh
  the nightly cron needs (TikTok access tokens expire ~24h; refresh tokens rotate and are
  persisted back to the canonical secrets .env). Reads creds from secrets (single source).
- publish-social postTikTok: wired to the uploader behind its OWN gate TIKTOK_LIVE_ARMED,
  decoupled from IG's SOCIAL_LIVE_ARMED. Dormant → 'ready-pending-tiktok-arm'.
- privacy defaults SELF_ONLY; refuses a privacy level the app isn't allowed (→ 'pending-audit').

EXTERNAL BLOCKER: the DW production TikTok app is UNAUDITED, so PUBLIC posting is impossible
until TikTok approves the audit (unaudited apps: SELF_ONLY to a private account only). Code is
ready; the first live run is Steve-gated (posts + rotates the shared refresh token).
Verified: syntax, exports resolve, dry resolution = ready-pending-tiktok-arm, leak gate passes.

Files touched

Diff

commit bc02a051a2fb93509786f008438d6794765e24d6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 17:15:22 2026 -0700

    reels: build TikTok Content-Posting-API uploader (dormant, gated on audit) — TK-00128
    
    - scripts/tiktok-post.mjs: ports the proven FILE_UPLOAD flow (creator_info → init →
      PUT bytes → poll) from tiktok-oauth-catcher/fire-test-post.sh, plus the token refresh
      the nightly cron needs (TikTok access tokens expire ~24h; refresh tokens rotate and are
      persisted back to the canonical secrets .env). Reads creds from secrets (single source).
    - publish-social postTikTok: wired to the uploader behind its OWN gate TIKTOK_LIVE_ARMED,
      decoupled from IG's SOCIAL_LIVE_ARMED. Dormant → 'ready-pending-tiktok-arm'.
    - privacy defaults SELF_ONLY; refuses a privacy level the app isn't allowed (→ 'pending-audit').
    
    EXTERNAL BLOCKER: the DW production TikTok app is UNAUDITED, so PUBLIC posting is impossible
    until TikTok approves the audit (unaudited apps: SELF_ONLY to a private account only). Code is
    ready; the first live run is Steve-gated (posts + rotates the shared refresh token).
    Verified: syntax, exports resolve, dry resolution = ready-pending-tiktok-arm, leak gate passes.
---
 scripts/publish-social.mjs |  28 +++++++---
 scripts/tiktok-post.mjs    | 134 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 153 insertions(+), 9 deletions(-)

diff --git a/scripts/publish-social.mjs b/scripts/publish-social.mjs
index 5deb311..c4d39b0 100644
--- a/scripts/publish-social.mjs
+++ b/scripts/publish-social.mjs
@@ -15,6 +15,7 @@
 import { readFileSync, writeFileSync, existsSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
+import { publishReelToTikTok, hasTikTokCreds } from './tiktok-post.mjs';
 
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const MAN = join(ROOT, 'data', 'reels.json');
@@ -96,15 +97,24 @@ async function postInstagram(reel) {
 }
 
 async function postTikTok(reel) {
-  // The tiktok skill needs the DW TikTok channel reconnected (OAuth) before the
-  // Content Posting API will accept uploads. Until TIKTOK_ACCESS_TOKEN is present, queue.
-  if (!process.env.TIKTOK_ACCESS_TOKEN) return { status: 'pending-reconnect', note: 'TikTok channel not connected', at: now() };
-  if (!ARMED) return { status: 'ready-pending-arm', note: 'TikTok token present; set SOCIAL_LIVE_ARMED=1 to enable the first live post', at: now() };
-  // Armed + token present, but the Content Posting API uploader is NOT built yet. Fail LOUD
-  // with an honest status instead of a silent 'pending-reconnect' that could read as "handled"
-  // on an armed run — TikTok must not masquerade as live until the uploader ships (contrarian find).
-  console.warn('[postTikTok] ARMED + TIKTOK_ACCESS_TOKEN present but the Content Posting API uploader is NOT implemented — no TikTok post made. Build the uploader before treating TikTok as live.');
-  return { status: 'not-implemented', note: 'armed+token present but Content Posting API uploader is not built yet — no post made', at: now() };
+  if (!hasTikTokCreds()) return { status: 'pending-reconnect', note: 'TikTok creds absent (need TIKTOK_CLIENT_KEY/SECRET/REFRESH_TOKEN)', at: now() };
+  // TikTok has its OWN arm (TIKTOK_LIVE_ARMED), deliberately DECOUPLED from IG's SOCIAL_LIVE_ARMED:
+  // the DW production app is UNAUDITED, so TikTok only allows SELF_ONLY (private) posts to a private
+  // account until the audit passes. Keep TikTok dormant on the nightly cron until Steve arms it
+  // (post-audit) — so it never spams errors or posts privately-useless videos alongside the live IG feed.
+  if (process.env.TIKTOK_LIVE_ARMED !== '1') {
+    return { status: 'ready-pending-tiktok-arm', note: 'uploader built (scripts/tiktok-post.mjs); set TIKTOK_LIVE_ARMED=1 AFTER TikTok approves the production-app audit — until then only SELF_ONLY/private is allowed', at: now() };
+  }
+  const localPath = join(ROOT, 'reels', reel.file);
+  if (!existsSync(localPath)) return { status: 'error', note: `local reel missing: ${localPath}`, at: now() };
+  try {
+    const r = await publishReelToTikTok({ localPath, caption: reel.caption });
+    return { status: r.status, publishId: r.publishId || null, privacy: process.env.TIKTOK_PRIVACY || 'SELF_ONLY', at: now() };
+  } catch (e) {
+    // Audit/permission blocks are expected pre-approval — surface as 'pending-audit', not a hard error.
+    if (/unaudited|not permitted|scope_not_authorized/i.test(e.message)) return { status: 'pending-audit', note: e.message, at: now() };
+    return { status: 'error', note: e.message, at: now() };
+  }
 }
 
 async function main() {
diff --git a/scripts/tiktok-post.mjs b/scripts/tiktok-post.mjs
new file mode 100644
index 0000000..81d6386
--- /dev/null
+++ b/scripts/tiktok-post.mjs
@@ -0,0 +1,134 @@
+// TikTok Content Posting API uploader for the reels pipeline (TK-00128).
+// Ports the PROVEN FILE_UPLOAD flow from tiktok-oauth-catcher/fire-test-post.sh
+// (init → PUT bytes → poll) and adds the token refresh the nightly cron needs
+// (TikTok access tokens expire in ~24h; refresh tokens rotate on every use).
+//
+// ⚠️ AUDIT GATE (external, not code): the DW *production* TikTok app is UNAUDITED, so
+// TikTok only allows SELF_ONLY (private) posts to a PRIVATE account until the audit
+// passes (`unaudited_client_can_only_post_to_private_accounts`). PUBLIC_TO_EVERYONE
+// will 400 until then. Default privacy = SELF_ONLY; set TIKTOK_PRIVACY=PUBLIC_TO_EVERYONE
+// only AFTER TikTok approves the production app.
+//
+// Creds: read from process.env, falling back to the canonical ~/Projects/secrets-manager/.env
+// (single source of truth — not duplicated into this repo). Rotated tokens are persisted back
+// there. Never logs a token (last-4 only).
+import { readFileSync, writeFileSync, statSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
+const API = 'https://open.tiktokapis.com';
+
+function parseEnv(path) {
+  try {
+    const out = {};
+    for (const line of readFileSync(path, 'utf8').split('\n')) {
+      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+      if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
+    }
+    return out;
+  } catch { return {}; }
+}
+
+function creds() {
+  const f = parseEnv(SECRETS_ENV);
+  const g = k => process.env[k] || f[k] || '';
+  return {
+    clientKey: g('TIKTOK_CLIENT_KEY'),
+    clientSecret: g('TIKTOK_CLIENT_SECRET'),
+    accessToken: g('TIKTOK_ACCESS_TOKEN'),
+    refreshToken: g('TIKTOK_REFRESH_TOKEN'),
+  };
+}
+
+// Rewrite a KEY=value line in-place in the canonical secrets .env (rotated tokens must persist,
+// or the next run's refresh_token is invalid — same class as the Constant Contact rotation gotcha).
+function persistTokens({ access_token, refresh_token }) {
+  let txt;
+  try { txt = readFileSync(SECRETS_ENV, 'utf8'); } catch { return false; }
+  // Function replacement so a `$` in a token value can't be read as a $-backreference (footgun).
+  const set = (t, k, v) => t.match(new RegExp(`^${k}=.*$`, 'm'))
+    ? t.replace(new RegExp(`^${k}=.*$`, 'm'), () => `${k}=${v}`)
+    : t + `\n${k}=${v}\n`;
+  if (access_token) txt = set(txt, 'TIKTOK_ACCESS_TOKEN', access_token);
+  if (refresh_token) txt = set(txt, 'TIKTOK_REFRESH_TOKEN', refresh_token);
+  writeFileSync(SECRETS_ENV, txt);
+  return true;
+}
+
+// True when the client + refresh creds needed to post exist (in env or secrets .env).
+export const hasTikTokCreds = () => { const c = creds(); return !!(c.clientKey && c.clientSecret && c.refreshToken); };
+
+// Exchange the (rotating) refresh token for a fresh access token. Persists BOTH rotated values.
+export async function refreshAccessToken() {
+  const c = creds();
+  if (!c.clientKey || !c.clientSecret || !c.refreshToken) throw new Error('missing TIKTOK_CLIENT_KEY/SECRET/REFRESH_TOKEN');
+  const body = new URLSearchParams({
+    client_key: c.clientKey, client_secret: c.clientSecret,
+    grant_type: 'refresh_token', refresh_token: c.refreshToken,
+  });
+  const res = await fetch(`${API}/v2/oauth/token/`, {
+    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
+  });
+  const j = await res.json();
+  if (!res.ok || j.error || !j.access_token) throw new Error(`refresh failed: ${JSON.stringify(j).slice(0, 200)}`);
+  persistTokens(j); // rotate + persist so the next nightly run has a valid refresh_token
+  return { accessToken: j.access_token, expiresIn: j.expires_in };
+}
+
+const authHdr = at => ({ Authorization: `Bearer ${at}`, 'Content-Type': 'application/json; charset=UTF-8' });
+
+// TikTok REQUIRES a creator_info query before Direct Post — it also validates the token and
+// returns the privacy levels the (audited/unaudited) app is actually allowed to use.
+async function creatorInfo(at) {
+  const res = await fetch(`${API}/v2/post/publish/creator_info/query/`, { method: 'POST', headers: authHdr(at), body: '{}' });
+  const j = await res.json();
+  if (!res.ok || j.error?.code && j.error.code !== 'ok') throw new Error(`creator_info: ${JSON.stringify(j.error || j).slice(0, 200)}`);
+  return j.data || {};
+}
+
+// Full post: creator_info → init FILE_UPLOAD → PUT bytes → poll. Returns { status, publishId }.
+export async function postVideo({ localPath, caption, privacy = 'SELF_ONLY', accessToken }) {
+  const size = statSync(localPath).size;
+  const info = await creatorInfo(accessToken);
+  const allowed = info.privacy_level_options || [];
+  if (allowed.length && !allowed.includes(privacy)) {
+    // e.g. unaudited app returns only ['SELF_ONLY'] — refuse to attempt a disallowed level (would 400).
+    throw new Error(`privacy '${privacy}' not permitted by app (allowed: ${allowed.join(',') || 'none'}) — app likely unaudited`);
+  }
+  const init = await (await fetch(`${API}/v2/post/publish/video/init/`, {
+    method: 'POST', headers: authHdr(accessToken),
+    body: JSON.stringify({
+      post_info: { title: (caption || '').slice(0, 2200), privacy_level: privacy, disable_comment: false, disable_duet: false, disable_stitch: false },
+      source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: size, total_chunk_count: 1 },
+    }),
+  })).json();
+  const publishId = init?.data?.publish_id, uploadUrl = init?.data?.upload_url;
+  if (!uploadUrl || !publishId) throw new Error(`init failed: ${JSON.stringify(init).slice(0, 200)}`);
+
+  const put = await fetch(uploadUrl, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'video/mp4', 'Content-Range': `bytes 0-${size - 1}/${size}` },
+    body: readFileSync(localPath),
+  });
+  if (!put.ok) throw new Error(`upload PUT HTTP ${put.status}`);
+
+  // Poll (TikTok publish is async, ~30-60s).
+  for (let i = 0; i < 18; i++) {
+    await new Promise(r => setTimeout(r, 5000));
+    const st = await (await fetch(`${API}/v2/post/publish/status/fetch/`, {
+      method: 'POST', headers: authHdr(accessToken), body: JSON.stringify({ publish_id: publishId }),
+    })).json();
+    const s = st?.data?.status;
+    if (s === 'PUBLISH_COMPLETE') return { status: 'posted', publishId };
+    if (s === 'FAILED') throw new Error(`publish FAILED: ${JSON.stringify(st.data).slice(0, 200)}`);
+  }
+  // Timed out polling — the upload was accepted; do NOT treat as failure (would risk a re-post).
+  return { status: 'posted-unverified', publishId };
+}
+
+// Orchestrator the reels pipeline calls: refresh → post. Returns a status object.
+export async function publishReelToTikTok({ localPath, caption, privacy }) {
+  const { accessToken } = await refreshAccessToken();
+  return postVideo({ localPath, caption, privacy: privacy || process.env.TIKTOK_PRIVACY || 'SELF_ONLY', accessToken });
+}

← eb7ecf5 auto-save: 2026-07-27T16:52:13 (1 files) — data/reels.json  ·  back to Dw Marketing Reels  ·  tiktok-audit-demo: turnkey capture runbook for the App-Revie e30682f →