Links · signed redirects & attribution
Every link, signed. No open redirect.
How an outbound link in an AI reply becomes a tracked redirect the visitor can trust: HMAC-signed at the origin, verified at the edge, refused if it was never signed, and shown as the real destination, never a bounce URL.
Section 01 · the problem
A link is a promise, and an unsigned redirect is a loaded gun.
When an AI reply points a visitor somewhere, two things have to be true at once: the click has to be measurable (did anyone follow it?), and the redirect that makes it measurable must never become a weapon.
The measurable part wants a bouncer: send the click through our domain, record it, then forward. But a bouncer that forwards to whatever URL it is handed is a phishing primitive. An attacker mails g.ou.chat/l?u=evil.example and borrows our domain's reputation to launder their link. That is an open redirect, and it is one of the most common ways a trusted domain gets abused. So the entire design is organised around a single invariant: there is no code path from an unverified target to a redirect.
Section 02 · architecture
Sign at the origin. Verify at the edge. Show the truth.
The link is signed once, in Go, when the reply is written. It is verified on every click, at the edge, nearest the visitor. And it is rendered as the real destination, never the bounce URL.
flowchart LR
subgraph SIGN["① SIGN · origin (Go)"]
AI["AI reply · markdown"] -->|"Rewrite"| RW["linkwrap.Rewrite
[original](bounce)"]
RW -->|"HMAC-SHA256, 16 bytes"| SG["signed target
u + s + attribution"]
end
subgraph EDGE["② VERIFY · edge (Worker on g.ou.chat)"]
SG --> V{"signature valid?"}
V -->|"no"| B["400 · no Location"]
V -->|"yes"| R["302 → real destination"]
R -.->|"waitUntil"| A[("web_events · link_click")]
end
subgraph SEE["③ SEE · widget"]
RW --> T["visitor reads the REAL host
href carries the bounce"]
end
waitUntil, so analytics never delays the visitor.Why the edge, not the API
A redirect sits on the critical path of a click. The Go API is one AWS region, so a visitor in Asia would wait on a transcontinental round trip before anything happened. A Worker answers from the nearest PoP, and links keep resolving even if the API is down.
Why a cookieless host
g.ou.chat was chosen only after confirming ou.chat sets no cookies at all (the widget uses localStorage). A redirector on a cookie-bearing domain would leak session state to every third-party destination on every click.
Section 03 · the signature
The bouncer refuses anything it did not sign.
The target rides in the URL as u=, alongside a detached HMAC-SHA256 signature s= over that exact target. Verification is the only path to a Location header.
flowchart TB
REQ["GET g.ou.chat/l?u=…&s=…"] --> P{"u present
and http(s)?"}
P -->|"no"| X1["400"]
P -->|"yes"| S{"s == HMAC(secret, u)
constant-time?"}
S -->|"no"| X2["400 · never a redirect"]
S -->|"yes"| SC{"scheme still http(s)?
(re-check)"}
SC -->|"no"| X3["400"]
SC -->|"yes"| OK["302 · Cache-Control: no-store"]
Two subtleties make this robust rather than merely present. The signature covers the finished destination, so nothing is ever concatenated onto the target at click time. Appending caller-supplied parameters is exactly how &redirect= or &state= gets injected into someone else's OAuth endpoint. And the URL scheme is re-checked after verification, so even a signing bug could never drive a javascript: or data: navigation on the host page. Any UTM must be baked into the target before signing, never added after.
Section 04 · the cross-language contract
Signing is Go. Verifying is JavaScript. Drift breaks every link at once.
The signer lives in the middleware; the verifier is a Cloudflare Worker. If the two implementations ever disagree by a single byte, every link in the product 400s simultaneously, so they are pinned to a shared test vector that must fail both suites together.
Three traps the vector guards
Go truncates the HMAC to 16 bytes, which rules out crypto.subtle.verify, since it only checks a full-length MAC. Go uses base64url without padding, so a plain btoa differs. And the signature covers the byte-exact target. Each is a silent 400 in production if it slips.
One fixed triple, two suites
One secret, one URL, one expected signature, asserted in internal/linkwrap/linkwrap_test.go and workers/linkgate/src/sig.test.ts. CI catches a divergence before a customer's links do.
The principle: when a security-critical constant is reimplemented in a second language, the contract is not the comment. It is a shared vector that fails both builds together. A verified signature computed independently by the Worker against the middleware's own production key is the only proof that the two agree.
Section 05 · the display layer
Show the destination, not the machinery.
A tracked link is only worth shipping if it does not look like tracking. The rewrite emits [original](bounce) markdown, so the visitor reads the real host while the href quietly carries the signed redirect.
This is how every platform that wraps links behaves. The bare URL a model writes becomes a clickable anchor whose text is the true destination; an existing markdown link keeps its human label and only has its href swapped. A URL already inside a link is never wrapped into a nested one. When a reply points at exactly one place, the widget also renders a call-to-action button labelled with the real destination host (read from the bounce URL's u= param), so it is correct even for a custom label, and it never says "Open g.ou.chat".
The one honest trade: because clicks are tracked, hover shows the g.ou.chat href, exactly as LinkedIn shows lnkd.in. Text and button both read as the real host; only the status bar reveals the hop. The alternative, a raw href, is a clean hover with zero analytics.
Section 06 · attribution & analytics
The click lands where the rest of the analytics already lives.
The bounce URL carries widget key, visitor, and session as query params. On a real click the Worker records a link_click into the existing web-events lane, after the redirect, so a metric never costs a visitor their click.
| Concern | How it's handled |
|---|---|
| Analytics latency | Recorded via waitUntil: fires after the 302 is sent. The visitor is already navigating. |
| Bot / prefetch noise | Known scanner user-agents are redirected but not counted; a HEAD is never counted. |
| No analytics sink | The redirect still happens. A missing metric must never break a working link. |
| No signing secret | Links are emitted untouched and still work; a missing key costs tracking, not clicks. |
| Where it's read | The same web_events table the traffic KPIs already scan, so a click count is one countIf, inheriting the tier gate and bounded window. |
A quiet lesson from shipping this: the ingest lane is fire-and-forget and always answers 204, so a malformed payload is indistinguishable from a delivered one. A one-character shape mismatch dropped every click silently. The fix was a shared payload-shape test and a visible warn log the moment a resolved widget sends a type the lane does not accept.
Section 07 · failure modes, by design
Two failure directions, chosen on purpose.
The system fails open on the link and closed on the redirect, which is the right way round.
Fail open: reach the page
No secret configured, no analytics sink, a bot user-agent: none of these stop the visitor reaching the destination. Anything that would cost a click degrades to "less tracking," never "broken link."
Fail closed: never redirect blind
A missing signature, a forged one, a wrong-key one, a non-http scheme: every one is a bare 400. The error page is deliberately terse: echoing the attempted target back would make the error page itself a phishing surface.
One consequence worth stating plainly: rewriting happens when the reply is persisted, because the widget renders a finalised reply from a WebSocket push rather than re-fetching it, so one hook covers both the live message and the transcript. The cost is that rotating LINK_SIGNING_SECRET invalidates links already written into transcripts. That key is not casually rotatable, and the code and commit say so.
Section 08 · implementation surface
Where it lives.
Signing and rewriting are Go; verification is a small dependency-free Worker; the display is the Preact widget. All live on main and serving in production.
Origin (Go middleware)
internal/linkwrap/linkwrap.go: HMAC sign/verify, scheme guard, markdownRewriteinternal/linkwrap/handler.go: the fallback/lroute (query + legacy path forms)cmd/server/wiring/ouchat_dispatcher.go:wrapLinksat persist time (web_widget only)internal/webingest: thelink_clickingest lane + drop-path logging
Edge + widget
workers/linkgate/src/{index,sig}.ts: verify + 302 + record, WebCrypto HMACworkers/linkgate/src/{sig,shape}.test.ts: the shared vector + payload-shape contractwidget/src/theme.ts:Rewrite-aware markdown render +extractLinkRefswidget/src/ui/message-row.tsx: the single-link CTA labelled by real host
Sources & further reading
The signing core, the edge Worker, and the display layer each carry their invariants in-file; the Worker README is the operational reference.
workers/linkgate/README.md: why the edge, the rotate-both-secrets rule, the cross-language contractinternal/linkwrap/: the signer + rewrite, with the open-redirect invariant stated at the top of the package- GitHub #140: the shipped bouncer; #149: surfacing clicks on the dashboard; #151: the ingest drop-path logging
Related engineering reading: the Dashboard & Telemetry Engine (the web_events lane this writes to), the Common Channel Wrapper (where replies originate), and the Agentic Harness.