Appearance
Epic 32 — Dynamic Share-Link Meta (App OG/Twitter Cards)
Public share links from the app (
i.castyou.app/share/jobs/:id) need real per-job link previews (WhatsApp, iMessage, LinkedIn, Slack, Discord, X, Facebook). The app is a client-rendered Vite SPA served as one staticindex.htmlvia nginx — social scrapers don't run JS, so per-job meta set inside React (PublicJobPage) is invisible to them. This epic adds a tiny server-side meta-injection layer in front of the static SPA, scoped to/share/*only. Same URL serves both audiences: scrapers read the injected<head>; humans get that HTML and the SPA boots on top and renders normally. No redirect, no SPA code change, no SSR migration.Scope is the app only. The landing app's SEO/OG is handled separately by FE-LANDING-002 (Next.js static
metadataexport) — that already works because Next renders server-side. This epic does not touch search-engine SEO: the app staysnoindex+ auth-gated; this is purely shared-link previews for the one public route.Deploy target: Coolify (Docker). The renderer ships inside the existing
Dockerfile.appimage alongside nginx (chosen Option A — two processes in one container, over a separate Coolify service — for operational simplicity and guaranteed template-freshness on a single low-traffic route). All Vercel config has been removed (apps/app/vercel.jsondeleted 2026-06-07); nginx'stry_filesalready covers SPA routing.Today's only public, shareable route is
/share/jobs/:id→PublicJobPage, backed by the already-publicjobListingGraphQL query (petJob.tsresolver — norequireProfile, so the renderer can fetch anonymously). Talent/producer profiles and castings are auth-gated; making them shareable is a separate product decision — they would plug into the same/share/*renderer when wanted.Relationships: [[Epic 5 — Job Board]] (source of share links), [[Epic 31 — Enhanced Flier Generation]] / Epic 8.5 (
flierUrlis the naturalog:image), FE-LANDING-002 (sibling SEO ticket for the landing app).
OG-001 — Meta-renderer service (server.mjs)
- [x] Done
Files:
- Create:
castyou-frontend/apps/app/meta-renderer/server.mjs
Description: A dependency-free Node service (built-in http + fetch, Node 18+ — no npm install) that handles GET /share/jobs/:id:
- Reads the built
dist/index.htmlonce at boot into memory (the SPA shell / template). - Runs a lean public
jobListingquery (only the fields a preview needs) againstGRAPHQL_URL, server-to-server, no token. - String-replaces the static OG/Twitter/canonical block in the
<head>with per-job tags, returns the HTML.
Lean query (renderer-only — do not reuse the heavy GET_JOB_LISTING):
graphql
query($id: ID!) {
jobListing(id: $id) {
__typename
... on Job { title description flierUrl status producer { companyName industryRole } }
... on PetJob { title description flierUrl status producer { companyName industryRole } }
}
}Tag mapping:
og:title/twitter:title=${title} — ${producer.companyName || producer.industryRole || 'casTyou'}og:description/twitter:description=descriptionwhitespace-collapsed, clipped ~160 chars, with a generic fallbackog:image/twitter:image=flierUrlif present, else branded${PUBLIC_ORIGIN}/og.jpgog:url/ canonical =${PUBLIC_ORIGIN}/share/jobs/:id- static:
og:type=website,og:site_name=casTyou,twitter:card=summary_large_image
Notes:
- Escape all interpolated values (
&"<>) before injection —title/descriptionare user-supplied; unescaped values break tags / allow injection. - Replacement anchors on the existing `` …
twitter:imageblock inindex.html(present today). See OG-004 for the marker comment that protects it. - Failure modes both return the SPA shell + generic tags with HTTP 200 (never 404 the document, or the SPA can't render its own not-found):
- unknown/non-
/share/jobs/path → shell - job not found / backend down / fetch throws → shell
- unknown/non-
- Reference implementation drafted in chat 2026-06-07 (~70 lines).
Acceptance criteria:
curl -A "facebookexternalhit/1.1" .../share/jobs/<valid-id>returns HTML whose<head>contains the job's title andflierUrl(or fallback image).- A job with no
flierUrlfalls back to/og.jpg. - A bad/unknown id returns 200 with the generic shell (no 5xx, no 404 document).
- Special characters in a job title/description appear correctly escaped in the output, not as raw
</".
OG-002 — nginx /share/ proxy rule
- [x] Done
Files:
- Edit:
castyou-frontend/apps/app/nginx.conf
Change: Add a location /share/ block above location / that proxies to the in-container renderer; leave location / (the try_files … /index.html static fallback) untouched.
nginx
location /share/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}Acceptance criteria:
/share/jobs/:idis served by the renderer; all other paths still serve the static SPA exactly as before.- A renderer that is briefly down does not take down static serving (nginx still answers
/).
OG-003 — Dockerfile.app runtime + dual-process wiring (Option A)
- [x] Done
Files:
- Edit:
castyou-frontend/Dockerfile.app
Changes (final nginx:alpine stage):
RUN apk add --no-cache nodejs(~40MB; provides built-infetch).COPY apps/app/meta-renderer/server.mjs /opt/meta-renderer/server.mjs.- Replace
CMDso both processes run, nginx foreground:CMD ["/bin/sh", "-c", "node /opt/meta-renderer/server.mjs & nginx -g 'daemon off;'"]
Notes:
- nginx remains effective PID 1; its exit stops the container.
- Known tradeoff (accepted for Option A): if the renderer crashes it is not auto-restarted —
/share/*previews degrade to generic tags until redeploy, while static serving is unaffected. If/share/*later becomes high-traffic, split the renderer into its own Coolify service (mechanical refactor;server.mjsunchanged but must then sourceindex.htmlvia shared volume/baked template).
Acceptance criteria:
- Built image serves the SPA on
:80and the renderer responds on:8080internally. - Container healthcheck (
wget http://localhost/) still passes.
OG-004 — Coolify env vars + index.html anchor protection
- [x] Done (code complete; set GRAPHQL_URL, PUBLIC_ORIGIN, META_PORT=8080 in Coolify app service)
Files:
- Edit:
castyou-frontend/apps/app/index.html— add a comment above the OG block warning it is regex-anchored by the renderer - Coolify: set service env vars (no file in repo)
Env vars (Coolify app service):
| Var | Example | Purpose |
|---|---|---|
GRAPHQL_URL | http://castyou-backend:4000/graphql | server-to-server, internal Coolify network |
PUBLIC_ORIGIN | https://i.castyou.app | canonical + image base |
META_PORT | 8080 | must match the nginx proxy_pass port |
index.html marker (above the `` block):
html
Acceptance criteria:
- Renderer reads all three env vars; missing
GRAPHQL_URLfalls back to a sane internal default and logs a warning. - The marker comment is present and the renderer's replace still matches.
TEST-OG-001 — Share-preview validation
- [x] Done (post-deploy validation completed)
Manual / scripted checks (no unit suite required for v1):
curlwithfacebookexternalhit,LinkedInBot,Twitterbot,WhatsAppuser-agents against a valid id → assert per-jobog:title/og:imagein body.- Same URL in a real browser → SPA renders
PublicJobPageinteractively (HTML injection didn't break hydration). - Bad id → generic tags + SPA
EmptyState. - Official validators: LinkedIn Post Inspector, Facebook Sharing Debugger, opengraph.xyz.
Acceptance criteria:
- At least LinkedIn + Facebook debuggers render a correct per-job card (title, description, flier image).
- Documented caveat acknowledged: scraper image caches are sticky — editing a job won't refresh an already-scraped card until the platform's TTL expires or it's re-submitted via the debugger.