Repository navigation
Conversation
A Clips link pasted into a video block's Link tab becomes a Clip block that plays in Content's own player, with the Clip's comments as markers on the scrubber. Content asks Clips for playback and comments over the peer contract, signed with the Content-Clips peer secret, and checks document access first, so signed-out readers of public pages can watch too. Registry blocks now keep their source on the clipboard, so a copied Clip pastes with its data instead of dropping out of the save.
…unreadable Clips bodies
…ts, instead of showing its MDX source
…etry and comment failures visible
|
Here's a visual recap of what changed: Open the full interactive recap |
… and keep Clip reads out of the browser's saved cache
|
The review body's LOW finding (private GET results kept in the browser's persisted query cache) is fixed in b9acde2. |
… through a failed source, and check pasted blocks - The public page's first paint parses the saved page and shows each registry block by its saved title or registry label; a Clip is a link to its recording on Clips, built from the block and deployment config only. - A source swapped out before its metadata loaded no longer leaves its restore behind, and the next source restores the reader's place. - The toolkit reads a pasted block only through its data attributes, checks the type and id format, and keeps a pasted source only when the app reads it back. Content keeps one only when it is exactly one registered block of the declared type, and view-screen quotes only ids and types Content writes.
|
The incremental review's two low findings are fixed in 2b1f055:
Both are covered by new tests in |
There was a problem hiding this comment.
Builder reviewed your changes and found 4 potential issues 🔴
Review Details
Incremental review — PR #8147
This update adds native Clips embeds to Content pages, including playback/comment-marker reads, public-page rendering, clipboard serialization for registry blocks, and bounded comment-thread reads on the Clips peer route. I classified this as high risk because it crosses public-read authorization boundaries and introduces peer API behavior. The overall design is thoughtful: reads are page-authorized before token minting, peer tokens are scoped and short-lived, response fields are schema-validated, comment queries are capped, and private playback/comment data is excluded from browser persistence.
Findings
- 🔴 HIGH: A playback grant is retained when the same Clip block is rendered under a different document, potentially carrying access from the prior page across navigation.
- 🟡 MEDIUM: The Content peer client buffers the full response before schema validation, with no response-byte cap.
- 🟡 MEDIUM: All anonymous readers of a given page/Clip share one read-rate-limit bucket.
- 🟡 MEDIUM: Pasting a registry block without
data-block-sourcebypasses the provided source validator and can create a block that serializes to nothing. - LOW: Unbound recording IDs can allocate rate-limit buckets before the Clips binding check, potentially exhausting the bucket map.
The previously open public-SSR issue is fixed: the initial server render now displays a named registry-block fallback, and that thread has been resolved. Browser testing was skipped because the browser-test-planner agent is unavailable in this environment; the dev server itself is running. No tests were run as part of this review.
…ount only opened Clips against the rate limit, and drop pasted blocks with no source - The Clip block's player is keyed by page and recording, so a block that ends up under another page never keeps the last page's grant. - Content reads each Clips answer through a byte cap sized from the contract. Clips now cuts a listed comment's text to 280 characters and its author's name and email to 320, so the longest list has a known size. - Clips counts a request against its rate limit only after it has opened the recording, so made-up recording ids make no buckets. - With readPastedSource set, a pasted registry block with no source is dropped instead of saving as nothing. Changeset and Content changelog updated.
|
On the review's LOW note (rate-limit buckets were made per recording id before Clips checked the recording was bound to the page or public): fixed in e65f245. A request now counts against a reader's bucket only after Clips has opened the recording for that reader. That means it exists, the reader may see it through the page's binding or because it's public, and it isn't trashed, expired or password-protected. A made-up recording id gets a 404 without making a bucket, so it can't fill the 5,000-bucket map or count against anyone else. A reader whose bucket is already full is still refused with 429 before Clips reads anything, so a real flood stays cheap to refuse. Test in |
Clips now counts requests whose recording doesn't open against a bucket per reader and page, and refuses them before any database read once it is spent, unless the Clip already opened for that reader. A comment list with no limit gets the most threads the contract allows, and the playback answer's title is cut to a length both contract files declare.
|
On the review body's mixed-versions point (no thread), fixed in 5e2d1fc:
|
… open A Clip's misses now share its own key, and a Clip is refused when either its opened bucket or its miss bucket is full. A Clip that opened once and then went away stays bounded, and made-up ids spend only their own misses.
There was a problem hiding this comment.
Builder reviewed your changes and found 1 potential issue 🟡
Review Details
Incremental review — PR #8147
The latest update moves miss accounting to a per-Clip key. I verified that this fixes two prior concerns: a Clip that becomes unavailable is still checked against its miss bucket, and misses for one Clip no longer block another Clip’s first open. I resolved those two threads. The existing thread about the shared anonymous per-Clip read bucket remains unchanged and is still open; I did not repost it.
New finding
- 🟡 MEDIUM: Per-Clip miss keys allow a caller to rotate through distinct invalid recording IDs and receive a fresh miss allowance for each. Each miss still performs several Clips database lookups, while the miss-map capacity evicts old keys rather than imposing an aggregate request limit.
The implementation otherwise applies the new per-Clip miss key consistently, and the tests cover unavailable Clips and cross-Clip isolation. This remains high risk because the path is reachable through anonymous public-page reads. Browser testing was skipped because the browser-test-planner agent is unavailable; the dev server is running. No tests were run during this review.
…ack on focus where the reader left off
|
Round 6 is in 6554fcb: two things A4's QA found. 1. When a grant refresh failed, it stopped a Clip that was still playing, and the player never came back.
2. After the flag was turned off, a reload still played some Clips for a while.
Checks:
|
…ps-content-clip-block
…ps-content-clip-block
There was a problem hiding this comment.
Builder reviewed your changes and found 1 potential issue 🟡
Review Details
Incremental review — PR #8147
The latest update replaces the process-wide 429 on a full opened-Clip bucket map with eviction. I confirmed that this fixes the prior “new Clip opens rejected when the map is full” thread and resolved it. The two other open comments remain applicable: anonymous viewers share the per-Clip bucket, and distinct invalid IDs still have separate miss buckets; I left both open and did not repost them.
New finding
- 🟡 MEDIUM: The new eviction path removes the oldest bucket even when its rate-limit window is still active. A burst of distinct opened-Clip keys can therefore evict an active counter; the evicted key’s next request creates a fresh window, weakening per-reader/Clip limits.
The fix avoids a process-wide outage, but should preserve active counters under capacity pressure or otherwise ensure eviction cannot grant a new allowance. Risk remains high because this affects public-read peer rate limits. Browser testing was skipped because the planner agent is unavailable; the dev server is running. No tests were run in this review.
…ps-content-clip-block
There was a problem hiding this comment.
Builder reviewed your changes and has a few items to flag 🟡
Review Details
Incremental review — PR #8147
The latest head merges the Clips comment-visibility and share-page changes. I reviewed their interaction with the Clips peer rate limiter, public-read authorization, document-scoped media grants, and playback refresh/resume. No distinct new confirmed issue was identified in this update. The three open rate-limit threads remain applicable: anonymous readers share a per-Clip bucket; per-Clip miss keys allow rotation through distinct invalid IDs; and capacity eviction can reset active buckets. I left those threads open and did not repost them.
The prior opened-map-full failure is already addressed by the eviction change, but the live-counter reset risk is covered by its existing open thread. The newer comment visibility path keeps reads and reactions behind their respective access checks, and I found no new public grant or playback regression. Risk remains high because the code handles public reads and peer authorization. Browser testing was skipped because browser-test-planner is unavailable; the dev server is running. No tests were run as part of this review.

A Clips recording pasted into a Content page should play there, for the people who can read the page, with the comments its viewers left on it. Today the video block's Link tab accepts any URL and saves it as a plain
<video>: a Clips share link or a YouTube link becomes a black player stuck at 0:00 that never plays, and it stays in the page that way.This is the Content side of "Clips videos embedded natively in Content docs" (step A3). It is stacked on #8090 (Clips' server side, A2), which stacks on #8088 and #8059, and it will be rebased onto
mainonce those merge. Every new Clip path is behind thecontent.clip-embedflag, which is off by default; with the flag off, Clips links are refused in the Link tab and existing Clip blocks say Clips in pages aren't turned on. Three changes apply with the flag off too: the Link tab now refuses YouTube links and web pages, which never played as a video and only saved a dead player; a copied registry block, such as an Inline database, keeps its data when pasted instead of dropping out of the page on save; and a public page names each registry block while it loads instead of printing the block's source.Approach
A pasted Clips link becomes a Clip block, not a video. The Link tab decides what a link is before anything is saved, without Content's server fetching it:
/r/,/share/or/embed/plus a recording id) becomes<ClipEmbed id="…" recordingId="…" origin="link" />in the page body. A share id is the recording id, so no call to Clips is needed to resolve it..mp4, is loaded by the pasting reader's own browser as a video before it's saved; if it doesn't load as one within 8 seconds, it's refused ("That link isn't a video file."). A file name proves nothing: a link ending in.mp4can still answer with a sign-in page.Content finds Clips from deployment config only. The Clips URL comes from Content's built-in app list (
getBuiltinAgents("content"), with the deployment's URL override and the beta lane mapping), never from a user's or organization's agent directory. That URL is where tokens go and which links count as Clips links, so it must not be something a workspace member can point elsewhere.Every read checks the page first, then asks Clips. The player calls two read actions,
get-clip-playbackandlist-clip-comments. Each one first checks that the caller can read the page that holds the block, and that the flag is on for them; only then does it mint a one-minute token with the page-access secret Content and Clips share (#8090'sCONTENT_CLIPS_PEER_SECRET) and ask Clips. Clips decides whether that page reader may see the recording. Minting lives in one function,mintContentClipToken. The token carries the reader, a scope, the page id, and the recording id, and its audience is Clips'/content-clipsendpoint. Content reads no more of Clips' answer than the longest one the contract allows: 64 KiB for playback (Clips cuts the title to 500 characters), and about 2.9 MiB for a comment list (500 threads with every field at its cut length). A longer answer gets the same "Clips isn't responding" state as one Content can't read.Signed-out readers get public read only. On a public page (
/p/:id), a reader with no session, or with Content's anonymous public-viewer identity, may play a Clip only when the page itself is public. Their token uses thecontent-clip:public-readscope and a fixed subject instead of an email, so Clips plays only a recording that is public, or one that was uploaded into that page. A signed-in reader needs ordinary read access to the page and getscontent-clip:readwith their email and name. A signed-out reader has no organization, so the flag is evaluated under the page's organization.Distinct states, not one generic failure. Clips not set up (
clips_not_configured, 503), Clips not answering, Clips refusing too many requests (429), no access or no such recording (the same message for both, so a page can't probe which recordings exist), an expired or password-protected Clip, the flag off, and a recording that can't play each show their own message. A short media grant is held until 30 seconds before it expires or until the video reports a media error, then refreshed; a second error on a fresh grant shows "can't be played" instead of retrying forever. If Clips doesn't answer a refresh while the video plays, the video keeps playing on its grant and the refresh is asked again after 5, 15 and 60 seconds while the grant lasts. The outage message replaces the player only when the source itself fails: a media error, or the grant running out with no refresh answered. A refusal (the flag off, no access, an expired Clip) still stops at once. Through an outage nothing asks on a timer: the tab becoming visible or the window regaining focus asks once, and Try again stays. When Clips answers, the player picks up at the time and play or pause state the reader left. A refreshed source picks up where the reader was, even when the source before it failed before loading. Try again shows the loading placeholder and then plays only what its own request returned, or shows that request's failure. If the comment list fails, the player still plays and says the comments couldn't load, keeping any markers it already had.The player is Clips' own. Controls, scrubber, speed, volume and full screen are copied from Clips, and comment markers are grouped by second the way Clips groups them. Hovering a marker shows the comment; clicking one seeks there. When Clips returns no comments (comments turned off), there are no markers. Content asks Clips for the first 500 comment threads by video time, and Clips reads only that many and cuts each one's text to the 280 characters a marker shows; when a Clip has more, the player says it is showing the first 500 instead of looking complete. A Clip whose source is a YouTube video plays in YouTube's no-cookie player, and only for a bare
youtube.com/embed/<id>URL from Clips.The block stores only the recording id. Media URLs, posters, titles, durations and comments are fetched per view and never saved in the page. A player belongs to one page and one recording, so a block that ends up under another page starts a new player and no grant crosses pages. Both reads declare
persistInBrowser: falseso that short-lived grants and comment text stay out of the browser's saved query cache. Core's action loaders drop that setting today, in dev and production, so until #7969 lands the browser still saves both answers (see Safety).What changed
templates/content/actions/:get-clips-links,get-clip-playback,list-clip-comments(allagentTool: false, GET, read-only) and_clip-embed-access.ts, which holds the page check and flag check they share.templates/content/server/lib/clips-peer.ts: where Clips lives, the token, and the one request helper that reads Clips' answers through a byte cap per route and maps them to typed results.templates/clips/server/lib/content-clip-peer.ts(Clips' route from Let Content document readers play and comment on Clips uploaded to the document #8090): this PR also bounds Clips' comment route.GET /api/content-clips/:recordingId/commentsnow takes?limit=<n>(1 to 500; a request with nolimitgets 500) and reads only thread starts withLIMIT n+1, instead of running the wholelist-commentsquery for Content to cut down. It answers{ comments, truncated }, keepinglist-comments' access check, order and author names, and cuts each thread start's text toCONTENT_CLIP_COMMENT_PREVIEW_CHARS(280) and its author's name and email toCONTENT_CLIP_COMMENT_AUTHOR_CHARS(320), so the longest list has a known size. The playback answer's title is cut toCONTENT_CLIP_RECORDING_TITLE_CHARS(500). It also changes the route's rate limit. A request counts against a reader's bucket for a Clip only after Clips has opened the recording, so a made-up recording id makes no bucket there. A request whose recording doesn't open counts against a separate miss bucket for that reader, page and recording. When either bucket is full, Clips refuses that Clip before any database read. When the map of buckets is full, Clips drops its oldest bucket rather than refuse a new reader. So a Clip that opened once and then went away stops costing lookups once its misses are spent, and one reader's made-up ids can't refuse anyone's first open of a real Clip. The contract constants and thevideo-sharingskill describe all of this.templates/content/shared/: theClipEmbedregistry block, the Link tab's link rules (video-link.ts), the Clips contract types, and the flag.templates/content/app/components/editor/clip-embed/: the block, player, scrubber and controls;extensions/VideoLinkForm.tsxis the Link tab, andVideoBlock.tsxswaps itself for a Clip block when the form returns a Clips link.templates/content/app/routes/p.$id.tsx: the public page provides a Clip-only block registry so signed-out readers get the player. Before the editor mounts, the page's body comes from parsing the saved page with Content's own parser: each registry block shows its saved title or its registry label instead of its MDX source, and a Clip is a link to its recording on Clips. That HTML is the one cached public shell, so it uses only the saved block and deployment config, never the reader's session or a call to Clips.view-screennames a selected Clip's recording, so the agent knows which Clip the user means. It quotes only the ids and block types Content writes.BubbleToolbar.tsx: the text toolbar no longer appears over a selected registry block (it had nothing to format there).packages/toolkit/src/editor/RegistryBlockNode.tsx: a registry block copied out of the editor carries its MDX source indata-block-source, so a pasted copy keeps its data and still saves instead of dropping out of the page. Clipboard HTML can come from any page, so a pasted block is read only through itsdata-*attributes: a malformed block type drops it, a malformed id is replaced with a fresh one, and the newreadPastedSourceoption keeps a pasted source only when the app reads it back. With that option set, a pasted block that carries no source is dropped, since it would save as nothing. Content's reader (extensions/registryBlocks.ts) accepts a source only when it parses as exactly one registered block of the declared type and id. Toolkit patch changeset included.Safety and operations
content.clip-embed, off by default. Turning it off stops new playback reads; Clip blocks already in pages stay in the body and show the "not turned on" message on their next read.persistInBrowser: false, so the browser saved the Clip's grant and restores it on reload, and the player doesn't ask again until that grant's refresh comes due. The block's comment read is refused at once, and that refusal clears the saved cache, so later reloads show "not turned on" straight away. With Keep all action settings when actions are loaded, in dev and production #7969, a reload asks Content and shows "not turned on" at once.CONTENT_CLIPS_PEER_SECRETset to the same value on Content and Clips (from Let Content document readers play and comment on Clips uploaded to the document #8090). Without it the player says Clips isn't set up; nothing else changes.<ClipEmbed … />tag; nothing else is written.limit, which Clips now answers with the first 500 threads, so that read stays bounded. This PR's Content, though, refuses a comment list withouttruncated, and a Clips from before this PR sends uncut comments and titles that can go over Content's caps. With the flag off, nothing calls either route.<ClipEmbed … />tags in page bodies with nothing to render them.Verification
clip-embedfolder and the Clip actions pass (4 files, 88 tests). They include: a refresh Clips doesn't answer while the video plays, which leaves the video alone and asks again; a later try's grant swapped in place, with the regular schedule after it; the outage shown once the grant runs out unrefreshed, or as soon as the source fails; a refusal still stopping at once; no asks while the tab is hidden and one when it shows; focus asking again until Clips answers; and, in the block, the player picking up at the same time and play state after an outage. The 7 new or changed hook tests failed before the change, the first with "expected 'refresh_failed' to be null", and the resume test failed without the saved place ("expected +0 to be 42").pnpm typecheckin Content is clean.get-clip-playbackandlist-clip-commentsanswer withx-agent-native-browser-persist: allowdespitepersistInBrowser: false. That is the Keep all action settings when actions are loaded, in dev and production #7969 loader bug behind the flag-off delay above.clip-embedfolder,clips-peer, the Clip actions,registryBlocks, the registry round trip and the public page route). They include a Clip block moved to a page whose check fails, which now shows that failure instead of the old page's video; a 429 showing its own message; a comment list withouttruncatedrefused; the longest comment list the contract allows being read; and, for each route, an answer exactly at the byte cap read and one a byte over refused.pnpm typecheckin Content is clean.server/lib/content-clips.test.tspass. They include made-up recording ids answering 404 without pushing a reader's spent bucket out of a shrunk limiter; a full map of opened Clips dropping its oldest bucket instead of refusing a new reader, while a reader at their limit stays refused when the map has room; a made-up id refused with 429 once its own misses are spent, while the reader's other ids and the Clip that opened for them still answer; a Clip that opened and then lost its binding refused with 429 once its misses are spent; signed-out readers' made-up ids not stopping another signed-out reader's first open of a real Clip; the oldest misses forgotten, not refused, when the map is full; a request with nolimitgetting the first 500 threads; and a 5,000-character comment, 400-character names and a 5,000-character title coming back cut to the contract's lengths.pnpm typecheckin Clips is clean.RegistryBlockNode.spec.tspasses (12 tests), including a pasted block with no source dropped when the app reads sources.truncatedand longest-list tests check behavior that was already right, as do the tests that made-up ids don't push a spent bucket out and that a reader at their limit stays refused.pnpm guardsagainstclips-content-bindings: all 93 checks pass, includingguard:i18n-catalogs,guard:i18n-changed-copyandguard:ssr-cache-shell.Review focus
use-clip-playback.ts: does anything still ask on a timer through an outage, or ask while the tab is hidden, and does every refusal still stop at once?actions/_clip-embed-access.ts: is there any path that mints a token or reaches Clips before the page check passes, or that gives a signed-out reader more thanpublic-read?server/lib/clips-peer.ts: are the token's claims exactly what Let Content document readers play and comment on Clips uploaded to the document #8090's Clips side verifies, and does every Clips answer map to the right typed state?shared/video-link.ts: the Link tab's accept/refuse rules, especially links Content's own server never fetches.listCommentThreadsin Clips'content-clip-peer.ts: the bounded read must keep whatlist-commentschecked and returned for a marker.content-clip-peer.ts: a request counts against its Clip's bucket once the recording has opened, and against that reader's miss bucket for the page and recording otherwise. Can any request do database work without counting against one of them, or let one reader's requests refuse another's?clips-peer.ts: do they hold the longest answer the contract allows?RegistryBlockNode.tsxand Content'sreadPastedContentBlock: now that a copy carries its source, what can pasted HTML from another page bring into a block?p.$id.tsx's first paint: it must stay one impersonal shell for every visitor.Known limits and follow-ups
<ClipEmbed … />as is. Separate fix.(recording_id, created_at)index, then sorts that Clip's thread starts by video time before the limit. It returns, sends and parses at most 501.🤖 Generated with Claude Code