Four things derived this session superseded what the plan said twenty-four hours earlier,
and the docs are corrected where each one died:
**Licensing needs no server.** Polar's customer-portal endpoints authenticate on the key
itself plus the public organization_id; the org access token is only needed to create
products. Channel binding rides in `conditions: {channel_id}`, re-checked on every
validate — so the "claim page" (a hosted OAuth flow that wrote the UC id onto the key)
and the key database it implied are deleted. Device management, key rotation and
subscription auto-revocation are Polar's; four states, one lease, no callback endpoint.
**Signing is deferred until two subscribers.** It never removes the day-one SmartScreen
warning (EV's instant-bypass died in 2024), so buying it before revenue buys only the
publisher line. Renamed to Azure Artifact Signing, $9.99/mo Basic, and it requires a
paid Azure subscription. Year-one fixed cost drops from ~$135 to ~$15.
**The Store account is free.** Microsoft waived the $19 individual fee and removed the
$99 company fee in May 2026, which makes the Store the only zero-cost route to a
Microsoft-signed install — the free answer to SmartScreen, noted beside the paid one.
**Support is a filter, not a promise.** Email, best effort, no SLA, refunds on request
without argument; no training service, no helpdesk. A repeated question becomes a
training video on @llamacasty, which is content and monetization instead of a ticket.
GitHub issues on the releases repo are the deliberate friction.
Also settled: binaries live in a second Gitea repo (`llamacasty-releases`) push-mirrored
to a public GitHub repo — issues and assets public, code private, one publish direction
(a git mirror carries refs, not GitHub Release assets); `@llamacasty` is
standard-verified with intermediate features, so live streaming, >15 min uploads and
custom thumbnails work today and no SIM or ID is needed; and OAuth comes out of Testing
mode by publishing the app (100-user lifetime cap, no 7-day token death).
TASKS.md now opens with the four-day surge slice — artifact shape, output gate,
entitlement seam, real-machine day — because those are what the first public release
waits on. HANDOFF.md rewritten. MyMistakes.md gains the Polar recipe and the
"verify means three different things" entry. MARCOM.md gains the support→content loop
and the surface roles.
26 KiB
YouTube Live API — design constraints (do not violate)
Go-live ordering and binding, the deliberate private-only lock, health parsing, end-stream
pre-flight, Test mode and chat-insert rules. Read before touching go-live, broadcasts, health,
or the chat service. The native alert box built on these events lives in
overlays.md.
✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND.
The bug, in one line:
contentDetails.boundStreamIdonliveBroadcasts.insertdoes not bind the broadcast. Binding requires an explicitPOST liveBroadcasts/bind?id=…&streamId=…&part=id.How it was finally proven, after a week of misdiagnosis — the diagnostic line, once its own JSON-nesting bug was fixed (
FirstItem), read:lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING | streamStatus=active activeChat=none cdn=rtmp/variable@variableEvery field is real, and together they are conclusive:
bound=NONEwithautoStart=true. Both live in the samecontentDetailsobject on the insert, so the request was well-formed and accepted — the API took one field and silently ignored the other. That asymmetry is the proof; a malformed request would have failed outright.lifeCycle=created, notready. Google:created= "incomplete settings, so it is not ready to transition to a live or testing status". An unbound broadcast is precisely incomplete.streamStatus=active— the ingest half was never broken. YouTube had an active stream and a broadcast that was not attached to it.The causal chain, complete: no binding →
lifeCycleStatusstuck atcreated→enableAutoStarthas no bound stream to fire from → never live → close-out 403sinvalidTransitionbecausecreated → completeis not a legal edge (onlyready/liveare).Why it survived a week, and the lesson worth keeping: every observable pointed away from the binding. ffmpeg pushed ~99 MB cleanly, the reusable stream went
active, close-out returned a transition error (which reads like a lifecycle-timing problem), and the insert returned a valid broadcast ID (which reads like success). A reusable stream accepts bytes for a broadcast that is not watching it, so a perfectly healthy RTMP session is exactly what an unbound broadcast looks like. Google says it outright in the migration guide: "a liveBroadcast must be bound to exactly one stream before the live broadcast can actually start. The broadcast is not bound to any streams at the time that it's created." That sentence was in the docs I read during the original design and was not applied. Read the failure modes in the docs, not just the happy path.Shipped:
YouTubeStreamService.BindBroadcastAsync, called fromPrepareAndStartLiveAsyncimmediately afterCreateBroadcastand before the pump starts. Failure is fatal to the session (an unbound broadcast can never go live, so a live-looking UI over it is a lie).boundStreamIdwas removed from the insert body and thestreamIdparameter removed fromCreateBroadcast— a parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. The old testCreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which asserted the disproven behaviour and therefore laundered a guess into a guarantee, is replaced byCreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamIdplusBindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body.Bind failures are not all "sign in again." Google documents six reasons (
insufficientLivePermissions,liveStreamingNotEnabled,liveBroadcastBindingNotAllowed,liveStreamNotFound,liveBroadcastNotFound,internalError, plus rate limiting) and only the first is an auth problem.BindFailureMessagemaps each to its own text;liveBroadcastBindingNotAllowedmatters most, because it means our ordering is wrong and a generic "check the sign-in" would hide a bug in our own code behind a plausible excuse.
These are the hard facts behind every decision. Full list in TASKS.md.
- One-click go-live — never call
transition(live). Insert the broadcast withenableAutoStart=true,enableAutoStop=true,enableMonitorStream=false,selfDeclaredMadeForKids=false,latencyPreference=low. The encoder starting brings YouTube live.enableMonitorStream=falseis what lets us skip the testing stage. - ⛔ CORRECTION (2026-10-04): the push is provably healthy — stop investigating it. The long
test run settles it:
liveBroadcasts.insertat 14:17:04.577, ffmpegOutput #0, flvtortmp://a.rtmp.youtube.com/live2/5f6z-…at 14:17:05.686, and at 14:18:56.473 ffmpeg's own muxing summary readsvideo:101229KiB audio:1419KiB,muxing overhead: 0.202949%— ~99 MB of real video plus audio over 112s, which is exactly what 8000+128 kb/s should produce. No socket error, no 4xx from ingest, no reconnect. The broadcast still never leftready, and close-out earned 403invalidTransitionagain. An earlier 8s run behaved identically, so "YouTube needed longer" is disproven — 112s is longer. The break is between ingest accepted and broadcast told to start, nowhere in the RTMP path. - The binding is the prime suspect, and the app was blind to it.
contentDetails.boundStreamId("the live stream bound to the broadcast") andenableAutoStart("start automatically when you start streaming video on the bound live stream") are both valid on insert, with no documented restriction against a reusable stream. But YouTube's migration guide carries the danger: "a liveBroadcast must be bound to exactly one stream before the live broadcast can actually start. The broadcast is not bound to any streams at the time that it's created." And this failure mode is invisible from the RTMP side, because a reusable stream accepts bytes for an unbound broadcast — ingest succeeding proves nothing. ⇒LogBroadcastDiagnosticsAsyncrides the existing stream-health poll (no second timer) and writes one greppable line:lifeCycleStatus,privacyStatus,recordingStatus,boundStreamIdcompared against the stream id we are actually pushing (BoundMatches→yes/MISMATCH/NO-BINDING/unknown),boundStreamLastUpdateTimeMs,enableAutoStart/Stop,liveChatIdpresence, the stream's ownstatus.streamStatus+activeLiveChatId, and the CDN settings we declared. It is self-limiting —_broadcastSeenLivelatches onlive/liveStarting, so the line shouts only while the broadcast is stuck and goes quiet once healthy. The next run reads this line; it is the whole experiment. Guarded byBroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush, which pinsunknownfor a missing expectation so an absent stream id can never be misreported as a match.- ⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).
liveBroadcasts.listreturns a list wrapper, so the broadcast lives atitems[0]. The original code descended intoitemsfor the stream but read the broadcast's response root, so every broadcast field came back?— andBoundMatchesthen dutifully reported a confidentboundMatches=NO-BINDINGfrom a parse that had never happened. Three lines of fabricated alarm. Two fixes, both load-bearing:FirstItem()descends intoitems[0]for both halves (and returnsdefaultwhen there is genuinely no item), and the line now printsboundMatches=unreadwhen the broadcast could not be read, so an absent parse can never be reported as a verdict. Guarded byBroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly. Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong answer — otherwise one level of JSON nesting manufactures a bug that isn't there. - What the one run that did work proved: the stream half was correct, and it shows
streamStatus=ready→streamStatus=activeabout 30s into the push. So the ingest does register as active server-side, which is a genuine step forward — combined with the 99MB muxed in the earlier run, the stream half of the system is now verified healthy. The broadcast half is still unobserved pending a re-run.
- ⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).
- Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).
The "always Private" enforcement in
YouTubeStreamService.CreateBroadcaststays while the creator runs multi-month real-world testing: non-private test streams would clutter the channel with VODs highlighting where the product breaks.recordFromStart/DVR stay ON — Private VODs are invisible to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a deliberate final-pass item in TASK 36 (gold pass), wired with the dialog selection — never opportunistic. The PRIVATE badge keeps showing when the stream is actually private. - ⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question.
(Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)
privacyStatus = "private"hardcoded inYouTubeStreamService.CreateBroadcastis exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep getting wrong — do not raise it as a question, an observation, a suggestion, or a "heads-up". The creator has now answered this more than once.- What it becomes: the hardcoded lock is replaced by a real, user-selectable visibility option in the Go-Live dialog — at v1, not before. Not at 1.0-candidate, not at GA prep, not opportunistically during any other unit.
- Why it exists: pre-v1 test streams would otherwise publish VODs of a product still breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review while staying invisible to the channel's audience.
- Do not let these look like symptoms of the one they resemble. All are working as designed:
privacy=privatein the broadcast diag line; the PRIVATE badge;recordFromStart/DVR ON; and the absence of any delete call in the codebase (there is no delete feature, by design at this stage — "can we delete the test VODs" is a retired question, answered 2026-10-04:videos.deleteacceptsyoutube.force-sslwhich we already hold, private is a visibility setting not a deletion lock, and anyway a broadcast that never reacheslivenever becomes a VOD at all). UpdateBroadcastis NOT the unlock — and it is also currently DEAD CODE. (Fact corrected 2026-10-04: an earlier version of this note saidCreateBroadcast:112/UpdateBroadcastMetadataAsync, both wrong.) The method that can change a live broadcast's privacy isYouTubeStreamService.UpdateBroadcast(broadcastId, meta), which sendsprivacyStatus = meta.Visibility ?? "private". It has no production caller — the only caller in the repo isBroadcastPullOutTests.cs:120. SoCreateBroadcasthardcoding private is not merely backed up by convention, it is the only thing enforcing it.- ⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing
creator doubt. Three independent hardcodes (
CreateBroadcast→"private";GoLiveViewModel.Visibility => "Private";BeginTestStream→StreamVisibility = "Private") plus one unreachable method. There is no path by which a broadcast this app creates can become non-private today. The creator's actual concern — "do not populate my channel with videos showing the app at its worst" — is fully met. - ⛔ THE LANDMINE, and the reason the above matters:
LiveBroadcastFormViewModelstill exposesVisibilityOptions = {private, unlisted, public}andUpdateBroadcastwould happily honour it. The v1 visibility option is the first thing that will ever wireUpdateBroadcastup — so v1 is exactly when this guarantee ends, by the creator's own choice. When that happens, private stops being guaranteed and becomes a default the user can change. That is intended at v1; it is a disaster before v1. Do not wireUpdateBroadcastinto any production path before v1, and do not "fix" the dead-code smell by calling it.
- ⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator
ruling 2026-10-04). This was absent from the map entirely until that date, which is how the
dry-run framing below came to be the only recorded one. Both are true, and the second is the
load-bearing one:
- Its real job is being the guaranteed-private path.
BeginTestStreamhard-setsStreamVisibility = "Private"(MainViewModel.Streaming.Operations.cs:105), so Test can never publish. That is why it exists for the whole pre-v1 phase — a dry run you cannot leak. - At v1 it is redundant and gets hidden, because once visibility is a real dialog option the user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it is part of the v1 visibility work, not a separate cleanup.
- The pre-existing "dry run" framing is now known to be incomplete (
ShowTestButton's own comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the button must persist, and do not re-derive the button's purpose from it. If a dry run is still wanted at v1 it must be re-justified on its own merits — that question is open, and is for v1, not now. - It is a computed predicate, not a setting —
ShowTestButton => ShowStreamActions && IsConnected(MainViewModel.Streaming.cs:182), feeding bothCanStartTestand the XAML visibility. So the v1 hide is a one-line predicate change, not a settings migration. - Not a v1 blocker: the Test route is the same pipeline as Go Live —
BeginTestStream→StartStreamingSession(alsoRecord: false)→PrepareAndStartLiveAsync— so Test and Go Live create identical broadcasts (boundStreamId,enableAutoStart). Test differs only in never recording (alsoRecord: false) and forcing Private. This is why Test is the correct way to run any diagnostic stream, and why a Test result is evidence about Go Live.
- Its real job is being the guaranteed-private path.
- Full broadcast form (TASK 9 item 7) — RESCOPED 2026-09-01 — the core editable fields ship (since 2026-08-24) as the always-visible Text drawer: title, description, tags, visibility, made-for-kids, live-editable; scheduling ships as TASK 34 (drawer ☑ Scheduled + adoption at Start). The old Advanced tab is permanently out of product (the 10% margin — see TASKS.md → "Out of product"): latency locked
low, DVR/record-from-start locked on, embed/projection/CC/region fixed at sane defaults, invisible — every exposed field is a support ticket.categoryIdis removed fromCreateBroadcast(not aliveBroadcastfield, silently ignored). Monetization enablement (if ever needed) rides the reward-events chain vialiveBroadcasts.update, not a form field. Go-live order (TASK 9, shipped 2026-08-16):BeginGoLive→PrepareAndStartLiveAsync— ensure the reusable stream (GetOrCreateReusableStreamAsync, cache it), create the broadcast bound to it (CreateBroadcast(..., stream.Id)→boundStreamId), THEN start the pump (the URL must exist beforeFramePump.StartAsync, which reads it once). Failure →StreamStatus.Error, never a crash;StopStreamclears_currentBroadcastId. - Variable reusable stream (shipped 2026-08-16) —
GetOrCreateReusableStreamAsynclistsliveStreams?mine=trueand reuses the existingcdn.isReusablestream, inserting once per channel (cdn.resolution=variable,cdn.frameRate=variable,isReusable=true) only on first use; the ingestion URL is cached viaLayoutStoreSettings (SaveReusableStream/LoadReusableStream) and bound to each broadcast at insert (boundStreamId). Any quality tier works without recreating the stream, and auto step-down rides the same property — we drop bitrate/resolution on the fly, zero API calls — but the deciding governor is not built: the old present tense there was a map-lie, corrected 2026-09-01; step-down is TASK 33 (v1 scope). - Quality is greyed out while live — resolution/frameRate/ingestionType are immutable after stream creation; editing title/description/privacy is fine at any time.
- Report-by-exception health (SHIPPED 2026-08-16, TASK 9 item 3) —
YouTubeStreamService.GetStreamHealthAsync(streamId)pollsliveStreams.list?part=status; render nothing ongood/ok/noData, surface a banner only onconfigurationIssues[]withwarning/errorseverity. The decision is the pureServices/StreamHealthReporter.BannerFor(null text = no banner; error beats warning). The VM polls every 30s while live (DispatcherTimer_healthPollTimer, first poll right after go-live) and clears on End viaResetHealth; poll failures log only. Parsing (fix 2026-09-22): the real API nestsstatus.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}— reading it as a string crashed every poll (requires an element of type 'String', seen 2026-09-22 on both test sessions); the parse now reads the nested.status/configurationIssuesand tolerates the old flat-string shape. UI = a full-width banner strip under the top bar,HealthIssueBannertext +HealthIssueBackground(amber#b8860bwarning / dark red#8f1f1ferror), hidden byNotNullToVis. The design's bottom-strip YouTube logo + green/red dot (clickable → dialog) is still queued. - One dialog, three states — not connected / connected-offline (all editable) / live (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile.
- End stream (SHIPPED 2026-09-01; pre-flight hardened 2026-09-22) — stop encoder →
YouTubeStreamService.EndBroadcastAsyncPOSTsliveBroadcasts/transition?broadcastStatus=complete&id=…&part=status(AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"). 2026-09-22: every end logged 403invalidTransition— a blind complete races enableAutoStop/YouTube's auto-complete (the old doc's "already fired" guess was wrong; it's a broadcast state race). The call now pre-flightsliveBroadcasts.list→status.lifeCycleStatusand only transitions fromlive/testing, skipping silently otherwise (enableAutoStopfinishes every skip — never throws, never toasts a finished session). Inconclusive pre-check still posts (old behavior, backstopped). Record-only stops make ZERO API calls (guard:wasLive && _currentBroadcastId != null). - Encoder compliance — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz, mono/stereo only. YouTube flags violations via health status.
- Broadcast ID == Video ID — one ID tracks status, health, and the auto-created VOD
(
recordFromStart+enableDvrdefault true). - Test Stream mode (TASK 41, SHIPPED 2026-09-22) — every broadcast is private-only anyway
(see the visibility lock above), so "Test" is a session variant over the real go-live
pipeline, not a new mode:
BeginTestStream→StartStreamingSession(alsoRecord:false)(the dialog path was extracted into this shared boot — no Go Live dialog for a Test, noBroadcastForm.CaptureGoLive"last live" stamp, no recording).IsTestStreamflips the face: gold top bar + glow (#9c6f1c), a TEST badge, and the End button reads "End Test";StopStreamclears it in every path (including pump-failure rollback). Once the liveChatId resolves,TestSession.BeginTest(broadcastId, liveChatId)opens the TEST drawer (third right-rail pull-out, three-way one-open-at-a-time exclusivity wired inMainViewModel.Ypp.cs; any left-click outside the rail closes whichever drawer is open — TEST included (fixed 2026-09-25, TASK 46; the code-behind outside-click close inMainWindowhad skipped TestSession, so only Stream Settings + YPP dismissed on an outside click). liveChatId resolution (fixed 2026-09-25, TASK 44; ⚠️ CORRECTED 2026-10-04): the id issnippet.liveChatId—contentDetailshas NO liveChatId property — andGetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)reads part=snippet and polls with a bounded retry, andPrepareAndStartLiveAsyncstarts the frame pump FIRST before resolving it. The old claim that "YouTube only populates it once the broadcast is LIVE" is WRONG, and it was inferred from a sample rather than from the API. The 2026-10-04 diagnostic caughtliveChatId=presentwhilelifeCycleStatus=created— not live, not evenready. The officialGetLiveChatId.javasample testsbroadcastStatus == activebefore returning the id, and that read as a statement about when YouTube populates the field; it is only a guard in the sample. Generalisation: a sample's defensive check is not a spec. Also note the old note said the missing id meant "the ready state" — the broadcasts were neverreadyat all, they werecreatedand unbound (see the SOLVED box above), so the terminology was wrong too. The bounded retry is harmless and stays, but the id is available far earlier than assumed — worth revisiting only if chat startup latency is ever reported as slow. Drawer tooling: Mock Chat Input = a REALliveChat/messages.insert(YouTubeStreamService.InsertChatMessageAsync) that round-trips through the real ~2s poll and renders through the live overlay path; simulated Subscriber / New Member / Super Chat =YouTubeChatService.InjectSimulatedMessage, which raises the poller'sMessageReceivedseam, markedChatMessage.IsSimulated. API fact (researched + hard constraint):liveChat/messages.insertonly createstextMessageEvents — SuperChats / memberships / sponsorships cannot be inserted via any API; simulated non-text events are local-only by design and never reach YouTube. Insert body shape (fixed 2026-09-25, TASK 45): the body MUST includesnippet.type="textMessageEvent"alongsideliveChatIdandtextMessageDetails.messageText— omitting type returns400 MISSING_REQUIRED_FIELD(found live on the next Test Stream; the TASK 41 test had asserted body presence but not type, so it stayed false-green). Scopesyoutube+youtube.force-sslalready cover insert. Real stinger/TTS alert widgets are DONE (TASK 43, shipped 2026-09-24) as the native alert box — see the next section; Text source (TASK 3 item 16) and the RewardEvent persistence (item 20) remain open.
Auth & session (YouTube OAuth)
- Publishing status, not phone verification, is what kills sessions. An OAuth project in
Testing with user type External expires every authorization and its refresh token after
7 days — so every user re-signs in weekly. Publish app (→ In production) removes that
expiry. It does not require Google's verification review, but an unverified production app
shows the "unverified app" warning and carries a 100-new-user cap for the lifetime of the
project, which cannot be reset — so publish when strangers are expected, not while testing.
Verification review only removes the warning and the cap. Source:
https://support.google.com/cloud/answer/15549945, and
commercial.md→ Entitlement is unrelated. - A channel must be verified before it can stream. YouTube's feature tiers: standard (nothing) → intermediate (phone verification: >15 min uploads, live streaming on a computer, custom thumbnails) → advanced (channel history or ID/video verification: higher daily limits, clickable description links, chapters, pin comments, monetization application). Live streaming is intermediate, not advanced — a user without it gets a live-streaming-disabled error from the API. Map that error in the go-live path to one sentence: "Verify your channel at youtube.com/verify, then try again — it takes a minute." A raw Google error here reads as "the app is broken". Source: https://support.google.com/youtube/answer/9890437.
Helpers/OAuthCredentials.csholds the real ClientId/ClientSecret. Auth is complete and the session persists via Windows DPAPI (Helpers/TokenStore.cs→%APPDATA%\ytLlive\ytLlive.auth, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token.- Sign-in / Change Account lives inside the Start Stream dialog — a two-state flow, no separate
Connect button. Sign-out is explicit only:
StopStream()does NOT clear the session ("stopping a recording leaves the creator signed in", 2026-08-29). YouTubeAuthServicetakes an optionalHttpClient+sessionChanged+openBrowsercallback (test seams + save hook; services are constructed inMainViewModel).- Change Account must never sign out first (fixed 2026-09-27):
SignOutYouTubeAsync→TokenStore.Clear()deletesytLlive.auth, so cancelling in the Google chooser left the creator logged out of a working account. It is now justawait SignInAsync()— success overwrites the token through thesessionChangedhook; cancel/failure leaves the session untouched. - The OAuth flow releases its loopback port on retry: an abandoned browser attempt holds
localhost:8765for its 5-minute timeout, soAuthenticateAsynccancels and awaits the abandoned attempt before binding, serialised by aSemaphoreSlim(_authGate) — without the gate, two overlapping calls both see a free port and race to bind it. - Test seam:
YouTubeAuthService.SetSession()is public — inject a channel with a futureTokenExpirysoEnsureToken()passes offline, paired withHttpClient(new StubHandler()).