Link Previews

Paste a mesh link into Teams, Slack or LinkedIn and one of two things happens: a card — title, description, picture — or a bare URL. This page explains what decides which, and how to get the card. It also disentangles the two features that both answer to the name "OG card" and point in opposite directions.

Two features, two directions

Direction What it does Where it lives
SEO head + share card outbound — our pages unfurling elsewhere serves og:* meta tags and a share image to crawlers Memex.Portal.Shared/Seo + SeoHead in the portal GUI shell
OgCard package inbound — other pages rendering inside ours a layout area drawing link-preview cards for external URLs or mesh nodes, embedded from markdown the OgCard store package (MeshWeaver.OgCard module)

When someone says "the link doesn't show a card in Teams", that is the outbound feature — installing or configuring the OgCard package changes nothing about it, because the inbound area renders cards for other pages, it does not describe ours to anyone.

The outbound pipeline — what a crawler sees

A chat app's crawler fetches the pasted URL unauthenticated and reads the initial HTML. The portal's first response therefore carries a crawler-facing head, rendered server-side before any Blazor circuit exists:

  1. SeoHead (in the portal shell's App.razor) resolves the request path to its node and emits <title>, meta description, canonical URL, the node's own icon, the full Open Graph set (og:site_name/type/title/description/url/image), twitter:card, and — for store plugins — Course/Product JSON-LD.

  2. og:title and og:description default to the NODE — its name, and its description (Description, else the content fallbacks listed below) — so a card needs no configuration. A node that wants its SHARE to read differently authors ogTitle / ogDescription in its content (SeoResolver.ShareTitle / ShareDescription); they win on the public card, the PublicPreview card and an ancestor card alike, while the page <title> and the search meta description keep the node's own text.

  3. og:image is the node's authored image when it declares one (PluginContent.OgImage, else poster/thumbnail, else a social post's mediaUrl), and otherwise /api/og/{path}.png — a 1200×630 card the portal draws itself (OgCardRenderer, SkiaSharp with an embedded font). Having a share image is the default, not something each page remembers to author.

    The card draws everything the node can say about itself (SeoEndpoints.CardContent): the node's name as the title; its description — Description, else the content's abstract/description, else the catalog copy tagline/summary/headline; its category (else the type's leaf) as the eyebrow; its own mark, the same backplated <svg> the favicon route rasterizes, drawn large on the right; a price chip when price is positive; and the instance name plus the path in the footer. A node with no mark gets a default badge — a rounded tile in the card's accent carrying the page's initial — so no card is ever text on a dark rectangle (2026-09-18: the Store shared into iMessage as a bare title beside the site favicon). The head declares og:image:type/width/height for every card the portal serves (an image on its own content route has a size unknown to the head) and mirrors it as twitter:image.

    An authored image on ANOTHER host is re-served, never declared raw. A social post's mediaUrl, or an ogImage that is an absolute https://… (or scheme-relative //host/…) URL, is declared as /api/og/{path}.jpg (SeoResolver.AuthoredCard): the route fetches the picture through the SSRF-guarded Http-pool fetcher (OpenGraphPreviewService.FetchImage), and OgCardRenderer.NormaliseAuthored redraws it as exactly what the head declares — a baseline JPEG of exactly 1200×630, at most MaxShareImageBytes (280 KB), the whole picture fitted inside the card with a blurred copy of itself filling the margin (nothing cropped), EXIF orientation applied, the source decoded at the smallest scale that covers the card and refused above MaxDecodedPixels (a tiny PNG can declare enormous dimensions). Nothing is cached in the process — a per-URL cache would be unbounded across nodes and edits, and stale when an author replaces the bytes behind one URL — so the response carries the card's own cache directive and a strong ETag, and the unfurlers and any CDN hold it. When the picture cannot be had — the host is down, answers a challenge page, exceeds the pixel or byte budget, or the node no longer authors one — the node's drawn card is served as JPEG, so the declared type and size stay true and the unfurl still carries a picture; the failure is logged.

    Why (2026-10-07): a post whose mediaUrl was a progressive 1264×848 JPEG on a third-party Supabase host (served with x-robots-tag: none) unfurled in WhatsApp as an EMPTY large card — title, description and domain shown, the picture box reserved and blank. The head declared that URL with no type and no size, while twitter:card=summary_large_image had already committed the unfurler to the large layout. Which of those properties WhatsApp objected to could not be isolated without WhatsApp itself (undeclared type/size, the foreign host's headers or bot protection, the progressive encoding, all plausible); re-serving removes every one of them at once, and the drawn card — a declared 1200×630 image on our own origin — is the shape that was never affected. An image on the portal's own /api/content/… route is still declared by URL alone.

    Every page has a card. The home page and a route that is no node share as the INSTANCE — og:title is the site name and og:image is /api/og.png, the site card (name + host, nothing read from the mesh). A node the anonymous gate withholds shares as its nearest PUBLIC ANCESTOR when it has one (below), and as the instance when it does not. A private page's own name, description and mark never reach either block.

  4. SeoNoScriptBody serves the page's pre-rendered markdown inside <noscript>, so non-JS crawlers index actual content rather than an empty Blazor shell.

The node's own icon is part of that head, and it is declared twice — as the node's <svg> mark and as a PNG rendered from it at /api/icon/{path}.png, because Safari renders no SVG favicon at all. See Content Favicon Rasterization.

The one bit that decides everything: anonymous read

By default, only what an anonymous visitor may read gets a card. SeoResolver gates every path through the AnonymousGate and fails closed: a gated node's page serves the generic head, its /api/og/… card answers 404, and a missing node and a private one are indistinguishable from outside. This is deliberate — a link preview is served to whoever holds the link, so a card for a private page discloses its title, description and image past the access system.

That is the default and it is never inferred. A partition whose page names are meant to travel can say so, per scope, with the publicPreview opt-in below; nothing else turns it on, and no heuristic ever will. The rule this page used to state — that a private page can never unfurl richly — was the right default stated as an absolute; what it was protecting against is disclosure without consent, and consent is exactly what the flag adds.

What that means in practice:

A partition opts in with one bit on its seeded or authored policy:

Content = new PartitionAccessPolicy
{
    Create = false, Update = false, Delete = false,   // still read-only
    PublicRead = true                                  // world-readable → unfurls, indexable
}

PublicRead grants Read to everyone including anonymous; Read merely caps (false = deny) and never grants — see Access Control.

A gated page under a public root: the public-ancestor card

A public root over gated content is a shape the platform ships on purpose — a store listing, a course catalog, a reporting space whose cover is the marketing surface and whose data is not. A link into one of those used to share as the bare site card, which is the least useful thing it could say.

Measured on www.meshweaver.cloud, 2026-09-20:

URL og:title og:image
/InitechReporting Fund Reporting /api/og/InitechReporting.png
/InitechReporting/Funds MeshWeaver /api/og.png
/InitechReporting/Funds/InsuranceCore MeshWeaver /api/og.png
/InitechReporting/Funds/InsuranceCore/2026-06-30 MeshWeaver /api/og.png
/Doc/Architecture/AccessControl Access Control Architecture /api/og/Doc/…png

The last row is the control: it was never about depth. InitechReporting/_Policy is a PartitionAccessPolicy with a RedirectOnDenied of InitechReporting/Subscribe and no PublicRead, so the root is a public listing and everything under it is gated — and the head, gating through the AnonymousGate, had nothing to say about any of it.

SeoResolver.ResolvePublicAncestor now walks UP from a withheld page to the nearest ancestor the gate DOES admit and builds the card from that:

🚨 Why this discloses nothing

The path segments are already in the URL the sharer pasted. Rendering them back in the title tells the reader nothing they are not already looking at in their own chat window. Everything else on the card belongs to the public ancestor and is already served to anyone who asks for it.

The withheld node's own Name, description, icon and content are never read. That is enforced by the shape of the code, not by care: the composition (SeoResolver.ComposeAncestorCard) takes an SeoPageData the gate ADMITTED plus the request path, and there is deliberately no overload that takes the requested node. The redirect target is not on the card either — it is the one thing here that is not in the pasted URL, and the card's link already goes there for whoever clicks it.

SeoPublicAncestorCardTest holds both sides: a public page still resolves its own card unchanged, a gated page under a public root gets the ancestor's, a gated page with no public ancestor still gets nothing — and one control names the withheld nodes' own words and asserts they appear in no field of the card.

When no ancestor is public either, the site card stays — unless the partition opted in (next section). That floor is reached more often than it looks: measured on a control instance, /Initech/LocalHardwareOffer unfurled as the site card and so did /Initech itself, so there was no public ancestor anywhere on that chain and the fallback above correctly had nothing to offer. A partition that is gated all the way up needs the opt-in, not the walk.

🚨 The order the three legs are tried, and why the opt-in goes FIRST

The two fallbacks are independent resolvers, so the ORDER is the renderer's decision, and a gated page can match BOTH — a catalog whose root is public AND whose scope opted in. SeoHead tries them in this order:

  1. the node's OWN card, when its scope states PublicPreview — ResolvePreviewAsync;
  2. else the nearest PUBLIC ancestor's card, captioned with the pasted path — ResolvePublicAncestorAsync;
  3. else the site card, which is what every such link said before either existed.

An explicit consent outranks an inference, which is the whole argument. The flag's stated meaning is page names and summaries may travel; the ancestor card is what a page gets when NOBODY said that and the tree above it has to be read instead. Trying the walk first would invert it: the most common shape for a partition that WANTS previews — a store, a catalog, an offers space — is exactly a public cover over gated pages, so the walk would always answer and the flag would be a no-op precisely where it was set on purpose. It would also hand the owner a less specific card than the one they asked for: the ancestor's title plus the URL's segments, rather than the page's own name and authored summary.

The two legs stay separate blocks in the head rather than one parameterised one, because they rest on OPPOSITE disclosure arguments — the preview leg reads the withheld node's own words BY CONSENT, the ancestor leg is safe precisely because it never reads them — and each block's comment is where a reviewer checks that argument against the markup it governs.

publicPreview: letting a gated page describe itself

The question a partition owner keeps asking of a gated link is couldn't it say the name of the node? And the description? And the icon? It can — once they say so, per scope:

Content = new PartitionAccessPolicy
{
    PublicPreview = true,                       // page NAMES and summaries may travel
    RedirectOnDenied = "Offers/Subscribe",      // …and here is the way in
}

With it set, a page the gate still refuses emits its own og:title, og:description and og:image, plus its own icon links, and keeps noindex — unfurlable without being indexable. With it unset, which is every partition until somebody sets it, nothing changes at all: that negative is the control SeoPublicPreviewOptInTest leads with.

What it does NOT do

The decisions, stated

Why an opt-in and not a behaviour

A blanket version of this would be a disclosure surface wearing a feature's colours — the same objection that made /health print the PARTITION rather than the node's own name (#4258/#3890). The difference is consent: the owner of the data states it, in the same _Policy that governs everything else about that subtree, and the card is built by a pure function (SeoResolver.ComposePreviewCard) that is handed the node and returns four strings plus icon links — it carries no MeshNode and no SeoPageData, so the body the crawler-facing body component renders cannot be reached from a preview card at all.

The inbound OgCard layout area

The store package OgCard ships the opposite convenience: a markdown page embeds link-preview cards for other targets — external URLs (their Open Graph head fetched server-side through the core OpenGraphPreviewService) or same-mesh nodes (read live off the node stream):

@@("Org/Doc/area/OgCard?url=https://example.org/Page")
@@("Org/Doc/area/OgCard/Some/Node/Path")

Several targets compose into one responsive grid. It is a module (MeshWeaver.OgCard); delisting it removes the server-side URL-fetch surface and existing embeds render the standard area-not-found placeholder. The /og-card skill documents the authoring rules.

Verifying an unfurl without pasting into chat

Fetch the page as a crawler would and read the head — the same check the platform's own sweep ran:

curl -sL -A "Mozilla/5.0 (compatible; SkypeUriPreview Preview/0.5)" \
  https://portal.example.com/Chess | grep -o '<meta[^>]*og:[^>]*>'

A page that unfurls shows the full og:* set and an og:image you can fetch anonymously. A page that serves an empty <title> and no og:* tags is not anonymous-readable — that is the gate working, not the feature missing. (Teams and LinkedIn cache unfurls aggressively; a fixed page can take hours to re-scrape, and LinkedIn's Post Inspector forces a refresh.)

Then fetch the declared og:image itself and read what an unfurler would receive — for a portal-served card the type, size and encoding must match the head:

curl -s -o card.jpg -w '%{http_code} %{content_type} %{size_download}\n' \
  https://portal.example.com/api/og/Posts/SomePost.jpg && file card.jpg
# expect: 200 image/jpeg <≤ 280000> … JPEG image data, … baseline, … 1200x630

Forcing a re-scrape after a fix

Every unfurler caches the preview per URL, and no response header reaches that copy.