Skip to content

fix(web): emit share metadata on the verified custom domain - #2123

Merged
richiemcilroy merged 3 commits into
CapSoftware:mainfrom
dmdfajardo00:fix/custom-domain-og-metadata
Aug 14, 2026
Merged

fix(web): emit share metadata on the verified custom domain#2123
richiemcilroy merged 3 commits into
CapSoftware:mainfrom
dmdfajardo00:fix/custom-domain-og-metadata

Conversation

@dmdfajardo00

@dmdfajardo00 dmdfajardo00 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #2122

The problem

A share page on a verified custom domain builds every absolute URL from NEXT_PUBLIC_WEB_URL. The page therefore advertises cap.so while the visitor is on the custom domain. Slack drops the preview image when og:url names a different host than the link it unfurls, so the same recording renders two different cards.

Both hosts return byte-identical tags today:

<meta property="og:url"   content="https://cap.so/s/<id>"/>
<meta property="og:image" content="https://cap.so/api/video/preview?videoId=<id>&fallback=og"/>
<meta property="og:video" content="https://cap.so/api/playlist?videoId=<id>&videoType=mp4"/>
<link rel="canonical"     href="https://cap.so/s/<id>"/>

The image endpoints already work on the custom domain. https://<custom-domain>/api/video/og?videoId=<id> returns HTTP 200 and image/png. Only the advertised URLs were wrong.

The Cap Slack app cannot cover this. apps/web/slack-app-manifest.json registers unfurl_domains as cap.so and cap.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

generateMetadata never learns the request host.

  1. apps/web/app/s/[videoId]/page.tsx:256 passed webUrl: buildEnv.NEXT_PUBLIC_WEB_URL into buildShareVideoMetadata.
  2. The PolicyDenied and VerifyVideoPasswordError branches repeated the same constant.
  3. The page component resolves customDomain further down, but the result feeds the UI, not the metadata.

The change

apps/web/lib/share-web-url.ts resolves the origin to advertise:

  1. Read the host from x-forwarded-host, then host.
  2. Return NEXT_PUBLIC_WEB_URL for cap.so, cap.link, localhost, the configured web URL, and the deployment hosts proxy.ts lists as main origins.
  3. Otherwise match the host against organizations.customDomain and require domainVerified.
  4. Return NEXT_PUBLIC_WEB_URL whenever the host is unknown, unverified, or the query throws.

generateMetadata then passes that value through all three branches.

Two surfaces stay on the default origin

getShareVideoUrls takes an optional canonicalWebUrl because two endpoints only answer on the default Cap origin. I checked both against a live verified custom domain:

Request on a custom domain Result
/api/video/og, /api/video/preview 200, or 302 to the signed CDN URL
/api/playlist 302 to the signed CDN URL
/embed/<id> 307 to cap.so, sent by proxy.ts:89
/api/oembed?url=https://<custom-domain>/s/<id> 400, parseCapShareUrl accepts cap.so and cap.link alone

So og:url, canonical, both images and the stream move to the request host. The Twitter and Iframely player URL, the oEmbed endpoint, and the oEmbed url parameter stay canonical. Widening /embed/ and parseCapShareUrl to 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.customDomain with domainVerified set, so an unknown or spoofed host falls back to NEXT_PUBLIC_WEB_URL. The lookup is an indexed point read on custom_domain_idx, and it runs only for hosts that are not already a main origin. generateMetadata calls headers(), which keeps the route dynamic, so there is no shared-cache path that could serve a custom domain response to a cap.so visitor.

Verification

  1. pnpm exec tsc -p apps/web/tsconfig.json --noEmit passes with 0 errors, after next typegen.
  2. apps/web unit tests pass, 22 of 22 across the two touched files.
  3. biome ci is clean on all five changed files.
  4. Endpoint behavior in the table above comes from live requests, not from reading the code.

One repo-wide biome ci failure exists in apps/web/actions/loom.ts. That file is untouched here and already fails on main.

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.

  • Adds request-host normalization and verified custom-domain resolution with safe fallback to the configured web origin.
  • Propagates the resolved origin through normal, restricted, and password-protected metadata branches.
  • Separates custom-domain share, image, and stream URLs from canonical player and oEmbed URLs.
  • Adds focused unit coverage for hostname resolution and metadata URL generation.

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

Filename Overview
apps/web/lib/share-web-url.ts Adds verified request-host resolution with default-origin fallback; behavior is sound, but several new comments violate the repository’s no-narrative-comments guidance.
apps/web/lib/share-video-metadata.ts Separates the visitor-facing metadata origin from the default origin required by embed and oEmbed endpoints.
apps/web/app/s/[videoId]/page.tsx Resolves the request-specific share origin once and applies it consistently across successful and access-denied metadata branches.
apps/web/tests/unit/share-web-url.test.ts Covers forwarded-host precedence, normalization, missing hosts, deployment origins, and fallback behavior.
apps/web/tests/unit/share-video-metadata.test.ts Verifies that custom-domain metadata retains canonical Cap URLs for player and oEmbed surfaces.
Prompt To Fix All With AI
### Issue 1
apps/web/lib/share-web-url.ts:25
**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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(web): keep the player and oEmbed URL..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_URL arrives as a full URL while the VERCEL_*_HOST values arrive as bare hosts. That is the only reason the parse sits behind a try/catch.
  • deploymentHostnames: it mirrors the mainOrigins list in proxy.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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: the proxy.ts mirror relationship is cross-file state that silently breaks if one list drifts from the other. Worth keeping.
  • resolveShareWebUrl JSDoc: 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.
@richiemcilroy
richiemcilroy self-requested a review August 14, 2026 10:35
@richiemcilroy
richiemcilroy merged commit bac7913 into CapSoftware:main Aug 14, 2026
14 of 16 checks passed
@richiemcilroy

Copy link
Copy Markdown
Member

nice ty!

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.

Custom domain share pages emit cap.so Open Graph metadata, so Slack shows no thumbnail

2 participants