Skip to content

Links in text are clickable, and say what is at the other end - #37

Merged
boubou666 merged 6 commits into
mainfrom
link-previews
Aug 9, 2026
Merged

boubou666 merged 6 commits into
mainfrom
link-previews

Conversation

@boubou666

@boubou666 boubou666 commented Aug 9, 2026 •

Copy link
Copy Markdown
Owner

A URL in any of a board's text is a link, and hovering one for a second says what is at the other end — title, description, thumbnail and the host it really came from, or that it could not be reached.

Links are recognised, not marked up

The store holds a plain string. That is what a paste is forced into and what innerText reads back, so nothing marks a link up and core/links.js is a recogniser that runs on the way out, in the view. The document keeps the characters somebody typed.

An explicit scheme or a www. counts. A bare domain deliberately does not — readme.md, e.g.something and 3.14 would all turn blue, and a link nobody asked for is worse than one they have to type six characters for. Trailing punctuation is prose, and so is a closing bracket the URL did not open, which is the difference between https://en.wikipedia.org/wiki/Ruby_(gem) and a sentence that mentions it.

Clicking opens a new tab. Dragging an object that happens to hold a link is a drag, shift-clicking is a selection, and inside the field being edited a click belongs to the caret — the same rule that lets you put the cursor anywhere in a card you are writing. A URL typed into a card becomes a link when the field is left, because the view will not write to a focused field and nothing else asks for a redraw: leaving one records history without applying an op.

The preview cannot be fetched by the browser

This is the part worth reading before the code. A cross-origin fetch in cors mode rejects whatever the server said; in no-cors mode it resolves with an opaque response whose status is 0 and whose body cannot be read, identically for a 200 and a 404. "Did it answer 2xx, and what is on it" is not a thing one origin may learn about another.

So supabase/functions/link-preview does the fetch — this repo's first Edge Function — and the client asks it through client.functions.invoke, which carries the session that verify_jwt checks. Previews therefore join sharing, live edits and cursors as things a project provides. With none configured, a hovered link says so rather than sitting on "loading" or blaming the page, and there is a test for that state because it is the state the published site is in until this is deployed.

It is written in plain JavaScript. There is no TypeScript anywhere else here, and writing it in JS is what lets its two halves that hold all the judgement — guard.js and extract.js — be imported by the node --test suite that already exists. preview takes its fetch as an argument too, so redirect following, the caps, the thumbnail and the not-a-page branch are driven offline as well; the only unreachable line is Deno.serve.

Nothing that comes back is executed

Five rules, each with a test:

  • The panel is built from nodes, never markup. A page's <title> is somebody else's text and the popover is our document, so it goes in with textContent. A title of <img src=x onerror=…> draws as those characters, and a test asserts nothing landed on window a moment later either.
  • The scheme is allow-listed to http and https — when the anchor is built, and again when it is opened. javascript: in a card would run in this origin with this session. An href tampered with in the DOM is still not opened, which is the only reason the second check exists.
  • The target page is never rendered. No iframe, no headless browser. What crosses the wire is extracted text and one bitmap, inlined by the function as a data URL and checked by isImageSource — the same guard a pasted image goes through, and also what stops the browser making any request of its own to the previewed site.
  • The function evaluates nothing it fetched. No parser, no eval, no new Function, no dynamic import. Regex over the head, and the worst case is a panel with no title.
  • rel="noopener noreferrer" on every anchor.

The function is not an SSRF hole

A function that fetches a URL from a card makes requests from inside the provider's network on someone else's behalf. "It is only our own users" is not a defence — it is the description of the attack. guard.js refuses:

  • every scheme but http/https, and any URL carrying credentials (https://docs.example.com@evil.test/ reads as the first host and is the second);
  • every address that is not on the internet: loopback, the private ranges, CGNAT, link-local — where a cloud instance keeps its credentials — multicast and everything reserved above it, in both families and through IPv4-mapped and NAT64 spellings. The URL parser normalises http://2130706433/ to 127.0.0.1 before the guard ever sees it, which is load-bearing and invisible, so a test pins that too;
  • names only a private network resolves (localhost, .local, .internal, .home.arpa) and any single-label name, since public hostnames have a dot;
  • whatever a name resolves to that the above would have refused — internal.example.com as an A record for 10.0.0.5 is the form this actually takes;
  • and each redirect separately, followed by hand, because redirect: 'follow' would let a public URL hop into the private network with nothing looking at it.

A refusal answers exactly what a silence answers: { ok: false, status: 0 }. Any difference between "not allowed" and "did not reply" makes this a scanner for the network it runs inside. The reason goes to the log and nowhere else.

Capped at six seconds, 256KB of page, 120KB of thumbnail, three redirects.

The popover is in the overlay, not in React

Imperatively, with the cursors and the guides. That is the line this app already draws: React renders chrome that shows state — the toolbar, the format bar — and transient pointer-driven chrome lives in the overlay. Being outside React is also what lets the whole thing be driven by a scheduler run by hand and a stubbed fetcher, which is why the states are cheap to test.

Answers are cached per href, so one link asked about twice is one invocation. A failure of ours — function down, network gone — is not cached and says "no preview" rather than "unreachable", because blaming the page for our own outage is a lie the person cannot act on.

Two things the tests found

Both are the "test that cannot fail" shape, and both are worth knowing about this suite:

  1. A hover that dispatched no pointerover. CDP leaves the pointer where the previous test put it, and moving to a position it already occupies dispatches nothing — so two tests were quietly passing against the app's own popover while the instance under test never woke up. hover() now moves off the link first, and this file stands the app's layer down so a gap like that fails loudly.
  2. A press on a link really opened a tab in Chromium, took the focus with it, and left every input dispatch afterwards five seconds slow. That suite went from 59s to 4s once window.open was stubbed.

Also fixed while writing them: typeof Deno?.resolveDns still evaluates Deno, and an undeclared identifier is a ReferenceError rather than undefined — the resolution guard threw everywhere outside the edge runtime. It would never have fired in production and it lived in a module nothing could import.

Tests

878 passing, 96.4% lines. Every new module at 100%: core/links.js, platform/links.js, platform/link-preview.js, shell/link-preview.js, and core/bar-position.js still.

  • test/node/links.test.js — what counts as a link, where one stops, and what will never be followed.
  • test/node/link-preview.test.js — the boundary where a stranger's page becomes something we draw.
  • test/node/link-preview-function.test.js — the guard and the extraction, including every private range and the parser's IPv4 normalisation.
  • test/node/link-preview-fetch.test.js — the function's fetching half against a fetch of our own, including the two SSRF cases the guard alone cannot show.
  • test/browser/links.test.js — the hover, the four states, the cache, and where the panel sits.
  • test/browser/views.test.js, interact.test.js — anchors in every field, hostile text as text, click/drag/shift/editing, and the tampered href.

Not in this PR

  • A link is plain text in an exported PNG — no colour, no underline. Per-run styling means splitting the wrapping across styled runs inside the fiddliest function in the codebase; a link in a picture is not clickable anyway.
  • DNS rebinding is not closed. The function resolves a name, checks the addresses, then hands the name to fetch, which resolves it again. Closing it needs connecting to a checked address while carrying the original Host, which fetch cannot express. Also, Deno.resolveDns is used where it exists; where it does not, the literal and name checks still apply but the resolution check silently does not — I will report which of those is true on the deployed runtime.
  • Nothing rate-limits previews beyond the one-second hover and the client cache.

Deploying

Once this is green I will run npx supabase functions deploy link-preview against vcmmkdrogucgcuatmire and verify from outside that a signed-in call returns a card and a dead URL reports as unreachable. Until then the live site's popover reports previews unavailable, which is the honest state.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ

Summary by CodeRabbit

  • New Features
    • Automatically detects and formats HTTP(S) and www links in board text.
    • Opens links securely in a new tab while preserving editing and dragging interactions.
    • Shows hover previews with loading, metadata, thumbnails, caching, and unavailable states.
    • Positions previews intelligently and dismisses them during board or viewport interactions.
  • Security
    • Blocks unsafe URL schemes, credentials, private destinations, and untrusted preview content.
  • Documentation
    • Added guidance covering link behavior, previews, security restrictions, architecture, and limitations.

Janacek and others added 2 commits August 9, 2026 02:21
A URL in any of a board's text is a link, and hovering one for a second opens a
panel with the page's title, description, thumbnail and real host — or says it
could not be reached.

Nothing marks a link up: the store holds a plain string, so `core/links.js` is a
recogniser that runs on the way out and the document keeps the characters
somebody typed. An explicit scheme or `www.` counts; a bare domain does not,
because `readme.md` and `3.14` would otherwise turn blue. Trailing punctuation is
prose, and a closing bracket the URL did not open is too — which is the
difference between a Wikipedia link and a sentence about one.

**The preview cannot be fetched by the browser.** A cross-origin `fetch` in
`cors` mode rejects whatever the server said; `no-cors` resolves opaque, status
0, body unreadable, a 404 identical to a 200. So "2xx, and here is its title" is
not something one origin may learn about another, and `supabase/functions/link-
preview` does the fetch — plain JavaScript, so its guard and its extraction are
imported by `node --test` rather than needing a runtime. Previews therefore join
sharing and live edits as things a project provides; without one, a hover says so
rather than blaming the page.

Nothing that comes back is executed, five ways: the panel is built from nodes and
not markup, the scheme is allow-listed at render *and* at open, the target page
is never rendered, the function evaluates nothing it fetched, and anchors carry
`rel="noopener noreferrer"`.

The function is not an open proxy or an SSRF hole: `verify_jwt` is on, and
`guard.js` refuses every scheme but http/https, credentials in a URL, every
address that is not on the internet in both families, names only a private
network resolves, whatever a name resolves *to*, and each redirect separately. A
refusal answers exactly what a silence answers, so it cannot be used to scan the
network it runs inside.

The popover is drawn imperatively in the overlay with the cursors rather than in
React, which is the line this app already draws between chrome that shows state
and chrome that follows a pointer — and is what lets it be driven with a
hand-run clock and a stubbed fetcher.

Two things found by writing the tests: a hover that dispatched no `pointerover`
because CDP leaves the pointer where the last test put it, which had two tests
passing against the *app's* popover instead of the one under test; and a press on
a link really opening a tab in Chromium, which left every later input dispatch
five seconds slow.

856 tests, 96.4% lines. Every new module at 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ
`preview` takes its `fetch` as an argument now, for the reason everything in
`src/` takes its environment as one — redirect following, the byte caps, the
thumbnail and the not-a-page branch were the parts most worth pinning and the
parts a unit test of the guard cannot reach. The default is still the real
`fetch`, and `guard.js` runs on every URL either way: the seam decides who
answers, never what is allowed. `Deno.serve` is now called only where there is a
Deno, so importing the module does not start a server.

Twenty-two tests came with it, and one bug: `typeof Deno?.resolveDns` still
evaluates `Deno`, and an identifier that was never declared is a ReferenceError
rather than undefined — so the resolution guard threw everywhere outside the edge
runtime. It would not have fired in production, and it would have hidden in a
module nothing could import.

The two that matter most are the SSRF pair the guard alone cannot show: a public
URL redirecting to 169.254.169.254 is not followed, and an og:image pointing
there is not fetched — both asserted on what the fake fetch was *asked*, not only
on what came back.

878 tests, 96.4% lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@boubou666, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fb1a173d-8290-48eb-bfef-4c6185429aca

📥 Commits

Reviewing files that changed from the base of the PR and between 0a8ca32 and b765e27.

📒 Files selected for processing (2)
  • README.md
  • test/browser/links.test.js
📝 Walkthrough

Walkthrough

The change adds clickable HTTP(S) and www. links to board text. It adds secure navigation, hover previews, metadata extraction, SSRF protections, caching, overlay positioning, application wiring, styling, and browser and Node.js tests.

Changes

Board links and previews

Layer / File(s) Summary
Link contracts and interaction
src/core/links.js, src/platform/views.js, src/platform/input.js, test/browser/interact.test.js, test/browser/views.test.js, test/node/links.test.js
Board text is split into validated link and prose runs. Links render with secure attributes. Clicks open validated URLs, while dragging, shift-clicking, and editing retain their existing behavior.
Guarded preview service
supabase/functions/link-preview/*, supabase/config.toml, test/node/link-preview-fetch.test.js, test/node/link-preview-function.test.js
The Edge Function validates HTTP(S) destinations, blocks private addresses, follows bounded redirects, limits responses, extracts metadata, and validates thumbnails.
Preview client and overlay integration
src/platform/link-preview.js, src/platform/links.js, src/core/bar-position.js, src/app.js, src/components/BoardCanvas.jsx, src/shell/link-preview.js, src/styles/canvas.css, test/browser/links.test.js, test/node/bar-position.test.js, test/node/link-preview.test.js
The application creates a configurable preview service. Hover previews support loading, ready, unreachable, and unavailable states, caching, positioning, dismissal, cleanup, and safe text rendering.
Link feature documentation
README.md
The README documents link behavior, architecture, preview security controls, limits, and known limitations.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BoardCanvas
  participant createApp
  participant createLinks
  participant linkPreview
  participant link_preview_Edge_Function
  BoardCanvas->>createApp: configure fetchPreview
  createApp->>createLinks: create hover controller
  createLinks->>linkPreview: request preview URL
  linkPreview->>link_preview_Edge_Function: invoke authenticated function
  link_preview_Edge_Function-->>linkPreview: return preview payload
  linkPreview-->>createLinks: normalized preview
  createLinks-->>BoardCanvas: render overlay
Loading

Possibly related PRs

  • boubou666/peerthink#4: Shares the application, canvas, input, view, and styling integration surfaces.
  • boubou666/peerthink#31: Shares the createInput interaction handling extended here for link activation and relinking.
  • boubou666/peerthink#36: Shares application, canvas, positioning, view, and documentation integration surfaces.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: clickable links and previews that describe linked content.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch link-previews

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 7

🧹 Nitpick comments (5)
supabase/functions/link-preview/guard.js (1)

53-67: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

blockedV4 does not validate the fourth octet.

The signature destructures only [a, b, c], so 1.2.3.999 passes the "not an address at all" check. The WHATWG URL parser rejects that host before checkUrl reaches here, so the gap is not reachable through checkUrl today. isBlockedAddress is exported, so the guard is still worth completing.

🛡️ Proposed fix
-function blockedV4([a, b, c]) {
-  if ([a, b, c].some((part) => part > 255)) return true;   // not an address at all
+function blockedV4([a, b, c, d]) {
+  if ([a, b, c, d].some((part) => !Number.isInteger(part) || part > 255)) return true; // not an address at all
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/link-preview/guard.js` around lines 53 - 67, Update
blockedV4 to validate all four IPv4 octets, including the fourth octet, before
applying the blocked-range checks. Preserve the existing range logic and ensure
invalid addresses such as 1.2.3.999 return blocked when reached through the
exported isBlockedAddress path.
src/core/links.js (1)

142-143: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

hasLink is not a cheap early out.

hasLink builds the complete run list before it answers. The comment says the opposite. A short-circuit stops at the first followable match.

♻️ Proposed short-circuit
-/** Whether text holds anything worth rendering as a link. Cheap early out. */
-export const hasLink = (text) => linkRuns(text).some((run) => Boolean(run.href));
+/** Whether text holds anything worth rendering as a link. Cheap early out. */
+export function hasLink(text) {
+  for (const match of String(text ?? '').matchAll(LINK)) {
+    if (hrefFor(match[0])) return true;
+  }
+  return false;
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/links.js` around lines 142 - 143, Update hasLink in the
link-detection flow to stop scanning as soon as the first run with a truthy href
is found, rather than building the complete linkRuns result before checking it.
Preserve the boolean return behavior for both matching and non-matching text,
and revise the misleading “Cheap early out” comment to reflect the
implementation.
test/browser/links.test.js (1)

422-425: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

This assertion does not check what its name claims.

beforeEach calls app.links.destroy() at line 118, and destroy() returns nothing and leaves the object in place. Boolean(app.links) is therefore true for a torn-down layer, and the message "nothing hovers at all" cannot be produced by this test. Assert something the destroyed layer cannot satisfy, or move this test above the beforeEach teardown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/links.test.js` around lines 422 - 425, Update the “the app still
wires a links layer” test to verify usable link-layer behavior rather than
merely checking that app.links is truthy after beforeEach teardown. Assert a
property or behavior that app.links.destroy() cannot leave satisfied, or move
the test before the teardown so it validates the intended wiring state.
supabase/functions/link-preview/index.js (1)

137-151: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider a budget for the whole request, not only per hop.

TIMEOUT_MS applies to each fetchImpl call. One preview can make up to four page requests plus four thumbnail requests. The worst case is about eight times TIMEOUT_MS before the function answers, and the caller holds a hover panel on "loading" for all of it. A single deadline shared by the page fetch and the thumbnail fetch bounds it.

Also applies to: 228-229

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/link-preview/index.js` around lines 137 - 151, Update
fetchGuarded to use a single request-wide deadline shared across all redirect
hops, and pass the remaining budget to each fetchImpl AbortSignal instead of
resetting TIMEOUT_MS per hop. Propagate that same deadline through the page and
thumbnail fetch flows around the additional referenced calls, ensuring the
combined operation returns within one bounded timeout.
supabase/functions/link-preview/extract.js (1)

50-55: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

\b also matches a hyphenated attribute name.

\bname\s*= matches data-name=, and \bcontent\s*= matches data-content=, because - is a non-word character. A tag such as <meta data-name="og:title" content="X"> is then read with the wrong key. The result is a wrong title rather than a security problem, so the priority is low.

The regex is also recompiled for every tag and every key. Precompiled patterns avoid that.

♻️ Proposed change
-const attribute = (tag, name) => {
-  const match = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(tag);
-  if (!match) return null;
-  return match[2] ?? match[3] ?? match[4] ?? null;
-};
+const ATTRIBUTES = new Map(['property', 'name', 'content'].map((name) => [
+  name,
+  new RegExp(`(?:^|[\\s/])${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i'),
+]));
+
+const attribute = (tag, name) => {
+  const match = ATTRIBUTES.get(name)?.exec(tag);
+  if (!match) return null;
+  return match[2] ?? match[3] ?? match[4] ?? null;
+};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/link-preview/extract.js` around lines 50 - 55, Update the
attribute parser around the attribute function so attribute names are matched
only at the start of the tag or after a character that cannot be part of an
attribute name, preventing data-name from matching name or data-content from
matching content. Reuse precompiled patterns for the supported keys instead of
constructing a new RegExp on every tag and lookup, while preserving quoted and
unquoted value handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 249-252: Update the link preview description in the README to
distinguish pages that return non-2xx responses from pages that are unreachable.
Replace the current “could not be reached” wording with language indicating the
preview is unavailable or the page returned a non-2xx response, consistent with
the status-aware behavior in links.js.
- Around line 254-256: Rewrite the sentence beginning “Nothing marks a link up”
to state explicitly that storage retains plain text, while rendering recognizes
URL-like text as links; keep the surrounding explanation about core/links.js
consistent with this distinction.
- Around line 262-267: The README’s CORS explanation incorrectly says cors-mode
fetch always rejects cross-origin responses. Update the paragraph describing the
edge function and cross-origin fetch so cors mode succeeds when the target
provides a matching Access-Control-Allow-Origin header, while noting arbitrary
pages usually do not, and preserve the explanation that no-cors produces an
unreadable opaque response.

In `@src/platform/input.js`:
- Around line 113-133: Prevent native activation of board links by adding click
handling for [data-link] anchors that cancels the browser’s default navigation
while preserving the existing openLink behavior. Update the relevant input event
flow near startDrag and ensure the test verifies no additional native tab or
navigation occurs when a board link is clicked.

In `@src/platform/links.js`:
- Around line 203-218: Update the open function’s fetchPreview result handling
so a null or undefined answer follows the same failure path as a thrown request:
render “unavailable” only when the panel is still active for the requested href,
then return without calling remember or reading answer.ok. Ensure the scheduler
callback invoking open cannot produce an unhandled rejection for missing
answers.

In `@supabase/functions/link-preview/index.js`:
- Around line 158-167: Cancel response.body before returning when absoluteUrl
cannot resolve the redirect target, matching the existing cancellation performed
for followed redirects. Update the next-is-null branch in the redirect handling
flow while preserving the returned status response.

In `@test/browser/links.test.js`:
- Around line 290-297: Replace the fixed sleeps with readiness waits in both
affected sites: in test/browser/links.test.js:290-297, replace the 80ms sleep
before the assertion with page.waitFor checking window.asked.length === 2 and
label it “the second question”; in test/browser/links.test.js:401-419, replace
the 40ms sleep after the y: 700 patch with page.waitFor for the anchor’s updated
position before reading the box, ensuring it cannot use the pre-move layout.

---

Nitpick comments:
In `@src/core/links.js`:
- Around line 142-143: Update hasLink in the link-detection flow to stop
scanning as soon as the first run with a truthy href is found, rather than
building the complete linkRuns result before checking it. Preserve the boolean
return behavior for both matching and non-matching text, and revise the
misleading “Cheap early out” comment to reflect the implementation.

In `@supabase/functions/link-preview/extract.js`:
- Around line 50-55: Update the attribute parser around the attribute function
so attribute names are matched only at the start of the tag or after a character
that cannot be part of an attribute name, preventing data-name from matching
name or data-content from matching content. Reuse precompiled patterns for the
supported keys instead of constructing a new RegExp on every tag and lookup,
while preserving quoted and unquoted value handling.

In `@supabase/functions/link-preview/guard.js`:
- Around line 53-67: Update blockedV4 to validate all four IPv4 octets,
including the fourth octet, before applying the blocked-range checks. Preserve
the existing range logic and ensure invalid addresses such as 1.2.3.999 return
blocked when reached through the exported isBlockedAddress path.

In `@supabase/functions/link-preview/index.js`:
- Around line 137-151: Update fetchGuarded to use a single request-wide deadline
shared across all redirect hops, and pass the remaining budget to each fetchImpl
AbortSignal instead of resetting TIMEOUT_MS per hop. Propagate that same
deadline through the page and thumbnail fetch flows around the additional
referenced calls, ensuring the combined operation returns within one bounded
timeout.

In `@test/browser/links.test.js`:
- Around line 422-425: Update the “the app still wires a links layer” test to
verify usable link-layer behavior rather than merely checking that app.links is
truthy after beforeEach teardown. Assert a property or behavior that
app.links.destroy() cannot leave satisfied, or move the test before the teardown
so it validates the intended wiring state.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b747a987-015f-4b0e-9e88-ce89efad9e93

📥 Commits

Reviewing files that changed from the base of the PR and between 8b39083 and 16462b5.

📒 Files selected for processing (23)
  • README.md
  • src/app.js
  • src/components/BoardCanvas.jsx
  • src/core/bar-position.js
  • src/core/links.js
  • src/platform/input.js
  • src/platform/link-preview.js
  • src/platform/links.js
  • src/platform/views.js
  • src/shell/link-preview.js
  • src/styles/canvas.css
  • supabase/config.toml
  • supabase/functions/link-preview/extract.js
  • supabase/functions/link-preview/guard.js
  • supabase/functions/link-preview/index.js
  • test/browser/interact.test.js
  • test/browser/links.test.js
  • test/browser/views.test.js
  • test/node/bar-position.test.js
  • test/node/link-preview-fetch.test.js
  • test/node/link-preview-function.test.js
  • test/node/link-preview.test.js
  • test/node/links.test.js

Comment thread README.md Resolved
Comment thread README.md Resolved
Comment thread README.md Resolved
Comment thread src/platform/input.js Resolved
Comment thread src/platform/links.js Resolved
Comment thread test/browser/links.test.js Resolved
…ody owned

- **A malformed answer became an unhandled rejection.** `fetchPreview` is
  injected, so reading `.ok` off whatever it resolves is trusting the thing the
  boundary exists to distrust: `null` threw inside a promise nobody awaits,
  leaving the panel on "loading" for ever. A missing answer is now the same case
  as a thrown one — our failure, not the page's — and the timer's call has a
  backstop `catch`.

- **The anchor's own activation is cancelled now.** Cancelling `pointerdown`
  does not cancel the `click`, and `target="_blank"` would mean a second tab
  beside the one we opened. I probed it: `defaultPrevented` was `false`, so
  nothing of ours owned that gesture and the only reason no tab appeared is that
  Chrome does not activate links inside a `contenteditable`. That is the
  browser's habit, not our decision. Still not cancelled while the object is
  being edited, where the click belongs to the caret.

- **The CORS explanation was wrong**, in four places. A `cors` fetch does not
  "reject whatever the server said" — it succeeds when the target sends an
  `Access-Control-Allow-Origin` naming the caller, and rejects because an
  arbitrary page has no reason to. The conclusion is unchanged; the reason
  given for it was not, and it was repeated in three doc comments and the README.

- A redirect body is cancelled before the target is checked rather than after,
  so the one path that returned early no longer leaves the stream open.

- Two fixed sleeps became conditions: the second question being asked, and the
  link actually reaching the bottom of the window before its box is read.

- The README says a non-2xx page reads as unreachable *and that the panel names
  the status*, since a 404 and a silence are different things to act on; and it
  states plainly that storage keeps plain text while the view does the
  recognising.

881 tests, 96.4% lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/platform/links.js`:
- Around line 226-229: Update the answer validation guard in the controller to
also require typeof answer.ok === 'boolean' before caching or rendering the
response. Keep malformed objects such as {} on the existing unavailable path so
they remain retryable client-side failures.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 30f4ef23-ab14-45c2-917e-b27e5bd0722a

📥 Commits

Reviewing files that changed from the base of the PR and between 16462b5 and 3da036f.

📒 Files selected for processing (7)
  • README.md
  • src/platform/input.js
  • src/platform/link-preview.js
  • src/platform/links.js
  • supabase/functions/link-preview/index.js
  • test/browser/interact.test.js
  • test/browser/links.test.js
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/platform/link-preview.js
  • test/browser/links.test.js
  • README.md
  • test/browser/interact.test.js
  • src/platform/input.js
  • supabase/functions/link-preview/index.js

Comment thread src/platform/links.js Resolved
`{}` is an object, so it passed the guard added in 3da036f, got cached, and drew
"unreachable" — which blames the page for a fetcher that answered nonsense.
`typeof answer.ok === 'boolean'` keeps every malformed shape in the one bucket
where it belongs: our failure, retryable, not remembered.

The test is now three shapes rather than one, and each also asserts the answer
was not cached, since a fetcher that answers nonsense once should be asked again
rather than having its nonsense drawn for the rest of the session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/browser/links.test.js`:
- Around line 320-326: Replace the fixed page.sleep(40) in the retry-hover
sequence with an explicit wait until [data-link-popover] is absent, then perform
hover(box). Keep the existing pointer movement, waitOut, and second-question
assertion unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e3018cd-81ab-442b-a880-74245126d242

📥 Commits

Reviewing files that changed from the base of the PR and between 3da036f and 0a8ca32.

📒 Files selected for processing (2)
  • src/platform/links.js
  • test/browser/links.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/platform/links.js

Comment thread test/browser/links.test.js Resolved
Five places moved the pointer off a link and then slept before hovering again.
Under load the next hover could start while the last panel was still up, and the
assertion after it would have been about the wrong one — so there is one `leave`
helper now that waits for `[data-link-popover]` to be absent, which is a real
condition and reads better at every call site.

Two sleeps are left, both before absence assertions that have no positive
condition of their own — and both after a call that has already settled what they
check: `open` renders synchronously before its first `await`, so a wrongly armed
timer would have drawn a panel by the time `flushTimers()` returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ
@boubou666

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The function is live on the Paris project, and `Deno.resolveDns` is present
there — worth checking rather than assuming, because the guard around it means a
runtime without it would lose the resolution check silently.

`http://10.0.0.1.nip.io/` and `http://localtest.me/` are public names that
resolve into private space: both answered `{ ok: false, status: 0 }` and both
logged `refused: resolves-private`, so nothing was fetched. A name that resolves
nowhere logs no refusal and reaches the same answer through the fetch failing,
which is the difference the code intends.

DNS rebinding is still open, and still written down as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGAkrDAKvwwHUFceiFYinQ
@boubou666

Copy link
Copy Markdown
Owner Author

Deployed and verified from outside

npx supabase functions deploy link-preview → live on vcmmkdrogucgcuatmire (Paris), version 1, verify_jwt: true, 9.3 kB bundled.

Checked against the real internet, with a key rather than from the app:

request answer
no Authorization at all 401 — the gate is on
https://example.com { ok: true, status: 200, host: "example.com", title: "Example Domain" }
https://github.com/boubou666/peerthink title, description, and a 61 kB inlined PNG thumbnail
https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API `"Fetch API - Web APIs
https://example.com/definitely-not-a-page-here { ok: false, status: 404 }
http://169.254.169.254/latest/meta-data/ { ok: false, status: 0 } — refused, indistinguishable from silence

The DNS check does fire

This is the open question from the description, and it is now closed. Deno.resolveDns is guarded because a runtime without it would throw — and a runtime without it would also lose the resolution check silently, which is the bad way to be wrong. So I checked rather than assumed, with two public names that resolve into private space:

  • http://10.0.0.1.nip.io/ → { ok: false, status: 0 } in 0.17s
  • http://localtest.me/ → { ok: false, status: 0 } in 0.14s

Both logged refused: resolves-private, so nothing was fetched. Timing alone would not have proved it — a blocked connect() can also fail fast — so the logs are the evidence. And https://this-name-does-not-exist-2f8a1.example logs no refusal and reaches the same answer through the fetch failing, which is exactly the distinction the code intends: no records is an unreachable link, not a blocked one.

Recorded in the README in b765e27. DNS rebinding remains open and remains written down.

One thing to know about the last review round

CodeRabbit reported "Review rate limited" on the 5b8ff0e push, and a rate-limited refusal still shows the check as pass — so that commit went unreviewed rather than approved. I have asked for a re-review; it is a test-only change (the leave helper).

Nothing calls the function until this merges and Pages redeploys, so it is live and idle until then.

@boubou666
boubou666 merged commit 5f24e15 into main Aug 9, 2026
3 checks passed
@boubou666
boubou666 deleted the link-previews branch August 9, 2026 01:25
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.

2 participants