Skip to content

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:

  1. POST /v2/post/publish/creator_info/query/mandatory preflight. Returns the creator's allowed privacy_level_options, plus interaction toggles (comment/duet/stitch_disabled) and max_video_post_duration_sec. We must read this and pick a privacy_level from the returned options (can't hardcode).
  2. POST /v2/post/publish/video/init/ — open the upload. Use source=FILE_UPLOAD with chunked upload (avoids the URL-domain verification that PULL_FROM_URL needs). Body carries post_info (title/caption, privacy_level, interaction flags) + source_info (video_size, chunk_size, total_chunk_count). Returns a publish_id + an upload_url.
  3. PUT the file to upload_url in chunks (Content-Range per chunk).
  4. POST /v2/post/publish/status/fetch/ — poll publish_id until PUBLISH_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 returned code for the user to copy, and keryx auth tiktok reads it via the paste path (pkg/oauth runs 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/oauth2 with a custom Endpoint (https://www.tiktok.com/v2/auth/authorize/ + token URL https://open.tiktokapis.com/v2/oauth/token/). TikTok uses client_key (not client_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):

  1. 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.
  2. Privacy SELF_ONLY until audited, intersected with creator_info options (fall back to SELF_ONLY + warn).
  3. Caption: Text (+ hashtags) → post_info.title; drop the link from the caption (not clickable) — keryx social owns TikTok-appropriate copy.

Still open:

  1. 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 against SELF_ONLY. Need the client key + client secret, and register the redirect above. (Domain verification of keryx.phpboyscout.uk may be required — confirm in app settings.)
  2. x/oauth2 fit (technical, mine). TikTok uses non-standard param names (client_key, token endpoint shape); verify oauth2.Config maps cleanly or add a thin exchange shim in the adapter, keeping capture on pkg/oauth.

10. Phased plan

  1. (review this spec; resolve §9) →
  2. keryx auth tiktok (capture via pkg/oauth → store refresh token; prove rotation persists) →
  3. tiktok Publisher (creator_info → init → chunked upload → poll) + dry-run →
  4. live SELF_ONLY post end-to-end → docs → MR.
  5. (parallel, you) TikTok app + audit to lift the SELF_ONLY cap.