Skip to content

fix: the backup key survives recovering the primary, and PLATFORMS.md gets its ninth column back - #600

Merged
rainmanjam merged 4 commits into
mainfrom
fix/remaining-pre070
Aug 27, 2026
Merged

fix: the backup key survives recovering the primary, and PLATFORMS.md gets its ninth column back#600
rainmanjam merged 4 commits into
mainfrom
fix/remaining-pre070

Conversation

@rainmanjam

@rainmanjam rainmanjam commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Closes #581 and the four doc corrections the docs lane could not reach.

Recovering one key destroyed the other — #581

A database restored without its secret.key reads every destination back with an empty StreamKey (the fail-closed rule) and flags it. The sealed bytes are still there — put the right key file back and every destination returns.

keepsSealedKey protected those bytes, but required both halves to be empty. So an operator who gave up on the primary and retyped it took the re-sealing branch for both columns — and sealStreamKey("") returns nil bytes, so backup_stream_key_enc became NULL.

The act of recovering one half destroyed the other, silently, and no later secret.key could bring it back.

The guard's own doc reasons correctly about why the ciphertext is worth keeping — that reasoning was applied to the pair rather than to each half. The read path condemns both together, which is right: neither can be shown to be readable. The write path inherited that coupling, where it's wrong. Now decided per half, four cases spelled out.

PLATFORMS.md had lost a column separator

Its header carried eight cells where the separator and every data row carry nineViewers and Start / end were fused, so the page rendered with every column after them labelled with its neighbour's name.

The drift test stayed green through it, and why is worth recording: its regexp matches data rows, and the cell count is what tells the capability table apart from the others — so a header short one | still parsed and every data row still counted right.

It now compares the header's cell count to the column list, which is the one thing tying what a reader sees at the top of the table to what the test compares underneath it. Verified by removing the separator again: it fails with "the capability table header has 7 capability columns, want 8".

Three more

  • CONTRIBUTING.md was the seventh site of the Go floor, still 1.26.5 against go.mod's 1.27.0
  • config.example.yaml never mentioned transcription — the only way to find the block was to read the struct
  • CHANGELOG.md said nothing about TLS moving to :443, which makes a console unreachable after an upgrade behind a firewall that only opens 8080

Both new guards mutation-verified. gofmt, go vet, full suite green.

https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL

… gets its ninth column back

Closes #581, and the four doc corrections the docs lane could not reach.

RECOVERING ONE KEY DESTROYED THE OTHER. A database restored without its
secret.key reads every destination back with an empty StreamKey -- the
fail-closed rule -- and flags it. The sealed bytes are still there, and putting
the right key file back returns every destination. keepsSealedKey protected
those bytes, but it required BOTH halves to be empty. So an operator who gave up
on the primary and retyped it took the re-sealing branch for both columns, and
sealStreamKey("") returns nil bytes: backup_stream_key_enc became NULL.

The act of recovering one half destroyed the other, silently, and no later
secret.key could bring it back. The guard's own doc reasons correctly about why
the ciphertext is worth keeping; that reasoning was applied to the pair rather
than to each half. The READ path condemns both together, which is right --
neither can be shown to be readable. The write path inherited the coupling,
where it is wrong.

Now decided per half, with the four cases spelled out.

PLATFORMS.MD HAD LOST A COLUMN SEPARATOR. Its header carried eight cells where
the separator and every data row carry nine: "Viewers" and "Start / end" were
fused, so the page rendered with every column after them labelled with its
neighbour's name -- Moderation's ticks under "Viewers", and so on.

The drift test stayed green through it, and the reason is worth recording: its
regexp matches DATA rows, and the cell count is what tells the capability table
apart from the others, so a header short one `|` still parsed and every data row
still counted right. It now compares the header's cell count to the column list,
which is the one thing that ties what a reader sees at the top of the table to
what the test compares underneath it. Verified by removing the separator again.

Also: CONTRIBUTING.md was the seventh site of the Go floor, still saying 1.26.5
against go.mod's 1.27.0. config.example.yaml never mentioned transcription, so
the only way to discover the block was to read the struct. And the CHANGELOG
said nothing about TLS moving to :443, which makes a console unreachable after
an upgrade behind a firewall that only opens 8080.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
Copilot AI lite review requested due to automatic review settings August 26, 2026 21:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Both appear in the destination preset catalogue and PLATFORMS.md records them as
unverified -- "not built, and the platform's API not confirmed either way". This
confirms them either way. It proposes building neither.

VIMEO IS GATED, NOT MISSING. Its OAuth is open to any app -- authorization code,
implicit, client credentials and DEVICE CODE, which is the flow this repo already
implements. Its live API is complete: create, activate, end, ingest status, RTMP
destinations, M3U8 playback. And the reference says, verbatim, "our live API is
available only to Vimeo Enterprise customers". An operator can therefore
authenticate successfully and still be unable to create an event, which for a
self-hosted product's audience is the same wall as LinkedIn Live's partner gate.
Recording it as "unverified" understates what is known; the obstacle is
commercial, not technical.

TROVO IS OPEN AND FULLY DOCUMENTED. Seven scopes, each with a documented
endpoint: stream key by channel_details_self, metadata by channel_update_self,
chat send, moderation via chat commands under manage_messages, and viewer counts
needing no scope and no token at all. Broadcast start/end genuinely does not
exist, which matches Twitch and Kick rather than being a gap.

Two things an implementer would otherwise be bitten by, so they are quoted
rather than summarised: a refresh token holds at most FIFTY access tokens at
once, so a client refreshing on a timer instead of on expiry exhausts it and the
failure arrives as a refused refresh; and the documented rate limit is a real
number, 1200/min, which the 2026-08-16 file's rule permits relying on -- unlike
YouTube's undocumented broadcast cap.

A THIRD FETCH TRAP, added to the two that file already records.
developer.vimeo.com answers HTTP 200 with a body containing only the word
"Vimeo" to a non-browser fetch: the docs render client-side. A fetcher trusting
the status code concludes the page is empty, and one trusting its own memory
fills the gap from training data. Every Vimeo claim here was read from a
rendered page.

Three things are named as NOT established rather than guessed: Trovo's
code-to-token exchange endpoint, whether Trovo supports PKCE, and whether any
Vimeo tier below Enterprise exposes live.

PLATFORMS.md is deliberately not edited. A cell that says "Works" must mean
polyemesis does it today.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
…ring an app

A decision recorded, not a change made. Nothing here is built.

Every self-hoster currently registers four developer applications to connect
four platforms, and the friction is wildly uneven: Twitch is minutes, YouTube is
more clicks, Facebook is App Review measured in days.

HOW OBS ACTUALLY SOLVES IT, read from its source rather than assumed. It runs a
broker: TwitchAuth.cpp points at OAUTH_BASE_URL, which CMakeLists defaults to
https://auth.obsproject.com/, and the client secret is not in the binary at all
because the broker holds it. The client ID is compiled in and obfuscated.

YouTube is the exception and the exception is the useful part: ui-config.h.in
ships a YOUTUBE_SECRET alongside its client id, while Twitch ships an id only.
Google treats an installed app's secret as non-confidential and Twitch does not.
THE PLATFORM'S OWN RULES DECIDE WHETHER A BROKER IS NEEDED, and they differ per
platform -- so any design treating "OAuth" as one thing will be wrong for at
least one of the four.

And a build without credentials loses the feature outright: feature-twitch.cmake
disables the whole integration when TWITCH_CLIENTID is empty. That is the honest
cost of the model, and it lands harder here than on OBS because this project's
users build from source more often.

THE RECOMMENDATION IS A HYBRID DEFAULTING TO BRING-YOUR-OWN, and the argument is
not operational. README sells a single static binary with no runtime
dependencies and COMPARISON sells self-hosting as what distinguishes this from
restream.io. A required call to a server we operate makes every install depend
on our uptime and routes every operator's tokens through us. As an option it is
straightforwardly good; as the only path it contradicts the product.

Two cheaper wins are recorded ahead of it. Kick needs no broker at all -- it is
OAuth 2.1 with PKCE, a public client, so a client id could ship today with no
secret held anywhere. And Facebook cannot be fixed by any of this, because App
Review gates the PERMISSIONS rather than the credentials. So this is worth
pricing as two and a half platforms, not four.

If the broker is built, Cloudflare Workers fits in two routes and four secrets
with no database, and it must be STATELESS: sign the state parameter rather than
storing a verifier in KV. That is not an optimisation. It is what makes "the
broker stores nothing about anyone" a true sentence, which for this product is
worth more than the code it saves. It still sees every token in transit, which
is unavoidable and is written down rather than buried.

Four preconditions are listed, including the one nobody enjoys: the project
would be accepting each platform's developer terms on behalf of every operator
who uses the default path.

Claude-Session: https://claude.ai/code/session_01A8N3W5ct9SZtHK9sCDD9cL
The pages build refuses any file in docs/ that is in neither PUBLISHED nor
NOT_PUBLISHED, so that publishing one is a decision somebody wrote down rather
than the default. Adding the design note without classifying it broke that gate,
which is the gate working.

NOT_PUBLISHED, matching its three siblings: it records what a broker would take
and explicitly builds nothing. Publishing a design note for unshipped work reads
as a roadmap commitment on a page that otherwise documents what exists.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
61.9% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@rainmanjam
rainmanjam merged commit c01f7e0 into main Aug 27, 2026
29 of 32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

release: on a key-unreadable destination, typing the primary key destroys the recoverable backup key

2 participants