Skip to content

Commit 017dc45

Browse files
fix(server): serve docs/index.html at GET / — x402.sentinel.co root was a Cloudflare cache ghost
The deployed server has no root route: GET / returns the JSON 404 and the HTML page on x402.sentinel.co exists only as a stale Cloudflare edge-cache copy from 2026-06-13. Same root cause left /llms.txt serving its stub — docs/ was never bundled into the Docker image. - index.ts: resolve docs/ once (image layout /app/docs, checkout ../../docs), serve docs/index.html at GET / with Cache-Control: public, max-age=300 so the edge can never pin a copy for weeks; 301 /index.html -> /; llms.txt route now uses the same resolver (fixes the live stub). - Dockerfile: build from repo-root context and COPY docs ./docs. - build.yml: context . and rebuild the image on docs/** changes too. Verified: tsc clean; smoke test on :4920 — GET / 200 text/html (latest docs fingerprint), GET /?x=1 200, /index.html 301, /llms.txt real content.
1 parent bcbecba commit 017dc45

3 files changed

Lines changed: 47 additions & 10 deletions

File tree

.github/workflows/build.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [ "master" ]
66
paths:
77
- "server/**"
8+
- "docs/**"
89
- ".github/workflows/build.yml"
910
workflow_dispatch:
1011

@@ -36,7 +37,8 @@ jobs:
3637
uses: docker/build-push-action@v6
3738
with:
3839
push: true
39-
context: ./server
40+
# Repo-root context so the image bundles docs/ (served at GET /)
41+
context: .
4042
file: ./server/Dockerfile
4143
tags: |
4244
ghcr.io/${{ env.REPO }}:latest

server/Dockerfile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# syntax=docker/dockerfile:1.7
2+
# Build context is the REPO ROOT (see .github/workflows/build.yml) so the
3+
# image can bundle docs/ — the server serves docs/index.html at GET /.
24

35
# ─── Build the server ───
46
FROM node:22-alpine AS build
57
WORKDIR /app
6-
COPY package.json package-lock.json* ./
8+
COPY server/package.json server/package-lock.json* ./
79
RUN npm ci --ignore-scripts --no-audit --no-fund --loglevel=error
8-
COPY tsconfig.json ./
9-
COPY src ./src
10+
COPY server/tsconfig.json ./
11+
COPY server/src ./src
1012
RUN npm run build \
1113
&& npm prune --omit=dev --ignore-scripts
1214

@@ -21,7 +23,8 @@ RUN addgroup -S app && adduser -S app -G app
2123

2224
COPY --from=build /app/node_modules ./node_modules
2325
COPY --from=build /app/dist ./dist
24-
COPY package.json ./
26+
COPY docs ./docs
27+
COPY server/package.json ./
2528

2629
USER app
2730

server/src/index.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createFacilitatorConfig } from '@coinbase/x402';
66
import { HTTPFacilitatorClient, type FacilitatorConfig } from '@x402/core/server';
77
import { initSentinel, provisionAgent, checkAgentStatus, checkProvisioningCapacity, getEnrichedPlanNodes, matchNodesByCountry } from './sentinel.js';
88
import { createSelfHostedFacilitator, startFacilitatorServer } from './facilitator.js';
9-
import { readFileSync } from 'node:fs';
9+
import { existsSync, readFileSync } from 'node:fs';
1010
import { fileURLToPath } from 'node:url';
1111
import { dirname, join } from 'node:path';
1212

@@ -596,18 +596,49 @@ app.get('/health', async (_req, res) => {
596596
res.json({ status: 'ok', uptime: process.uptime(), capacity });
597597
});
598598

599+
// ─── Docs (served by this process — x402.sentinel.co has no separate static host) ───
600+
// docs/ lives at /app/docs in the Docker image (see Dockerfile) and at ../../docs
601+
// in a repo checkout (__dirname is server/dist or server/src).
602+
const docsDir = [join(__dirname, '..', 'docs'), join(__dirname, '..', '..', 'docs')]
603+
.find(dir => existsSync(join(dir, 'index.html')));
604+
605+
// Human-readable docs page at the root. max-age=300 keeps Cloudflare from
606+
// pinning a stale copy at the edge for weeks.
607+
let docsHtmlCache: string | null = null;
608+
app.get('/', (_req, res) => {
609+
if (!docsHtmlCache && docsDir) {
610+
try {
611+
docsHtmlCache = readFileSync(join(docsDir, 'index.html'), 'utf8');
612+
} catch (err) {
613+
console.warn('[x402] docs/index.html not readable:', (err as Error).message);
614+
}
615+
}
616+
if (!docsHtmlCache) {
617+
return res.json({
618+
protocol: 'x402',
619+
message: 'Docs page not bundled in this build. See GET /manifest for the machine-readable spec.',
620+
docs: '/manifest',
621+
});
622+
}
623+
res.set('Cache-Control', 'public, max-age=300').type('html').send(docsHtmlCache);
624+
});
625+
626+
app.get('/index.html', (_req, res) => res.redirect(301, '/'));
627+
599628
// llms.txt — AI-readable summary (https://llmstxt.org convention)
600629
let llmsTxtCache: string | null = null;
601630
app.get('/llms.txt', (_req, res) => {
602-
if (!llmsTxtCache) {
631+
if (!llmsTxtCache && docsDir) {
603632
try {
604-
llmsTxtCache = readFileSync(join(__dirname, '..', '..', 'docs', 'llms.txt'), 'utf8');
633+
llmsTxtCache = readFileSync(join(docsDir, 'llms.txt'), 'utf8');
605634
} catch (err) {
606635
console.warn('[x402] docs/llms.txt not readable, serving stub:', (err as Error).message);
607-
llmsTxtCache = '# x402\n\nSee /manifest for the machine-readable spec.\n';
608636
}
609637
}
610-
res.type('text/plain').send(llmsTxtCache);
638+
if (!llmsTxtCache) {
639+
llmsTxtCache = '# x402\n\nSee /manifest for the machine-readable spec.\n';
640+
}
641+
res.set('Cache-Control', 'public, max-age=300').type('text/plain').send(llmsTxtCache);
611642
});
612643

613644
// Node list — free, enriched with live geo data from each node's /status
@@ -715,6 +746,7 @@ async function start() {
715746
console.log(' POST /vpn/connect/30days $1.00');
716747
console.log('');
717748
console.log(' Free Endpoints:');
749+
console.log(' GET / (docs page)');
718750
console.log(' GET /manifest (AI-readable JSON spec)');
719751
console.log(' GET /llms.txt (AI-readable summary)');
720752
console.log(' GET /pricing');

0 commit comments

Comments
 (0)