Repository navigation
Play a pasted Clips link inside a Content page, with its comment markers - #8147
Conversation
…cess opt-in
Clips' Content routes now verify tokens with CONTENT_CLIPS_PEER_SECRET
alone, a core config field read through resolveContentClipsPeerSecret.
Tokens signed with the deployment A2A_SECRET or a workspace secret are
refused, and an unset or unusable secret answers a typed 503
(content_clips_not_configured) without reading or writing anything.
Media grants are signed with a key derived from the peer secret, through
a new signingKey option on core's short-lived tokens.
Host access resolvers are asked about signed-out requests only when they
register with { includeSignedOut: true }; Clips is the only one that does.
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 |
…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:
|
# Conflicts: # docs/environment-variables.md # packages/core/src/server/index.ts # templates/clips/server/plugins/db.ts
# Conflicts: # templates/content/app/i18n-data.ts # templates/content/parity/matrix.md # templates/content/shared/feature-flags.ts
# Conflicts: # templates/clips/server/plugins/db.ts
…n't all pass it before the first opens the Clip
…ntent-clip-block # Conflicts: # templates/clips/.agents/skills/video-sharing/SKILL.md # templates/clips/server/lib/content-clip-peer.ts # templates/clips/server/lib/content-clips.test.ts # templates/clips/shared/content-clip-contract.ts
|
Recap of the findings in the latest review bodies, at c8bc0d8:
|
There was a problem hiding this comment.
Builder reviewed your changes — looks good ✅
Review Details
Incremental review — PR #8147
The latest head (c8bc0d8f) adds pending-request accounting around Clips peer reads, adjusts comment-author email matching, and merges main. The prior finding about concurrent requests reaching database work before the rate limit was charged is fixed: withOpenedClip rechecks the budget, reserves a pending slot without an intervening await, and settles it on both opened and miss paths. I resolved that review thread before this submission.
Two independent reviewers found no new actionable issues in the incremental changes, including the action output contracts and mixed-case comment-author path. Targeted Clips and Content tests reported by one reviewer passed; a separate database test failed during collection and was not part of this focused review. Risk classification remains High because of the cross-service authorization and rate-limit paths.
🧪 Browser testing: Skipped — setup failed during the toolkit build with TypeScript errors, and the browser-test-planner agent is unavailable.

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 builds on #8090 (Clips' server side, A2), now merged, and targets
main. 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, each declaring its result in anoutputSchema) 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. A signed-out reader's list (the public-read scope) carries no commenter's email:authorEmailis null, and an author name made from the address, such as the address itself or its local part, is dropped. 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. A request that has passed that check counts against both buckets until it is counted against one, so a burst of concurrent requests can't all pass the check before the first one opens its Clip. 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/clips/actions/delete-comment.ts: an author can delete their own comment through a document whatever case their email was stored in. Content sends the reader's email lowercased, anddelete-commentcompared it case-sensitively, unlikeupdate-commentandresolve-comment; it now uses theirsameOwnerEmail.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.delete-commenttest (an author whose stored email has capitals deleting their comment through a lowercased request) fails before the fix.server/lib/content-clips.test.tsandactions/delete-comment.test.tspass (99 tests). The new burst test holds the Clip lookup open while six concurrent requests for a made-up id arrive with a miss limit of 2, and expects two lookups, two 404s and four 429s. On the code before this change all six reach the lookup.tscpasses, and the Clip action andclip-embedtests pass (4 files, 88 tests) with the new output schemas.tscpasses, andguard:strict-lint,guard:no-new-escape-hatches,guard:action-output-schema,guard:no-oracle-loosening,guard:i18n-catalogsandguard:i18n-changed-copypass.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