← back to Dw Marketing Reels
reels: add TIKTOK_DRY_RUN wiring-verify mode for postTikTok (no publish) — TK-00128
9829533a41fdddf9752801a5285e3e71478a0bd0 · 2026-07-27 20:23:23 -0700 · Steve Abrams
dryRunTikTok() exercises the real pre-publish path: token refresh (auth
plumbing, rotates+persists), creator_info (validates token + reads the
privacy levels the app is actually allowed to use), and init-body assembly
— then STOPS before video/init. Nothing is uploaded, no post is created.
Wired into postTikTok as an un-armed escape hatch (TIKTOK_DRY_RUN=1), so the
wiring can be verified without TIKTOK_LIVE_ARMED and without the pending
TikTok app audit. Live publish path unchanged and still gated.
Offline test (fetch stubbed, throwaway HOME): SELF_ONLY -> wouldPost true;
PUBLIC_TO_EVERYONE on unaudited app -> wouldPost false; only token +
creator_info called, no init/upload/status. $0 (no live API call).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M scripts/publish-social.mjsM scripts/tiktok-post.mjs
Diff
commit 9829533a41fdddf9752801a5285e3e71478a0bd0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 27 20:23:23 2026 -0700
reels: add TIKTOK_DRY_RUN wiring-verify mode for postTikTok (no publish) — TK-00128
dryRunTikTok() exercises the real pre-publish path: token refresh (auth
plumbing, rotates+persists), creator_info (validates token + reads the
privacy levels the app is actually allowed to use), and init-body assembly
— then STOPS before video/init. Nothing is uploaded, no post is created.
Wired into postTikTok as an un-armed escape hatch (TIKTOK_DRY_RUN=1), so the
wiring can be verified without TIKTOK_LIVE_ARMED and without the pending
TikTok app audit. Live publish path unchanged and still gated.
Offline test (fetch stubbed, throwaway HOME): SELF_ONLY -> wouldPost true;
PUBLIC_TO_EVERYONE on unaudited app -> wouldPost false; only token +
creator_info called, no init/upload/status. $0 (no live API call).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/publish-social.mjs | 9 +++++++--
scripts/tiktok-post.mjs | 36 +++++++++++++++++++++++++++++++++++-
2 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/scripts/publish-social.mjs b/scripts/publish-social.mjs
index c4d39b0..bef2bc4 100644
--- a/scripts/publish-social.mjs
+++ b/scripts/publish-social.mjs
@@ -98,17 +98,22 @@ async function postInstagram(reel) {
async function postTikTok(reel) {
if (!hasTikTokCreds()) return { status: 'pending-reconnect', note: 'TikTok creds absent (need TIKTOK_CLIENT_KEY/SECRET/REFRESH_TOKEN)', at: now() };
+ const DRY = process.env.TIKTOK_DRY_RUN === '1';
// 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() };
+ // TIKTOK_DRY_RUN=1 is the ESCAPE HATCH: it exercises the real wiring (token refresh + creator_info +
+ // init-body assembly) but publishes NOTHING, so it's safe to run un-armed and un-audited.
+ if (!DRY && process.env.TIKTOK_LIVE_ARMED !== '1') {
+ return { status: 'ready-pending-tiktok-arm', note: 'uploader built (scripts/tiktok-post.mjs); set TIKTOK_DRY_RUN=1 to verify the wiring without posting, or 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 });
+ // Dry-run returns a rich wiring-verification object; pass its fields through unchanged (never a live post).
+ if (r.status === 'dry-run') return { ...r, at: now() };
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.
diff --git a/scripts/tiktok-post.mjs b/scripts/tiktok-post.mjs
index 81d6386..0c9b755 100644
--- a/scripts/tiktok-post.mjs
+++ b/scripts/tiktok-post.mjs
@@ -127,8 +127,42 @@ export async function postVideo({ localPath, caption, privacy = 'SELF_ONLY', acc
return { status: 'posted-unverified', publishId };
}
-// Orchestrator the reels pipeline calls: refresh → post. Returns a status object.
+// DRY-RUN: exercise the full pre-publish wiring — token refresh (auth plumbing) → creator_info
+// (validates the access token AND returns the privacy levels the app is actually allowed to use)
+// → assemble the exact init request body — but STOP before the video/init publish call. Nothing
+// is uploaded and no post is created. This is the reversible, un-gated way to prove postTikTok is
+// wired correctly (creds resolve, token refreshes, the API accepts our auth) without a live post.
+// Returns { status:'dry-run', privacyRequested, privacyAllowed, wouldPost, initBodyPreview }.
+export async function dryRunTikTok({ localPath, caption, privacy }) {
+ const wantPrivacy = privacy || process.env.TIKTOK_PRIVACY || 'SELF_ONLY';
+ const size = statSync(localPath).size; // fails loud if the local reel is missing — same check the real path makes
+ const { accessToken } = await refreshAccessToken(); // real refresh; rotates + persists tokens (auth plumbing verified)
+ const info = await creatorInfo(accessToken); // real token validation; no post side-effect
+ const allowed = info.privacy_level_options || [];
+ const permitted = !allowed.length || allowed.includes(wantPrivacy);
+ // The init body that WOULD be sent — proves request assembly without firing it.
+ const initBodyPreview = {
+ post_info: { title: (caption || '').slice(0, 2200), privacy_level: wantPrivacy, disable_comment: false, disable_duet: false, disable_stitch: false },
+ source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: size, total_chunk_count: 1 },
+ };
+ return {
+ status: 'dry-run',
+ wouldPost: permitted,
+ privacyRequested: wantPrivacy,
+ privacyAllowed: allowed, // e.g. ['SELF_ONLY'] on an unaudited app — proves the audit gate is real, not guessed
+ creatorNickname: info.creator_nickname || null,
+ videoBytes: size,
+ initBodyPreview,
+ note: permitted
+ ? `wiring OK — token refreshed, creator_info accepted, init body assembled; NOT posted (dry-run)`
+ : `wiring OK but privacy '${wantPrivacy}' is NOT permitted (allowed: ${allowed.join(',') || 'none'}) — app likely unaudited; NOT posted (dry-run)`,
+ };
+}
+
+// Orchestrator the reels pipeline calls: refresh → post (or dry-run). Returns a status object.
+// TIKTOK_DRY_RUN=1 short-circuits to the no-publish wiring check above (safe, reversible, un-gated).
export async function publishReelToTikTok({ localPath, caption, privacy }) {
+ if (process.env.TIKTOK_DRY_RUN === '1') return dryRunTikTok({ localPath, caption, privacy });
const { accessToken } = await refreshAccessToken();
return postVideo({ localPath, caption, privacy: privacy || process.env.TIKTOK_PRIVACY || 'SELF_ONLY', accessToken });
}
← e30682f tiktok-audit-demo: turnkey capture runbook for the App-Revie
·
back to Dw Marketing Reels
·
auto-save: 2026-07-28T07:28:16 (3 files) — data/new-arrivals ccd338e →