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.xmlreturns 404 with 2698 bytes oftext/html, which is the not-found page.GET /robots.txtreturns 200 with 1248 bytes oftext/plain, 24 lines, every one a comment, with noUser-agent, noAllow, noDisallowand noSitemap.GET /404returns 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, becauseconfig/cloud.ts#L111setsSTACKS_DEFAULT_ROUTES: 'none'on the release, whichconfig/cloud.ts#L13-L14records as deliberate. - Reaching those two actions would be the wrong fix.
SitemapActionwalksresources/viewsandstorage/framework/defaults/resources/views(storage/framework/defaults/app/Actions/SitemapAction.ts#L32-L33, both roots iterated at#L158) whileconfig/ui.ts#L50setsdefaultViews: 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/testerand/forgot-password. Only/is a page this app serves, and the deploy already asserts that/errors/testermust 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-L11only 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 aprefix: ''entry, documented atstorage/framework/defaults/app/Routes.ts#L33and restated inroutes/api.ts#L8-L12. The audit's citation ofapp/Routes.ts:30-45points at nothing, because that file is 11 lines long. /404is addressable because stx resolves a view from its filename with no route registration, whichroutes/api.ts#L24-L27says in as many words about/coming-soon.resources/views/404.stx#L13setsrobots noindex, so the index damage is contained, but a crawler or an uptime check that requests/404is told the request succeeded.- The fix for that half is already in the repo.
resources/views/dashboard.stx#L16-L17callsnotFound()so the deployed copy answers 404, and.github/workflows/deploy.yml#L219verifies it. - The current
robots.txtcomes 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 nocontent-signal:line after it.
Proposal
- Add
routes/seo.tsholdingroute.get('/sitemap.xml', ...)androute.get('/robots.txt', ...), both answering from a literal list written in that file. - Give
app/Routes.tsa 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 atstorage/framework/defaults/app/Routes.ts#L33. - The sitemap lists one URL,
/. Return it withresponse.xml, asstorage/framework/defaults/app/Actions/SitemapAction.ts#L230does, and build the absolute host the waySitemapAction.ts#L188andstorage/framework/defaults/app/Actions/RobotsAction.ts#L23-L30do. robots.txtanswersUser-agent: *,Allow: /,Disallow: /dashboard, then a blank line andSitemap: https://uplink.stacksjs.com/sitemap.xml./dashboardis the one path worth naming:resources/views/dashboard.stxexists in the repo and 404s in production.- Add
notFound()to the<script server>block ofresources/views/404.stx, the same single line asresources/views/dashboard.stx#L16-L17, so the path reports the status its body describes. - Extend the live verification in
.github/workflows/deploy.yml#L216-L226, which already has astatus()helper, with three checks:/sitemap.xmlis 200,/robots.txtis 200 and carries aSitemap:line,/404is 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_URLcarries no scheme in this deploy.config/cloud.ts#L107setsAPP_URL: APP_DOMAINand.github/workflows/deploy.yml#L188passesuplink.stacksjs.com, whileconfig/app.ts#L17reads the value raw. Prefixhttps://when it is missing, exactly asRobotsAction.ts#L23-L30does, or both the<loc>values and theSitemap:line come out unusable.- The sitemap host must match the canonical.
resources/views/index.stx#L15hardcodeshttps://uplink.stacksjs.comand#L95builds therel="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.txttoday 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 thestacksjs.comzone. 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'atconfig/cloud.ts#L111is what keeps/login,/cartand the error tester off the public site, and.github/workflows/deploy.yml#L222-L224fails the deploy if they return. - Content types stay correct.
application/xmlfor the sitemap andtext/plainfor robots.response.xmlandresponse.textalready set them. - The not-found body keeps working for unmatched paths.
resources/views/404.stxis both the page at/404and the body served for anything unmatched, which is why/sitemap.xmlreturns its 2698 bytes today. After the change an unknown path must still render it, with a 404 status. - Scope. The dead
.u-coderules atresources/views/index.stx#L152-L153are 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.xmlreturns 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.txtcontainsUser-agent: *,Allow: /andSitemap: 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/404returns 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 lintand./buddy typecheckpass.