Specify server-generated Open Graph cards

62f07cb5469f · AtlantisPleb · · parent 503ebf2425fe

Specify server-generated Open Graph cards

Records what GitHub's card system does, measured from production
responses -- content-addressed PNGs, immutable caching, and a repo-level
card reused verbatim on file pages -- and specifies our version: meta
tags in the SSR HTML for every public surface, a signed content-versioned
image endpoint behind the same visibility predicate as code browsing,
SVG templates rasterized by librsvg with fixed fonts and escaped text,
and blob/commit/issue cards that carry resource-specific information
GitHub leaves out.

Changelog: none

Changelog-Category: docs

Changelog-Visibility: public
Changelog
none
Changelog-Category
docs
Changelog-Visibility
public

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added docs/2026-08-21-open-graph-cards.md

Diff

1 file changed, +200 -0

docs/2026-08-21-open-graph-cards.md added +200

@@ -0,0 +1,200 @@

1
# Server-generated Open Graph cards
2
3
Date: 2026-08-21
4
5
Status: Proposed. This document records what GitHub's card system actually
6
does, measured from production responses, and specifies how OpenAgents builds
7
its own version with strictly more information per card.
8
9
## What links to our pages look like today
10
11
The application renders no Open Graph or Twitter meta tags at all. A link to
12
`https://openagents.com/OpenAgentsInc/openagents.com` pasted into Slack,
13
X, iMessage, Discord, or LinkedIn renders as either a bare URL or whatever
14
those platforms guess. Every forge page we built to be public — repository
15
home, blobs, commits, issues — is invisible in exactly the surfaces where
16
people share links.
17
18
## What GitHub does, measured
19
20
Facts collected from live responses on 2026-08-21:
21
22
- The repository home serves `<meta property="og:image">` pointing at
23
  `https://opengraph.githubassets.com/{digest}/OpenAgentsInc/openagents`.
24
  The hex `{digest}` is derived from the card inputs, so the URL itself is
25
  the cache key: change the description and the digest changes.
26
- The image is a 1200×600 RGB PNG. It shows the owner avatar, the
27
  `owner/repo` path, the description ("Monorepo & docs" for our sister
28
  repository), and star/fork/watch counters.
29
- `cache-control: public, max-age=21600, immutable` on the image response.
30
- The **blob page serves the identical URL** — same digest, same bytes. So do
31
  directory listings. A file shared on Slack looks exactly like the
32
  repository landing page: nothing about the file appears in the card.
33
- An individual issue gets its own template and digest
34
  (`.../{digest}/elixir-lang/elixir/issues/10000`), with title, body excerpt,
35
  author, state, and comment count rendered into the image, and
36
  `og:image:alt` carrying the body excerpt for screen readers.
37
38
Two design lessons fall out of this. First, the card URL is
39
content-addressed: immutability plus a long cache makes generation cost
40
irrelevant and stale links harmless. Second, GitHub stops at the repository
41
boundary — a file link wastes its card. That is the gap worth beating.
42
43
## Goals
44
45
1. Every public forge page emits correct `og:*` and `twitter:*` meta tags
46
   with a card that says what the page is.
47
2. Cards are generated by the Phoenix application itself: no third-party
48
   service, consistent with ADR 0001.
49
3. File, commit, and issue cards carry information specific to that resource
50
   — deliberately more than GitHub ships.
51
4. Generation is bounded, cached, and safe on untrusted text.
52
53
## Non-goals
54
55
- Per-user personalized cards. Cards describe resources, not viewers.
56
- Rendering arbitrary README screenshots or images fetched from Git content.
57
- Supporting scrapers that execute JavaScript; crawlers read the initial
58
  server-rendered HTML, which LiveView provides.
59
60
## Architecture
61
62
### Layer 1: meta tags
63
64
Crawlers do not run JavaScript, so the tags must be in the initial SSR HTML.
65
LiveView renders that HTML once per mount, which is exactly what a crawler
66
fetches.
67
68
Add an optional `@og` assign consumed by the root layout, with a site-level
69
default so every page produces valid tags even when a view forgets:
70
71
```heex
72
<meta property="og:title" content={@og.title} />
73
<meta property="og:description" content={@og.description} />
74
<meta property="og:image" content={@og.image_url} />
75
<meta property="og:image:width" content="1200" />
76
<meta property="og:image:height" content="630" />
77
<meta property="og:url" content={@og.url} />
78
<meta property="og:type" content={@og.type} />
79
<meta name="twitter:card" content="summary_large_image" />
80
```
81
82
Each public view fills the map during `handle_params/3`: repository home,
83
blob, commit, issue show, docs, changelog, status, home. Views behind
84
authentication — chat, settings, repositories management — keep the bare
85
site card or none at all. Private repositories emit no resource-specific tags
86
whatsoever: the meta tags must not leak the existence or description of
87
something the visitor could not otherwise see.
88
89
### Layer 2: the card endpoint
90
91
One plug-style controller serves all images:
92
93
```text
94
GET /og/v/{version}/repos/{owner}/{repo}.png
95
GET /og/v/{version}/blobs/{owner}/{repo}/{ref}/{path...}.png
96
GET /og/v/{version}/commits/{owner}/{repo}/{sha}.png
97
GET /og/v/{version}/issues/{owner}/{repo}/{number}.png
98
GET /og/static/card.png
99
```
100
101
Properties of this endpoint:
102
103
- **Content-addressed version.** `{version}` is a short SHA-256 prefix over
104
  the exact inputs (titles, counts, dates, colors). When any input changes,
105
  the page emits a new URL and every CDN entry for the old one ages out.
106
  Stale shares keep working: the controller renders the current data for any
107
  well-formed request and lets the version be advisory, the way GitHub's
108
  digest behaves in practice.
109
- **HMAC, not enumeration.** Requests carry `?sig=` computed over the path
110
  with a server-side key. Without a valid signature the endpoint 404s, so the
111
  endpoint cannot be used as an unbounded free rendering API by third
112
  parties.
113
- **Concealment.** The controller resolves the repository through the same
114
  visibility predicate as public code browsing. A private or missing
115
  repository returns an indistinguishable 404.
116
- **Cache headers.** `cache-control: public, max-age=21600, immutable`,
117
  matching what GitHub ships, plus `content-type: image/png`.
118
- **Bounded work.** One rasterization per miss; the LB/CDN absorbs repeats.
119
  A concurrency-limited task queue caps worst-case load, and requests queue
120
  rather than fan out.
121
122
### Layer 3: rendering
123
124
Cards are laid out as SVG templates in Elixir — pure functions from a data
125
struct to a string, trivially unit-testable — then rasterized to PNG.
126
127
Rasterization options considered:
128
129
| Option | Verdict |
130
| --- | --- |
131
| System `rsvg-convert` (librsvg) invoked via port | Recommended. One package in the release image; deterministic output with pinned fonts; battle-tested parser. |
132
| ImageMagick | Same shape as above but a much larger attack and maintenance surface than librsvg alone. |
133
| Pure-Elixir PNG encoder plus a glyph atlas | Zero system dependencies, but hand-rolled shaping, wrapping, and hinting; weeks of work for visibly worse type. |
134
| Rustler NIF wrapping resvg | Best fidelity and speed, but adds a NIF toolchain to every release build and rollback artifact. |
135
| Headless-Chrome sidecar service | Contradicts the one-application architecture; heaviest option by far. |
136
137
The port approach carries three hard requirements, which are also the
138
security boundary:
139
140
1. **Fixed fonts.** The release image ships the Geist faces we already use
141
   (TTF form for fontconfig). No user-influenced font selection, ever.
142
2. **Escaped, capped text.** All dynamic strings pass through XML escaping,
143
   length caps, and a line-clamping layout helper before entering the
144
   template. Titles wrap at two lines with an ellipsis; paths truncate from
145
   the left, keeping the filename visible.
146
3. **No remote references.** The SVG may not contain `<image>`, external
147
   CSS, or URLs the rasterizer would fetch — that closes SSRF and removes
148
   network nondeterminism. Avatars render as initial-filled discs in palette
149
   tones, not fetched bitmaps.
150
151
If the rasterizer binary is missing at runtime, the endpoint falls back to
152
the static brand card and logs a warning. Social previews degrade; nothing
153
errors.
154
155
## Card contents
156
157
Canvas: 1200×630, dark palette from our token ladder, brand lockup bottom-left
158
on every card, `openagents.com` wordmark bottom-right. This is where our
159
cards diverge from GitHub's on purpose.
160
161
| Surface | Card shows |
162
| --- | --- |
163
| Site fallback | Wordmark, one-line product description, logo mark |
164
| Repository | `owner/repo`, description (or an honest fallback), visibility pill, open/closed issue counts, default branch, last commit date, "Imported from GitHub" provenance line when a repository import exists |
165
| Issue | State pill colored by state and close reason, `#number`, wrapped title, author login, comment count, up to three label chips, opened date |
166
| Blob | Small `owner/repo` context line, the full path breadcrumb wrapping across two lines with the filename kept intact, language inferred from extension, file size, line count, branch/ref |
167
| Commit | Short SHA, wrapped subject, author login, files-changed count, date |
168
169
The blob and commit cards are the deliberate step past GitHub: their file
170
links render the generic repository card, while ours answer "what am I about
171
to open?" — language, size, and where it sits in the tree — without a click.
172
Everything shown is already served on the corresponding public page, so the
173
cards leak nothing new; the rule is simply that a card may contain only data
174
the anonymous page already displayed.
175
176
## Testing
177
178
- Golden-file tests pin each SVG template's output for fixture inputs, so a
179
  layout regression fails loudly.
180
- Controller tests cover: PNG content type, cache headers, signature
181
  enforcement, version-mismatch behavior, and indistinguishable 404s for
182
  private and missing repositories.
183
- Escaping tests feed hostile strings — angle brackets, control characters,
184
  emoji, ten-thousand-character titles — and assert bounded, escaped output.
185
- The redaction scan extends to card payloads: tokens and credentials never
186
  appear in card inputs because card inputs are page-visible data only.
187
188
## Delivery sequence
189
190
1. **Meta tags plus the static brand card.** No new dependencies, immediate
191
   improvement everywhere links are shared. Ships alone.
192
2. **Repository and issue cards.** Lands the SVG pipeline, the signed
193
   endpoint, the rasterizer requirement in the release image, and the two
194
   highest-value templates.
195
3. **Blob and commit cards.** The beyond-GitHub information layer.
196
197
Phase 2 is gated on the release-image change shipping to staging first,
198
since a node without `rsvg-convert` silently serves the fallback card — a
199
degradation worth catching in a staging check rather than in production
200
analytics.

This page updates live while a promote is in flight · changelog