feat(web): embeddable player, oEmbed, and per-channel RSS - #77
Merged
Conversation
Three distribution surfaces so a PairUX live can leave pairux.com. All three read existing data through RPCs that are already anon-executable, so there is no migration. - /embed/<joinCode>: a bare, framable player. Plays a finished recording, or shows a poster linking out when the room is live. Middleware relaxes frame-ancestors to * for this path only (the page is read-only, so there is nothing to clickjack) and flags the request so the root layout drops analytics, the feedback widget and the install prompt. - /api/oembed: oEmbed provider, discovered from /l/<joinCode>. Turns a pasted permalink into the player in Slack, Notion, WordPress and Ghost. joinCodeFromUrl allowlists our hosts so a crafted url cannot point the provider elsewhere. - /c/<handle>/rss.xml: the channel's finished recordings as an RSS feed with itunes:* tags and enclosures, making a channel subscribable in podcast apps and feed readers. enclosure length is 0 because the public RPC does not expose size_bytes. /l/<joinCode> gains a VideoObject JSON-LD block, canonical + oEmbed discovery links, a player Twitter card and a copyable embed snippet. It stays the single canonical URL for a live: no rival permalink was added, so ranking signals are not split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
marked this pull request as ready for review
August 17, 2026 01:54
| <script | ||
| type="application/ld+json" | ||
| nonce={nonce} | ||
| dangerouslySetInnerHTML={{ __html: JSON.stringify(videoSchema(session)) }} |
ThreatCrush Security Scan66 finding(s) HIGH/CRITICAL: 17 | MEDIUM: 49
…and 16 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
The CI Lint job runs `pnpm format:check` alongside eslint; the three new files were eslint-clean but not Prettier-formatted, which failed the job. Formatting only — no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three distribution surfaces so a PairUX live can leave pairux.com, taken from the feature set of podcast hosts like Transistor. All three read data through RPCs that are already anon-executable, so there is no migration.
What's new
/embed/<joinCode>— embeddable playerA bare, framable page: plays a finished recording, or shows a poster that links out when the room is live. Titled bar underneath links back to the live and the channel.
Middleware relaxes
frame-ancestorsto*for this path only. That's safe because the page is read-only — a<video>plus outbound links, no authenticated action — so there is nothing for a framing site to clickjack. Every other path keeps the restrictive'self' chrome-extension:list. The same flag tells the root layout to drop analytics, the feedback widget and the PWA install prompt, which have no business rendering inside someone else's page./api/oembed— oEmbed providerDiscovered via
<link rel="alternate" type="application/json+oembed">on/l/<joinCode>. Slack, Notion, WordPress, Ghost and Discord turn a pasted permalink into the embedded player.joinCodeFromUrlallowlists our own hosts, so a craftedurlparam can't aim the provider at a third party — including lookalikes likepairux.com.evil.com. Honoursmaxwidth/maxheight, returns 501 forformat=xmlper spec./c/<handle>/rss.xml— per-channel feedA channel's finished recordings as RSS 2.0 with
itunes:*tags and<enclosure>s, making any channel subscribable in Apple Podcasts, Overcast, Pocket Casts or an ordinary feed reader. Autodiscovered from the channel page, plus a visible RSS button by the Recordings heading./l/<joinCode>enhancementsVideoObjectJSON-LD, canonical + oEmbed discovery links, a player Twitter card, and a copyable embed snippet.A note on scope
The original plan called for a new per-recording permalink at
/c/<handle>/r/<id>. That turned out to be redundant —/l/<joinCode>already is the per-session permalink, with the video, description, likes and comments. A second URL for the same content would have split ranking signals, so this PR enriches the existing page instead of adding a rival to it.Known limitation
<enclosure length="0">— the RSS spec wants a byte count, butlist_channel_recordingsdoesn't exposesize_bytes. Zero is the conventional "unknown" and clients tolerate it. Adding it properly means a migration, which this PR deliberately avoids.Verification
pnpm lint— 0 errors (2 warnings, both pre-existing on untouched files)pnpm typecheck— cleanpnpm test— 711 passed / 73 files, including 21 new tests for the helperspnpm build— succeeds, all three routes present/embedservesframe-ancestors *while/livestill serves'self' chrome-extension:; RSS and/l/both 404 cleanly on unknown slugs.🤖 Generated with Claude Code