ReviewOS

stacks/uplink

fix(site): sitemap.xml 404s, robots.txt has no directives, /404 answers 200

#31
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Part of #6.

Nothing in this app owns its crawler-facing surface. The framework would own it, but production deliberately mounts no framework routes, and switching them back on would publish a sitemap that is mostly dead URLs. The three defects share one cause and one fix: a route file this app owns.

What is there today

  • Measured live on 2026-09-28. GET /sitemap.xml returns 404 with 2698 bytes of text/html, which is the not-found page. GET /robots.txt returns 200 with 1248 bytes of text/plain, 24 lines, every one a comment, with no User-agent, no Allow, no Disallow and no Sitemap. GET /404 returns 200 with the same 2698 byte not-found body.
  • The framework does register both paths, at storage/framework/defaults/routes/dashboard.ts#L113-L114. Neither loads in production, because config/cloud.ts#L111 sets STACKS_DEFAULT_ROUTES: 'none' on the release, which config/cloud.ts#L13-L14 records as deliberate.
  • Reaching those two actions would be the wrong fix. SitemapAction walks resources/views and storage/framework/defaults/resources/views (storage/framework/defaults/app/Actions/SitemapAction.ts#L32-L33, both roots iterated at #L158) while config/ui.ts#L50 sets defaultViews: false. Running its own walk and exclusion list over the two trees gives 14 URLs: /, /404, the seven /cms/blocks/* pages, /cms/page, /coming-soon, /errors/404, /errors/tester and /forgot-password. Only / is a page this app serves, and the deploy already asserts that /errors/tester must answer 404 (.github/workflows/deploy.yml#L222-L224).
  • The app has no way to register a path at the document root today. app/Routes.ts#L10-L11 only re-exports the defaults registry, whose single key is 'api' (storage/framework/defaults/app/Routes.ts#L38-L41), and every key other than 'web' becomes a URL prefix (storage/framework/defaults/app/Routes.ts#L11-L14). The escape hatch is a prefix: '' entry, documented at storage/framework/defaults/app/Routes.ts#L33 and restated in routes/api.ts#L8-L12. The audit's citation of app/Routes.ts:30-45 points at nothing, because that file is 11 lines long.
  • /404 is addressable because stx resolves a view from its filename with no route registration, which routes/api.ts#L24-L27 says in as many words about /coming-soon. resources/views/404.stx#L13 sets robots noindex, so the index damage is contained, but a crawler or an uptime check that requests /404 is told the request succeeded.
  • The fix for that half is already in the repo. resources/views/dashboard.stx#L16-L17 calls notFound() so the deployed copy answers 404, and .github/workflows/deploy.yml#L219 verifies it.
  • The current robots.txt comes from the edge and not from the app. Cloudflare fronts this host (.github/workflows/deploy.yml#L96-L97), and those 24 lines are Cloudflare's content signals preamble with no content-signal: line after it.

Proposal

  1. Add routes/seo.ts holding route.get('/sitemap.xml', ...) and route.get('/robots.txt', ...), both answering from a literal list written in that file.
  2. Give app/Routes.ts a registry of its own instead of the bare re-export: spread the default export it currently forwards and add 'seo': { path: 'seo', prefix: '' }, the form documented at storage/framework/defaults/app/Routes.ts#L33.
  3. The sitemap lists one URL, /. Return it with response.xml, as storage/framework/defaults/app/Actions/SitemapAction.ts#L230 does, and build the absolute host the way SitemapAction.ts#L188 and storage/framework/defaults/app/Actions/RobotsAction.ts#L23-L30 do.
  4. robots.txt answers User-agent: *, Allow: /, Disallow: /dashboard, then a blank line and Sitemap: https://uplink.stacksjs.com/sitemap.xml. /dashboard is the one path worth naming: resources/views/dashboard.stx exists in the repo and 404s in production.
  5. Add notFound() to the <script server> block of resources/views/404.stx, the same single line as resources/views/dashboard.stx#L16-L17, so the path reports the status its body describes.
  6. Extend the live verification in .github/workflows/deploy.yml#L216-L226, which already has a status() helper, with three checks: /sitemap.xml is 200, /robots.txt is 200 and carries a Sitemap: line, /404 is 404.

Requirements

  • Both lists are literal, never a directory walk. The walk is the whole reason the framework actions are not being reused, so do not reintroduce it in app code. Adding a public page later is then one line in routes/seo.ts, which is also the only place that can be reviewed against what the site actually serves.
  • APP_URL carries no scheme in this deploy. config/cloud.ts#L107 sets APP_URL: APP_DOMAIN and .github/workflows/deploy.yml#L188 passes uplink.stacksjs.com, while config/app.ts#L17 reads the value raw. Prefix https:// when it is missing, exactly as RobotsAction.ts#L23-L30 does, or both the <loc> values and the Sitemap: line come out unusable.
  • The sitemap host must match the canonical. resources/views/index.stx#L15 hardcodes https://uplink.stacksjs.com and #L95 builds the rel="canonical" from it. A different host, or a trailing slash that disagrees, makes the sitemap and the canonical contradict each other.
  • The route still has to win at the edge. What answers /robots.txt today is Cloudflare's, not the app's, so an origin route is not proof of a fix. Check the live bytes after deploying, and if the response is still comments only, the managed robots.txt has to be turned off in the stacksjs.com zone. Cloudflare appending its content signal comments below our directives is fine as long as the directives are present.
  • No invented lastmod. Omit the element rather than emit a build timestamp, which would change on every deploy while the page copy has not.
  • Do not switch the framework route bundles back on to get this. STACKS_DEFAULT_ROUTES: 'none' at config/cloud.ts#L111 is what keeps /login, /cart and the error tester off the public site, and .github/workflows/deploy.yml#L222-L224 fails the deploy if they return.
  • Content types stay correct. application/xml for the sitemap and text/plain for robots. response.xml and response.text already set them.
  • The not-found body keeps working for unmatched paths. resources/views/404.stx is both the page at /404 and the body served for anything unmatched, which is why /sitemap.xml returns its 2698 bytes today. After the change an unknown path must still render it, with a 404 status.
  • Scope. The dead .u-code rules at resources/views/index.stx#L152-L153 are the other half of the audit finding this issue shares. They belong to the page weight issue, not to this one.

Acceptance

  • curl -si https://uplink.stacksjs.com/sitemap.xml returns 200 with an XML content type and exactly one <loc>, https://uplink.stacksjs.com/.
  • Every <loc> in the sitemap answers 200.
  • curl -s https://uplink.stacksjs.com/robots.txt contains User-agent: *, Allow: / and Sitemap: https://uplink.stacksjs.com/sitemap.xml. A Cloudflare comment block around them is acceptable.
  • curl -o /dev/null -w '%{http_code}' https://uplink.stacksjs.com/404 returns 404, and the body is still the not-found page.
  • A path that was never a view, for example /nope, returns 404 with that same body.
  • The deploy workflow fails if any of the three regresses.
  • ./buddy lint and ./buddy typecheck pass.

Sign in to comment on this issue.