Skip to content

feat: rich route-aware Thingtime link previews - #607

Open
lopugit wants to merge 27 commits into
mainfrom
codex/rich-link-previews
Open

feat: rich route-aware Thingtime link previews#607
lopugit wants to merge 27 commits into
mainfrom
codex/rich-link-previews

Conversation

@lopugit

@lopugit lopugit commented Sep 4, 2026

Copy link
Copy Markdown
Owner

✅ Develop S3 preview ready

The alias passed the develop bucket CORS preflight and a final live PR/SHA fence.

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

Summary

  • Generate colourful 1200 by 630 Thingtime PNG cards for public shared URLs, using the browser-banner treatment and five-colour mark.
  • Resolve contextual previews for posts, photo collages, polls, listings, shares, profiles, media, Things, published pages, feeds, and public docs/catalogue routes.
  • Route social surfaces through Nitro, emit complete Open Graph and Twitter image metadata, and verify Vercel traces the native PNG renderer.

Safety

  • Preview data is resolved anonymously and falls back generically for private or missing content.
  • Only stored public Thingtime image attachments are fetched for collages; linked remote URLs, malformed paths, non-images, and oversized data fail closed.

Validation

  • Social preview tests: 7 passing.
  • Targeted ESLint passed.
  • Vercel config tests: 2 passing.
  • Full Remix production build passed, including the Vercel output verifier.
  • Browser screenshot and curl proof for the local social-card feed route: valid 1200 by 630 PNG, correct Open Graph tags, and visual review.
  • Updated and followed the Social meta and link unfurls checklist in TESTING.md; public post, poll, listing, and page data-plane permutations remain suitable for preview-environment smoke testing.

Note

  • Repository-wide TypeScript remains red on pre-existing unrelated errors in migrations, Lopu, Nav, Thingtime, docs, schemas, smarts, and duplicated Nitro config. A filtered check reports no errors in the preview files or server route changed here.

@github-actions github-actions Bot added lopu: mergeable The PR branches can currently be merged without conflicts lopu: overlapping files This PR changes files also changed by another open PR labels Sep 4, 2026
@github-actions github-actions Bot added lopu: out-of-date The PR branch is behind its target branch and removed lopu: mergeable The PR branches can currently be merged without conflicts labels Sep 4, 2026
@github-actions github-actions Bot added the lopu: queued The current PR snapshot is waiting in Lopu's PR-management queue label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: ✅ Resolver attempt finished

Current phase: GitHub mergeability refresh is still pending; the detailed result is posted

Estimated completion: Done — no further active-work ETA.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-04 14:05 UTC (UTC+00:00) 2026-09-04 07:05 PDT (UTC-07:00) 2026-09-05 00:05 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 20
Repository Conflicting 3
Repository Out-of-date with target 3
Repository GitHub state unknown 3
Repository Part of an open stack 2
Repository Touch files changed by another open PR 12
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 1
This resolver batch Waiting 3
This resolver batch Finished 3

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: main is a repository root/integration branch.
  • Changed-file overlap: 4 changed files are also touched by #10, #291, #295, #554, #564, #578, #595, #610, #611, #612.

Exact branch pair: maincodex/rich-link-previews.

Timeline

  • 13:32 UTC — Detected that main needs to be merged into codex/rich-link-previews; assigning the exact snapshot to the resolver queue.
  • 13:34 UTC — The immutable head/base selection is reserved; waiting for its worker job to enter the serialized lane.
  • 13:35 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 13:46 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:56 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:59 UTC — Preparing the isolated repository environment.
  • 14:00 UTC — Rebuilding Graphify structure and semantic context.
  • 14:02 UTC — The resolver worker completed successfully; waiting briefly for GitHub to recompute mergeability.
  • 14:05 UTC — The resolver worker completed successfully; GitHub did not publish a final mergeability verdict within three minutes.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged main into codex/rich-link-previews to bring this PR up to date via Lopu's PR manager.

No AI resolution was needed by merge time; the branch was updated with a plain merge commit.

Rebuilt and committed the merged code graph with graphify update .; no LLM semantic credential was available for the second phase.

Please review the merge commit before relying on it.

Lopu-Conflict-Resolution: run=33878695508 pr=607
Structural `graphify update` completed (graphify 0.9.4, no semantic credential available).

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/33878695508

Lopu-Conflict-Resolution: run=33878695508 pr=607
@github-actions github-actions Bot added lopu: unknown state GitHub is still computing the PR branch state and removed lopu: out-of-date The PR branch is behind its target branch lopu: queued The current PR snapshot is waiting in Lopu's PR-management queue labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Branch status still computing. The base branch moved, and GitHub had not finished recomputing whether this PR conflicts or is behind after the detector waited 500s — so no branch update was started this round. The next push or the twice-hourly scheduled sweep (minutes :02/:32) re-checks automatically.

Posted by the conflict detector at 07:10 UTC, 2026-09-05; this notice is edited in place on re-checks.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu — reviewed 0c05a10f against main (6ad5ef21). All live checks are green, the CodeQL snapshot for this head is empty, and I traced the security-sensitive claims to their implementations rather than taking the comments at their word:

  • viewerOf(null) really does return null, so getThing goes through the anonymous findViewableThing walk — private/missing content is indistinguishable and degrades to the generic card.
  • The image loader is not an SSRF surface: it only fetches the presigned URL from getAttachmentDownload, and download() 404s linked attachments outright before a URL is ever produced.
  • readBodyWithin bounds the read on the stream instead of trusting content-length, which is the right call — and the four unit tests around it cover the boundary properly.
  • Skipping runWithMongoEndpoint on /social-card is correct and load-bearing: it's what stops a caller-selected data plane from being cached under a public URL, and it keeps the customMongoActive() guard in download() meaningful.

I made one change — remix/package.json picked up tab indentation on three dependency lines in an otherwise 4-space file, which the next npm/pnpm write would reformat into unrelated diff noise. Nothing else was worth touching; the implementation is careful and the comments explain why.

Two things I'd like your call on, neither blocking:

  1. /social-card rate. v is part of the CDN cache key but is never read by the renderer, so ?v=<random> bypasses s-maxage=3600 for free. Each miss is a synchronous 1200×630 resvg rasterization on the event loop, plus DB reads and up to four 2 MB S3 fetches on content paths. The route isn't under /api/, so the API rate limiter doesn't apply. Per-request cost is bounded well; the rate isn't. An origin-level limit or a derived/signed v would close it — I didn't want to add rate limiting to a Nitro non-API route speculatively on this branch.
  2. ^/$ now rewrites to /__server with private, no-store, so every homepage hit becomes a function invocation that also resolves a Mongo endpoint. Necessary for server-injected meta and clearly intentional — just worth accepting knowingly rather than finding later in a bill.

Validation: focused suite 24/24 (twice, with the real @resvg/resvg-js native binding and the bundled font — the inkInBand assertions are a genuinely good test, they catch the fontless-deploy failure that magic bytes can't); vercel-config 2/2; and since the route patcher/verifier pair has no unit test, I ran the real patch-vercel-output.mjs against a synthetic Nitro config and then the real verifier's route assertions against its output — ordering and header invariants hold (filesystem 8 → api 10 → card 11 → meta 12 → SPA 13 → fallback 14).

Full detail in the review report.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu review — PR #607 (head c3f2f0eb vs main @ 6ad5ef21)

This head is one commit past 0c05a10f, and that commit is just the package.json indentation fix my previous pass made — so the code is byte-identical to what was already reviewed. Rather than restate that pass, I re-verified its security conclusions from the source and then went looking for what it hadn't covered.

Checks are green (build + typecheck ratchet, API suite, CodeQL). Find merge-conflicting PRs is still pending, and that pending check is the entire UNSTABLE mergeState — the PR itself is MERGEABLE. The CodeQL snapshot for this head is empty, so there was nothing to fix or dispose.

I found two things earlier passes missed, and fixed both.

1. /thing/:id resolved the same thing twice. thingPreview did const result = await getThing(…) and then, when the target turned out to be post-shaped, called postPreview(path, id) — which ran getThing again for the id it had just resolved. That's not a cheap read to repeat: toPublicPosts batch-embeds comments/reactions/views, and for a comment or media attachment getThing also walks the parent chain at uncapped depth, one round trip per level (things.ts flags that as a tail-latency cliff it had already optimised once). It compounds too — a shared /thing/:id triggers an HTML shell resolve and a separate /social-card resolve, so one unfurl cost four full projections where two would do, on a public unauthenticated path.

I split the post-shaped projection into publicPostPreview(path, result) that takes an already-resolved result. postPreview keeps its own getThing; thingPreview reuses what it has. The tt:all/tt:inherit belt-and-braces check moved into the shared helper, so both entry points still enforce it and non-public targets still degrade to the generic card. No behaviour change on any path.

2. One preview branch skipped the module's own truncation convention. In staticSocialPreview, the named catalogue/docs branch built its title and description with no truncateSocialText, while every other preview in the file caps at TITLE_MAX/DESCRIPTION_MAX. leaf is a raw URL segment and normaliseSocialPreviewPath allows 2048 characters, so /docs/<600 chars> put a path-length og:title and og:description in the head. The card was never affected — it wraps and clamps by measured width — so this was head bloat, not a broken image. Now bounded like its siblings, with a regression test that also pins the ordinary /components/colourful-button title so the fix can't start truncating normal names.

Considered and deliberately left alone: socialCardFontFiles does existsSync then writeFileSync, which isn't atomic. I checked before touching it — the function is fully synchronous so one Node process can't interleave, Vercel gives each instance its own /tmp, and ecosystem.config.js declares no instances/exec_mode: cluster. The race needs a concurrent second process this deployment doesn't have, so it wasn't worth the churn; worth a write-then-renameSync only if you ever move to clustered PM2.

Security claims I traced rather than took at face value: the image loader only ever fetches the presigned URL from getAttachmentDownload, which hard-404s linked attachments before minting one — two independent fences against SSRF. Blocked and pending-moderation attachments 404 for anonymous callers, so an unreviewed image can't be rasterised into a publicly cached card. And I checked the worry that verify-vercel-output.mjs could silently no-op if serverFallbackIndex came back -1: the patcher re-appends serverFallbackRoute at the end, and the four earlier /__server routes are excluded by index, so the assertions genuinely run.

Validation: test:social-previews 25/25 (24 + my new one), against the real @resvg/resvg-js binding and the bundled font; tsc --noEmit filtered to the changed files clean; eslint clean on both. Repo-wide tsc is still red on the pre-existing unrelated errors your description already documents — my changes add none.

The two open questions from my last pass (the /social-card rate, since v is in the CDN cache key but never read by the renderer; and ^/$ now rewriting to /__server on every homepage hit) are still yours to call — neither blocking, and I've not touched either.

Lovely piece of work. The strongest part is that the anonymous projection is shared between the text tags and the image, so the two can't drift, and the comments explain why on the non-obvious calls. Nice to see TESTING.md honest enough to document its own tofu gap for CJK/Arabic/Thai.

— Lopu 🦄

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🦄 Lopu review — PR #607 (head 2e741405 vs main @ 6ad5ef21)

Sixth pass on this branch. Rather than restate the previous five, I re-derived the security boundary from source and then went looking only at ground they hadn't covered.

Checks: 20 pass / 63 skipping / 3 pending / 0 failing. Nothing to diagnose — the UNSTABLE state is entirely the pending entries, and those are Hand off one Lopu repository review session (this session) and Find merge-conflicting PRs (the detector). CodeQL's snapshot for this exact head is empty, so there was nothing to fix or dispose, and no controller repair was warranted.

Two changes, both worth defending:

1. HEAD /social-card was paying for a preview it then threw away. The handler resolved the preview before checking the method, and a HEAD body is empty while none of those headers depend on which card would be drawn — so the resolve was work nothing could consume. That's worth more than a tidy: the endpoint is public and unauthenticated, ?v= is in the CDN key but never read back, and a content path costs a full anonymous getThing projection (uncapped parent walk on a comment or attachment). So HEAD bought that projection repeatedly with no rasterisation to make the miss worth caching — the cheapest half of the rate concern I raised last pass, and the half that can just be deleted. HEAD now returns before any data-plane work.

While hoisting the shared headers I kept the failure path's deliberately shorter public, max-age=300 (no s-maxage) as an explicit override with a comment — that intent was previously implicit in a duplicated object literal. One honest behaviour change: on the rare failure path a HEAD now advertises the success Cache-Control rather than the fallback one. HEAD describes the canonical resource and crawlers fetch cards with GET, so it's inconsequential — but it isn't a pure refactor and I'd rather say so.

2. Removed socialPreviewFromRequest. Exported, never referenced anywhere in the repo (full-tree search; the only hit was its own definition). It's a second copy of the request → origin → pathname → preview derivation that resolveSocialMeta already does, in a brand-new module — exactly the shape that drifts and then gets adopted by a later caller. Its getRequestOrigin import went with it. This one's a judgement call, not a defect; one revert if you meant it as a seed for a future surface.

Traced rather than trusted: the anonymous attachment path genuinely fails closed — viewer?.id === doc.ownerId is undefined === ownerId, so a bound attachment needs canViewTarget(null, doc), blocked/pending-moderation attachments 404, and attachmentLinked 404s before a URL is minted, so the loader can only fetch a presigned URL the app itself produced. The card geometry holds arithmetically (poll rows end y=547 against BADGE_ROW_TOP=548; three description lines are only reachable when options is empty). CARD_THEMES is Record<SocialPreviewVariant, …>, so the theme.primary dereference is exhaustive by construction. And the rewritten socialMeta.ts export surface is a superset of base, so rss.ts is unaffected.

Left alone deliberately: mediaLayout's unused theme param and resolveSocialPreview's unused origin param — harmless, ESLint-clean, and changing the resolver's signature touches three call sites for no behavioural gain. Also the MEDIA VIDEO / MEDIA AUDIO badges from humanPostKind(mediaVariant as SocialPreviewKind): the cast is a smell and they read shouty next to Reply and 6 photos, but that's a styling call on a card I can't see rendered.

Validation: test:social-previews 25/25 both before and after my edits (real @resvg/resvg-js binding + bundled font — the inkInBand assertions remain the best thing in that suite); test:vercel-config 2/2; tsc --noEmit filtered to the changed files clean; eslint clean on all six. Repo-wide tsc is still red on the pre-existing unrelated errors your description documents — I add none.

Still yours to call, none blocking:

  1. With HEAD free, the residual rate exposure is GET ?path=…&v=<random> — a cheap forced miss costing a synchronous resvg rasterisation plus up to four 2 MB S3 fetches, on a route the API rate limiter doesn't cover. A derived or signed v would close it.
  2. Cards are s-maxage=3600, stale-while-revalidate=86400, and privatising or deleting a post doesn't invalidate an already-cached card URL — so a card for content that was public can keep serving for about a day. Normal for preview images, worth accepting knowingly.
  3. ^/$/__server makes every homepage hit a function invocation. Intentional; flagged for the bill.

The design decision this all rests on — one anonymous projection shared by the text tags and the image, so the two can't drift — is the right one, and the non-obvious calls all explain why in place. Nice to see TESTING.md document its own CJK/Arabic/Thai tofu gap instead of hiding it.

— Lopu 🦄

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu review — rich route-aware link previews

I compared 2d910d95 against main @ 6ad5ef21 in a clean worktree. No code changes were needed on this branch. Checks are green on this head (nothing failing or cancelled), and the CodeQL snapshot for this head is empty, so there was nothing to fix or dispose.

What I verified rather than assumed

  • No SSRF surface. /social-card never fetches a caller-supplied URL. postImages admits only stored attachments (!attachment.url), and bytes arrive only via a presigned getAttachmentDownload. Linked attachments are excluded twice — here, and by attachmentLinked 404ing inside download itself.
  • ACL genuinely holds. getAttachmentDownload(null, …) runs the anonymous viewer through canViewTarget; viewer?.id === doc.ownerId cannot match a null viewer. The ACL_ALL/ACL_INHERIT re-check in publicPostPreview is real belt-and-braces, not decoration.
  • Attachment ids line up. attachment.id and PublicThing.id are both doc.shareId, which is exactly what attachmentMatch looks up — so the /media/:id card resolves the object it means to.
  • Declining the mongo-endpoint override on the card route is the right call, and for the reason the comment gives: the response is s-maxage-shared and shared caches key on URL alone.
  • server/routes/index.ts re-exports the [...] handler, so moving ^/$ to /__server really does reach the new controller.

Findings — none blocking

  1. Unauthenticated render amplification. Both query params are cache-busters: path takes any local path up to 2048 chars, and v is in the CDN key but never read back. So s-maxage=3600 is trivially defeated — ?path=/&v=1…N is N distinct keys and N full renders, each costing an anonymous getThing (uncapped parent walk), up to four 2 MB S3 fetches, and a 1200×630 resvg rasterise. This is the one I'd want an explicit decision on: rate limit, bounded in-process render cache, or reject unknown query keys?
  2. The fallback render can itself throw. The second renderSocialCardPng(staticSocialPreview(path)) inside the catch is unguarded, so a systemic renderer failure still 500s the branch whose whole job is "always return a safe card".
  3. / now costs a function invocation per homepage load, having previously come off static output — for metadata that is constant (staticSocialPreview('/')) and a shell that is private, no-store, so nothing recovers the cost.
  4. Un-publish staleness. Make a public post private and the HTML meta switches to the generic card at once, but the already-issued ?v=<updatedAt> image keeps serving the rich card for up to 1 h s-maxage + 24 h stale-while-revalidate.
  5. Nits: resolveSocialPreview(origin, …) never uses origin; mediaLayout(…, theme) never uses theme.

Validation I ran

node --import tsx --test …/socialPreview.test.ts …/socialCard.test.ts25/25 pass, including the real resvg rasterisations (measured-width wrap/clamp, the pixel-ink assertions that catch the blank-card-with-no-system-fonts regression, the .notdef tofu check, the undecodable-image fallback). scripts/vercel-config.test.mjs — 2/2. I also ran the real patch-vercel-output.mjs against a synthetic Nitro config and replayed verify-vercel-output.mjs's route assertions against the output — 8/8 — and checked socialMetaRoute.src against the actual app/routes.tsx table: all 23 public routes match, and none of /settings, /admin*, /messages, /saved, /notifications, /lopu*, /login, /register, /authorize do. The "never an oracle for a private screen" boundary is intact.

Nicely done — the suite pins real defect classes (blank cards, tofu glyphs, pill overflow, unbounded bodies) rather than restating the implementation, and the comments carry the reasoning that usually gets lost. The space→tab reformat of server/routes/[...].ts matches remix/.prettierrc (useTabs: true), so that's convergence, not drift.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu review — codex/rich-link-previews @ 2d910d95 vs main @ 6ad5ef21

No code changes needed. I traced the security boundaries rather than trusting the comments, and they hold. Two convention-only additions; details below.

Checks & CodeQL

22 pass / 52 skipping / 1 still running (Find merge-conflicting PRs). No failures to diagnose — UNSTABLE here is the skipped control-plane contexts, not a red check. CodeQL: zero open alerts on this head.

What I verified

The leak question — defended twice. Every data-backed preview goes through getThing(viewerOf(null), …)findViewableThingAs, with a belt-and-braces ACL_ALL/ACL_INHERIT re-check in publicPostPreview. The new /thing/:id and /media/:id branches rely wholly on findViewableThingAs — I checked, and that is the sufficient boundary. Separately, loadAttachmentDataUrigetAttachmentDownload(null, …) re-enforces the anonymous ACL independently (attachments.ts:1245-1249: with a null viewer the owner branch is unreachable and it requires targetId + canViewTarget). So even a preview that names an attachment the caller cannot see degrades to the branded tile. Private and missing both yield the identical generic card — not an existence oracle.

No proxy, no SSRF. The fetched URL is S3-signed from server config, never caller input. Linked remote attachments are excluded before resolution and would fail the download guard.

Skipping runWithMongoEndpoint on /social-card is right, and the reasoning in the code is correct: the response is s-maxage shared-cacheable, shared caches key on URL alone, so honouring an unauthenticated x-tt-mongo-url would let a caller store a card from a data plane they control under the URL every later unfurler receives.

Injection. escapeXml is a real choke point with & escaped first. I specifically chased the control-character path: staticSocialPreview's catalogue branch is the one place a raw URL segment reaches a title without cleanSocialText — but the HTML path gets a still-percent-encoded URL.pathname, and the card path re-cleans inside wrap(). Nothing raw reaches either sink. The second decodeURIComponent throws on a malformed %, but every call site is inside the try, so it degrades to the generic card rather than 500ing.

Layout arithmetic checked, not assumed. Poll rows (454 + 3×34, h25) land one pixel above BADGE_ROW_TOP = 548; the two-line description cap clears the poll strip; three title lines at pitch 50 from y=246 clear y=408. wrap()'s maxLines loop has no off-by-one.

Routing. ^/$ → /__server reuses the same pre-filesystem rewrite pattern wellKnownDiscoveryRoute already proves in production. The public-surface regex genuinely can't leak a private screen — things/:x, bare /profile and search all land on staticSocialPreview, which reads no data.

The tests are the best part. Pixel-level ink counting means the exact defect this PR fixes — fontless runtime producing full artwork, zero glyphs, and still a valid PNG of plausible length — fails the suite instead of sneaking through. Same for the undecodable-image tile, checked by mean tile colour rather than markup. That is the right instinct.

Changes I made (convention only, no source touched)

  1. Added PRs/607-codex-rich-link-previews--rich-route-aware-thingtime-link-previews.md. AGENTS.md requires a PRs/ note for large PRs or PRs with several debugging rounds — this is both; the source comments alone record five (fontless runtime, tofu glyphs, poll/badge overprint, unclamped eyebrow/author lines, blank undecodable tiles). It was the only AGENTS.md convention this PR didn't already meet.
  2. Linked it from the existing CHANGELOG.md entry, matching PR feat(notifications): Lopu toast position setting + /notifications history + system action-run notes #611's form.

❓ Needs your call: base branch

AGENTS.md says all PRs target develop, and that a main-based PR which isn't an authorized promotion should be retargeted. This is a feature branch on main. I did not retarget it — five other open PRs are also main-based, the develop → main promotion flow is active (#590 + the promote/* family), and flipping the base on a 21-file PR mid-review could disrupt the Feature Stack machinery. Your decision, not mine to force silently.

Non-blocking follow-ups (recorded in the PR note)

  • /social-card is public, unauthenticated, and does real work per request: anonymous getThing (uncapped parent walk on a comment/attachment), up to four S3 fetches, and a synchronous resvg rasterisation that blocks the event loop. path is attacker-variable, so the shared-cache key busts arbitrarily and every request is a miss. Per-request bounds are good; there's no rate bound. Worth a resolved-path allowlist, a render concurrency guard, or a negative cache if abuse shows up.
  • If the fallback renderSocialCardPng(staticSocialPreview(path)) inside the catch throws, the error escapes and an image request gets a 500 HTML body.
  • ^/$ now costs a function invocation on the highest-traffic route, where the card is entirely static and only the request-derived origin needs the server.
  • Unused: origin in resolveSocialPreview, theme in mediaLayout, and SocialPreviewImage.label is resolved but never rendered.

Validation I ran

  • npm --prefix remix run test:social-previews25/25 pass (re-run after my edits, still 25/25)
  • Focused ESLint on all six changed source files — pass (only the known @remix-run/eslint-config deprecation warning)
  • Bootstrapped deps and confirmed @resvg/resvg-js loads its native package — the pixel tests can't run otherwise
  • Did not re-run the full production build / verify:vercel-output: green on this head, and my changes are Markdown-only

Verdict: careful work, comments that explain the why where the code is, and tests aimed at real failure modes. Boundaries hold under tracing. Nothing found that warranted a code change.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu — repository review of 47cdb8dd

Read the whole preview stack against main and traced the safety boundaries into their implementations rather than taking the PR note for it. getAttachmentDownload(null, …) really does re-enforce the anonymous ACL, reject linked attachments before any fetch, and 404 blocked/pending moderation, so there is no image proxy and no SSRF surface here. readBodyWithin capping the stream instead of trusting content-length is the right instinct and the off-by-one is pinned. Skipping runWithMongoEndpoint on a shared-cacheable response is the correct call, and the comment explaining why is the kind I want in this repo.

No failing checks — everything completed is green; BLOCKED is the pending Analyze (javascript-typescript) and merge-conflict scan, not a red result. CodeQL snapshot for this head is empty, so nothing to fix or dispose.

One real defect, fixed in the worktree

mediaLayout drew every count ≥ 3 into a 2×2 grid. At exactly three photos the fourth cell was never drawn — and an undrawn cell isn't empty, it's the white card panel showing through. That's the same blank slab imageTile paints its branded tile to prevent, and that the suite already guards for undecodable bytes. Only count === 3 had it.

Measured on the real renderer — mean RGB-sum of the panel's bottom-right quadrant (>740 ≈ white panel):

imageCount before after
1 591.1 painted 591.1 painted
2 513.7 painted 513.7 painted
3 765.0 blank 510.7 painted
4 433.9 painted 433.9 painted
6 433.9 painted 433.9 painted

Three photos is an ordinary post, so this shipped a visible white square on a real share shape. Fix is a tall-left / stacked-right collage mirroring the existing count === 2 branch, plus a pixel regression test over counts 1/2/3/4/6. Focused suite: 25/25 before, 26/26 after, and the new test fails as expected with the fix stashed, so it isn't vacuous.

Worth your call, not blocking

  • socialCard.ts — and through it the resvg native binding plus the 1.1 MB socialCardFontData.ts — is a static top-level import in server/routes/[...].ts, so every page-shell cold start now pays it, including /, even though only /social-card can use it. The file already establishes the lazy await import() convention for exactly this reason. I left it alone deliberately: it changes what Nitro externalises, and verify-vercel-output.mjs now asserts the trace declares both @resvg/resvg-js and its platform N-API package — I can't exercise that without a full build.
  • Vercel's route-level headers on ^/social-card$ pin s-maxage=3600, which may override the handler's deliberately shortened max-age=300 on the failure path. Worth confirming precedence, since the comment there says a failure card must not sit in the CDN for an hour.
  • Your follow-ups list is accurate and I agree with the triage. One footnote on the XML path: resvg's parser throws on a raw C0 control character (I verified U+0001/0000/000B/001F), and the catch-block fallback re-renders from the same path — so a control char reaching the SVG would be a 500, not a fallback card. It isn't reachable today, because the one input that skips cleanSocialText (the raw URL leaf in staticSocialPreview) still passes through wrap, which cleans it. Load-bearing, so worth knowing it's load-bearing.

Nice work — the measured-width layout and the pixel-level font assertions are the right way to test this, and they're why the fontless-deploy defect can't come back.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu review — PR #607 (head 2be20233 vs main @ 6ad5ef21)

Ninth pass. Checks are green (nothing failing or cancelled — the pending entries are this review's own dispatch), and the CodeQL snapshot for this head is [], so there was nothing to fix or dispose there.

Rather than restate the eight passes before me, I re-derived the safety boundary from source and then went hunting on ground they hadn't covered. I found one real defect, with two faces, and it's fixed on this branch.

Unrenderable values were taking the slot of renderable ones

The card strips everything the bundled Liberation face can't draw, so resvg never paints a .notdef box — good. But that strip lives inside cardText/escapeXml, which is downstream of every decision about what to draw. Two places chose their content before it was measured, so a value that strips to nothing still claimed its slot and then painted nothing into it:

  1. The avatar disc rendered blank for any emoji-leading display name. escapeXml(preview.initial || 'T') — for 🌸 Rosie, initialOf gives 🌸, which is perfectly truthy, so the 'T' fallback never fires. The escape then drops it, and you get an empty coloured circle next to a correctly-drawn "Rosie".
  2. Empty badge pills, displacing real ones. preview.badges.slice(0, 3) ran before the strip. A post with structured status: '🎉' and an emoji tag produced ['task', '🎉', '#🎉', '#garden'] → three pills labelled task, "" and "#", with #garden pushed off the card entirely. Worse than blank: it cost you a good badge.

Both reproduced against the real renderer first. Fixed by choosing from what survives the strip — cardInitial() falls back initial → first renderable character of the author → 'T', and badgeRenders() filters before the slice to three.

Deliberately confined to the card: the preview model still carries the emoji, because the OG/Twitter text tags are drawn by the unfurler with its own fonts. Only the PNG is limited to the face we ship — exactly the split your comment at the top of the file already describes.

Validation

socialPreview.test.ts + socialCard.test.ts27/27 (26 before my addition), including the real resvg rasterisations. Negative control: with the fix reverted the new test fails expected: 'R', actual: ''; restored, it passes. End-to-end on a real PNG for a 🌸 Rosie post: 80 white glyph pixels inside the avatar disc, where the pre-fix card drew zero.

Verified rather than assumed (no change needed)

  • No SSRF. postImages admits only stored attachments (!attachment.url) and bytes arrive solely via a presigned getAttachmentDownload(null, …), which re-runs the anonymous ACL and 404s linked attachments independently. Two locks, not one.
  • The projection genuinely fails closed, decodeURIComponent throwing on /post/% included.
  • Geometry holds: poll rows end at y=547 against BADGE_ROW_TOP=548, and the two-line description cap under options clears POLL_ROW_TOP. clampToWidth can't emit a double ellipsis, and wrap()'s dropped check is right on both boundaries.
  • One nuance on the mongo-endpoint reasoning: "the HTML shell below is private/no-store" is true in production, but it comes from appShellHeaders in patch-vercel-output.mjs — the handler itself sets no Cache-Control. Correct on Vercel; worth knowing if that output is ever served elsewhere.

Still open from earlier passes (your call, not mine to patch)

  • The fallback renderSocialCardPng inside the route's catch is unguarded, so a systemic renderer failure still 500s the branch whose entire job is "always return a safe card".
  • ?path= and ?v= are both pure cache-busters, so s-maxage=3600 is trivially defeated — each distinct key buys an anonymous getThing, up to four 2 MB S3 fetches and a full rasterise on a public endpoint. That wants a product decision.
  • An emoji-only poll option still draws a bar with no label. Left alone on purpose: unlike a badge pill the bar carries meaning, and filtering would desync the card from the og:description option list.
  • Two dead parameters: resolveSocialPreview(origin, …) and mediaLayout(…, theme).

This remains lovely work — the suite pins real defect classes rather than restating the implementation, and the comments carry the reasoning that usually evaporates. The bug I found is the kind you only get because the emoji strip exists; it's the seam between it and the layout, not the strip itself.

Lopu · Thingtime PR manager · Claude Opus 5

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu review — PR #607 (head 6f05f27b vs main @ 6ad5ef21)

Eleventh pass. Checks are green — 23 pass, 63 skipping, 0 failing; the BLOCKED state is entirely pending entries (Analyze (javascript-typescript), conversation_gate, the merge-conflict scan, and this review's own dispatch). The CodeQL snapshot for this head is [], so there was nothing to fix or dispose.

Rather than restate the ten passes before me, I went looking on ground they hadn't covered — the cost of a card rather than its correctness or its ACL. I found one real defect, I measured it, and I deliberately did not patch it. Reasoning below; I'd like your call.

A 436 KB attachment costs 1.09 GB of RAM to draw

loadAttachmentDataUri bounds the fetch by bytes — and readBodyWithin does that properly, capping the stream as it arrives rather than trusting content-length. But a byte cap is not a pixel cap, and resvg decodes the whole source bitmap before scaling it into the 430×446 tile. getAttachmentDownload hands back the original object, not a resized variant, so those are whatever the poster uploaded.

Measured on this branch's own buildSocialCardSvg + socialCardRenderOptions, one tile, 12000×12000 flat-colour PNG:

stored size 436 KB — 21% of the 2 MB cap
source pixels 144 MP — 3.6× the repo's own 40 MP guard
peak RSS 1.09 GB
render time 2.39 s

renderSocialCardPng draws up to four tiles and holds all four data URIs in one SVG, so the ceiling is ~4.4 GB and ~10 s per request.

Reachability is ordinary rather than exotic: upload that PNG to your own public post. postImages admits it (stored image, not linked, not pending) and getAttachmentDownload(null, …) allows it precisely because the post is public — the ACL is working correctly, it just isn't the control that's missing here. The endpoint is unauthenticated, and ?v= is in the CDN key but never read by the handler, so varying it replays a full cache-missing render at will. That's the concrete edge of the /social-card rate question raised a few passes back.

What makes me confident this is a gap rather than a judgement call: attachments/imageVariants.ts:15 already defends exactly this threat on the sibling image path — limitInputPixels: 40_000_000, .timeout({ seconds: 8 }), a 20 MB input cap, and a 4-way concurrency gate that returns 503. The posture exists in this repo. The card path just doesn't reuse it.

Why I left it for you instead of fixing it

The natural fix is a sharp header-only metadata() dimension check mirroring imageVariants.ts. I stopped short, because sharp is currently reachable only from app/routes/api/v1/attachments/content/_content.tsx — which lands in the /api/[...] function. The card runs in /__server, which has no sharp today.

Adding a native dependency to that function is the exact trace hazard this PR already added verify-vercel-output.mjs machinery to catch for @resvg/resvg-js. If it silently failed to trace, the dynamic import would throw and every photo tile would degrade to the branded fallback — a regression nobody would see until they looked at a card. Confirming it needs a full production build plus the trace verifier, and I hit my scheduling handover boundary before I could stand that up. An accurate finding beats a half-verified native-dep change on your branch.

When you land it, I'd suggest either:

  1. Reject over-budget images before rasterising via sharp(...).metadata() (width × height × pages), failing closed to the branded tile — and extend the verify-vercel-output.mjs assertion to pin sharp in __server the same way it now pins @resvg/resvg-js. The tile is 430×446, so even a ~4 MP budget is generous.
  2. Or read the already-resized variant the app serves elsewhere instead of the original object — smaller fetch, smaller data URI, and the pixel bound comes for free.

Smaller things, none blocking

  • The failure-path cache intent may not survive deployment. The handler deliberately drops the error card to public, max-age=300 so a bad card can't sit in the CDN for an hour — but patch-vercel-output.mjs stamps s-maxage=3600, stale-while-revalidate=86400 as a route-level header on ^/social-card$. If Vercel route headers take precedence over function-set ones (I believe they do; I couldn't confirm it without triggering the error path on a real deployment), that comment describes an intent the config quietly overrides. One line from you settles it.
  • That same route header lands on the 405 for non-GET/HEAD, making a method rejection shared-cacheable. Tiny.
  • SocialPreviewImage.label is populated in both postImages and mediaPreview and never read — it reads like it drives alt text.
  • webpageBlockCount recurses with no depth cap while its sibling webpageCopy is bounded at 12. It fails closed (the overflow is caught and degrades to the generic card), so this is asymmetry rather than a bug.

What I re-derived from source and accept

  • No SSRF, no proxy. The only URL fetched is the S3-signed one; linked attachments are excluded twice — !attachment.url here, and attachmentLinked 404ing inside download itself.
  • The ACL genuinely holds. getAttachmentDownload(null, …) re-enforces it independently: the viewer?.id === doc.ownerId branch is unreachable with a null viewer, and it still requires targetId + canViewTarget, with blocked/pending moderation and unbound drafts 404ing. Private and missing produce the identical generic card.
  • profilePreview exposes nothing new — it's the same findUserByUsername + toPublicProfile pair the already-anonymous GET /api/v1/users/profile loader uses.
  • webpagePreview fails closed — anonymous resolveWebpage falls back to acl: 'tt:all', so unpublished pages don't resolve.
  • Declining runWithMongoEndpoint on /social-card is correct and load-bearing, for exactly the reason the comment gives.
  • The Vercel route surgery is sound. ^/$/__server sits before the filesystem phase (same precedent as well-known discovery), card and meta routes sit after filesystem/API and before the SPA fallback, and the revised serverFallbackIndex correctly excludes the three social indexes so the verifier still pins the real Nitro fallback.

Validation

From the PR worktree at 6f05f27b, pnpm install --frozen-lockfile:

  • pnpm run test:social-previews27 passing, 0 failing
  • pnpm run test:vercel-config2 passing, 0 failing
  • Bomb repro built and measured against this branch's renderer (table above); scratch files removed, git status clean.

No code changes on this branch. 🦄

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu reviewcodex/rich-link-previews @ 6f05f27b vs main @ 6ad5ef21.

Nice piece of work: the shared anonymous projection between the OG tags and the card is the right shape, and the authorization path holds up — getAttachmentDownload(null, …) runs the full anonymous walk, moderation fails closed, linked attachments 404 so there's no open-redirect, and the fetched URL is a server-generated version-pinned presigned S3 URL, so no SSRF surface. Skipping runWithMongoEndpoint on /social-card is exactly right for a shared-cacheable response, and the comment explaining why is the kind I want to find in six months.

One real defect, fixed in the worktree.

Decompression bomb → OOM in the public card renderer

loadAttachmentDataUri bounded the embedded image by bytes (SAFE_IMAGE_TYPES + the 2 MiB readBodyWithin cap) and then handed those bytes to resvg. resvg decodes an embedded raster to RGBA8 before scaling it into the 430×446 tile, and @resvg/resvg-js@2 exposes no input-pixel limit — so the byte cap was never a memory cap.

Reproduced with a PNG of uniform pixels; both inputs pass every guard in the loader:

input file size peak RSS wall
20000×20000 380 KiB 2.02 GiB 5.2 s
40000×40000 1.52 MiB 7.89 GiB 20.5 s

renderSocialCardPng loads up to four tiles per card, and GET /social-card is public, unauthenticated, and trivially cache-bustable (?v= is part of the CDN key but is never read back by the handler) — so this is a remotely triggerable OOM of the Nitro function, not just a slow request. There's no upload-time dimension check to lean on either: the only limitInputPixels in the attachments tree is in imageVariants.ts, which guards the resize endpoint, not what gets stored.

Fix: socialCardImageExtent() reads intrinsic dimensions from the image's own header before embedding — PNG, GIF, JPEG (segment walk to SOFn), WebP (VP8 /VP8L/VP8X) and AVIF (meta → iprp → ipco → ispe box walk).

  • Dispatch is on magic bytes, not the declared Content-Type — the served type is author-supplied attachment metadata, so trusting it is the gap that let un-measured bytes through in the first place. That also closes the mislabelled-bytes case your imageTile comment already documented.
  • Unmeasurable → fails closed to the branded tile imageTile already paints, so no new blank-slab state.
  • Per-image cap 40 MP, matching the limitInputPixels imageVariants.ts already applies to the same user images — an image the resize endpoint refuses can't slip in through the card instead.
  • A separate 80 MP collage budget is spent in tile order after the (still concurrent) S3 fetches, so four in-budget photos can't multiply the per-image ceiling on one request.

Same 40000×40000 input, before → after: 7.89 GiB / 20.5 s → 66 MiB / 25 ms.

For AVIF I walk the box tree and take the largest ispe rather than scanning for the fourcc: a raw scan can match inside compressed image data and read a bogus, possibly small size — and under-reading is the one direction this guard must never fail in.

Validation: 30 passing / 0 failing on test:social-previews (your 27 + 3 new covering per-format parsing, the fail-closed cases, and the bomb itself). tsc --strict clean across app/api/utils/meta/.

Three for you to judge — I changed none of them

  1. ^/$ now routes to /__server. The homepage moves from a static file to a function invocation on every hit. staticSocialPreview('/') touches no data plane so it's cheap, but it's still a cold-start and cost change on the busiest route, and the only gain over the static shell is an og:image that could be baked in at build time. Looks deliberate, so I left it.
  2. No rate limit on /social-card. With the pixel guard the worst case is bounded, but each cache miss still costs an anonymous getThing (uncapped parent walk for a comment or attachment), up to four S3 fetches, and a resvg render — and ?v= defeats the CDN. imageVariants.ts handles the comparable case with a pending.size >= 4 → 503 concurrency guard. Design call, not a defect.
  3. ACL belt-and-braces asymmetry. publicPostPreview re-checks ACL_ALL/ACL_INHERIT on top of the anonymous walk; mediaPreview and thingPreview rely on the walk alone. The walk is the real gate so this isn't a leak — just an inconsistency in the defence-in-depth.

No CodeQL alerts open on this head. No failing checks — the UNSTABLE state is just Find merge-conflicting PRs still running.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu review — head 560752a4 vs main @ 6ad5ef21.

I read the full diff and traced every path where this PR turns an unauthenticated request into a data read, an outbound fetch, or a decode. No blocking defect — no change made, and the worktree is left clean. CI is green on this exact head SHA (Build + typecheck ratchet + unit tests, API suite, Analyze (actions) all success on run 34060486166); BLOCKED is just the pending contexts. CodeQL snapshot for this head is empty, so there is nothing to fix or dispose.

What held up under scrutiny

  • The runWithMongoEndpoint omission on /social-card is correct and load-bearing. The response is s-maxage=3600 and shared caches key on URL alone, so honouring an unauthenticated x-tt-mongo-url would let a caller store an attacker-plane card under the public URL. Keeping the home plane also keeps customMongoActive() false — which is what makes download()'s attachmentPurpose !== 'profile' guard meaningful here rather than bypassed. The HTML branch still honours the override, which it must.
  • No image proxy hides in here. postImages drops attachments carrying url/pending, and download() independently 404s attachmentLinked records, so crystal.url never becomes a server-side fetch. The only outbound target is an S3 URL signed from objectKey + immutable objectVersionId.
  • Bombs die before resvg. socialCardImageExtent dispatches on magic bytes, never the declared Content-Type, and returns null for anything it can't measure with certainty. The AVIF reader walks the box tree instead of scanning for the ispe fourcc and takes the largest extent — the only safe direction when the failure mode is under-reading. 40 MP matching imageVariants.ts limitInputPixels is the right number: a photo the resize endpoint refuses can't slip in here instead.
  • readBodyWithin being a streaming cap rather than a content-length pre-check is right, since the header is absent exactly on chunked bodies.
  • Verified rather than assumed: mediaPreview passing thing.id as the attachment id is correct — PublicThing.id is the shareId and attachmentStore.getById matches on shareId.
  • The layout arithmetic holds at every author/options/line-count combination, and the suite asserts it directly instead of by eyeball.

Non-blocking, for a follow-up

  1. /social-card has per-request bounds but no per-caller bound. ?v= is in the CDN key and never read back, so any attacker-chosen v (or any extra param) is a guaranteed cache miss costing a getThing — with the uncapped parent walk for a comment or attachment — plus up to four S3 round trips and a 1200×630 rasterisation. Everything inside one request is capped; nothing caps the rate. Worth pairing with app/api/utils/rateLimit/, or deriving v as a signed/short hash. I deliberately didn't do it here: the route avoids the endpoint context on purpose, so wiring a limiter is a design decision, not a repair.
  2. / moves from a static file to the Nitro function. Justified — crawlers need an origin-absolute og:image and the app serves several hostnames — but the highest-traffic page now pays an invocation and cold start per request under no-store, while its own preview content is entirely static. Flagging the cost, not disputing the call.
  3. resolveSocialPreview(origin, path) never reads origin — dead parameter.
  4. humanPostKind(mediaVariant) falls to the default branch, so a video on a text post badges as MEDIA VIDEO while every sibling badge is Title Case (Photo, Reply, Sold).
  5. patch-vercel-output.mjs now mixes tab-indented new lines with the file's existing 2-space lines. remix/.prettierrc (useTabs: true) wins over the disagreeing remix/.prettierrc.js, so the new lines are the conformant ones — the dual config is the real issue.

Validation

Ran the new test:social-previews suite on this head: 30/30 pass. It genuinely exercises the deployed path — rasterises through resvg with the bundled Liberation Sans and asserts no glyph renders as .notdef, that a truncated PNG degrades to the branded tile, and that a deflate bomb is rejected on pixel count rather than byte count.

Nice work — this is unusually well-defended code, and the comments explaining why each bound exists are the reason the review was quick.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lopu review — 560752a4 vs main @ 6ad5ef21

Approve. I read every non-generated file in the diff, plus the code the new paths
depend on but don't change (attachments.ts download(), the webpage-block
validation in app/schemas/registry.ts, nitro.config.ts), and checked the
security claims rather than trusting the comments.

Checks are clean — 22 pass / 63 skipping / 1 pending, and the pending entry is
Find merge-conflicting PRs in the very run that woke this review. CodeQL has
no open alerts on this head.

What I verified holds

  • Skipping runWithMongoEndpoint on /social-card is the right call, not an
    oversight — with s-maxage=3600 and URL-only cache keys, honouring
    x-tt-mongo-url there would let anyone store an attacker-plane card under the
    public URL every later unfurler receives. Nothing else in that path reads a
    header or cookie.
  • Anonymous authorization is real: download(null, …) can't match
    viewer?.id === doc.ownerId, so it falls through to canViewTarget(null, doc),
    and publicPostPreview adds an ACL_ALL/ACL_INHERIT check on top.
  • No SSRF — attachmentLinked is refused by download() and postImages drops
    anything carrying a url.
  • The bomb guard is ordered correctly: bounded during the read, dispatched on magic
    bytes rather than the author-supplied Content-Type, and the 40 MP bound matches
    imageVariants.ts's existing limitInputPixels for the same images.
  • webpageCopy/webpageBlockCount have no depth guard of their own, but
    MAX_WEBPAGE_BLOCK_DEPTH/MAX_WEBPAGE_BLOCKS are enforced at write time, so the
    tree can't be deep enough to overflow. Not a defect.
  • I resolved the emitted config.routes index-by-index (^/$→4, well-known→7,
    filesystem→8, api→9/10, card→11, meta→12, SPA→13, fallback→14); every assertion
    in the reworked verify-vercel-output.mjs holds.

The tests are the best part — asserting drawn ink instead of PNG magic bytes is
the only thing that could have caught the fontless-runtime bug, and the byte cap is
pinned against a body with no content-length.

Four follow-ups, none blocking — I made no changes

  1. Cold start. server/routes/[...].ts statically imports socialCard, so the
    resvg native addon + 1.1 MB of font data (~51 ms measured) load on every
    __server cold start — including the homepage, which ^/$ now routes through
    the function. socialMeta.ts documents the opposite convention two files away.
    I deliberately didn't switch it to await import(): that moves the renderer
    into its own chunk, and this PR's new verifier assertion depends on Vercel still
    tracing @resvg/resvg-js + its platform package into __server.func. I can't
    run a full Vercel build here to prove the trace survives, and I'm not trading a
    green build for 50 ms on an unverified bundling change. Worth doing behind a real
    build.
  2. /social-card amplification. Each miss costs an anonymous getThing, up to
    four 2 MiB S3 fetches and a 1200×630 rasterisation. ?v= is in the CDN key but
    never read by the handler, so arbitrary v values force unlimited misses at full
    cost for the same card — and Nitro routes sit outside the /api/v1 rate limiter.
    A shape check on v, or a concurrent-render cap, would close it.
  3. Dead param. resolveSocialPreview(origin, pathInput) never reads origin
    (socialPreview.ts:663).
  4. Badge copy. humanPostKind(mediaVariant as SocialPreviewKind)
    (socialPreview.ts:510) hits the default branch, so a text post with a video
    gets a MEDIA VIDEO pill in screaming caps beside Photo / Reply / #cats.
    The variant is tested; this string isn't. Your call on the copy.

Validation I ran

test:social-previews 30/30, test:vercel-config 2/2, test:vercel-root
5/5, ESLint clean on all 9 changed files, and tsc --noEmit shows 0 errors in
any file this PR touches
(108 pre-existing repo-wide — your PR note is accurate).

— Lopu

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: ✅ Lopu finished — this PR is mergeable

Current phase: GitHub verified the published branch result

Estimated completion: Done — no further active-work ETA.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 07:36 UTC (UTC+00:00) 2026-09-07 00:36 PDT (UTC-07:00) 2026-09-07 17:36 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 29
Repository Conflicting 3
Repository Out-of-date with target 3
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 20
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 1
This resolver batch Waiting 2
This resolver batch Finished 4

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: main is a repository root/integration branch.
  • Changed-file overlap: 4 changed files are also touched by #10, #295, #557, #564, #590, #595, #602, #611, #631, #638, #659, #660, +5 more.

Exact branch pair: maincodex/rich-link-previews.

Timeline

  • 05:58 UTC — Detected that main needs to be merged into codex/rich-link-previews; assigning the exact snapshot to the resolver queue.
  • 06:08 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 06:19 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 06:29 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 06:40 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 06:51 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:02 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:12 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:23 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:33 UTC — Rebuilding Graphify structure and semantic context.
  • 07:36 UTC — The resolver worker completed successfully and GitHub now reports this PR as mergeable.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged main into codex/rich-link-previews to bring this PR up to date via Lopu's PR manager.

No AI resolution was needed by merge time; the branch was updated with a plain merge commit.

graphify-out/ was reset wholesale to the main side (repo rule: one side, never mixed — the graph merge driver is unavailable in CI).

Rebuilt and committed the merged code graph with graphify update .; no LLM semantic credential was available for the second phase.

Please review the merge commit before relying on it.

Lopu-Conflict-Resolution: run=34088829197 pr=607

# Conflicts:
#	graphify-out/snapshots/v1/5ade579f61c50115e225aec6f7fd58535bbe56111ee8a830bceba7f295a448a6/d434f49a12523783144e6eff947d75b2f6c4a1ae0bfc949a759af35ad006814d/GRAPH_REPORT.md
#	graphify-out/snapshots/v1/5ade579f61c50115e225aec6f7fd58535bbe56111ee8a830bceba7f295a448a6/d434f49a12523783144e6eff947d75b2f6c4a1ae0bfc949a759af35ad006814d/cost.json
#	graphify-out/snapshots/v1/5ade579f61c50115e225aec6f7fd58535bbe56111ee8a830bceba7f295a448a6/d434f49a12523783144e6eff947d75b2f6c4a1ae0bfc949a759af35ad006814d/graph.json
#	graphify-out/snapshots/v1/5ade579f61c50115e225aec6f7fd58535bbe56111ee8a830bceba7f295a448a6/d434f49a12523783144e6eff947d75b2f6c4a1ae0bfc949a759af35ad006814d/manifest.json
#	graphify-out/snapshots/v1/94433d17c176f2bdac5c0a3e90f71417ae106a476d7bd861d5d9b811b7fa25aa/a72fe54e341fcdc9153402f7ce761c58d7f7269bd5a972f94ac68e85ffc25a27/GRAPH_REPORT.md
#	graphify-out/snapshots/v1/94433d17c176f2bdac5c0a3e90f71417ae106a476d7bd861d5d9b811b7fa25aa/a72fe54e341fcdc9153402f7ce761c58d7f7269bd5a972f94ac68e85ffc25a27/cost.json
#	graphify-out/snapshots/v1/94433d17c176f2bdac5c0a3e90f71417ae106a476d7bd861d5d9b811b7fa25aa/a72fe54e341fcdc9153402f7ce761c58d7f7269bd5a972f94ac68e85ffc25a27/graph.json
#	graphify-out/snapshots/v1/94433d17c176f2bdac5c0a3e90f71417ae106a476d7bd861d5d9b811b7fa25aa/a72fe54e341fcdc9153402f7ce761c58d7f7269bd5a972f94ac68e85ffc25a27/manifest.json
#	graphify-out/snapshots/v1/f7e629902b574fb823945c6fdf643102dc6d2d2a45d808e682ff1d6e0309ad29/9fdf09c60851ff3f5884a59af3930c059b5c85d70b1265a35aab688e61ae68c0/GRAPH_REPORT.md
#	graphify-out/snapshots/v1/f7e629902b574fb823945c6fdf643102dc6d2d2a45d808e682ff1d6e0309ad29/9fdf09c60851ff3f5884a59af3930c059b5c85d70b1265a35aab688e61ae68c0/cost.json
#	graphify-out/snapshots/v1/f7e629902b574fb823945c6fdf643102dc6d2d2a45d808e682ff1d6e0309ad29/9fdf09c60851ff3f5884a59af3930c059b5c85d70b1265a35aab688e61ae68c0/graph.json
#	graphify-out/snapshots/v1/f7e629902b574fb823945c6fdf643102dc6d2d2a45d808e682ff1d6e0309ad29/9fdf09c60851ff3f5884a59af3930c059b5c85d70b1265a35aab688e61ae68c0/manifest.json
Structural `graphify update` completed (graphify 0.9.4, no semantic credential available).

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/34088829197

Lopu-Conflict-Resolution: run=34088829197 pr=607
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: 🛠️ Lopu is actively working

Current phase: Rebuilding Graphify structure and semantic context

Estimated completion: around 08:48 UTC (~10 minutes; adjusted as work moves).

Next automatic check-in: within 10 minutes, or sooner when the phase changes. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 08:38 UTC (UTC+00:00) 2026-09-07 01:38 PDT (UTC-07:00) 2026-09-07 18:38 AEST (UTC+10:00)
Estimated finish 2026-09-07 08:48 UTC (UTC+00:00) 2026-09-07 01:48 PDT (UTC-07:00) 2026-09-07 18:48 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 28
Repository Conflicting 5
Repository Out-of-date with target 8
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 18
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 1
This resolver batch Currently resolving 1
This resolver batch Waiting 0
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: main is a repository root/integration branch.
  • Changed-file overlap: 4 changed files are also touched by #10, #295, #564, #590, #595, #602, #611, #638, #662, #665, #680, #682.

Exact branch pair: maincodex/rich-link-previews.

Timeline

  • 07:46 UTC — Detected that main needs to be merged into codex/rich-link-previews; assigning the exact snapshot to the resolver queue.
  • 07:48 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 07:58 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 08:09 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 08:20 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 08:30 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 08:38 UTC — Rebuilding Graphify structure and semantic context.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged main into codex/rich-link-previews to bring this PR up to date via Lopu's PR manager.

No AI resolution was needed by merge time; the branch was updated with a plain merge commit.

Rebuilt and committed the merged code graph with graphify update .; no LLM semantic credential was available for the second phase.

Please review the merge commit before relying on it.

Lopu-Conflict-Resolution: run=34097120597 pr=607
Structural `graphify update` completed (graphify 0.9.4, no semantic credential available).

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/34097120597

Lopu-Conflict-Resolution: run=34097120597 pr=607
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

last preview built 07/09 17:38 AEST #607 tt-pv1:607:develop:ada510cb3978bfea167cfe351a9329b41b118b04:1788766691432 lopu: out-of-date The PR branch is behind its target branch lopu: overlapping files This PR changes files also changed by another open PR lopu: queued The current PR snapshot is waiting in Lopu's PR-management queue preview: develop ready tt-preview-state:v1:develop:ready

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant