Skip to content

0007 — YouTube (Shorts) publisher + auth

Status: IMPLEMENTED (auth + Shorts publisher landed; live private upload validated 2026-06-19. Public posting still gated on the Google audit — §10 Q6.) Date: 2026-06-18 Depends on: 0001 §4 (posting), §8.2 (platform research); 0002 §4.4/§5 (contracts); the Instagram adapter + keryx auth machinery already shipped.

1. Goal & scope

Add YouTube as the second publish.Publisher, plus keryx auth youtube, so a rendered vertical reel publishes as a YouTube Short — on demand from the CLI and (later) unattended. Mirror the Instagram seam exactly: an additive internal/publish/youtube package that registers itself, with no call-site changes to post/approve/postcmd.

In scope: videos.insert upload, the Google OAuth capture flow, the platforms.youtube config block, token storage (env → keychain → config), and docs. Out of scope (later phases): auth refresh (rotation job — YouTube shapes its design but doesn't build it here), scheduling, the studio.

2. Publishing approach (Data API v3)

There is no Shorts API. A Short is just a regular upload that YouTube auto-classifies: vertical (9:16) and ≤ 3 min ⇒ Short. So:

  • Upload via the official youtube/v3 clientVideos.Insert("snippet,status", &Video{…}).Media(file).Do() — which performs the resumable upload (uploadType=resumable to …/upload/youtube/v3/videos) for us. (We do not hand-roll the raw init→PUT→complete flow, unlike Instagram; see §4.)
  • Request body (init): snippet{title, description, tags, categoryId} + status{privacyStatus, selfDeclaredMadeForKids}.
  • Classification levers: vertical aspect (our reels are 1080×1920 already) and duration ≤ 3 min (our reels are 30–45 s). Appending #Shorts to the title/description is the conventional reinforcement.
  • Map from publish.PostMeta:
  • Titlesnippet.title (≤ 100 chars; required by the API — see §10 Q3 for the fallback when empty).
  • Text (+ Link) → snippet.description (≤ 5000 chars).
  • Hashtags → appended to description (YouTube derives hashtags from description text, not a tags array) and snippet.tags gets the bare keywords.
  • selfDeclaredMadeForKids: false (these are marketing reels).
  • PostResult{ID: video.id, URL: "https://youtube.com/shorts/<id>"}.

Privacy / audit gate

youtube.upload is a restricted scope. Until the app passes OAuth verification + the Audit & Quota Extension (+ CASA security assessment), the API forces uploads to private regardless of what we request (unverified-app cap). So:

  • Default status.privacyStatus comes from config: platforms.youtube.privacy (private | unlisted | public), default private until audited.
  • --dry-run (R-POST-1) reports the effective privacy and warns if public is requested while the app is unverified.

3. Auth (Google OAuth) — and how it differs from Instagram

YouTube needs a user OAuth refresh token (service accounts don't work). The flow is the OAuth 2.0 installed-app / loopback flow — same shape as keryx auth instagram, but Google's endpoints and a few rules differ:

Instagram (Meta) YouTube (Google)
Redirect https only, exact-match, fixed port (we self-sign + auto-pick from a registered range) RFC 8252 loopback: http://127.0.0.1:<port> or http://localhost:<port>, any/ephemeral port allowed
TLS on callback required (self-signed cert + click-through) not needed — plain HTTP loopback
Token long-lived (~60d), refreshable refresh token (no expiry in normal use) + 1 h access token
Gotcha publish app to Live publish OAuth app to "In production" or refresh tokens die after 7 days

This is the payoff of having built the loopback machinery generically. Google is RFC-8252-compliant, so YouTube auth can use plain-HTTP loopback with an ephemeral port and no cert — the cleaner path the Instagram code already supports (it serves plain HTTP when the redirect is http://, and the all- interfaces bind + paste fallback still apply). See §4 for whether we share code or copy.

Flow: build Google authorize URL (access_type=offline, prompt=consent to force a refresh token, scope=https://www.googleapis.com/auth/youtube.upload, PKCE S256) → capture code on the loopback (or paste) → exchange at https://oauth2.googleapis.com/token for {access_token, refresh_token, expires_in} → store the refresh token (the durable secret) + channel_id (from channels.list?mine=true). Token never printed.

4. Design decision — extract a shared OAuth core, built on off-the-shelf libs

Resolved (review): extract — and build the core on golang.org/x/oauth2, not a hand-rolled protocol. Both libraries below are already in our module graph (transitive via the Gemini SDK), so adopting them is ~free:

  • golang.org/x/oauth2 (v0.35.0, already present) — the OAuth2 protocol: oauth2.Config.AuthCodeURL(...) (with AccessTypeOffline, ApprovalForce, PKCE via S256ChallengeOption), Exchange(...), and TokenSource with automatic access-token refresh from the stored refresh token. We do not hand-roll code exchange or refresh anymore — including reworking the shipped Instagram exchange onto it (keeping the one IG-specific ig_exchange_token long-lived upgrade as a small custom step after the standard exchange).
  • google.golang.org/api/youtube/v3 (via google.golang.org/api v0.264.0, already present) — the official client. Videos.Insert(...).Media(reader) performs the resumable upload natively, so §2's raw resumable endpoint is replaced by the client (inject our *http.Client via option.WithHTTPClient for testing). No hand-rolled upload protocol.

What we still build (the genuinely bespoke part, and our value-add): the CLI capture UX, extracted to a shared, provider-neutral package (pkg/oauth): the loopback callback server (all-interfaces bind, https + self-signed for Meta vs plain-http loopback for Google/RFC-8252, auto-port from a registered range, stdin paste fallback, browserless handling) wrapping an oauth2.Config, plus the token store. x/oauth2/oauth2cli-style libs cover the easy Google loopback but not our Meta https-self-signed + headless-paste requirements, so we keep one unified capture layer — now sitting on x/oauth2 rather than our own exchange code. Instagram migrates first (behaviour-preserving, its tests stay green); YouTube + the future auth refresh consume the same core.

Considered and rejected: github.com/int128/oauth2cli (good for the plain Google loopback, but doesn't handle the Meta https-self-signed/headless cases, so it'd fragment the UX). Referenced as prior art for the Google path.

5. Token model & refresh implications (for 0001 §4.2 / later auth refresh)

  • Durable secret = the refresh token. Access tokens are minted on demand from it (1 h). Unlike Instagram (refresh the long-lived token) and TikTok (rotating refresh token), YouTube's refresh token does not rotate in normal use — so auth refresh for YouTube is "mint a fresh access token from the stored refresh token; keep the refresh token used at least every 6 months". This is the third token shape; capturing it now de-risks the eventual refresh design.
  • Store: refresh token via env → keychain → config (mirror tokenstore.go). platforms.youtube.{enabled, channel_id} written on success.

6. Config & secrets

platforms:
  youtube:
    enabled: false        # `keryx auth youtube` sets true on success
    client_id: ""         # Google OAuth client ID (Desktop app)
    channel_id: ""        # set by `keryx auth youtube`
    privacy: private      # private | unlisted | public (forced private until audited)
    category_id: "22"     # People & Blogs (default; configurable)
    # refresh_token written here only as the headless/CI fallback (no keychain)

Secrets (never committed): YOUTUBE_CLIENT_SECRET (OAuth client secret, env), YOUTUBE_REFRESH_TOKEN (CI/manual override, env). Client id is non-secret config.

7. Package layout & integration

internal/publish/youtube/
  youtube.go      # Publisher: init()→publish.Register("youtube"), New, Name, Publish (resumable insert)
  auth.go         # RunAuth(youtube) — provider config over the shared OAuth core (§4A)
  tokenstore.go   # resolveToken/storeToken (env→keychain→config), refresh-token keyed
  *_test.go       # faked HTTP doer; request-shaping + token-precedence unit tests
  *_integration_test.go  # INT_TEST=1, real upload to a private Short

Wiring (mirrors Instagram): blank-import _ ".../internal/publish/youtube" in pkg/cmd/post/main.go; add a case "youtube" in pkg/cmd/auth/main.go. Seed the platforms.youtube block in pkg/cmd/root/assets/init/config.yaml. New command flags (if any) via gtb --ci generate command. Mockery for any new interface.

8. Contracts honoured

R-POST-1 (--dry-run validates auth + vertical/duration/title/privacy, posts nothing), R-POST-2 (refuse unless approved), R-POST-3 (idempotent ledger: record posted+post_url, skip if already posted), R-POST-5..8 (post all independence/aggregation), R-AUTH-1 (interactive capture), R-AUTH-4 (refresh failure alerts — design only here). Per-platform PostMeta already supports the title-vs-description split (0002 §4.4).

9. Testing

TDD: fake httpDoer scripts the resumable init → upload → insert and the OAuth token exchange; unit-test request shaping (vertical/Shorts fields, privacy, PKCE), title/description mapping, and token precedence. ffmpeg/HTTP faked. One env-gated *_integration_test.go does a real private upload + cleanup. A godog scenario covers keryx auth youtube (loopback capture) and keryx post youtube --dry-run.

10. Questions

Resolved in review (2026-06-18):

  1. Extract a shared OAuth core, built on golang.org/x/oauth2 + google.golang.org/api/youtube/v3 (both already transitive deps — §4). Don't roll our own protocol/upload. Instagram migrates first, tests stay green.
  2. Privacy default private until the app is audited and the implementation is confirmed working.
  3. Empty-title fallback: first non-empty of PostMeta.Title → post H1 → "<slug> #Shorts".
  4. Adapter ensures #Shorts in the description idempotently.

Still open:

  1. Channel selection. Single channel assumed (capture channel_id at auth); defer multi-channel. (Proposed default — flag if you have multiple channels.)
  2. Approval paperwork — long pole, start now. Google Cloud project + YouTube Data API v3 + OAuth consent screen + youtube.upload scope + publish to "In production" + (for public) verification/Audit/CASA. Gates public posting; weeks of lead time. You kick this off in parallel while we build against private; we need the Desktop-app OAuth client id + secret to wire auth end-to-end. See the how-to (being written as we go, mirroring the Instagram guide).

11. Phased plan

  1. ✅ review this spec; resolve §10 (Q1–Q4 done; Q5 deferred; Q6 in progress)
  2. ✅ OAuth core extraction (§4A) — pkg/oauth (capture + Store), Instagram migrated, tests green
  3. keryx auth youtube — loopback/paste capture → refresh token stored; validated live
  4. youtube Publisher — videos.insert (private), #Shorts, PostMeta mapping; unit-tested
  5. ✅ live private Short end-to-end (youtube/shorts/<id>, 2026-06-19) → docs → MR
  6. ⏳ (parallel, you) Google app verification + Audit/CASA to lift the forced-private cap for public posting

Deferred / follow-on: auth refresh (mint access tokens from the stored refresh token — YouTube's refresh token doesn't rotate; informed by this work), and posting via the full post pipeline (dry-run + approval gate) once a rendered reel flows through.