0008 — TikTok publisher + auth¶
Status: IN PROGRESS (decisions resolved; Direct Post adapter + callback page built & unit-tested. auth tiktok + live SELF_ONLY validation pending the TikTok app — §9 Q4.)
Date: 2026-06-19
Depends on: 0001 §4 (posting), §8.3 (platform research); 0002 §4.4/§5 (contracts, esp. R-AUTH-3); pkg/oauth + the YouTube/Instagram adapters already shipped.
1. Goal & scope¶
Add TikTok as the third publish.Publisher + keryx auth tiktok, posting a
rendered reel via the Content Posting API (Direct Post). Mirror the established
seam: additive internal/publish/tiktok package, self-registers, no call-site
changes. TikTok is the hardest platform and the one that most shapes
auth refresh — its refresh token rotates on every use (must be persisted
each refresh), which is exactly the case auth refresh (R-AUTH-3) must handle.
In scope: the Direct Post upload flow, TikTok OAuth (with rotating-refresh-token
storage), the platforms.tiktok config block, docs. Out of scope: auth
refresh itself (this de-risks its design), scheduling, the studio.
2. Publishing approach (Content Posting API — Direct Post)¶
Direct Post publishes straight to the account (vs. "upload to inbox/draft"). Flow:
POST /v2/post/publish/creator_info/query/— mandatory preflight. Returns the creator's allowedprivacy_level_options, plus interaction toggles (comment/duet/stitch_disabled) andmax_video_post_duration_sec. We must read this and pick aprivacy_levelfrom the returned options (can't hardcode).POST /v2/post/publish/video/init/— open the upload. Usesource=FILE_UPLOADwith chunked upload (avoids the URL-domain verification thatPULL_FROM_URLneeds). Body carriespost_info(title/caption,privacy_level, interaction flags) +source_info(video_size,chunk_size,total_chunk_count). Returns apublish_id+ anupload_url.PUTthe file toupload_urlin chunks (Content-Rangeper chunk).POST /v2/post/publish/status/fetch/— pollpublish_iduntilPUBLISH_COMPLETE(or a failure status), like the IG container poll.
Map from publish.PostMeta: Text(+Hashtags) → post_info.title (the caption;
TikTok has no separate description — front-load it, ~2,200 chars). Link is not
clickable in-caption (note for the social-copy layer). PostResult{ID: publish_id,
URL: ...} — TikTok's response may not give a public URL until processed; capture
what's available (publish id is the idempotency key regardless).
Audit gate (like YouTube's private cap)¶
Until the app passes TikTok's mandatory audit, an unaudited app can only post
SELF_ONLY (private). So default privacy_level to a config value that is
SELF_ONLY until audited, intersected with the privacy_level_options
returned by creator_info (step 1 wins — if the API doesn't offer the configured
level, fall back to SELF_ONLY and warn). --dry-run reports the effective level.
3. Auth (TikTok OAuth) — the rotating refresh token¶
Scopes: video.publish + user.info.basic. Tokens:
- access token ≈ 24h, refresh token ≈ 365 days but ROTATES on every
refresh — each refresh returns a new refresh token that must be persisted
immediately, or the old one is dead and you're locked out. This is the headline
difference from IG (refresh-in-place) and YouTube (durable, non-rotating), and
the concrete driver for
R-AUTH-3. - Capture = hosted callback + paste (resolved). TikTok rejects loopback
redirects, so the loopback server doesn't apply. We register
https://keryx.phpboyscout.uk/oauth/tiktok/(a static page on the keryx docs site — self-contained, doesn't touch the blog) as the redirect; it renders the returnedcodefor the user to copy, andkeryx auth tiktokreads it via the paste path (pkg/oauthruns paste-only for a non-loopback redirect — no server). Stopgap: a loopback-free, no-paste flow is a future goal. - Build on
golang.org/x/oauth2with a customEndpoint(https://www.tiktok.com/v2/auth/authorize/+ token URLhttps://open.tiktokapis.com/v2/oauth/token/). TikTok usesclient_key(notclient_id) and PKCE; verify x/oauth2 maps cleanly or add a thin shim.
keryx auth tiktok stores the refresh token via oauth.Store
(env→keychain→config) and writes platforms.tiktok.{open_id, enabled}.
4. Token store & the auth refresh implication¶
Reuse oauth.Store. Critical: any code path that refreshes a TikTok token
must immediately Save the new refresh token returned alongside the access
token. For the adapter's per-post token minting, that means the refresh isn't a
pure read — it writes back. This needs a writable store at post time (config/
keychain locally; the GitLab-variable/secret-manager write-back in CI — 0001 §4.2).
We surface the rotation seam now so auth refresh consumes it uniformly across
platforms.
5. Config & secrets¶
platforms:
tiktok:
enabled: false # `keryx auth tiktok` sets true on success
client_key: "" # TikTok app client key (note: not "client id")
open_id: "" # set by `keryx auth tiktok` (the creator's open_id)
privacy: SELF_ONLY # SELF_ONLY until audited; then PUBLIC_TO_EVERYONE etc.
# refresh_token written here only as the headless/CI fallback (no keychain)
Secrets (never committed): TIKTOK_CLIENT_SECRET (env), TIKTOK_REFRESH_TOKEN
(CI/manual override, env). client_key is non-secret config.
6. Package layout & integration¶
internal/publish/tiktok/
tiktok.go # Publisher: init()→publish.Register, New, Name, Publish (Direct Post flow)
auth.go # RunAuth(tiktok) — x/oauth2 (custom Endpoint, client_key, PKCE) over pkg/oauth
tokenstore.go # oauth.Store (env→keychain→config), refresh-token keyed
*_test.go # faked httpDoer; flow + mapping + token-precedence units
*_integration_test.go # INT_TEST=1 + TT_LIVE_POST=1, real SELF_ONLY post
Wiring (mirrors YouTube): blank-import in pkg/cmd/post/main.go; case "tiktok"
in pkg/cmd/auth/main.go; seed platforms.tiktok in the init config asset.
The upload flow is raw HTTP (no official Go SDK), so — like Instagram — inject an
httpDoer + a sleep so the multi-step flow is unit-tested without the network.
7. Contracts honoured¶
R-POST-1 (dry-run validates auth + video vs limits + effective privacy, posts
nothing), R-POST-2 (refuse unless approved), R-POST-3 (idempotent ledger via
publish_id), R-POST-5..8 (post all), R-AUTH-1 (interactive capture),
R-AUTH-3 (persist the rotated refresh token immediately), R-AUTH-4
(refresh failure alerts — design only here).
8. Testing¶
TDD: fake httpDoer scripts creator_info → init → chunk PUT → status poll, and
the OAuth token exchange/refresh (asserting the rotated refresh token is
persisted). Unit-test caption mapping, privacy intersection
(config ∩ creator_info options → SELF_ONLY fallback), chunking maths, and token
precedence. One env-gated *_integration_test.go does a real SELF_ONLY post.
A godog scenario covers keryx auth tiktok + keryx post tiktok --dry-run.
9. Questions¶
Resolved in review (2026-06-19):
- ✅ Capture = hosted callback + paste. TikTok rejects loopback redirects, so
register
https://keryx.phpboyscout.uk/oauth/tiktok/(a static page on the keryx docs site — keeps it self-contained, off the blog) and use the paste path. Future goal: engineer away the manual paste. - ✅ Privacy
SELF_ONLYuntil audited, intersected withcreator_infooptions (fall back toSELF_ONLY+ warn). - ✅ Caption:
Text(+ hashtags) →post_info.title; drop the link from the caption (not clickable) —keryx socialowns TikTok-appropriate copy.
Still open:
- App registration — long pole, start now. TikTok for Developers app +
Content Posting API +
video.publish/user.info.basic+ the mandatory audit (SELF_ONLY until passed). Weeks of lead time. You kick it off in parallel; we build againstSELF_ONLY. Need the client key + client secret, and register the redirect above. (Domain verification ofkeryx.phpboyscout.ukmay be required — confirm in app settings.) - x/oauth2 fit (technical, mine). TikTok uses non-standard param names
(
client_key, token endpoint shape); verifyoauth2.Configmaps cleanly or add a thin exchange shim in the adapter, keeping capture onpkg/oauth.
10. Phased plan¶
- (review this spec; resolve §9) →
keryx auth tiktok(capture via pkg/oauth → store refresh token; prove rotation persists) →tiktokPublisher (creator_info → init → chunked upload → poll) + dry-run →- live SELF_ONLY post end-to-end → docs → MR.
- (parallel, you) TikTok app + audit to lift the SELF_ONLY cap.