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/v3client —Videos.Insert("snippet,status", &Video{…}).Media(file).Do()— which performs the resumable upload (uploadType=resumableto…/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
#Shortsto the title/description is the conventional reinforcement. - Map from
publish.PostMeta: Title→snippet.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) andsnippet.tagsgets 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.privacyStatuscomes from config:platforms.youtube.privacy(private|unlisted|public), defaultprivateuntil audited. --dry-run(R-POST-1) reports the effective privacy and warns ifpublicis 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(...)(withAccessTypeOffline,ApprovalForce, PKCE viaS256ChallengeOption),Exchange(...), andTokenSourcewith 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-specificig_exchange_tokenlong-lived upgrade as a small custom step after the standard exchange).google.golang.org/api/youtube/v3(viagoogle.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.Clientviaoption.WithHTTPClientfor 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 refreshfor 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):
- ✅ 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. - ✅ Privacy default
privateuntil the app is audited and the implementation is confirmed working. - ✅ Empty-title fallback: first non-empty of
PostMeta.Title→ post H1 →"<slug> #Shorts". - ✅ Adapter ensures
#Shortsin the description idempotently.
Still open:
- Channel selection. Single channel assumed (capture
channel_idat auth); defer multi-channel. (Proposed default — flag if you have multiple channels.) - Approval paperwork — long pole, start now. Google Cloud project + YouTube
Data API v3 + OAuth consent screen +
youtube.uploadscope + 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 againstprivate; 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¶
- ✅ review this spec; resolve §10 (Q1–Q4 done; Q5 deferred; Q6 in progress)
- ✅ OAuth core extraction (§4A) —
pkg/oauth(capture + Store), Instagram migrated, tests green - ✅
keryx auth youtube— loopback/paste capture → refresh token stored; validated live - ✅
youtubePublisher —videos.insert(private),#Shorts, PostMeta mapping; unit-tested - ✅ live private Short end-to-end (
youtube/shorts/<id>, 2026-06-19) → docs → MR - ⏳ (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.