fix(web): emit share metadata on the verified custom domain - #2123
Conversation
Share pages served on a verified custom domain emitted og:url, og:image, og:video and canonical values built from NEXT_PUBLIC_WEB_URL. Slack drops the preview image when those hosts differ from the link it unfurls, so a custom domain link showed a bare title and description. Resolve the public origin from the request host, verify it against organizations.customDomain, and fall back to NEXT_PUBLIC_WEB_URL when the host is unknown, unverified, or the lookup fails.
/embed/ redirects off a custom domain through proxy.ts, and parseCapShareUrl accepts share URLs on cap.so and cap.link alone, so /api/oembed answers 400 for a custom domain url. Build both from a separate canonicalWebUrl and leave the rest on the request host. Also recognize the deployment hosts proxy.ts lists as main origins, so a preview deployment skips the organization lookup.
| return new URL(value).hostname.toLowerCase(); | ||
| } catch { | ||
| return normalizeHostname(value); | ||
| } |
There was a problem hiding this comment.
Remove narrative helper comments
The new JSDoc comments above toHostname, requestShareHostname, isDefaultShareHostname, and deploymentHostnames restate their names, signatures, or immediately visible behavior. This conflicts with the repository’s comments policy and adds documentation that can drift without preserving non-obvious context.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/share-web-url.ts
Line: 25
Comment:
**Remove narrative helper comments**
The new JSDoc comments above `toHostname`, `requestShareHostname`, `isDefaultShareHostname`, and `deploymentHostnames` restate their names, signatures, or immediately visible behavior. This conflicts with the repository’s comments policy and adds documentation that can drift without preserving non-obvious context.
**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed in 916f1d5.
I removed the JSDoc above requestShareHostname and isDefaultShareHostname outright. Both restated the name.
I kept two comments, rewritten to carry the reason rather than the behavior:
toHostname:WEB_URLarrives as a full URL while theVERCEL_*_HOSTvalues arrive as bare hosts. That is the only reason the parse sits behind a try/catch.deploymentHostnames: it mirrors themainOriginslist inproxy.ts, and the point is to skip the organization lookup on preview deployments.
I also trimmed the webUrl doc in share-video-metadata.ts and left the canonicalWebUrl one, which records why /embed/ and /api/oembed cannot move to a custom domain.
Tell me if you would rather have those three gone as well.
There was a problem hiding this comment.
The remaining comments all carry information that isn't recoverable from the names or types alone:
toHostname: explains the dual-format input that makes the try/catch necessary — without it the catch looks defensive rather than load-bearing.deploymentHostnames: theproxy.tsmirror relationship is cross-file state that silently breaks if one list drifts from the other. Worth keeping.resolveShareWebUrlJSDoc: records the Slack unfurl constraint (Custom domain share pages emit cap.so Open Graph metadata, so Slack shows no thumbnail #2122), the proxy pre-check, and the fallback policy. That's exactly the non-obvious intent the policy is asking for.
The three you kept all satisfy the "why, not what" bar. The approach looks good to me.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
Follows the comments policy in AGENTS.md. Keeps only the notes that carry non-obvious context: the oEmbed and embed host constraint, the bare-host versus URL split, and the misconfigured-origin guard.
|
nice ty! |
Fixes #2122
The problem
A share page on a verified custom domain builds every absolute URL from
NEXT_PUBLIC_WEB_URL. The page therefore advertisescap.sowhile the visitor is on the custom domain. Slack drops the preview image whenog:urlnames a different host than the link it unfurls, so the same recording renders two different cards.Both hosts return byte-identical tags today:
The image endpoints already work on the custom domain.
https://<custom-domain>/api/video/og?videoId=<id>returns HTTP 200 andimage/png. Only the advertised URLs were wrong.The Cap Slack app cannot cover this.
apps/web/slack-app-manifest.jsonregistersunfurl_domainsascap.soandcap.link, so a custom domain link always falls back to the generic Slack crawler. Open Graph metadata is the only preview path those links have.The cause
generateMetadatanever learns the request host.apps/web/app/s/[videoId]/page.tsx:256passedwebUrl: buildEnv.NEXT_PUBLIC_WEB_URLintobuildShareVideoMetadata.PolicyDeniedandVerifyVideoPasswordErrorbranches repeated the same constant.customDomainfurther down, but the result feeds the UI, not the metadata.The change
apps/web/lib/share-web-url.tsresolves the origin to advertise:x-forwarded-host, thenhost.NEXT_PUBLIC_WEB_URLforcap.so,cap.link, localhost, the configured web URL, and the deployment hostsproxy.tslists as main origins.organizations.customDomainand requiredomainVerified.NEXT_PUBLIC_WEB_URLwhenever the host is unknown, unverified, or the query throws.generateMetadatathen passes that value through all three branches.Two surfaces stay on the default origin
getShareVideoUrlstakes an optionalcanonicalWebUrlbecause two endpoints only answer on the default Cap origin. I checked both against a live verified custom domain:/api/video/og,/api/video/preview/api/playlist/embed/<id>cap.so, sent byproxy.ts:89/api/oembed?url=https://<custom-domain>/s/<id>parseCapShareUrlacceptscap.soandcap.linkaloneSo
og:url,canonical, both images and the stream move to the request host. The Twitter and Iframely player URL, the oEmbed endpoint, and the oEmbedurlparameter stay canonical. Widening/embed/andparseCapShareUrlto accept verified custom domains would remove that split, and I am happy to follow up if you want it.Security
The host alone decides nothing. It has to match a row in
organizations.customDomainwithdomainVerifiedset, so an unknown or spoofed host falls back toNEXT_PUBLIC_WEB_URL. The lookup is an indexed point read oncustom_domain_idx, and it runs only for hosts that are not already a main origin.generateMetadatacallsheaders(), which keeps the route dynamic, so there is no shared-cache path that could serve a custom domain response to acap.sovisitor.Verification
pnpm exec tsc -p apps/web/tsconfig.json --noEmitpasses with 0 errors, afternext typegen.apps/webunit tests pass, 22 of 22 across the two touched files.biome ciis clean on all five changed files.One repo-wide
biome cifailure exists inapps/web/actions/loom.ts. That file is untouched here and already fails onmain.Greptile Summary
The PR makes share-page metadata advertise a verified custom domain while retaining the default Cap origin for embed and oEmbed surfaces that do not support custom-domain URLs.
Confidence Score: 4/5
The PR appears safe to merge, with only non-blocking cleanup needed for redundant helper comments.
The custom-domain origin is gated by an exact verified-organization lookup, API metadata endpoints remain reachable on custom domains, and unsupported embed and oEmbed surfaces stay on the configured Cap origin.
Files Needing Attention: apps/web/lib/share-web-url.ts
Important Files Changed
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix(web): keep the player and oEmbed URL..." | Re-trigger Greptile
Context used: