openvibers/openvibe.bot Commits
A missing page answers the site's 404 page in the Frame (#266)
Changes
@@@ -43,7 +43,7 @@ const { createJobs } = require('./jobs');const { createActorLimits } = require('./api/actor-limits');const { createSessionRoutes, viewerMiddleware } = require('./web/session');const { createWebRoutes } = require('./web/routes');--const { HOME_CSP } = require('./web/home');++const { HOME_CSP, renderNotFound } = require('./web/home');const { createSubmissions } = require('./submissions');const { createRoutines } = require('./routines');const { createWebhooks } = require('./webhooks');@@@ -53,18 +53,6 @@ const { createBotReadiness, registerBotGauges } = require('./observability');const VERSION = require('../package.json').version;--/**-- * The browser-facing 404 (the API's is problem+json): a plain, JavaScript-free page with a way back.-- * The app's CSP applies, so it carries no inline styles or scripts.-- */--const NOT_FOUND_HTML = `<!doctype html>--<html lang="en">--<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">--<meta name="robots" content="noindex"><title>Not found — OpenVibe.Bot</title></head>--<body><main><h1>Not found</h1><p>There is nothing at this address. Go to the <a href="/">OpenVibe.Bot home page</a>.</p></main></body>--</html>--`;--function createApp(opts = {}) {const config = opts.config || loadConfig();const db = opts.db || openDb(config);@@@ -205,8 +193,11 @@ function createApp(opts = {}) {app.use(webRoutes);app.use((req, res) => {-- if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });-- return res.status(404).type('html').send(NOT_FOUND_HTML);++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/') || req.accepts(['application/json', 'text/html']) !== 'text/html') {++ return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });++ }++ res.setHeader('Content-Security-Policy', HOME_CSP);++ return res.status(404).type('html').set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').send(renderNotFound({ config }));});// eslint-disable-next-line no-unused-varsapp.use((err, req, res, next) => {@@@ -269,7 +269,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =const footerBoot = (footer) => `<script>document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(${JSON.stringify(footer)}); } catch (e) { /* the Frame is optional */ } });</script>`;/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */--function framePage({ config, title, description, canonicalPath = '/', body, head = '' }) {++function framePage({ config, title, description, canonicalPath = '/', body, head = '', noindex = false }) {const nav = {service: 'bot',apiBase: NETWORK_URL,@@@ -281,9 +281,11 @@ function framePage({ config, title, description, canonicalPath = '/', body, headnotificationsRealtime: true,};return shell.page({-- name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,-- canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',-- agentLayer: require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',++ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description,++ ...(noindex ? {} : { summary: description }),++ canonical: noindex ? undefined : `${config.baseUrl}${canonicalPath}`,++ robots: noindex ? 'noindex, follow' : 'index, follow',++ agentLayer: !noindex && require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),@@@ -291,6 +293,30 @@ function framePage({ config, title, description, canonicalPath = '/', body, head});}++/** The page a browser gets for a path nothing serves: Bot's Frame, one way back, and no clue about what was asked for. */++function renderNotFound({ config }) {++ const host = String(config.baseUrl).replace(/^https?:\/\//, '').replace(/\/.*$/, '');++ return framePage({++ config,++ title: `Page not found · ${SITE_NAME}`,++ description: `There is no page at this address on ${host}.`,++ noindex: true,++ head: [++ `<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,++ UPDATES_FONT,++ ].join('\n'),++ body: `<main id="main" class="page">++<h1>Page not found</h1>++<p>There is no page at this address on ${esc(host)}. The link may be old or mistyped.</p>++<div class="sc-actions">++<a class="sc-btn sc-primary" href="/">${SITE_NAME} home</a>++<a class="sc-btn" href="/robots">Robots</a>++<a class="sc-btn" href="/docs">Build</a>++</div>++</main>`,++ });++}++// The page's type: shell.page sets no font and showcase.css styles only its own sections, so without these rules// /updates fell back to the browser's Times New Roman (domain sweep, 2026-11-01). Zero specificity: any site rule wins.const UPDATES_FONT = "<style>:where(body){font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif)}:where(main.page){display:block;max-width:880px;margin:0 auto;padding:24px max(16px,env(safe-area-inset-right,0px)) 56px max(16px,env(safe-area-inset-left,0px))}:where(.ov-updates h1){font-size:clamp(1.6rem,5.4vw,2.2rem);line-height:1.15;margin:0 0 10px;text-wrap:balance}</style>";@@@ -354,4 +380,4 @@ ${footerBoot(footer)}`,});}--module.exports = { renderHome, renderUpdates, framePage, HOME_CSP, sections, faqItems };++module.exports = { renderHome, renderUpdates, renderNotFound, framePage, HOME_CSP, sections, faqItems };@@@ -102,6 +102,25 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");});++ await check('a browser that asks for a missing page gets the site 404 page in the Frame', async () => {++ const r = await fetch(t.base + '/no-such-page-xyz', { headers: { Accept: 'text/html,*/*;q=0.8' }, redirect: 'manual' });++ assert.strictEqual(r.status, 404);++ assert.match(r.headers.get('content-type'), /^text\/html/);++ const html = await r.text();++ assert.ok(html.includes('<h1>Page not found</h1>'), html.slice(0, 300));++ assert.ok(html.includes('There is no page at this address on bot.test. The link may be old or mistyped.'));++ assert.ok(html.includes('id="navbar-mount"'), 'the page is in the site Frame');++ assert.ok(html.includes('<meta name="robots" content="noindex, follow">'));++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex');++ assert.match(r.headers.get('content-security-policy'), /style-src [^;]*'unsafe-inline'/);++ assert.ok(html.includes('class="sc-btn sc-primary" href="/"'), 'home is the first way back');++ const api = await fetch(t.base + '/api/no-such', { headers: { Accept: 'text/html' } });++ assert.strictEqual(api.status, 404);++ assert.match(api.headers.get('content-type'), /problem\+json/);++ const notHtml = await fetch(t.base + '/no-such-page-xyz', { headers: { Accept: 'application/json' } });++ assert.match(notHtml.headers.get('content-type'), /problem\+json/);++ });++await check('the vhost proxies / and /shared/ to the app; the legal pages are the app\'s, not the Sites files', async () => {const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.bot.conf'), 'utf8');assert.match(conf, /location \^~ \/shared\/ \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);@@@ -20,7 +20,7 @@ const { boot, check, done } = require('./helpers/app');(async () => {const t = await boot({ openre: false });-- const get = (p) => fetch(t.base + p, { redirect: 'manual' });++ const get = (p, init = {}) => fetch(t.base + p, { redirect: 'manual', ...init });await check('GET /terms, /privacy, /dmca: OpenVibe.Bot legal pages served by the app', async () => {for (const p of ['/terms', '/privacy', '/dmca']) {@@@ -69,14 +69,19 @@ const { boot, check, done } = require('./helpers/app');assert.ok(Array.isArray(m.icons) && m.icons.length > 0, 'has icons');});-- await check('an unknown page is the app\'s 404 HTML page; the API keeps its problem+json 404', async () => {-- const page = await get('/no-such-page');++ await check('an unknown page is the app\'s 404 HTML page for a browser; the API keeps its problem+json 404', async () => {++ const page = await get('/no-such-page', { headers: { Accept: 'text/html' } });assert.strictEqual(page.status, 404);assert.match(page.headers.get('content-type'), /^text\/html/);-- assert.match(await page.text(), /Not found/);++ const html = await page.text();++ assert.ok(html.includes('<h1>Page not found</h1>'), html.slice(0, 300));++ assert.ok(html.includes('id="navbar-mount"'), 'the page is in the site Frame');const api = await fetch(t.base + '/api/no-such-route');assert.strictEqual(api.status, 404);assert.match(api.headers.get('content-type'), /application\/problem\+json/);++ const notHtml = await get('/no-such-page');++ assert.strictEqual(notHtml.status, 404);++ assert.match(notHtml.headers.get('content-type'), /application\/problem\+json/, 'a client that does not accept HTML keeps JSON');});await check('the vhost no longer serves anything from a Sites checkout', async () => {
@@ -43,7 +43,7 @@ const { createJobs } = require('./jobs');
@@ -43,7 +43,7 @@ const { createJobs } = require('./jobs');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
const { createWebRoutes } = require('./web/routes');
const { createWebRoutes } = require('./web/routes');
-const { HOME_CSP } = require('./web/home');
+const { HOME_CSP, renderNotFound } = require('./web/home');
const { createSubmissions } = require('./submissions');
const { createSubmissions } = require('./submissions');
const { createRoutines } = require('./routines');
const { createRoutines } = require('./routines');
const { createWebhooks } = require('./webhooks');
const { createWebhooks } = require('./webhooks');
@@ -53,18 +53,6 @@ const { createBotReadiness, registerBotGauges } = require('./observability');
@@ -53,18 +53,6 @@ const { createBotReadiness, registerBotGauges } = require('./observability');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-/**
- * The browser-facing 404 (the API's is problem+json): a plain, JavaScript-free page with a way back.
- * The app's CSP applies, so it carries no inline styles or scripts.
- */
-const NOT_FOUND_HTML = `<!doctype html>
-<html lang="en">
-<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
-<meta name="robots" content="noindex"><title>Not found — OpenVibe.Bot</title></head>
-<body><main><h1>Not found</h1><p>There is nothing at this address. Go to the <a href="/">OpenVibe.Bot home page</a>.</p></main></body>
-</html>
-`;
-
function createApp(opts = {}) {
function createApp(opts = {}) {
const config = opts.config || loadConfig();
const config = opts.config || loadConfig();
const db = opts.db || openDb(config);
const db = opts.db || openDb(config);
@@ -205,8 +193,11 @@ function createApp(opts = {}) {
@@ -205,8 +193,11 @@ function createApp(opts = {}) {
app.use(webRoutes);
app.use(webRoutes);
app.use((req, res) => {
app.use((req, res) => {
- if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
- return res.status(404).type('html').send(NOT_FOUND_HTML);
+ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/') || req.accepts(['application/json', 'text/html']) !== 'text/html') {
+ return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
+ }
+ res.setHeader('Content-Security-Policy', HOME_CSP);
+ return res.status(404).type('html').set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').send(renderNotFound({ config }));
});
});
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
app.use((err, req, res, next) => {
@@ -269,7 +269,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =
@@ -269,7 +269,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =
const footerBoot = (footer) => `<script>document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(${JSON.stringify(footer)}); } catch (e) { /* the Frame is optional */ } });</script>`;
const footerBoot = (footer) => `<script>document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(${JSON.stringify(footer)}); } catch (e) { /* the Frame is optional */ } });</script>`;
/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */
/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */
-function framePage({ config, title, description, canonicalPath = '/', body, head = '' }) {
+function framePage({ config, title, description, canonicalPath = '/', body, head = '', noindex = false }) {
const nav = {
const nav = {
service: 'bot',
service: 'bot',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
@@ -281,9 +281,11 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
@@ -281,9 +281,11 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
notificationsRealtime: true,
notificationsRealtime: true,
};
};
return shell.page({
return shell.page({
- name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,
- canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',
- agentLayer: require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',
+ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description,
+ ...(noindex ? {} : { summary: description }),
+ canonical: noindex ? undefined : `${config.baseUrl}${canonicalPath}`,
+ robots: noindex ? 'noindex, follow' : 'index, follow',
+ agentLayer: !noindex && require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',
navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',
navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',
navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),
head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),
@@ -291,6 +293,30 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
@@ -291,6 +293,30 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
});
});
}
}
+/** The page a browser gets for a path nothing serves: Bot's Frame, one way back, and no clue about what was asked for. */
+function renderNotFound({ config }) {
+ const host = String(config.baseUrl).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
+ return framePage({
+ config,
+ title: `Page not found · ${SITE_NAME}`,
+ description: `There is no page at this address on ${host}.`,
+ noindex: true,
+ head: [
+ `<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
+ UPDATES_FONT,
+ ].join('\n'),
+ body: `<main id="main" class="page">
+<h1>Page not found</h1>
+<p>There is no page at this address on ${esc(host)}. The link may be old or mistyped.</p>
+<div class="sc-actions">
+<a class="sc-btn sc-primary" href="/">${SITE_NAME} home</a>
+<a class="sc-btn" href="/robots">Robots</a>
+<a class="sc-btn" href="/docs">Build</a>
+</div>
+</main>`,
+ });
+}
+
// The page's type: shell.page sets no font and showcase.css styles only its own sections, so without these rules
// The page's type: shell.page sets no font and showcase.css styles only its own sections, so without these rules
// /updates fell back to the browser's Times New Roman (domain sweep, 2026-11-01). Zero specificity: any site rule wins.
// /updates fell back to the browser's Times New Roman (domain sweep, 2026-11-01). Zero specificity: any site rule wins.
const UPDATES_FONT = "<style>:where(body){font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif)}:where(main.page){display:block;max-width:880px;margin:0 auto;padding:24px max(16px,env(safe-area-inset-right,0px)) 56px max(16px,env(safe-area-inset-left,0px))}:where(.ov-updates h1){font-size:clamp(1.6rem,5.4vw,2.2rem);line-height:1.15;margin:0 0 10px;text-wrap:balance}</style>";
const UPDATES_FONT = "<style>:where(body){font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif)}:where(main.page){display:block;max-width:880px;margin:0 auto;padding:24px max(16px,env(safe-area-inset-right,0px)) 56px max(16px,env(safe-area-inset-left,0px))}:where(.ov-updates h1){font-size:clamp(1.6rem,5.4vw,2.2rem);line-height:1.15;margin:0 0 10px;text-wrap:balance}</style>";
@@ -354,4 +380,4 @@ ${footerBoot(footer)}`,
@@ -354,4 +380,4 @@ ${footerBoot(footer)}`,
});
});
}
}
-module.exports = { renderHome, renderUpdates, framePage, HOME_CSP, sections, faqItems };
+module.exports = { renderHome, renderUpdates, renderNotFound, framePage, HOME_CSP, sections, faqItems };
@@ -102,6 +102,25 @@ const { boot, check, done } = require('./helpers/app');
@@ -102,6 +102,25 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
});
});
+ await check('a browser that asks for a missing page gets the site 404 page in the Frame', async () => {
+ const r = await fetch(t.base + '/no-such-page-xyz', { headers: { Accept: 'text/html,*/*;q=0.8' }, redirect: 'manual' });
+ assert.strictEqual(r.status, 404);
+ assert.match(r.headers.get('content-type'), /^text\/html/);
+ const html = await r.text();
+ assert.ok(html.includes('<h1>Page not found</h1>'), html.slice(0, 300));
+ assert.ok(html.includes('There is no page at this address on bot.test. The link may be old or mistyped.'));
+ assert.ok(html.includes('id="navbar-mount"'), 'the page is in the site Frame');
+ assert.ok(html.includes('<meta name="robots" content="noindex, follow">'));
+ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex');
+ assert.match(r.headers.get('content-security-policy'), /style-src [^;]*'unsafe-inline'/);
+ assert.ok(html.includes('class="sc-btn sc-primary" href="/"'), 'home is the first way back');
+ const api = await fetch(t.base + '/api/no-such', { headers: { Accept: 'text/html' } });
+ assert.strictEqual(api.status, 404);
+ assert.match(api.headers.get('content-type'), /problem\+json/);
+ const notHtml = await fetch(t.base + '/no-such-page-xyz', { headers: { Accept: 'application/json' } });
+ assert.match(notHtml.headers.get('content-type'), /problem\+json/);
+ });
+
await check('the vhost proxies / and /shared/ to the app; the legal pages are the app\'s, not the Sites files', async () => {
await check('the vhost proxies / and /shared/ to the app; the legal pages are the app\'s, not the Sites files', async () => {
const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.bot.conf'), 'utf8');
const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.bot.conf'), 'utf8');
assert.match(conf, /location \^~ \/shared\/ \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);
assert.match(conf, /location \^~ \/shared\/ \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);
@@ -20,7 +20,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -20,7 +20,7 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
const t = await boot({ openre: false });
const t = await boot({ openre: false });
- const get = (p) => fetch(t.base + p, { redirect: 'manual' });
+ const get = (p, init = {}) => fetch(t.base + p, { redirect: 'manual', ...init });
await check('GET /terms, /privacy, /dmca: OpenVibe.Bot legal pages served by the app', async () => {
await check('GET /terms, /privacy, /dmca: OpenVibe.Bot legal pages served by the app', async () => {
for (const p of ['/terms', '/privacy', '/dmca']) {
for (const p of ['/terms', '/privacy', '/dmca']) {
@@ -69,14 +69,19 @@ const { boot, check, done } = require('./helpers/app');
@@ -69,14 +69,19 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(Array.isArray(m.icons) && m.icons.length > 0, 'has icons');
assert.ok(Array.isArray(m.icons) && m.icons.length > 0, 'has icons');
});
});
- await check('an unknown page is the app\'s 404 HTML page; the API keeps its problem+json 404', async () => {
- const page = await get('/no-such-page');
+ await check('an unknown page is the app\'s 404 HTML page for a browser; the API keeps its problem+json 404', async () => {
+ const page = await get('/no-such-page', { headers: { Accept: 'text/html' } });
assert.strictEqual(page.status, 404);
assert.strictEqual(page.status, 404);
assert.match(page.headers.get('content-type'), /^text\/html/);
assert.match(page.headers.get('content-type'), /^text\/html/);
- assert.match(await page.text(), /Not found/);
+ const html = await page.text();
+ assert.ok(html.includes('<h1>Page not found</h1>'), html.slice(0, 300));
+ assert.ok(html.includes('id="navbar-mount"'), 'the page is in the site Frame');
const api = await fetch(t.base + '/api/no-such-route');
const api = await fetch(t.base + '/api/no-such-route');
assert.strictEqual(api.status, 404);
assert.strictEqual(api.status, 404);
assert.match(api.headers.get('content-type'), /application\/problem\+json/);
assert.match(api.headers.get('content-type'), /application\/problem\+json/);
+ const notHtml = await get('/no-such-page');
+ assert.strictEqual(notHtml.status, 404);
+ assert.match(notHtml.headers.get('content-type'), /application\/problem\+json/, 'a client that does not accept HTML keeps JSON');
});
});
await check('the vhost no longer serves anything from a Sites checkout', async () => {
await check('the vhost no longer serves anything from a Sites checkout', async () => {