diff --git a/server/providers/news.js b/server/providers/news.js index 0ad78665a..9184161a1 100644 --- a/server/providers/news.js +++ b/server/providers/news.js @@ -8,7 +8,8 @@ import { createKeyedNews } from './news/keyed.js'; // is no plain origin, warned of once), Core answers the same routes itself // (news/keyed.js): Conflicts from GDELT's event exports with no key, and // Liveuamap's items beside them with LIVEUAMAP_API_KEY; Local from World -// News API, "key required" while WORLDNEWSAPI_KEY is unset. +// News API, "key required" while WORLDNEWSAPI_KEY is unset. A host that +// serves news only from its own server mounts it with `builtIn: false`. const TIMEOUT_MS = 15_000; const MAX_BYTES = 4 * 1024 * 1024; @@ -63,8 +64,10 @@ function configuredOrigin(env, log) { /** * A thin, validated GET proxy for the news layer's three read routes, or - * their keyed answers without a news server. - * @param {{env?: () => object, log?: (message: string) => void, origin?: () => string|null, fetchImpl?: typeof fetch, timeoutMs?: number, keyed?: {answer: (path: string, params: URLSearchParams) => Promise<{status: number, body: object}>}}} [options] + * their keyed answers without a news server. A host that serves news only + * from its own server passes `builtIn: false`: without GEV_NEWS_ORIGIN the + * routes then answer "not configured" and Core reads nothing itself. + * @param {{env?: () => object, log?: (message: string) => void, origin?: () => string|null, fetchImpl?: typeof fetch, timeoutMs?: number, builtIn?: boolean, keyed?: {answer: (path: string, params: URLSearchParams) => Promise<{status: number, body: object}>}|null}} [options] */ export function newsProxy({ env = () => process.env, @@ -72,7 +75,8 @@ export function newsProxy({ origin = configuredOrigin(env, log), fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = TIMEOUT_MS, - keyed = createKeyedNews({ env, fetchImpl, log }), + builtIn = true, + keyed = builtIn ? createKeyedNews({ env, fetchImpl, log }) : null, } = {}) { const json = (res, status, value) => { if (res.destroyed) return; @@ -96,6 +100,12 @@ export function newsProxy({ return json(res, 400, { error: 'invalid_news_request' }); const base = origin(); if (!base) { + if (!keyed) + return json(res, 503, { + error: 'news_not_configured', + unavailable: true, + retryable: false, + }); try { const { status, body } = await keyed.answer( local.pathname, diff --git a/server/providers/news.test.mjs b/server/providers/news.test.mjs index 07c1f0fd1..bef3512c5 100644 --- a/server/providers/news.test.mjs +++ b/server/providers/news.test.mjs @@ -192,3 +192,29 @@ test('a built-in answer that throws is a 502, logged in one line without address assert.match(logs[0], /TypeError/); assert.doesNotMatch(logs[0], /key=abc|\n/); }); + +test('a host that serves news only from its own server turns the built-in backend off', async () => { + let fetches = 0; + const call = mount({ + origin: () => null, + builtIn: false, + fetchImpl: async () => { + fetches++; + throw new Error('unexpected'); + }, + }); + for (const url of [ + '/hotspots', + '/feed?hotspot=all', + '/outline?at=51.13,71.43&v=4', + ]) { + const answer = await call(url); + assert.equal(answer.status, 503, url); + assert.deepEqual(answer.json, { + error: 'news_not_configured', + unavailable: true, + retryable: false, + }); + } + assert.equal(fetches, 0); +});