Skip to content

seo: technical fix bundle — robots, canonicals, receive-from, noindex catch-all, real 404s, redirects, sitemap lastmod - #2685

Merged
abalinda merged 11 commits into
devfrom
seo/technical-fixes
Aug 12, 2026
Merged

seo: technical fix bundle — robots, canonicals, receive-from, noindex catch-all, real 404s, redirects, sitemap lastmod#2685
abalinda merged 11 commits into
devfrom
seo/technical-fixes

Conversation

@0xkkonrad

Copy link
Copy Markdown
Contributor

Consolidates the five SEO fix PRs (#2676, #2678, #2681, #2682, #2684) into one PR at the CTO's request — one commit per fix, so any single change can be reverted surgically. Every fix was implemented and then adversarially reviewed by an independent agent that re-derived the evidence itself; all review catches are already folded in as their own commits. Full evidence lives on the five closed PRs; the load-bearing parts are summarized below.

What's in here (commit order)

1. 2648b5aaf — robots.txt, canonicals, receive-from loader (from #2676)

  • robots.ts: the Googlebot group had only Allow: /api/og, so per the robots spec Googlebot ignored all 24 * disallows. Now shares the disallow list.
  • /careers + /lp/card emitted rel=canonical → homepage (inherited); both now self-canonicalize. Verified by grepping the canonical <link> from rendered preview HTML vs the production defect.
  • corridors.ts: loadReceiveSources() intersected receive-from content with corridor origins, silently dropping 10 fully-authored countries (australia, india, kenya, malaysia, netherlands, pakistan, philippines, saudi-arabia, singapore, UAE). Now enumerates published content directly: RECEIVE_SOURCES 9 → 19. corridors.test.ts rewritten to the invariant it actually protects (every slug has published content — the May-2026 incident was the reverse). Review re-derived the counts and confirmed the new guard goes red on the old loader.

2. 936b012f9 — review catch on the above: scripts/verify-content.ts had a stale second copy of the old loader — Pass 11 (the blocking sitemap-coverage CI check) would have silently stopped covering the 10 recovered slugs. Script and app now both return the same 19; Pass 11 coverage 1115 → 1165 URLs.

3. 153c6bae5 — noindex the username catch-all (from #2678)
Any username-shaped string (/brazil, /pricing, /zzzfake1) rendered an indexable "X on Peanut" shell — the existence check is client-side, invisible to crawlers, and the root layout injects index, follow, so Google indexed thousands. All 4 generateMetadata return paths now ship robots: {index:false, follow:false}. Decision on record (12 Aug): no carve-outs, including real profiles and .eth pages.

4. 69b3e5dbf — HIGH review catch on the above: the noindex was shipping alongside the inherited rel=canonical → homepage — a combination Google's guidance warns can attribute the noindex to the canonical cluster head (the homepage, sitemap priority 1.0). alternates: {canonical: null} on all four branches; noindex pages now carry no canonical.

5. 5cb458a46 — real HTTP 404s (from #2681)
[...recipient]/loading.tsx made the catch-all a Suspense boundary, so Next streamed an HTTP 200 shell before notFound() resolved — the site never returned a real 404 (breaks GSC soft-404 reporting, link tools, crawl budget). One file deleted; the route's render path was confirmed to have no other boundary. Preview-verified: garbage URL → 404, real pages → 200, profiles → 200. Review verdict SHIP after independently re-probing.

6. 74ca6e544 + 7. f5034b218 — redirects bundle (from #2682)

  • /pricing, /stories, /content added to DEDICATED_ROUTES + bare→/en/… redirects (they were being squatted by the username catch-all).
  • /help/:path* bare→locale redirect — /help/delete-account (the Google Play account-deletion compliance URL) soft-404'd; it now resolves to the real article.
  • docs.peanut.to host-redirect rule (inert until ops attaches the domain — see asks).
  • Trailing-slash 308s scoped to the locale content tree; skipTrailingSlashRedirect untouched (protects the PostHog /relay/* proxy — probed: still proxies).
  • Review caught a genuine blocker in the first cut: :path* matched zero segments, so /es-ar/, /es-419/, /pt-br/ 308-looped to themselves (measured on the preview; browsers cache 308s permanently). Fixed with :path+; re-probed — bare locales 200, subpaths still redirect, follow /es-ar/ = 0 hops.

8. 8ce2b769e — real sitemap lastmod (from #2684)
All 709 URLs reported the deploy timestamp as lastmod, so Google discounts the signal. Dates now come from each page's generated_at frontmatter; static/legal pages keep BUILD_DATE. Review independently cross-checked 686 URLs against the content tree: 0 mismatches; dev deploy serves 1 distinct lastmod, this branch serves 26, identical URL set.

9. d2765ee81 — stale comment cleanup flagged by review.

Verification status

  • Local (this branch): jest (corridors + content) green, validate-links all passes (Pass 11 = 1165 URLs), tsc --noEmit exit 0, eslint 0 errors, all 9 commits SSH-signed.
  • next build cannot run on the dev box (earlyoom) — CI + the Vercel preview are the build gates; each constituent branch already had them green. A fresh probe pass runs on THIS PR's preview after CI; evidence will be posted as a comment.
  • Note preview limitations: robots.txt and the noindex meta can't be observed on previews (non-production BASE_URL noindexes everything) — those two rest on code + the per-branch evidence.

Asks / follow-ups (not in this PR)

  • Backend: add stories and content to the server-side reserved-username list (pricing is already reserved; the other two are claimable today, and frontend routing alone doesn't stop a signup claim).
  • Ops (needs access): attach docs.peanut.to to this Vercel project + repoint DNS; flip the redirect.pizza rule for peanut.to deep paths to path-preserving.
  • Post-deploy checks: X/Twitter card unfurls on payment-request/receipt links (they now carry noindex; most unfurlers ignore it, Twitterbot historically sometimes doesn't). Legal pages still report deploy-time lastmod (last_updated frontmatter isn't read — small follow-up).
  • Expected conflicts: fix(seo): harden Split public content boundary #2671 (base main) touches robots.ts/redirects.json in non-overlapping hunks; feat: host creator contest on peanut.me #2605 adds creator-contest to DEDICATED_ROUTES in the same region (trivial).
  • The 10 recovered receive-from pages have zero inbound internal links — tracked in the content workstream.

Replaces #2676, #2678, #2681, #2682, #2684 (closed with pointers; branches kept until this merges).

🤖 Generated with Claude Code

0xkkonrad and others added 9 commits August 12, 2026 14:39
…m pages

Three independent SEO hygiene defects.

robots.ts — the Googlebot group declared only `allow: ['/api/og']` with no
disallow key. A crawler obeys the single most specific group that matches it,
so Googlebot was reading that group INSTEAD of `*` and treating every
auth-gated route (/home, /profile, /settings, /kyc, ...) as crawlable. Extract
the `*` disallow list into a shared DISALLOWED_PATHS const and apply it to the
Googlebot group too, mirroring how the AI-crawler group is built. The narrower
/api/og allow still wins over /api/ by longest-match, so OG images keep
working.

careers + lp/card — both call the metadata helper without `canonical`, so they
inherited the root layout's `alternates: { canonical: '/' }` and declared the
homepage as their canonical while sitting in the sitemap. Pass the real path,
matching the existing exchange/layout.tsx workaround.

corridors.ts — loadReceiveSources seeded from CORRIDORS.from and intersected
with the receive-from content tree. receive-from is authored independently of
corridors, so the intersection silently dropped 10 authored countries
(australia, india, kenya, malaysia, netherlands, pakistan, philippines,
saudi-arabia, singapore, united-arab-emirates) whose articles were live but
unreachable. Enumerate published receive-from content directly via the
existing listPublishedSlugs helper. RECEIVE_SOURCES goes 9 -> 19; colombia and
mexico stay out, correctly, as origins with no article.

corridors.test.ts was a checked-in regression guard from the May 2026 no-content
incident asserting RECEIVE_SOURCES subset-of CORRIDORS.from. That subset relation
was an artifact of how the list was built, not what protected us — the real
invariant is "every entry has a published article". Rewritten to assert that in
both directions: no entry without content (no 404s) and no published article
left out (no orphans). Verified the new guard fails against the old loader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…comments

Adversarial-review findings on this PR:
- scripts/verify-content.ts gateReceiveSources() was a stale mirror of the
  old corridor-intersection loader — Pass 11 (blocking CI check) silently
  stopped covering the 10 recovered receive-from slugs. Now enumerates
  published receive-from articles directly, matching RECEIVE_SOURCES.
- robots.ts: DISALLOWED_PATHS comment overclaimed ('every named crawler
  group'); reworded to what the file actually does.
- lp/card: canonical comment misattributed the inherited '/' to the root
  layout; it comes from lp/layout.tsx's deliberate alias policy.
The [...recipient] catch-all renders an indexable "X on Peanut" shell for
ANY username-shaped string — existence is only checked client-side, so a
non-existent handle still returns HTTP 200 with full metadata. Google has
been indexing those as thin/soft-404 pages.

Set robots: { index: false, follow: false } on all four generateMetadata
return paths (reserved route, missing recipient, !couldBeRecipient, and the
full metadata object). No carve-outs: real profiles, ETH addresses, .eth ENS
names and request/receipt links are app surface, not search landing pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review catch: every branch of this route inherits
alternates.canonical '/' from the root layout, so the noindex was
shipping on pages that canonicalize to the homepage - a combination
Google's canonicalization guidance warns can bleed the noindex into
the cluster head (the homepage itself, sitemap priority 1.0).
alternates: { canonical: null } suppresses the inherited value; the
noindex pages now carry no canonical at all.
The [...recipient] route's loading.tsx created a Suspense boundary at the
segment. Page() suspends on use(props.params), so React rendered the
fallback, flushed the shell with HTTP 200, and only then resolved
notFound() — which landed as a NEXT_HTTP_ERROR_FALLBACK;404 marker inside
an already-200 response body.

Removing the boundary makes the segment's suspension propagate to the
shell, so notFound() throws before headers flush and Next sets a real 404
status. The route's render is otherwise synchronous (params guard checks
only), and PaymentPage is a client component, so there is no server data
fetch left to cover with a fallback.
…e trailing-slash dupes

Four routing defects, all in the redirect/reserved-route layer:

1. /pricing, /stories and /content are real [locale]/(marketing) pages, but
   the bare paths are 7 lowercase letters, so couldBeRecipient() accepted them
   and the [...recipient] catch-all served a payment-profile shell on HTTP 200.
   Reserved in DEDICATED_ROUTES + 301'd to /en/....

2. /help/delete-account (and every other help article at its bare path)
   soft-404'd the same way: only bare /help was redirected, never /help/:path*.
   That URL is the account-deletion link Google Play requires. Added
   /help/:path* and /stories/:path* so the newly-reserved prefixes keep
   resolving instead of hard-404ing.

3. skipTrailingSlashRedirect is on (it must stay — the PostHog /relay proxy is
   called with trailing slashes), so /en/help/ and /en/help both returned 200.
   Added one slash-stripping redirect scoped to the locale-prefixed tree only.

4. docs.peanut.to had no host rule; only docs.peanut.me did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review blocker: :path* matches zero segments, so bare
locale homepages (/es-ar/, /es-419/, /pt-br/) compiled a 308 redirect
to themselves - an infinite loop, cached permanently by browsers.
Measured live on the preview; /en/ survived only because the /en -> /
rule sits earlier in the array. :path+ requires a segment, so bare
locale + slash falls through to its current 200 (matches production).
Verified with next's checkCustomRoutes + compiled-rule replay of the
loop paths and the intended redirect paths.
Every sitemap URL carried lastModified: BUILD_DATE, so each deploy told
crawlers all 709 pages had just changed. That is noise, and it costs the
signal on pages that genuinely did change.

Content-backed URLs now report the generated_at of the exact file that
serves them. 684 of 709 URLs get a real date across 21 distinct values;
the remaining 25 (hand-built pages and index pages with no single backing
file) keep BUILD_DATE as the fallback.

contentGeneratedAt() coerces the frontmatter value to a Date. Note the
type/runtime mismatch it guards: ContentFrontmatter declares generated_at
as a string, but gray-matter runs js-yaml, which parses unquoted YAML
timestamps into Date objects — so both shapes have to work.

The lookups read through the cache the has*Content() guards already
populate, so they add no file reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 12, 2026 3:09pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 227bc56d-25d5-4aa8-ba3c-66c900977267

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7158.83 → 7158.46 (-0.37)
Findings: -1 net (+17 new, -18 resolved)

🆕 New findings (17)

  • critical complexity — src/lib/content.ts — CC 64, MI 61.45, SLOC 252
  • high complexity — src/app/[...recipient]/page.tsx — CC 49, MI 43.64, SLOC 145
  • high method-complexity — src/app/[...recipient]/page.tsx:30 — generateMetadata CC 42 SLOC 124
  • high complexity — src/app/sitemap.ts — CC 26, MI 43.05, SLOC 219
  • medium high-mdd — src/app/sitemap.ts:49 — generateSitemap: MDD 80.0 (uses across many lines from declarations)
  • medium high-mdd — src/app/[...recipient]/page.tsx:30 — generateMetadata: MDD 46.5 (uses across many lines from declarations)
  • medium structural-dup — app/[...recipient]/page.tsx:175 — 28 duplicate lines / 101 tokens with app/invite/page.tsx:58
  • medium method-complexity — src/app/sitemap.ts:49 — generateSitemap CC 21 SLOC 197
  • medium complexity — src/constants/routes.ts — CC 14, MI 61.4, SLOC 46
  • medium complexity — src/data/seo/corridors.ts — CC 12, MI 62.58, SLOC 48
  • medium complexity — src/app/robots.ts — CC 4, MI 60.33, SLOC 32
  • low high-dlt — src/app/[...recipient]/page.tsx:30 — generateMetadata: DLT 18 (calls 18 distinct functions — high context load)
  • low high-mdd — src/lib/content.ts:368 — listAllContent: MDD 11.3 (uses across many lines from declarations)
  • low missing-return-type — src/app/[...recipient]/page.tsx:30 — generateMetadata: exported fn missing return type annotation
  • low missing-return-type — src/app/[...recipient]/page.tsx:202 — Page: exported fn missing return type annotation
  • low missing-return-type — src/app/careers/page.tsx:14 — CareersPage: exported fn missing return type annotation
  • low missing-return-type — src/app/lp/card/page.tsx:18 — CardLPPage: exported fn missing return type annotation

✅ Resolved (18)

  • src/lib/content.ts — CC 58, MI 61.8, SLOC 239
  • src/app/[...recipient]/page.tsx — CC 49, MI 44.48, SLOC 135
  • src/app/[...recipient]/page.tsx:19 — generateMetadata CC 42 SLOC 121
  • src/app/sitemap.ts — CC 23, MI 37.57, SLOC 174
  • src/app/sitemap.ts:27 — generateSitemap: MDD 73.8 (uses across many lines from declarations)
  • src/app/[...recipient]/page.tsx:19 — generateMetadata: MDD 46.3 (uses across many lines from declarations)
  • app/[...recipient]/page.tsx:158 — 32 duplicate lines / 107 tokens with app/invite/page.tsx:54
  • src/app/sitemap.ts:27 — generateSitemap CC 21 SLOC 167
  • src/constants/routes.ts — CC 14, MI 61.44, SLOC 46
  • src/data/seo/corridors.ts — CC 14, MI 62.7, SLOC 53
  • src/app/robots.ts — CC 4, MI 60.85, SLOC 30
  • src/app/[...recipient]/page.tsx:19 — generateMetadata: DLT 18 (calls 18 distinct functions — high context load)
  • src/lib/content.ts:347 — listAllContent: MDD 11.3 (uses across many lines from declarations)
  • src/app/[...recipient]/loading.tsx:3 — Loading: exported fn missing return type annotation
  • src/app/[...recipient]/page.tsx:19 — generateMetadata: exported fn missing return type annotation
  • src/app/[...recipient]/page.tsx:189 — Page: exported fn missing return type annotation
  • src/app/careers/page.tsx:11 — CareersPage: exported fn missing return type annotation
  • src/app/lp/card/page.tsx:14 — CardLPPage: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/app/sitemap.ts 14.3 12.0 -2.3

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2969 ran, 0 failed, 0 skipped, 48.9s

📊 Coverage (unit)

metric %
statements 66.5%
branches 51.7%
functions 56.6%
lines 67.3%
⏱ 10 slowest test cases
time test
3.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
0.9s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

…itterbot exemption

Review findings 1+2 from #2676 (abalinda): the AI-crawler group's
6-entry list left claim links, receipts, QR, card-payment, invite and
withdraw crawlable by GPTBot/ClaudeBot/PerplexityBot while every other
crawler was blocked. They now share DISALLOWED_PATHS - AI engines
should read (and cite) content pages, not transactional surface.
Twitterbot's empty disallow is deliberate (card unfurls on shared
claim/payment links) and now documented as such.
)

- loader + verify-content gate skip a future 'index' meta directory
  (same guard sitemap.ts already applies to other intents)
- test parses frontmatter with gray-matter instead of a regex, so YAML
  edge cases (published: False, trailing comments) agree with the loader
- test fails with an actionable message when the src/content submodule
  is not initialized, instead of a raw ENOENT
@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Response to @abalinda's six findings from #2676 (they all transfer here — this PR contains that code):

1. Twitterbot (fixed — 8057851c2): the empty disallow is deliberate — Twitterbot fetches user-shared claim/payment links to render X cards and doesn't index; restricting it would break unfurls on exactly the links users share. Now documented in the file so the comment no longer denies the exemption.

2. AI-crawler group (fixed — 8057851c2): agreed and applied — the AI group now shares DISALLOWED_PATHS. AI engines should read and cite content pages, not claim links and receipts. Note the trade-off accepted here: ChatGPT-User (user-initiated fetches) also loses access to transactional URLs.

3. Googlebot newly blocks /invite /pay /qr /claim /home — ruled INTENDED, ship as-is (Konrad). GSC can't be checked yet (access grant is a pending workstream item), so we pulled PostHog instead: google-referred visitors on those prefixes over 90 days = 586 total (/home 350 — navigational, those users will land on the homepage; /invite 231 ≈ 77/mo — likely indexed referral URLs; /qr-pay 5). The disallow list was always the site's declared policy — Googlebot ignoring it was the defect — and user-specific invite URLs in the index are junk surface. If we ever want /invite crawlable it's a one-line carve-out.

4. index/ meta-directory guard (fixed — 31bced6ba): loader, verify-content gate, and test all skip index, matching sitemap.ts's existing guard.

5. Frontmatter regex vs YAML (fixed — 31bced6ba): the test now parses with gray-matter directly (still not via the loader's helper, so independence is preserved) — published: False, trailing comments, and nesting now agree with the loader.

6. Submodule ENOENT (fixed — 31bced6ba): the suite now fails with run: git submodule update --init instead of a raw ENOENT.

Gates on the new head: jest 3/3, validate-links all passes, tsc clean, eslint 0 errors, prettier clean, both commits SSH-signed.

🤖 Generated with Claude Code

@abalinda
abalinda merged commit 88d3700 into dev Aug 12, 2026
20 checks passed
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.

2 participants