From c31263deababbf25c8c484acbee4695225756a48 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 09:47:12 +0200 Subject: [PATCH 01/49] test: a missing site is the 404, and an upstream that cannot answer is the 503 red. head.html still decides whether a site exists, so a site not yet previewed on the requested ref is refused at 404 with its document already read and discarded. an upstream that cannot be reached still reports the failure in a return value, and a preview host that throws still reaches the browser as a 500 with no body. the full suite aborts on load, because src/storage/site.js does not exist yet. --- test/index.test.js | 2 +- test/routes/da-admin.test.js | 79 ++-- test/routes/source-read.test.js | 620 +++++++++++++++++++++++++++---- test/routes/source-write.test.js | 68 ++-- test/storage/config.test.js | 31 +- test/storage/site.test.js | 209 +++++++++++ test/utils/aemCtx.test.js | 33 +- 7 files changed, 896 insertions(+), 146 deletions(-) create mode 100644 test/storage/site.test.js diff --git a/test/index.test.js b/test/index.test.js index 0bdacba0..3ba9d9ba 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -189,7 +189,7 @@ describe('worker fetch handler', () => { // reaches the caller if that rebuild carries it describe('a refused write on a source-bus site', () => { const busWorker = async () => (await esmock('../src/index.js', READ_HANDLER_MOCKS, { - '../src/storage/source-bus.js': { default: async () => true }, + '../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index aad41fc2..b0aa0de3 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -33,7 +33,7 @@ const recorder = () => { const env = { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', - HLX_ADMIN: 'https://admin.hlx.page', + HLX_CONFIG_SERVICE: 'https://config.aem.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -44,23 +44,26 @@ const recorder = () => { return { env, fetched }; }; -// answers the real /ping, which is the only lookup the routes make. `upgraded` is the set of -// `org/site` keys the probe reports as enrolled. -const stubPing = (upgraded = []) => { +// stands in for config.aem.page, the only lookup the routes make +// answers that any site exists; `upgraded` lists the `org/site` keys on the source bus +const stubConfig = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const key = url.slice(url.indexOf('/ping/') + '/ping/'.length); - const headers = upgraded.includes(key) ? { 'x-api-upgrade-available': 'true' } : {}; - return new Response('', { status: 200, headers }); + const [, site, org] = new URL(url).pathname.split('/')[1].split('--'); + const source = upgraded.includes(`${org}/${site}`) + ? `https://api.aem.live/${org}/sites/${site}/` + : `https://content.da.live/${org}/${site}/`; + const body = JSON.stringify({ content: { source: { url: source, type: 'markup' } } }); + return new Response(body, { status: 200 }); }; return asked; }; const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { - default: async () => false, + '../../src/storage/site.js': { + default: async () => ({ exists: true, onSourceBus: false }), }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), @@ -111,10 +114,11 @@ describe('daSourceGet', () => { // `{ headHtml: undefined }` actually simulates a missing head.html, instead // of being masked by the default parameter value. const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; + const site = overrides.site ?? { exists: true, onSourceBus: false }; calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { - default: async () => false, + '../../src/storage/site.js': { + default: async () => site, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), @@ -138,7 +142,8 @@ describe('daSourceGet', () => { buildQuickEditCookie: (p) => `da-quick-edit=${encodeURIComponent(p)}; Path=/`, }, '../../src/storage/config.js': { - getSiteConfig: async () => { throw new Error('no config'); }, + // da-admin answers a site with no config with a 404, which getSiteConfig reports as null + getSiteConfig: async () => null, }, })).daSourceGet; }; @@ -233,8 +238,8 @@ describe('daSourceGet', () => { assert.strictEqual(await res.text(), 'composed'); }); - it('returns a working 404 shell for quick-edit when head.html is missing', async () => { - const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + it('returns a working 404 shell for quick-edit when there is no such site', async () => { + const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); const daCtx = getDaCtx(req); @@ -245,11 +250,11 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); const html = await res.text(); assert.ok(html.includes('importmap')); - assert.ok(!html.includes('Unable to retrieve AEM branch')); + assert.ok(!html.includes('There is no site at this address')); }); - it('still returns branch-not-found for non-quick-edit when head.html is missing', async () => { - const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + it('returns not-found for non-quick-edit when there is no such site', async () => { + const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const daCtx = getDaCtx(req); @@ -259,15 +264,26 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); assert.strictEqual(calls.ue, 0); const html = await res.text(); - assert.ok(html.includes('Unable to retrieve AEM branch')); + assert.ok(html.includes('There is no site at this address')); + }); + + it('composes the page when the site has no head.html', async () => { + const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + const daCtx = getDaCtx(req); + + const res = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(calls.compose.length, 1); + assert.strictEqual(calls.ue, 1); }); }); describe('source URLs', () => { - // these drive the unmocked module, so the /ping lookup really does reach out; answer it - // without the upgrade header, which is the legacy store these tests describe + // answers the unmocked lookup with a da-admin source, the legacy store these tests describe beforeEach(() => { - stubPing(); + stubConfig(); }); afterEach(() => { @@ -402,17 +418,16 @@ describe('daSourcePost to a non-HTML path', () => { }); describe('daSourcePost', () => { - // these drive the unmocked module, so the /ping lookup really does reach out; answer it - // without the upgrade header, which is the legacy store these tests describe + // answers the unmocked lookup with a da-admin source, the legacy store these tests describe beforeEach(() => { - stubPing(); + stubConfig(); }); afterEach(() => { delete globalThis.fetch; }); - describe('on a site /ping reports as enrolled', () => { + describe('on a site the lookup reports as enrolled', () => { const write = async (site, env) => { const html = new File(['hello'], 'page.html', { type: 'text/html' }); const req = formReq(`https://main--${site}--org.ue.da.live/page`, html); @@ -420,7 +435,7 @@ describe('daSourcePost', () => { }; it('is refused with 405 and nothing is written', async () => { - stubPing(['org/refused']); + stubConfig(['org/refused']); const { env, fetched } = recorder(); const res = await write('refused', env); @@ -432,16 +447,16 @@ describe('daSourcePost', () => { // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes // effect on the next one - it('probes once per write', async () => { - const asked = stubPing(['org/probedeach']); + it('looks the site up once per write', async () => { + const asked = stubConfig(['org/lookedupeach']); const { env } = recorder(); - await write('probedeach', env); - await write('probedeach', env); + await write('lookedupeach', env); + await write('lookedupeach', env); assert.deepStrictEqual(asked, [ - 'https://admin.hlx.page/ping/org/probedeach', - 'https://admin.hlx.page/ping/org/probedeach', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index d51cb27b..8f3f8bd0 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -14,9 +14,17 @@ import assert from 'assert'; import esmock from 'esmock'; import { getDaCtx } from '../../src/utils/daCtx.js'; +// a namespace import, so a body pinned to a constant this branch does not export yet fails +// its own assertion rather than taking the file down at load +import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); +// what the site lookup answers +const SOURCE_BUS = { exists: true, onSourceBus: true }; +const LEGACY_STORE = { exists: true, onSourceBus: false }; +const NO_SITE = { exists: false, onSourceBus: false }; + /** * Builds the route module with the network replaced. `bus` answers the source bus, `legacy` * answers da-admin, and every request to each is recorded so a test can assert where a read @@ -27,13 +35,19 @@ const build = async (overrides = {}) => { bus = () => new Response('from the source bus', { status: 200, headers: { etag: '"busetag"' } }), legacy = () => new Response('from da-admin', { status: 200 }), } = overrides; - // `in overrides` rather than a destructured default on these two, so passing an explicit - // undefined really does simulate a missing head.html and a probe that could not answer + // `in overrides` rather than a destructured default on these, so an explicit undefined reads + // as a missing head.html, a template the preview host does not have, or a failed lookup const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; - const onSourceBus = 'onSourceBus' in overrides ? overrides.onSourceBus : false; - const probeError = 'probeError' in overrides ? overrides.probeError : new TypeError('fetch failed'); + const templateHtml = 'templateHtml' in overrides + ? overrides.templateHtml + : 'from the template'; + const site = 'site' in overrides ? overrides.site : LEGACY_STORE; + const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); + const { + headError, templateError, configError, composeError, config = null, + } = overrides; const seen = { - bus: [], legacy: [], ue: 0, lookups: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -52,33 +66,50 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { + '../../src/storage/site.js': { default: async () => { seen.lookups += 1; - // the probe reports a failure by throwing, so undefined stands for "could not answer" - if (onSourceBus === undefined) throw probeError; - return onSourceBus; + // throws for undefined, which is how the lookup reports a failure + if (site === undefined) throw lookupError; + return site; }, }, '../../src/utils/aemCtx.js': { - getAemCtx: () => ({}), - getAEMHtml: async () => headHtml, + getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), + // answers both preview host reads, and fails them separately so a test can reach the + // template read with head.html answering + getAEMHtml: async (aemCtx, path) => { + const isHead = path === '/head.html'; + if (isHead && headError) throw headError; + if (!isHead && templateError) throw templateError; + seen.aem.push(path); + return isHead ? headHtml : templateHtml; + }, }, '../../src/render/compose.js': { - composeHtml: async (daCtx, aemCtx, bodyHtml) => ({ bodyHtml }), + // returns a hast root, so quick-edit can walk what was built + composeHtml: async (daCtx, aemCtx, bodyHtml, head) => { + if (composeError) throw composeError; + seen.head.push(head); + return { type: 'root', children: [], bodyHtml }; + }, serializeHtml: (tree) => `${tree.bodyHtml}`, }, '../../src/ue/ue.js': { applyUEInstrumentation: async () => { seen.ue += 1; }, }, '../../src/storage/config.js': { - getSiteConfig: async () => { throw new Error('no config'); }, + // da-admin answers a site with no config with a 404, which getSiteConfig reports as null + getSiteConfig: async () => { + if (configError) throw configError; + return config; + }, }, }); return { ...mod, env, seen }; }; -describe('when /ping cannot say which store holds the site', () => { +describe('when the lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); @@ -86,7 +117,7 @@ describe('when /ping cannot say which store holds the site', () => { // picking a store without an answer is a coin flip, and reading the wrong one hands the author // the wrong document at 200 it('refuses an html read with 503 and touches neither store', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); + const { daSourceGet, env, seen } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -96,7 +127,7 @@ describe('when /ping cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ onSourceBus: undefined }); + const { daSourceGet, env } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -104,8 +135,21 @@ describe('when /ping cannot say which store holds the site', () => { assert.ok(Number(res.headers.get('Retry-After')) > 0); }); + // the preview iframe renders this body, and the store answered nothing here: it was never + // asked, since which store to ask is what could not be determined + it('says the store could not be determined, not that it did not answer', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + const body = await res.text(); + assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(body, messages.SOURCE_UNDETERMINED_HTML_MESSAGE); + }); + it('refuses a non-html read too', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); + const { daSourceGet, env, seen } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -115,7 +159,7 @@ describe('when /ping cannot say which store holds the site', () => { }); it('refuses a HEAD with 503 and no body', async () => { - const { daSourceHead, env, seen } = await build({ onSourceBus: undefined }); + const { daSourceHead, env, seen } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -125,61 +169,60 @@ describe('when /ping cannot say which store holds the site', () => { assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); - // both 503s carry the same status and a body nobody parses, so the header is what tells an - // unanswerable probe apart from a store that was picked and then failed - it('names the failed probe in x-error', async () => { - const { daSourceGet, env } = await build({ onSourceBus: undefined }); + // both 503s share a status and an unparsed body, so only the header separates them + it('names the failed lookup in x-error', async () => { + const { daSourceGet, env } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /ping/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('names the failed probe on a HEAD too', async () => { - const { daSourceHead, env } = await build({ onSourceBus: undefined }); + it('names the failed lookup on a HEAD too', async () => { + const { daSourceHead, env } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /ping/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); // a read answers the same 503 and the same body whichever of the two failed, so the header is // the only thing on the wire that separates a timeout from a dropped connection - it('carries the probe cause, not a category', async () => { + it('names the lookup cause, not a category', async () => { const { daSourceGet, env } = await build({ - onSourceBus: undefined, - probeError: new DOMException('timed out', 'TimeoutError'), + site: undefined, + lookupError: new DOMException('timed out', 'TimeoutError'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); }); // rendering a thrown non-Error as "undefined: undefined" would leave the 503 saying nothing it('survives a thrown non-Error', async () => { - const { daSourceGet, env } = await build({ onSourceBus: undefined, probeError: 'boom' }); + const { daSourceGet, env } = await build({ site: undefined, lookupError: 'boom' }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 503); - assert.strictEqual(res.headers.get('x-error'), '/ping failed: Error: boom'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); }); - it('tells a probe failure apart from a store failure', async () => { + it('tells a lookup failure apart from a store failure', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: fetch failed'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: fetch failed'); }); }); @@ -190,7 +233,7 @@ describe('reading from the store that holds the site', () => { describe('an html read on a source-bus site', () => { it('reads from the source bus', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -201,7 +244,7 @@ describe('reading from the store that holds the site', () => { }); it('composes the source-bus document, not da-admin\'s copy of it', async () => { - const { daSourceGet, env } = await build({ onSourceBus: true }); + const { daSourceGet, env } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -210,7 +253,7 @@ describe('reading from the store that holds the site', () => { }); it('forwards the author token to the source bus', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -234,7 +277,7 @@ describe('reading from the store that holds the site', () => { describe('the store lookup on a read', () => { it('happens once, and only the store it named is asked', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -247,7 +290,7 @@ describe('reading from the store that holds the site', () => { describe('when the store cannot be reached at all', () => { it('answers 503 rather than throwing on an html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -259,7 +302,7 @@ describe('reading from the store that holds the site', () => { it('answers 503 rather than throwing on a non-html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -269,7 +312,7 @@ describe('reading from the store that holds the site', () => { it('answers 503 rather than throwing on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -288,7 +331,7 @@ describe('reading from the store that holds the site', () => { it('asks the caller to retry', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -302,7 +345,7 @@ describe('reading from the store that holds the site', () => { // body that says what happened rather than an empty page it('says what happened, on both GET paths', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const html = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -311,13 +354,13 @@ describe('reading from the store that holds the site', () => { const htmlRes = await daSourceGet({ req: html, env, daCtx: getDaCtx(html) }); const assetRes = await daSourceGet({ req: asset, env, daCtx: getDaCtx(asset) }); - assert.match(await htmlRes.text(), /503/); - assert.match(await assetRes.text(), /503/); + assert.strictEqual(await htmlRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await assetRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); }); it('answers 503 with no body on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -332,38 +375,38 @@ describe('reading from the store that holds the site', () => { // log is not where the caller is looking it('names the cause in x-error on an html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('Network connection lost'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); it('names the cause on a non-html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('Network connection lost'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); it('names the cause on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new DOMException('The operation timed out', 'TimeoutError'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TimeoutError: The operation timed out'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TimeoutError: The operation timed out'); }); it('names the cause when da-admin is unreachable', async () => { @@ -374,21 +417,21 @@ describe('reading from the store that holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); // a header value cannot span lines, so a message carrying a stack would throw where the 503 // is built and turn the 503 into a 500 it('collapses a multi-line cause onto one line', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('lost\n at fetch'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: lost at fetch'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: lost at fetch'); }); }); @@ -398,7 +441,7 @@ describe('reading from the store that holds the site', () => { [401, 403, 429, 500, 502].forEach((status) => { it(`keeps the store's ${status} as the status`, async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -413,7 +456,7 @@ describe('reading from the store that holds the site', () => { [429, 500, 502].forEach((status) => { it(`passes the store's body through on a ${status}`, async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -424,12 +467,13 @@ describe('reading from the store that holds the site', () => { }); }); - // /ping reads no token, so the store is the only thing to see one. the authorbus extension - // matches this sentinel exactly and recovers by refetching /gimme_cookie and refreshing + // the lookup does not send the author token, so the store is the only place a 401 can come + // from. the authorbus extension matches this sentinel and recovers by refetching + // /gimme_cookie and refreshing [401, 403].forEach((status) => { it(`serves the da:401 shell when the store answers ${status} on an html read`, async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -443,7 +487,7 @@ describe('reading from the store that holds the site', () => { it('does not ask the caller to retry a 401, since retrying cannot help', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -455,7 +499,7 @@ describe('reading from the store that holds the site', () => { it('passes a store 401 through bare on a non-html read, which renders nothing', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -468,7 +512,7 @@ describe('reading from the store that holds the site', () => { it('answers a store 401 on a HEAD with no body', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -479,11 +523,11 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(await res.text(), ''); }); - // a wrong yes from /ping produces this, and there is no second store to retry against: the - // author is handed the starter template over whatever da-admin still holds + // hands the author the starter template over whatever da-admin still has, with no second + // store to retry against it('composes the starter template on a 404 without asking the other store', async () => { const { daSourceGet, env, seen } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -498,7 +542,7 @@ describe('reading from the store that holds the site', () => { describe('UE instrumentation', () => { it('is applied on a UE host', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -507,7 +551,7 @@ describe('reading from the store that holds the site', () => { }); it('is not applied on a preview host', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.preview.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -534,7 +578,7 @@ describe('reading from the store that holds the site', () => { daadmin: { fetch: async () => new Response('', { status: 200 }) }, }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { default: async () => true }, + '../../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), getAEMHtml: async () => '', @@ -552,7 +596,7 @@ describe('reading from the store that holds the site', () => { describe('a non-html read', () => { it('goes to the source bus fully normalized', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/Media/Holiday.PNG'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -572,7 +616,7 @@ describe('reading from the store that holds the site', () => { describe('a HEAD', () => { it('goes to the source bus on a source-bus site', async () => { - const { daSourceHead, env, seen } = await build({ onSourceBus: true }); + const { daSourceHead, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -594,7 +638,7 @@ describe('reading from the store that holds the site', () => { it('passes a 404 from the store through, since HEAD composes nothing', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -692,7 +736,7 @@ describe('reading from the store that holds the site', () => { // refusal reaches the caller as itself it('refuses a video read when the store could not be reached', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); @@ -703,7 +747,7 @@ describe('reading from the store that holds the site', () => { }); it('reads a video from the source bus on a source-bus site', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -712,19 +756,435 @@ describe('reading from the store that holds the site', () => { }); }); - describe('the order of the two things that can fail', () => { - // a missing AEM branch is answered as it was before, so quick-edit still gets its shell - it('reports a missing AEM branch even when the store did not answer', async () => { + describe('the order of the three things that can fail', () => { + // answers the settled 404 ahead of the retryable 503 + it('reports no such site even when the store did not answer', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, - headHtml: undefined, + site: NO_SITE, bus: () => { throw new TypeError('fetch failed'); }, + legacy: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + }); + + // names which failure it was: a site that does not exist has no preview host either + it('reports no such site even when the preview host did not answer', async () => { + const { daSourceGet, env } = await build({ + site: NO_SITE, + headError: new TypeError('Network connection lost'), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + }); + + it('reports an unreachable store on a site with no head.html', async () => { + const { daSourceGet, env } = await build({ + headHtml: undefined, + legacy: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + assert.strictEqual(res.status, 503); + }); + }); + + // #258: head.html does not say whether a site exists, so a missing head.html is not a 404 + describe('when the site serves no head.html', () => { + it('reads the document anyway', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(await res.text(), 'from da-admin'); + }); + + // serves the page without the project's css and js rather than not at all + it('composes with no project head entries', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.head.length, 1); + assert.strictEqual(seen.head[0] ?? '', ''); + }); + + it('composes the starter template when the document is missing too', async () => { + const { daSourceGet, env } = await build({ + headHtml: undefined, + legacy: () => new Response('', { status: 404 }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.match(await res.text(), /
/); + }); + + it('instruments UE without a head', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(seen.ue, 1); + }); + + // sets no cookie: the cookie names the entry script, and no head.html means no entry script + it('serves quick-edit the page without an entry-script cookie', async () => { + const { daSourceGet, env } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('Set-Cookie'), null); + }); + + it('is not answered 404 by a missing head.html alone', async () => { + const { daSourceGet, env } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + }); + + describe('when there is no such site', () => { + it('refuses an html read with 404 and touches neither store', async () => { + const { daSourceGet, env, seen } = await build({ site: NO_SITE }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + assert.strictEqual(res.status, 404); + assert.match(await res.text(), /Site not found/); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // nothing renders an image, so a body would only corrupt it + it('refuses a non-html read with 404 and no body', async () => { + const { daSourceGet, env, seen } = await build({ site: NO_SITE }); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + it('refuses a HEAD with 404 and no body', async () => { + const { daSourceHead, env, seen } = await build({ site: NO_SITE }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // needs a shell with the import map, since the editor loads into this page + it('gives quick-edit its shell', async () => { + const { daSourceGet, env } = await build({ site: NO_SITE }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.match(await res.text(), /importmap/); + }); + }); + + // a throw out of getAEMHtml used to reach the worker's catch as a 500 with no body + describe('when the preview host cannot be reached', () => { + const dead = () => new TypeError('Network connection lost'); + + it('answers 503 rather than throwing', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + }); + + it('names the cause in x-error', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'preview host failed: TypeError: Network connection lost'); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build({ + headError: new DOMException('The operation timed out', 'TimeoutError'), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); }); + + // says what happened, since the preview iframe renders the refusal + it('says the preview host did not answer, not the store', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(await res.text(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); + }); + + // a 404 would say the site is gone, which is #258 again, this time on the preview host + it('does not answer 404', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + }); +}); + +describe('when the site config cannot be reached', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + const missing = () => ({ + legacy: () => new Response('', { status: 404 }), + configError: new TypeError('fetch failed'), + }); + + // the config names the template built over a document that is not there, so a store that did + // not answer would hand the author the wrong blank page to save over + it('refuses with 503 rather than composing the starter template', async () => { + const { daSourceGet, env } = await build(missing()); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + }); + + // da-admin serves the config as well as the document, so the store is what did not answer. + // x-error is what separates the two reads + it('says the store did not answer', async () => { + const { daSourceGet, env } = await build(missing()); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(await res.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + }); + + it('names the site config in x-error', async () => { + const { daSourceGet, env } = await build(missing()); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'site config failed: TypeError: fetch failed'); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build(missing()); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + // da-admin answers a site with no config with a 404, not a throw, and that is the ordinary case + it('composes the starter template when there is no config at all', async () => { + const { daSourceGet, env } = await build({ legacy: () => new Response('', { status: 404 }) }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.match(await res.text(), /
/); + }); +}); + +// getAEMHtml is not stubbed here: a stub hides a preview host that answers, but with something +// other than head.html +describe('when the preview host refuses head.html', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + const readWithPreviewStatus = async (status) => { + globalThis.fetch = async () => new Response('preview said no', { status }); + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, + }; + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/site.js': { default: async () => LEGACY_STORE }, + }); + // a preview host rather than a UE host, so nothing is instrumented onto the composed page + const req = authedReq('https://main--site--org.preview.da.live/folder/content'); + return daSourceGet({ req, env, daCtx: getDaCtx(req) }); + }; + + // a 200 with no head.html is a page with no stylesheet, no scripts and no entry script + it('refuses a 500 with 503 rather than composing an empty project head', async () => { + const res = await readWithPreviewStatus(500); + + assert.strictEqual(res.status, 503); + }); + + it('names the preview host in x-error', async () => { + const res = await readWithPreviewStatus(500); + + assert.match(res.headers.get('x-error'), /preview host failed/); + }); + + // a host behind Helix auth refuses without a site token, and a retry answers the same + it('composes the page without a head on a 401', async () => { + const res = await readWithPreviewStatus(401); + + assert.strictEqual(res.status, 200); + }); + + // a ref that was never previewed has no head.html, which is not a failure + it('composes the page without a head on a 404', async () => { + const res = await readWithPreviewStatus(404); + + assert.strictEqual(res.status, 200); + }); +}); + +describe('a read that carries no token', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // the authorbus extension recovers off the da:401 meta rather than off the status + it('is refused with the da:401 shell, and nothing is looked up', async () => { + const { daSourceGet, env, seen } = await build(); + const req = new Request('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(await res.text(), messages.UNAUTHORIZED_HTML_MESSAGE); + assert.strictEqual(seen.lookups, 0); + }); +}); + +describe('a path the site config gives a template', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // the template is composed over a document that is not there, so the store answers 404 + const missingDoc = (rows) => ({ + legacy: () => new Response('', { status: 404 }), + config: rows.map((value) => ({ key: 'editor.ue.template', value })), + }); + + it('composes the configured template rather than the starter', async () => { + const { daSourceGet, env, seen } = await build(missingDoc(['/folder=/scripts/tpl.html'])); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(await res.text(), 'from the template'); + assert.ok(seen.aem.includes('/scripts/tpl.html')); + }); + + // unwrapped, this read escapes as the bodyless 500 that #258 is about + it('refuses with 503 when the template read cannot be reached', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/tpl.html']), + templateError: new TypeError('fetch failed'), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.match(res.headers.get('x-error'), /preview host failed/); + }); + + it('takes the longest matching prefix', async () => { + const { daSourceGet, env, seen } = await build(missingDoc([ + '/=/scripts/site.html', + '/folder=/scripts/folder.html', + ])); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(seen.aem.includes('/scripts/folder.html')); + assert.ok(!seen.aem.includes('/scripts/site.html')); + }); + + it('ignores a template configured for another path', async () => { + const { daSourceGet, env, seen } = await build(missingDoc(['/other=/scripts/other.html'])); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.match(await res.text(), /
/); + assert.strictEqual(seen.aem.length, 1); + }); + + // the config names a path the preview host answers 404 for, which leaves the starter + it('falls back to the starter when the preview host has no such template', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/gone.html']), + templateHtml: undefined, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.match(await res.text(), /
/); + }); +}); + +describe('when the worker itself has a bug', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // only an unreachable upstream is retryable, and a 503 would keep the throw out of the log + // the worker boundary writes + it('lets the throw through rather than rendering it as a 503', async () => { + const { daSourceGet, env } = await build({ composeError: new TypeError('tree is not iterable') }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await assert.rejects( + () => daSourceGet({ req, env, daCtx: getDaCtx(req) }), + /tree is not iterable/, + ); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index ec9ce54f..cd0b7297 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -19,6 +19,11 @@ import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../.. const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; +// what the site lookup answers +const SOURCE_BUS = { exists: true, onSourceBus: true }; +const LEGACY_STORE = { exists: true, onSourceBus: false }; +const NO_SITE = { exists: false, onSourceBus: false }; + /** The shape the Universal Editor Service posts: a `data` blob in a multipart form. */ const uePost = (url, html = DOC) => { const body = new FormData(); @@ -28,10 +33,9 @@ const uePost = (url, html = DOC) => { const build = async (overrides = {}) => { const { status = 201 } = overrides; - // `in overrides` rather than a destructured default, so passing an explicit undefined really - // does simulate a probe that could not answer - const onSourceBus = 'onSourceBus' in overrides ? overrides.onSourceBus : false; - const probeError = 'probeError' in overrides ? overrides.probeError : new TypeError('fetch failed'); + // `in overrides` rather than a destructured default, so an explicit undefined reaches here + const site = 'site' in overrides ? overrides.site : LEGACY_STORE; + const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); const seen = { bus: [], legacy: [], lookups: 0, order: [], }; @@ -69,13 +73,13 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { + '../../src/storage/site.js': { default: async () => { seen.lookups += 1; seen.order.push('lookup'); - // the probe reports a failure by throwing, so undefined stands for "could not answer" - if (onSourceBus === undefined) throw probeError; - return onSourceBus; + // throws for undefined, which is how the lookup reports a failure + if (site === undefined) throw lookupError; + return site; }, }, }); @@ -98,7 +102,7 @@ describe('writing to the store that holds the site', () => { // the document at 201 for a key nothing serves, so the write is refused rather than misplaced describe('a source-bus site', () => { it('is refused with 405 and touches neither store', async () => { - const { res, seen } = await post({ onSourceBus: true }); + const { res, seen } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.status, 405); assert.strictEqual(seen.bus.length, 0); @@ -106,13 +110,13 @@ describe('writing to the store that holds the site', () => { }); it('names the methods that are left', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.headers.get('Allow'), 'GET, HEAD, OPTIONS'); }); it('does not ask the caller to retry, since retrying cannot help', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.headers.get('Retry-After'), null); }); @@ -120,7 +124,7 @@ describe('writing to the store that holds the site', () => { // nothing renders a POST body, and UES embeds it verbatim in its problem+json error string, // so the exact text is what the author is shown it('says what happened in plain text', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); assert.match(res.headers.get('Content-Type'), /^text\/plain/); assert.strictEqual(await res.text(), SOURCE_BUS_READ_ONLY_MESSAGE); @@ -129,9 +133,9 @@ describe('writing to the store that holds the site', () => { // a write is the one operation a wrong store cannot be walked back from, so no answer means no // write rather than a guess - describe('when /ping cannot say which store holds the site', () => { + describe('when the lookup cannot say which store holds the site', () => { it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ onSourceBus: undefined }); + const { res, seen } = await post({ site: undefined }); assert.strictEqual(res.status, 503); assert.strictEqual(seen.bus.length, 0); @@ -139,30 +143,42 @@ describe('writing to the store that holds the site', () => { }); it('asks the caller to retry, unlike the source-bus refusal', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ site: undefined }); assert.ok(Number(res.headers.get('Retry-After')) > 0); }); it('says which of the two refusals it is', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ site: undefined }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - it('names the failed probe in x-error', async () => { - const { res } = await post({ onSourceBus: undefined }); + it('names the failed lookup in x-error', async () => { + const { res } = await post({ site: undefined }); - assert.match(res.headers.get('x-error'), /ping/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('carries the probe cause, not a category', async () => { + it('names the lookup cause, not a category', async () => { const { res } = await post({ - onSourceBus: undefined, - probeError: new DOMException('timed out', 'TimeoutError'), + site: undefined, + lookupError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + }); + }); + + // a 404 from the config service says there is no AEM site config, not that the DA org and site + // are bogus. a read of the same path answers 404, so the editor cannot reach this state + describe('a site the lookup says does not exist', () => { + it('is written to da-admin all the same', async () => { + const { res, seen } = await post({ site: NO_SITE }); + + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(res.status, 201); }); }); @@ -257,7 +273,7 @@ describe('writing to the store that holds the site', () => { }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { - const { seen } = await post({ onSourceBus: true }); + const { seen } = await post({ site: SOURCE_BUS }); assert.strictEqual(seen.lookups, 1); }); @@ -287,7 +303,7 @@ describe('writing to the store that holds the site', () => { const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); }); @@ -295,7 +311,7 @@ describe('writing to the store that holds the site', () => { // driven on a source-bus site, so the 415 has to come from the extension check rather than // from the refusal below it. on a legacy site either ordering would pass. it('is refused before anything is resolved', async () => { - const { daSourcePost, env, seen } = await build({ onSourceBus: true }); + const { daSourcePost, env, seen } = await build({ site: SOURCE_BUS }); const req = uePost('https://main--site--org.ue.da.live/folder/data.json'); const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); diff --git a/test/storage/config.test.js b/test/storage/config.test.js index 843f6dca..a0336a1a 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -127,13 +127,36 @@ describe('Config Module', () => { assert.deepStrictEqual(result, multiSheet.data.data); }); - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; + it('should return null when there is no config', async () => { + mockFetch.nextResponse = { ok: false, status: 404 }; const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); assert.strictEqual(result, null); }); + + // da-admin answers 403 when the author may not read the site config, and a retry answers the + // same, so the starter template is used rather than refusing + [401, 403].forEach((status) => { + it(`should return null when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; + + assert.strictEqual(await configModule.getSiteConfig(mockEnv, mockDaCtx), null); + }); + }); + + // a store that could not answer is not the same as a site with no config, and reading it as + // one hands the author a blank page to save over a document that exists + [429, 500, 502].forEach((status) => { + it(`should throw when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; + + await assert.rejects( + () => configModule.getSiteConfig(mockEnv, mockDaCtx), + new RegExp(String(status)), + ); + }); + }); }); describe('getOrgConfig', () => { @@ -194,8 +217,8 @@ describe('Config Module', () => { assert.deepStrictEqual(result, multiSheet.data.data); }); - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; + it('should return null when there is no config', async () => { + mockFetch.nextResponse = { ok: false, status: 404 }; const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); diff --git a/test/storage/site.test.js b/test/storage/site.test.js new file mode 100644 index 00000000..db6e4807 --- /dev/null +++ b/test/storage/site.test.js @@ -0,0 +1,209 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; + +const { default: getSite } = await import('../../src/storage/site.js'); + +const env = { + AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-token', +}; + +const daCtx = (over = {}) => ({ + org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, +}); + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(input.toString(), init); + }; +}; + +// has the two fields the lookup reads; the service also answers with admin roles and secrets +const config = (sourceUrl) => () => new Response( + JSON.stringify({ content: { source: { url: sourceUrl, type: 'markup' } } }), + { status: 200 }, +); +const legacy = config('https://content.da.live/org/site/'); +const sourceBus = config('https://api.aem.live/org/sites/site/'); +const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); + +describe('getSite', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks the config service for the admin-scoped site config', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); + }); + + it('takes the config service from env, so dev can point elsewhere', async () => { + stubFetch(legacy); + + await getSite({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); + + assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); + }); + + it('names the ref the request came in on', async () => { + stubFetch(legacy); + + await getSite(env, daCtx({ ref: 'feature' })); + + assert.match(calls[0].url, /\/feature--site--org\//); + }); + + it('sends the shared token', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); + }); + + // the edge answers 400 without it, and that failure reads like a bad path + it('sends the backend type', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); + }); + + // the author's token has no business at a service-to-service endpoint + it('sends no author token', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls[0].init.headers.Authorization, undefined); + }); + + it('gives up rather than hanging', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.ok(calls[0].init.signal); + }); + }); + + describe('which store holds the site', () => { + it('reads the source bus off the content source url', async () => { + stubFetch(sourceBus); + + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: true }); + }); + + it('reads a da-admin site as legacy', async () => { + stubFetch(legacy); + + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: false }); + }); + + it('tolerates a trailing slash on the configured source bus', async () => { + stubFetch(sourceBus); + + const site = await getSite({ ...env, AEM_API: 'https://api.aem.live/' }, daCtx()); + + assert.strictEqual(site.onSourceBus, true); + }); + + // a prefix match on the bare string would take api.aem.live.evil.example for the source bus + it('does not take a lookalike host for the source bus', async () => { + stubFetch(config('https://api.aem.live.evil.example/org/sites/site/')); + + const site = await getSite(env, daCtx()); + + assert.strictEqual(site.onSourceBus, false); + }); + + it('reads a config with no content source as legacy', async () => { + stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); + + const site = await getSite(env, daCtx()); + + assert.strictEqual(site.onSourceBus, false); + }); + }); + + describe('when there is no such site', () => { + it('says so on a 404', async () => { + stubFetch(absent); + + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, onSourceBus: false }); + }); + + // an unparseable hostname leaves org and site undefined, so there is nothing to ask about + it('says so without asking when there is no org or site', async () => { + stubFetch(absent); + + const site = await getSite(env, daCtx({ site: undefined })); + + assert.strictEqual(site.exists, false); + assert.strictEqual(calls.length, 0); + }); + }); + + // a refusal leaves both answers unknown, and a guess reads the wrong store at 200 + describe('when the lookup cannot answer', () => { + [401, 403, 429, 500, 502].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects(() => getSite(env, daCtx()), /502|500|429|403|401/); + }); + }); + + it('throws when the config service cannot be reached', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(() => getSite(env, daCtx()), /fetch failed/); + }); + + it('names the status it got', async () => { + stubFetch(() => new Response('', { status: 401 })); + + await assert.rejects(() => getSite(env, daCtx()), /401/); + }); + + // reads a 200 with an error page as a store it does not know, not as a legacy site + it('throws when the body is not JSON', async () => { + stubFetch(() => new Response('the edge said no', { status: 200 })); + + await assert.rejects(() => getSite(env, daCtx())); + }); + + // a misconfigured worker gets no answer, and calling that a missing site would 404 the pages + it('throws when the config service host is missing, without asking', async () => { + stubFetch(legacy); + + await assert.rejects(() => getSite({ ...env, HLX_CONFIG_SERVICE: undefined }, daCtx())); + assert.strictEqual(calls.length, 0); + }); + }); +}); diff --git a/test/utils/aemCtx.test.js b/test/utils/aemCtx.test.js index 8b4432d2..2f17923b 100644 --- a/test/utils/aemCtx.test.js +++ b/test/utils/aemCtx.test.js @@ -70,11 +70,14 @@ describe('AEM context', () => { const mockAemCtx = { previewUrl: 'https://main--site--org.aem.page', }; + let status; beforeEach(async () => { + status = 200; // Mock global fetch - global.fetch = async (url) => ({ - ok: url.includes('success'), + global.fetch = async () => ({ + ok: status === 200, + status, text: async () => 'test content', }); }); @@ -88,10 +91,34 @@ describe('AEM context', () => { assert.strictEqual(html, 'test content'); }); - it('should return undefined for failed request', async () => { + // a ref that was never previewed has no head.html, and the page is composed without it + it('should return undefined for a 404', async () => { + status = 404; const html = await getAEMHtml(mockAemCtx, '/fail-path'); assert.strictEqual(html, undefined); }); + + // a preview host behind Helix authentication refuses without a site token, and a retry + // answers the same, so the page is built without the project head + [401, 403].forEach((code) => { + it(`should return undefined for a ${code}`, async () => { + status = code; + const html = await getAEMHtml(mockAemCtx, '/fail-path'); + assert.strictEqual(html, undefined); + }); + }); + + // a preview host that could not answer is not a ref that was never previewed, and reading it + // as one serves the page at 200 with no stylesheet, no scripts and no entry script + [429, 500, 502].forEach((code) => { + it(`should throw on a ${code}`, async () => { + status = code; + await assert.rejects( + () => getAEMHtml(mockAemCtx, '/fail-path'), + new RegExp(String(code)), + ); + }); + }); }); describe('fixUrlsWhenLocalDev', () => { From 4686062659ad816b62675cacab503544101962d7 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 13:22:38 +0200 Subject: [PATCH 02/49] fix: ask the config service what exists, and stop guessing what silence means head.html decided whether a site exists, so a site not yet previewed on the requested ref was refused at 404 with its document already read and discarded. config.aem.page answers both questions in one read: whether there is such a site, and which store holds its content. content.source.url is the field helix-admin sets x-api-upgrade-available from, so it is the boolean /ping conveyed. the /ping probe and HLX_ADMIN are gone. three upstreams reported a failure by returning a value, and two swallowed one. reach() names the upstream and rethrows a typed UpstreamError, and each route entry point catches once and builds the 503 its method takes. x-error names the upstream every time, not only on the site lookup. a 401 or 403 cannot be retried, so it is not a 503. neither is a 404. the preview host and the config store fall back on all three and log the refusal, and only a status that means no answer becomes an UpstreamError. a throw that is not an UpstreamError reaches the worker boundary, which logs it and answers 500. Relates to #258 --- src/handlers/get.js | 10 +- src/routes/da-admin.js | 173 ++++++++++++++++------------ src/storage/config.js | 6 +- src/storage/site.js | 53 +++++++++ src/storage/source-bus.js | 35 ------ src/utils/aemCtx.js | 11 +- src/utils/constants.js | 6 +- src/utils/quick-edit.js | 8 +- src/utils/upstream.js | 62 ++++++++++ test/storage/source-bus.test.js | 194 -------------------------------- 10 files changed, 248 insertions(+), 310 deletions(-) create mode 100644 src/storage/site.js delete mode 100644 src/storage/source-bus.js create mode 100644 src/utils/upstream.js delete mode 100644 test/storage/source-bus.test.js diff --git a/src/handlers/get.js b/src/handlers/get.js index 591cada5..85e8f02f 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -35,8 +35,14 @@ export default async function getHandler({ req, env, daCtx }) { handleAEMProxyRequest({ req, env, daCtx }), ]); - const storeRes = daSourceGetRes.status === 'fulfilled' ? daSourceGetRes.value : undefined; - const aemRes = aemProxyRes.status === 'fulfilled' ? aemProxyRes.value : undefined; + // logs a rejection rather than rethrowing it, since the other read may still answer + const settled = (result, read) => { + if (result.status === 'fulfilled') return result.value; + console.error(`${read} threw on ${path}`, result.reason); + return undefined; + }; + const storeRes = settled(daSourceGetRes, 'the store read'); + const aemRes = settled(aemProxyRes, 'the aem proxy'); let response; if (storeRes?.status === 200) { diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index d2484b6f..a7c3be68 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,39 +22,57 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head503, post405, post503, + daResp, get401, get404, get415, get503, head401, head404, head503, post405, post503, } from '../responses/index.js'; import { - BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, + PREVIEW_UNREACHABLE_HTML_MESSAGE, + SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, + SOURCE_UNDETERMINED_HTML_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, SOURCE_UNREACHABLE_HTML_MESSAGE, SOURCE_UNREACHABLE_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; -import isSourceBus from '../storage/source-bus.js'; +import getSite from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; +import { + CONTENT_STORE, + PREVIEW_HOST, + SITE_CONFIG, + SITE_LOOKUP, + UpstreamError, + reach, +} from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; +const HEAD_HTML_PATH = '/head.html'; /** - * Renders a failure for the `x-error` header. + * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off + * da-admin, so it takes the default body and `x-error` is what tells the two reads apart. */ -function causeOf(e) { - return `${e?.name ?? 'Error'}: ${e?.message ?? e}` - .replace(/[^\x20-\x7e]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 1024); -} +const UNREACHABLE_HTML = { + [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, + [SITE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, +}; +const UNREACHABLE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; -function probeFailed(e, method, sourcePath) { - const cause = `/ping failed: ${causeOf(e)}`; - console.warn(`503 ${method} ${sourcePath}, ${cause}`); - return cause; +/** + * Only an upstream that could not be reached is retryable. Anything else reaches the worker + * boundary in src/index.js, which logs it and answers 500. + */ +function refuseUnreachable(e, method, sourcePath) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 ${method} ${sourcePath}, ${e.message}`); + if (method === 'HEAD') return head503(e.message); + if (method === 'POST') { + return post503(UNREACHABLE_TEXT[e.upstream] ?? SOURCE_UNREACHABLE_MESSAGE, e.message); + } + return get503(UNREACHABLE_HTML[e.upstream] ?? SOURCE_UNREACHABLE_HTML_MESSAGE, e.message); } export function isHtmlPostType(type) { @@ -73,12 +91,8 @@ function getTextBody(data) { } async function getPageTemplate(env, daCtx, aemCtx) { - let config; - try { - config = await getSiteConfig(env, daCtx); - } catch (e) { - return DEFAULT_HTML_TEMPLATE; - } + // answers null for a site with no config, so a store that refuses or is unreachable throws + const config = await reach(SITE_CONFIG, () => getSiteConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -95,7 +109,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { } const templatePath = matchingTemplates[0].template; - const templateHtml = await getAEMHtml(aemCtx, templatePath); + const templateHtml = await reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); if (templateHtml) { return templateHtml; } @@ -104,39 +118,27 @@ async function getPageTemplate(env, daCtx, aemCtx) { } /** - * Sends a request to a store and reports why it could not be reached at all. + * Reads the document from the store that holds the site. * - * @returns {Promise<{response?: Response, error?: string}>} - */ -async function reachStore(store, send) { - try { - return { response: await send() }; - } catch (e) { - const error = causeOf(e); - console.warn(`503 ${store.url}, the store could not be reached: ${error}`); - return { error }; - } -} - -/** - * Reads from the store that holds the site. + * Sets `noSuchSite` when there is no such site, `response` otherwise. * - * @returns {Promise<{response?: Response, error?: string}>} `error` says why there is no response + * @returns {Promise<{response?: Response, noSuchSite?: boolean}>} + * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - let onSourceBus; - try { - onSourceBus = await isSourceBus(env, daCtx); - } catch (e) { - return { error: probeFailed(e, init.method, daCtx.sourcePath) }; + const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + + if (!site.exists) { + console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return { noSuchSite: true }; } - const store = getStore(env, daCtx, onSourceBus); + const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); - return reachStore(store, () => store.fetch(store.url, init)); + return { response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)) }; } -export async function daSourceGet({ req, env, daCtx }) { +async function sourceGet({ req, env, daCtx }) { const { ext, authToken } = daCtx; // check if Authorization header is present @@ -158,27 +160,35 @@ export async function daSourceGet({ req, env, daCtx }) { if (ext !== 'html') { // for non-HTML files, simply proxy the request without processing. A refusal is passed on as // itself: nothing renders an image, so the da:401 shell would only corrupt it. - const { response, error } = await readSource(env, daCtx, { method: 'GET', headers }); - if (!response) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, error); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'GET', headers }); + if (noSuchSite) return get404(); console.log(`<- ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } - // the store lookup costs a round trip, so it runs alongside head.html rather than after it + // runs the lookup alongside head.html, since it costs a round trip + // settles both rather than racing, so a dead preview host cannot preempt the no-such-site 404 const aemCtx = getAemCtx(env, daCtx); - const [headHtml, { response: sourceResp, error: sourceError }] = await Promise.all([ - getAEMHtml(aemCtx, '/head.html'), + const [preview, source] = await Promise.allSettled([ + reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, HEAD_HTML_PATH)), readSource(env, daCtx, { method: 'GET', headers }), ]); - if (!headHtml) { + + // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. + // drops the preview failure on purpose: a site that does not exist has no preview host either + if (source.status === 'fulfilled' && source.value.noSuchSite) { // quick-edit still needs a working shell (with the import map) so the editor - // can load into this page, even when the AEM branch doesn't exist yet. + // can load into this page, even when the site does not exist. if (isQuickEdit) { return buildQuickEditNotFoundResponse(); } - return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); + return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - if (!sourceResp) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, sourceError); + if (preview.status === 'rejected') throw preview.reason; + if (source.status === 'rejected') throw source.reason; + + const headHtml = preview.value; + const { response: sourceResp } = source.value; console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // the store is the only thing to see the token, and the authorbus extension recovers off the @@ -198,8 +208,8 @@ export async function daSourceGet({ req, env, daCtx }) { ? await sourceResp.text() : await getPageTemplate(env, daCtx, aemCtx, headHtml); - // compose the page the same way for every request type - const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml); + // builds the page without head.html, which a ref that was never previewed does not have + const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); // layer the request-specific instrumentation on top of the composed page const extraHeaders = []; @@ -225,7 +235,16 @@ export async function daSourceGet({ req, env, daCtx }) { }); } -export async function daSourceHead({ env, daCtx }) { +/** Wraps sourceGet, turning an unreachable upstream into a 503 in HTML the editor renders. */ +export async function daSourceGet({ req, env, daCtx }) { + try { + return await sourceGet({ req, env, daCtx }); + } catch (e) { + return refuseUnreachable(e, 'GET', daCtx.sourcePath); + } +} + +async function sourceHead({ env, daCtx }) { const { authToken } = daCtx; if (!authToken) { @@ -235,13 +254,22 @@ export async function daSourceHead({ env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); - const { response, error } = await readSource(env, daCtx, { method: 'HEAD', headers }); - if (!response) return head503(error); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'HEAD', headers }); + if (noSuchSite) return head404(); console.log(`<- HEAD ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return new Response(null, { status: response.status, headers: response.headers }); } -export async function daSourcePost({ req, env, daCtx }) { +/** Wraps sourceHead, turning an unreachable upstream into a bodyless 503. */ +export async function daSourceHead({ env, daCtx }) { + try { + return await sourceHead({ env, daCtx }); + } catch (e) { + return refuseUnreachable(e, 'HEAD', daCtx.sourcePath); + } +} + +async function sourcePost({ req, env, daCtx }) { const { sourcePath, ext, authToken } = daCtx; // the body is rewritten as HTML below, so anything but an HTML document would be @@ -276,13 +304,7 @@ export async function daSourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); // the payload is settled, so the only question left is where it goes - let onSourceBus; - try { - onSourceBus = await isSourceBus(env, daCtx); - } catch (e) { - const cause = probeFailed(e, 'POST', sourcePath); - return post503(SOURCE_UNDETERMINED_MESSAGE, cause); - } + const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); if (onSourceBus) { console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); @@ -294,15 +316,26 @@ export async function daSourcePost({ req, env, daCtx }) { const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); - const { response, error } = await reachStore(store, () => store.fetch(new Request(store.url, { + const response = await reach(CONTENT_STORE, () => store.fetch(new Request(store.url, { method: 'POST', body, headers: { Authorization: authToken }, }))); - if (!response) return post503(SOURCE_UNREACHABLE_MESSAGE, error); console.log(`<- ${store.url.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } return get415(); } + +/** + * Wraps sourcePost, turning an unreachable upstream into the plain text the editor shows the + * author. + */ +export async function daSourcePost({ req, env, daCtx }) { + try { + return await sourcePost({ req, env, daCtx }); + } catch (e) { + return refuseUnreachable(e, 'POST', daCtx.sourcePath); + } +} diff --git a/src/storage/config.js b/src/storage/config.js index 8f87ef32..1b8816af 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -21,9 +21,13 @@ async function fetchConfig(env, daCtx, path) { const configUrl = new URL(path, env.DA_ADMIN); const res = await env.daadmin.fetch(configUrl, opts); - if (!res.ok) { + // a site with no config answers 404, and an author who may not read it is refused 403. both + // answered, so the starter template is used rather than refusing the request + if (res.status === 404 || res.status === 401 || res.status === 403) { + if (res.status !== 404) console.warn(`${configUrl} answered ${res.status}, using no config`); return null; } + if (!res.ok) throw new Error(`${configUrl} answered ${res.status}`); const json = await res.json(); if (!json) return []; const data = getFirstSheet(json); diff --git a/src/storage/site.js b/src/storage/site.js new file mode 100644 index 00000000..5adce1e2 --- /dev/null +++ b/src/storage/site.js @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const TIMEOUT_MS = 5 * 1000; +const NO_SITE = { exists: false, onSourceBus: false }; + +/** + * Asks the config service whether a site exists and which store holds its content. + * + * `content.source.url` decides the store, and helix-admin sets `x-api-upgrade-available` from + * the same field, so a request to /ping would say the same thing. + * + * Throws on any refusal but a 404, which is the only status that means there is no such site. + * Reads the status and the source url only, since the response also has admin roles and + * resolved secrets. + * + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it, `AEM_API` is the source bus the source url is + * compared against + * @param {Object} daCtx + * @returns {Promise<{exists: boolean, onSourceBus: boolean}>} + */ +export default async function getSite(env, daCtx) { + const { org, site, ref } = daCtx; + // an unparseable hostname leaves org and site undefined, and there is no site to ask about + if (!org || !site) return NO_SITE; + + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (response.status === 404) return NO_SITE; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); + + const { content } = await response.json(); + // the bare prefix would also match a host like api.aem.live.evil.example + const sourceBus = new URL('/', env.AEM_API).href; + return { exists: true, onSourceBus: !!content?.source?.url?.startsWith(sourceBus) }; +} diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js deleted file mode 100644 index 9cfe6b89..00000000 --- a/src/storage/source-bus.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -const TIMEOUT_MS = 5 * 1000; -const UPGRADE_HEADER = 'x-api-upgrade-available'; - -/** - * Asks `/ping` whether a site is on the source bus. - * - * An answer without the header is legacy: helix-admin sets it when config resolution succeeded and - * named the API. A probe that cannot answer throws, so the caller refuses with the cause rather - * than picking a store. - * - * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes - * @param {Object} daCtx - * @returns {Promise} - */ -export default async function isSourceBus(env, daCtx) { - const { org, site } = daCtx; - // an unparseable hostname leaves org and site undefined, and there is no site to ask about - if (!org || !site) return false; - - const url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); - const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); - return response.headers.get(UPGRADE_HEADER) !== null; -} diff --git a/src/utils/aemCtx.js b/src/utils/aemCtx.js index 54ddaa25..b02c2b84 100644 --- a/src/utils/aemCtx.js +++ b/src/utils/aemCtx.js @@ -48,8 +48,15 @@ export function withAemAuth(aemCtx, init = {}) { export async function getAEMHtml(aemCtx, path) { const { previewUrl } = aemCtx; - const resp = await fetch(`${previewUrl}${path}`, withAemAuth(aemCtx)); - if (!resp.ok) return undefined; + const url = `${previewUrl}${path}`; + const resp = await fetch(url, withAemAuth(aemCtx)); + // a ref that was never previewed answers 404, and a host behind Helix auth refuses without a + // site token. both answered, so the page is built without the fragment + if (resp.status === 404 || resp.status === 401 || resp.status === 403) { + if (resp.status !== 404) console.warn(`${url} answered ${resp.status}, using no fragment`); + return undefined; + } + if (!resp.ok) throw new Error(`${url} answered ${resp.status}`); const headHtml = await resp.text(); return headHtml; } diff --git a/src/utils/constants.js b/src/utils/constants.js index 69706aed..6462ee2d 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -49,10 +49,14 @@ export const UNAUTHORIZED_HTML_MESSAGE = ` export const DEFAULT_HTML_TEMPLATE = '
'; -export const BRANCH_NOT_FOUND_HTML_MESSAGE = '

Not found: Unable to retrieve AEM branch

'; +export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found

There is no site at this address.

'; + +export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry.

'; +export const SOURCE_UNDETERMINED_HTML_MESSAGE = '

503: Content store undetermined

Which store holds this document could not be determined. Please retry.

'; + export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry.'; export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry.'; diff --git a/src/utils/quick-edit.js b/src/utils/quick-edit.js index 30be4054..c8cf10f2 100644 --- a/src/utils/quick-edit.js +++ b/src/utils/quick-edit.js @@ -189,14 +189,12 @@ export function prepareQuickEditDocument(html, nonce) { } /** - * Build the quick-edit 404 response for when the AEM branch itself can't be - * resolved (e.g. head.html is missing): a minimal page shell with the import - * map injected (no entry script), status 404, so the editor can still load - * into it. Reuse this anywhere quick-edit needs to degrade the same way. + * Build the quick-edit 404 response for when there is no such site: a minimal page shell with + * the import map injected and no entry script, so the editor can still load into it. * @returns {Response} */ export function buildQuickEditNotFoundResponse() { - console.log('[quick-edit] doc compose: head.html not found on origin, serving a minimal scaffold'); + console.log('[quick-edit] doc compose: no such site, serving a minimal scaffold'); const tree = fromHtml(`${DEFAULT_HTML_TEMPLATE}`); applyQuickEditToDocument(tree, undefined); const body = toHtml(tree, { allowDangerousHtml: true }); diff --git a/src/utils/upstream.js b/src/utils/upstream.js new file mode 100644 index 00000000..0dc46f7b --- /dev/null +++ b/src/utils/upstream.js @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** Names the upstream in the worker log and in `x-error`. */ +export const PREVIEW_HOST = 'preview host'; +export const CONTENT_STORE = 'content store'; +export const SITE_CONFIG = 'site config'; +export const SITE_LOOKUP = 'site lookup'; + +/** + * Renders a failure for the `x-error` header. + */ +export function causeOf(e) { + return `${e?.name ?? 'Error'}: ${e?.message ?? e}` + .replace(/[^\x20-\x7e]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 1024); +} + +/** + * Thrown when an upstream could not be reached at all. + * + * Not a refusal: an upstream that answered 401 or 404 has answered, and the route decides what + * that means. An UpstreamError means there is no answer to read, and it is retryable. + * + * @property {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + */ +export class UpstreamError extends Error { + constructor(upstream, cause) { + super(`${upstream} failed: ${causeOf(cause)}`, { cause }); + this.name = 'UpstreamError'; + this.upstream = upstream; + } +} + +/** + * Runs `read` and rethrows anything it throws as an UpstreamError naming `upstream`. + * + * @param {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + * @param {() => Promise} read + * @returns {Promise} + * @template T + */ +export async function reach(upstream, read) { + try { + return await read(); + } catch (e) { + // keeps the inner upstream name rather than overwriting it + if (e instanceof UpstreamError) throw e; + throw new UpstreamError(upstream, e); + } +} diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js deleted file mode 100644 index 8093d3fd..00000000 --- a/test/storage/source-bus.test.js +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ -import assert from 'assert'; - -const { default: isSourceBus } = await import('../../src/storage/source-bus.js'); - -const env = { AEM_API: 'https://api.aem.live', HLX_ADMIN: 'https://admin.hlx.page' }; - -const daCtx = (over = {}) => ({ - org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, -}); - -let calls; - -const stubFetch = (respond) => { - calls = []; - globalThis.fetch = async (input, init) => { - calls.push({ url: input.toString(), init }); - return respond(input.toString(), init); - }; -}; - -const ping = (headers = {}, status = 200) => new Response('', { status, headers }); -const upgraded = () => ping({ 'x-api-upgrade-available': 'true' }); - -describe('isSourceBus', () => { - afterEach(() => { - delete globalThis.fetch; - }); - - describe('the request it makes', () => { - it('asks /ping on the admin host', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); - }); - - it('takes the admin host from env, so stage can point elsewhere', async () => { - stubFetch(upgraded); - - await isSourceBus({ ...env, HLX_ADMIN: 'https://admin.stage.example' }, daCtx()); - - assert.strictEqual(calls[0].url, 'https://admin.stage.example/ping/org/site'); - }); - - // both stores read one config service and the source is per site, so the branch cannot change - // the answer - it('does not vary by ref', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx({ ref: 'branch' })); - - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); - }); - - // /ping is exempt from authorize() in helix-admin and answers the same with or without a token - it('sends no token, since /ping does not read one', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(new Headers(calls[0].init.headers).get('Authorization'), null); - }); - - it('gives up rather than hanging', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx()); - - assert.ok(calls[0].init.signal, 'the probe carries an abort signal'); - }); - }); - - describe('when /ping says the site is upgraded', () => { - it('answers true', async () => { - stubFetch(upgraded); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - - // presence, not value: da-nx tests the same header with `!== null` (nx2/utils/api.js, - // isHlx6), and two clients reading it differently would split one site across two stores - ['false', '', 'TRUE'].forEach((value) => { - it(`counts any value, including ${JSON.stringify(value)}`, async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': value })); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - }); - - // no status test, for the same reason. the edge sets the header from its dictionary, so a - // rate-limited or erroring origin behind it does not make an enrolled site legacy - [429, 500, 503].forEach((status) => { - it(`counts it on a ${status}, since the header is what carries the answer`, async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, status)); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - }); - }); - - describe('when /ping does not say so', () => { - [ - ['the header is absent', {}, 200], - ['the header is absent on a 404', {}, 404], - ['the header is absent on a 405', {}, 405], - ['the header is absent on a 500', {}, 500], - ].forEach(([what, headers, status]) => { - it(`answers false: ${what}`, async () => { - stubFetch(() => ping(headers, status)); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - }); - }); - - // an answer without the header is legacy. no answer is not an answer, and the caller refuses - // rather than picking a store on a coin flip - describe('when /ping cannot answer', () => { - // the cause reaches the caller, which reports it on the 503 as `x-error`. swallowing it here - // would leave a timeout and a dropped connection indistinguishable - it('lets the failure through', async () => { - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - - await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); - }); - - it('lets it through when HLX_ADMIN is unusable, without asking', async () => { - stubFetch(upgraded); - - await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); - assert.strictEqual(calls.length, 0); - }); - - // the distinction the caller acts on: false is a store, a failure is no store - it('is distinguishable from a legacy answer', async () => { - stubFetch(() => ping()); - assert.strictEqual(await isSourceBus(env, daCtx()), false); - - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - await assert.rejects(isSourceBus(env, daCtx())); - }); - }); - - describe('when there is no site to ask about', () => { - // either one missing is enough: a half-parsed request would otherwise build a ping url with - // "undefined" in it - [ - ['neither', { org: undefined, site: undefined }], - ['no org', { org: undefined }], - ['no site', { site: undefined }], - ['an empty org', { org: '' }], - ['an empty site', { site: '' }], - ].forEach(([what, over]) => { - it(`answers false without making a request: ${what}`, async () => { - stubFetch(upgraded); - - assert.strictEqual(await isSourceBus(env, daCtx(over)), false); - assert.strictEqual(calls.length, 0); - }); - }); - }); - - // nothing is remembered between calls, so an enrolment takes effect on the next read and a - // config blip cannot pin a stale answer - it('probes every time it is asked', async () => { - let enrolled = false; - stubFetch(() => (enrolled ? upgraded() : ping())); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - enrolled = true; - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - assert.strictEqual(calls.length, 2); - }); -}); From b33f8cc8c75ac830cc205d0b735c601f4917e0b4 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 16:05:51 +0200 Subject: [PATCH 03/49] chore: point at the config service, and deploy with the wrangler the repo pins HLX_ADMIN goes out of all three environments: its only reader was the deleted /ping probe. dev points at dev/config-shim.js on 4713, which stands in for config.aem.page so the worker runs locally without the shared secret, and .gitignore gains the glob because npm start runs --env dev and wrangler reads .dev.vars.dev before .dev.vars. the deploy job had no npm ci, so wrangler-action installed its own default, 3.90.0, which has no secrets key and would deploy past a missing HLX_CONFIG_SERVICE_TOKEN. da-admin and da-collab install and run npm run deploy instead of using the action; this matches them. node 24, above the floor wrangler and miniflare declare. --- .github/workflows/deploy.yaml | 31 ++++++++++++++---------- .github/workflows/pull-request.yaml | 2 +- .gitignore | 2 +- README.md | 7 +++++- dev/config-shim.js | 37 +++++++++++++++++++++++++++++ dev/config-shim.toml | 9 +++++++ package.json | 1 + wrangler.toml | 11 ++++++--- 8 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 dev/config-shim.js create mode 100644 dev/config-shim.toml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 054d9499..65a8809d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - name: Checkout repository uses: actions/checkout@v7 @@ -38,18 +38,25 @@ jobs: needs: test steps: - uses: actions/checkout@v7 - + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24.x + + - name: Install dependencies + run: npm ci + - name: Deploy to Cloudflare Workers (production) if: github.ref_name == 'main' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - + run: npm run deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + - name: Deploy to Cloudflare Workers (stage) if: github.ref_name == 'stage' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - environment: stage + run: npm run deploy:stage + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index dc484538..16c6e560 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - name: Checkout repository uses: actions/checkout@v7 diff --git a/.gitignore b/.gitignore index 71619106..c4fbad99 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ jspm_packages/ .env.production.local .env.local -.dev.vars +.dev.vars* .wrangler/ .DS_Store .cursor diff --git a/README.md b/README.md index 5205fd84..c06bdaf5 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,19 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. +Site lookups go to config.aem.page, which needs a shared secret, so local development points at `dev/config-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404. + To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. -1. In a terminal, run `npm run dev` this repo's folder. +1. In a terminal, run `npm run dev:config` to start the stand-in config service on port 4713. +1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 +Anyone who has the shared secret can point `npm run dev` at config.aem.page instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. + ### Run on stage You can deploy da-universal on Cloudflare stage via `npm deploy:stage` to test it in a real worker environment. diff --git a/dev/config-shim.js b/dev/config-shim.js new file mode 100644 index 00000000..75265e58 --- /dev/null +++ b/dev/config-shim.js @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// stands in for config.aem.page, which needs a shared secret +// answers 200 for a site in SITES, 404 for anything else +const SITES = { + 'org/site': 'https://content.da.live/org/site/', +}; + +export default { + async fetch(req) { + const url = new URL(req.url); + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); + if (!org || !site) { + return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); + } + + const source = SITES[`${org}/${site}`]; + if (!source) { + return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); + } + + const body = JSON.stringify({ + ref, site, org, content: { source: { type: 'markup', url: source } }, + }); + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); + }, +}; diff --git a/dev/config-shim.toml b/dev/config-shim.toml new file mode 100644 index 00000000..d3c4fa48 --- /dev/null +++ b/dev/config-shim.toml @@ -0,0 +1,9 @@ +# the stand-in gets its own config, so wrangler dev does not read wrangler.toml: no daadmin +# binding to connect, and no HLX_CONFIG_SERVICE_TOKEN to warn about +name = "da-ue-config-shim" +main = "config-shim.js" +compatibility_date = "2023-11-21" + +[dev] +port = 4713 +inspector_port = 9234 diff --git a/package.json b/package.json index 15c6fb3f..634708dc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "deploy": "wrangler deploy", "deploy:stage": "wrangler deploy --env stage", "dev": "wrangler dev --local-protocol https --env dev", + "dev:config": "wrangler dev -c dev/config-shim.toml", "start": "wrangler dev --local-protocol https --env dev", "test": "c8 mocha --spec=test/**/*.test.js", "lint": "eslint ." diff --git a/wrangler.toml b/wrangler.toml index b8fdfed1..b3aaafe1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,19 +2,24 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [dev] port = 4712 [env.dev] -vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] +# the stand-in needs no token, and without this empty list wrangler warns about a missing +# HLX_CONFIG_SERVICE_TOKEN on npm run dev +secrets = { required = [] } [env.stage] -vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage.observability] enabled = true From e5a14cec84ea2f3430136f1a871afcc2fe339565 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 16:05:51 +0200 Subject: [PATCH 04/49] ci: bump wrangler-action to v4, which knows the required-secrets declaration v3 installs its own wrangler 3.90.0, which has no secrets key in its config schema, so `secrets = { required = [...] }` is ignored and a deploy without HLX_CONFIG_SERVICE_TOKEN goes out green. v4 defaults to wrangler 4. same two lines as #214. --- .github/workflows/deploy.yaml | 31 +++++++++++------------------ .github/workflows/pull-request.yaml | 2 +- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 65a8809d..f54932c9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [24.x] + node-version: [20.x] steps: - name: Checkout repository uses: actions/checkout@v7 @@ -38,25 +38,18 @@ jobs: needs: test steps: - uses: actions/checkout@v7 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: 24.x - - - name: Install dependencies - run: npm ci - + - name: Deploy to Cloudflare Workers (production) if: github.ref_name == 'main' - run: npm run deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + - name: Deploy to Cloudflare Workers (stage) if: github.ref_name == 'stage' - run: npm run deploy:stage - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + environment: stage diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 16c6e560..dc484538 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [24.x] + node-version: [20.x] steps: - name: Checkout repository uses: actions/checkout@v7 From 0a29287e1af145d847d21597853a08d8809022c3 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 13:56:59 +0200 Subject: [PATCH 05/49] test: the page head comes from the config service, and a refused read is a 503 --- test/routes/da-admin.test.js | 6 +- test/routes/source-read.test.js | 99 ++++++++++++++++++-------- test/storage/site.test.js | 120 +++++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 33 deletions(-) diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index b0aa0de3..e4a763cd 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -64,10 +64,11 @@ const stubConfig = (upgraded = []) => { const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: false }), + getSiteHead: async () => '', }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => '', + getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async () => ({ tree: true }), @@ -119,10 +120,11 @@ describe('daSourceGet', () => { return (await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { default: async () => site, + getSiteHead: async () => headHtml, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => headHtml, + getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 8f3f8bd0..54e9f9ca 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -36,7 +36,7 @@ const build = async (overrides = {}) => { legacy = () => new Response('from da-admin', { status: 200 }), } = overrides; // `in overrides` rather than a destructured default on these, so an explicit undefined reads - // as a missing head.html, a template the preview host does not have, or a failed lookup + // as a ref with no head.html, a template the preview host does not have, or a failed lookup const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; const templateHtml = 'templateHtml' in overrides ? overrides.templateHtml @@ -47,7 +47,7 @@ const build = async (overrides = {}) => { headError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, heads: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -73,17 +73,19 @@ const build = async (overrides = {}) => { if (site === undefined) throw lookupError; return site; }, + getSiteHead: async () => { + if (headError) throw headError; + seen.heads += 1; + return headHtml; + }, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), - // answers both preview host reads, and fails them separately so a test can reach the - // template read with head.html answering + // the template is the only preview host read left on this path getAEMHtml: async (aemCtx, path) => { - const isHead = path === '/head.html'; - if (isHead && headError) throw headError; - if (!isHead && templateError) throw templateError; + if (templateError) throw templateError; seen.aem.push(path); - return isHead ? headHtml : templateHtml; + return templateHtml; }, }, '../../src/render/compose.js': { @@ -797,6 +799,40 @@ describe('reading from the store that holds the site', () => { }); }); + // the config service reads head.html off the code bus, so a site behind Helix authentication + // and a ref the preview host will not serve both still get the project's css and js + describe('where the page head comes from', () => { + it('reads it off the config service, and asks the preview host for nothing', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.heads, 1); + assert.deepStrictEqual(seen.aem, []); + }); + + it('composes the page with it', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.head[0], ''); + }); + + // one read each, rather than a second lookup to carry the head + it('reads it alongside the lookup', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.heads, 1); + }); + }); + // #258: head.html does not say whether a site exists, so a missing head.html is not a 404 describe('when the site serves no head.html', () => { it('reads the document anyway', async () => { @@ -912,8 +948,8 @@ describe('reading from the store that holds the site', () => { }); }); - // a throw out of getAEMHtml used to reach the worker's catch as a 500 with no body - describe('when the preview host cannot be reached', () => { + // a throw out of the head read used to reach the worker's catch as a 500 with no body + describe('when the head read cannot be reached', () => { const dead = () => new TypeError('Network connection lost'); it('answers 503 rather than throwing', async () => { @@ -931,7 +967,7 @@ describe('reading from the store that holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'preview host failed: TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'page head failed: TypeError: Network connection lost'); }); it('asks the caller to retry', async () => { @@ -946,16 +982,16 @@ describe('reading from the store that holds the site', () => { }); // says what happened, since the preview iframe renders the refusal - it('says the preview host did not answer, not the store', async () => { + it('says the page head did not arrive, not that the store is undetermined', async () => { const { daSourceGet, env } = await build({ headError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(await res.text(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await res.text(), messages.HEAD_UNREACHABLE_HTML_MESSAGE); }); - // a 404 would say the site is gone, which is #258 again, this time on the preview host + // a 404 would say the site is gone, which is #258 again, this time on the head read it('does not answer 404', async () => { const { daSourceGet, env } = await build({ headError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -1029,18 +1065,20 @@ describe('when the site config cannot be reached', () => { }); }); -// getAEMHtml is not stubbed here: a stub hides a preview host that answers, but with something -// other than head.html -describe('when the preview host refuses head.html', () => { +// getSiteHead is not stubbed here: a stub hides a config service that answers, but with +// something other than a head +describe('when the config service refuses the head read', () => { afterEach(() => { delete globalThis.fetch; }); - const readWithPreviewStatus = async (status) => { - globalThis.fetch = async () => new Response('preview said no', { status }); + const readWithConfigStatus = async (status) => { + globalThis.fetch = async () => new Response('the config service said no', { status }); const env = { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-token', daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { @@ -1051,29 +1089,30 @@ describe('when the preview host refuses head.html', () => { return daSourceGet({ req, env, daCtx: getDaCtx(req) }); }; - // a 200 with no head.html is a page with no stylesheet, no scripts and no entry script + // a 200 with no head is a page with no stylesheet, no scripts and no entry script it('refuses a 500 with 503 rather than composing an empty project head', async () => { - const res = await readWithPreviewStatus(500); + const res = await readWithConfigStatus(500); assert.strictEqual(res.status, 503); }); - it('names the preview host in x-error', async () => { - const res = await readWithPreviewStatus(500); + it('names the page head in x-error', async () => { + const res = await readWithConfigStatus(500); - assert.match(res.headers.get('x-error'), /preview host failed/); + assert.match(res.headers.get('x-error'), /page head failed/); }); - // a host behind Helix auth refuses without a site token, and a retry answers the same - it('composes the page without a head on a 401', async () => { - const res = await readWithPreviewStatus(401); + // the shared secret is the worker's own, so a 401 is a deploy without it rather than a site + // that has no head.html + it('refuses a 401 with 503', async () => { + const res = await readWithConfigStatus(401); - assert.strictEqual(res.status, 200); + assert.strictEqual(res.status, 503); }); - // a ref that was never previewed has no head.html, which is not a failure + // a ref that was never built has no head.html, which is not a failure it('composes the page without a head on a 404', async () => { - const res = await readWithPreviewStatus(404); + const res = await readWithConfigStatus(404); assert.strictEqual(res.status, 200); }); diff --git a/test/storage/site.test.js b/test/storage/site.test.js index db6e4807..3877feb2 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -13,7 +13,7 @@ /* eslint-env mocha */ import assert from 'assert'; -const { default: getSite } = await import('../../src/storage/site.js'); +const { default: getSite, getSiteHead } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', @@ -44,6 +44,13 @@ const legacy = config('https://content.da.live/org/site/'); const sourceBus = config('https://api.aem.live/org/sites/site/'); const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); +const STYLESHEET = ''; +// the pipeline scope carries the code bus object, under a lastModified the delivery pipeline reads +const withHead = (html) => () => new Response( + JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), + { status: 200 }, +); + describe('getSite', () => { afterEach(() => { delete globalThis.fetch; @@ -207,3 +214,114 @@ describe('getSite', () => { }); }); }); + +describe('getSiteHead', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks the config service for the pipeline-scoped config', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); + }); + + // the code bus holds one head.html per ref, so a branch gets its own + it('names the ref the request came in on', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx({ ref: 'feature' })); + + assert.match(calls[0].url, /\/feature--site--org\//); + }); + + it('sends the shared token', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); + }); + + it('sends the backend type', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); + }); + + it('gives up rather than hanging', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.ok(calls[0].init.signal); + }); + }); + + describe('the head it answers', () => { + it('reads head.html out of the config', async () => { + stubFetch(withHead(STYLESHEET)); + + assert.strictEqual(await getSiteHead(env, daCtx()), STYLESHEET); + }); + + // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 + it('answers nothing when the ref has no head.html', async () => { + stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); + + assert.strictEqual(await getSiteHead(env, daCtx()), undefined); + }); + + it('answers nothing when the config carries no head at all', async () => { + stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); + + assert.strictEqual(await getSiteHead(env, daCtx()), undefined); + }); + + // the lookup answers 404 with the site, and one 404 page is enough + it('answers nothing on a 404', async () => { + stubFetch(absent); + + assert.strictEqual(await getSiteHead(env, daCtx()), undefined); + }); + + it('asks nothing when there is no org or site', async () => { + stubFetch(absent); + + assert.strictEqual(await getSiteHead(env, daCtx({ site: undefined })), undefined); + assert.strictEqual(calls.length, 0); + }); + }); + + // the token is the worker's own, so a refusal is a broken worker rather than a site with no + // head.html, and composing an empty project head would serve that as a page + describe('when the read cannot answer', () => { + [401, 403, 429, 500, 502].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects(() => getSiteHead(env, daCtx()), /502|500|429|403|401/); + }); + }); + + it('throws when the config service cannot be reached', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(() => getSiteHead(env, daCtx()), /fetch failed/); + }); + + it('throws when the body is not JSON', async () => { + stubFetch(() => new Response('the edge said no', { status: 200 })); + + await assert.rejects(() => getSiteHead(env, daCtx())); + }); + }); +}); From 1ffeb8151209c2637ee1a2231c3b99c554fe1243 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:00:39 +0200 Subject: [PATCH 06/49] fix: read head.html from the config service, not the preview host --- dev/config-shim.js | 13 ++++++-- src/routes/da-admin.js | 24 ++++++++------- src/storage/site.js | 54 +++++++++++++++++++++++++++------ src/utils/constants.js | 2 ++ src/utils/upstream.js | 1 + test/routes/source-read.test.js | 9 ++++-- 6 files changed, 77 insertions(+), 26 deletions(-) diff --git a/dev/config-shim.js b/dev/config-shim.js index 75265e58..734f0cf8 100644 --- a/dev/config-shim.js +++ b/dev/config-shim.js @@ -16,6 +16,9 @@ const SITES = { 'org/site': 'https://content.da.live/org/site/', }; +// what the code bus holds at {owner}/{repo}/{ref}/head.html, which the pipeline scope carries +const HEAD_HTML = '\n\n'; + export default { async fetch(req) { const url = new URL(req.url); @@ -29,9 +32,13 @@ export default { return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); } - const body = JSON.stringify({ - ref, site, org, content: { source: { type: 'markup', url: source } }, - }); + const body = url.searchParams.get('scope') === 'pipeline' + ? JSON.stringify({ + ref, site, org, head: { html: HEAD_HTML }, + }) + : JSON.stringify({ + ref, site, org, content: { source: { type: 'markup', url: source } }, + }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, }; diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index a7c3be68..74af0826 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -26,6 +26,7 @@ import { } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, + HEAD_UNREACHABLE_HTML_MESSAGE, PREVIEW_UNREACHABLE_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -36,11 +37,12 @@ import { UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; -import getSite from '../storage/site.js'; +import getSite, { getSiteHead } from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { CONTENT_STORE, + PAGE_HEAD, PREVIEW_HOST, SITE_CONFIG, SITE_LOOKUP, @@ -49,7 +51,6 @@ import { } from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; -const HEAD_HTML_PATH = '/head.html'; /** * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off @@ -58,6 +59,7 @@ const HEAD_HTML_PATH = '/head.html'; const UNREACHABLE_HTML = { [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, [SITE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, + [PAGE_HEAD]: HEAD_UNREACHABLE_HTML_MESSAGE, }; const UNREACHABLE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; @@ -166,16 +168,16 @@ async function sourceGet({ req, env, daCtx }) { return response; } - // runs the lookup alongside head.html, since it costs a round trip - // settles both rather than racing, so a dead preview host cannot preempt the no-such-site 404 + // runs the lookup alongside the head read, since it costs a round trip + // settles both rather than racing, so a failed head read cannot preempt the no-such-site 404 const aemCtx = getAemCtx(env, daCtx); - const [preview, source] = await Promise.allSettled([ - reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, HEAD_HTML_PATH)), + const [head, source] = await Promise.allSettled([ + reach(PAGE_HEAD, () => getSiteHead(env, daCtx)), readSource(env, daCtx, { method: 'GET', headers }), ]); // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops the preview failure on purpose: a site that does not exist has no preview host either + // drops the head failure on purpose: a site that does not exist has no head.html either if (source.status === 'fulfilled' && source.value.noSuchSite) { // quick-edit still needs a working shell (with the import map) so the editor // can load into this page, even when the site does not exist. @@ -184,10 +186,10 @@ async function sourceGet({ req, env, daCtx }) { } return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - if (preview.status === 'rejected') throw preview.reason; + if (head.status === 'rejected') throw head.reason; if (source.status === 'rejected') throw source.reason; - const headHtml = preview.value; + const headHtml = head.value; const { response: sourceResp } = source.value; console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); @@ -206,9 +208,9 @@ async function sourceGet({ req, env, daCtx }) { // use the stored content when available, otherwise fall back to a template const bodyHtml = sourceResp.status === 200 ? await sourceResp.text() - : await getPageTemplate(env, daCtx, aemCtx, headHtml); + : await getPageTemplate(env, daCtx, aemCtx); - // builds the page without head.html, which a ref that was never previewed does not have + // builds the page without head.html, which a ref that was never built does not have const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); // layer the request-specific instrumentation on top of the composed page diff --git a/src/storage/site.js b/src/storage/site.js index 5adce1e2..8e3485d1 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -13,6 +13,19 @@ const TIMEOUT_MS = 5 * 1000; const NO_SITE = { exists: false, onSourceBus: false }; +/** One read of the config service, at the scope the caller needs. */ +function askConfigService(env, daCtx, scope) { + const { org, site, ref } = daCtx; + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=${scope}`, env.HLX_CONFIG_SERVICE); + return fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); +} + /** * Asks the config service whether a site exists and which store holds its content. * @@ -30,18 +43,11 @@ const NO_SITE = { exists: false, onSourceBus: false }; * @returns {Promise<{exists: boolean, onSourceBus: boolean}>} */ export default async function getSite(env, daCtx) { - const { org, site, ref } = daCtx; + const { org, site } = daCtx; // an unparseable hostname leaves org and site undefined, and there is no site to ask about if (!org || !site) return NO_SITE; - const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); - const response = await fetch(url, { - headers: { - 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, - 'x-backend-type': 'aws', - }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); + const response = await askConfigService(env, daCtx, 'admin'); if (response.status === 404) return NO_SITE; if (!response.ok) throw new Error(`the config service answered ${response.status}`); @@ -51,3 +57,33 @@ export default async function getSite(env, daCtx) { const sourceBus = new URL('/', env.AEM_API).href; return { exists: true, onSourceBus: !!content?.source?.url?.startsWith(sourceBus) }; } + +/** + * Reads the site's head.html from the config service. + * + * The pipeline scope carries the code bus object the delivery pipeline renders into every page of + * the site, and the admin scope does not carry it at all. Reading it here rather than from + * `{ref}--{site}--{org}.aem.page/head.html` answers for a ref the preview host never built and + * for a site behind Helix authentication, which refuses that path without a site token. + * + * Throws on any refusal but a 404. The token is the worker's own, so a refusal is a deploy + * without it rather than a site that has no head.html. + * + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the read goes and + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it + * @param {Object} daCtx + * @returns {Promise} undefined when the ref has no head.html + */ +export async function getSiteHead(env, daCtx) { + const { org, site } = daCtx; + if (!org || !site) return undefined; + + const response = await askConfigService(env, daCtx, 'pipeline'); + + // getSite answers the missing site, and one 404 for the page is enough + if (response.status === 404) return undefined; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); + + const { head } = await response.json(); + return head?.html; +} diff --git a/src/utils/constants.js b/src/utils/constants.js index 6462ee2d..57ca8c2c 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -53,6 +53,8 @@ export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found< export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; +export const HEAD_UNREACHABLE_HTML_MESSAGE = '

503: Page head unreachable

The site\'s head.html could not be read. Please retry.

'; + export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry.

'; export const SOURCE_UNDETERMINED_HTML_MESSAGE = '

503: Content store undetermined

Which store holds this document could not be determined. Please retry.

'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 0dc46f7b..baba2a54 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -15,6 +15,7 @@ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; export const SITE_CONFIG = 'site config'; export const SITE_LOOKUP = 'site lookup'; +export const PAGE_HEAD = 'page head'; /** * Renders a failure for the `x-error` header. diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 54e9f9ca..efc928bd 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -580,10 +580,13 @@ describe('reading from the store that holds the site', () => { daadmin: { fetch: async () => new Response('', { status: 200 }) }, }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, + '../../src/storage/site.js': { + default: async () => ({ exists: true, onSourceBus: true }), + getSiteHead: async () => '', + }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), - getAEMHtml: async () => '', + getAEMHtml: async () => 'from the template', }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -1192,7 +1195,7 @@ describe('a path the site config gives a template', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.match(await res.text(), /
/); - assert.strictEqual(seen.aem.length, 1); + assert.deepStrictEqual(seen.aem, []); }); // the config names a path the preview host answers 404 for, which leaves the starter From bd96daad02b7548891578848eb31e218cbe95ad5 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:01:00 +0200 Subject: [PATCH 07/49] test: the store the lookup could not name outranks a failed head read --- test/routes/source-read.test.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index efc928bd..6c3a8e07 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -776,8 +776,8 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // names which failure it was: a site that does not exist has no preview host either - it('reports no such site even when the preview host did not answer', async () => { + // names which failure it was: a site that does not exist has no head.html either + it('reports no such site even when the head read did not answer', async () => { const { daSourceGet, env } = await build({ site: NO_SITE, headError: new TypeError('Network connection lost'), @@ -789,6 +789,21 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); + // one service answers both reads, so an outage fails them together, and the store it could + // not name is the more useful of the two failures + it('reports the undetermined store when the head read failed with it', async () => { + const { daSourceGet, env } = await build({ + site: undefined, + headError: new TypeError('Network connection lost'), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.match(res.headers.get('x-error'), /site lookup failed/); + }); + it('reports an unreachable store on a site with no head.html', async () => { const { daSourceGet, env } = await build({ headHtml: undefined, From 01082cd2707a75cf310cb4a6d0fbfe29b5d768fe Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:01:18 +0200 Subject: [PATCH 08/49] fix: report the store the lookup could not name before a failed head read --- src/routes/da-admin.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 74af0826..e0643de9 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -186,8 +186,10 @@ async function sourceGet({ req, env, daCtx }) { } return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - if (head.status === 'rejected') throw head.reason; + // the lookup first: one service answers both reads, and the store it could not name is the + // more useful of the two failures if (source.status === 'rejected') throw source.reason; + if (head.status === 'rejected') throw head.reason; const headHtml = head.value; const { response: sourceResp } = source.value; From fd17d550c8a098f606c369b1ed51a29a2f888c78 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:16:27 +0200 Subject: [PATCH 09/49] fix: declare the config service token in the dev env, so .dev.vars.dev binds it --- README.md | 2 ++ wrangler.toml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c06bdaf5..faf25c28 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ To run da-universal locally: 1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 +Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. + Anyone who has the shared secret can point `npm run dev` at config.aem.page instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. ### Run on stage diff --git a/wrangler.toml b/wrangler.toml index b3aaafe1..39d66735 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -12,9 +12,9 @@ port = 4712 [env.dev] vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] -# the stand-in needs no token, and without this empty list wrangler warns about a missing -# HLX_CONFIG_SERVICE_TOKEN on npm run dev -secrets = { required = [] } +# the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker +# with no token and every lookup 401s. running against the stand-in warns that it is missing +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage] vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } From ce03d15f67a08212e036977e1930cc0b1a9bfd94 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:37:46 +0200 Subject: [PATCH 10/49] test: pin the head read to the html GET, and keep the preview host 503 body asserted --- README.md | 2 +- src/storage/site.js | 4 ++-- src/utils/upstream.js | 4 ++-- test/routes/da-admin.test.js | 2 -- test/routes/source-read.test.js | 25 +++++++++++++++++-------- test/storage/site.test.js | 8 ++++++++ 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index faf25c28..ad1094c0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Anyone who has the shared secret can point `npm run dev` at config.aem.page inst ### Run on stage -You can deploy da-universal on Cloudflare stage via `npm deploy:stage` to test it in a real worker environment. +You can deploy da-universal on Cloudflare stage via `npm run deploy:stage` to test it in a real worker environment. ## Customer documentation https://docs.da.live/developers/reference/universal-editor diff --git a/src/storage/site.js b/src/storage/site.js index 8e3485d1..230ef6c8 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -63,8 +63,8 @@ export default async function getSite(env, daCtx) { * * The pipeline scope carries the code bus object the delivery pipeline renders into every page of * the site, and the admin scope does not carry it at all. Reading it here rather than from - * `{ref}--{site}--{org}.aem.page/head.html` answers for a ref the preview host never built and - * for a site behind Helix authentication, which refuses that path without a site token. + * `{ref}--{site}--{org}.aem.page/head.html` answers for a site behind Helix authentication, which + * refuses that path without a site token. * * Throws on any refusal but a 404. The token is the worker's own, so a refusal is a deploy * without it rather than a site that has no head.html. diff --git a/src/utils/upstream.js b/src/utils/upstream.js index baba2a54..66ab125b 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -34,7 +34,7 @@ export function causeOf(e) { * Not a refusal: an upstream that answered 401 or 404 has answered, and the route decides what * that means. An UpstreamError means there is no answer to read, and it is retryable. * - * @property {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + * @property {string} upstream one of the names at the top of this file */ export class UpstreamError extends Error { constructor(upstream, cause) { @@ -47,7 +47,7 @@ export class UpstreamError extends Error { /** * Runs `read` and rethrows anything it throws as an UpstreamError naming `upstream`. * - * @param {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + * @param {string} upstream one of the names at the top of this file * @param {() => Promise} read * @returns {Promise} * @template T diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index e4a763cd..b112e196 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -68,7 +68,6 @@ const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async () => ({ tree: true }), @@ -124,7 +123,6 @@ describe('daSourceGet', () => { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 6c3a8e07..850cd5a1 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -586,7 +586,6 @@ describe('reading from the store that holds the site', () => { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), - getAEMHtml: async () => 'from the template', }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -817,8 +816,8 @@ describe('reading from the store that holds the site', () => { }); }); - // the config service reads head.html off the code bus, so a site behind Helix authentication - // and a ref the preview host will not serve both still get the project's css and js + // the config service reads head.html off the code bus, which a site behind Helix authentication + // serves to the worker's own token while its preview host refuses it describe('where the page head comes from', () => { it('reads it off the config service, and asks the preview host for nothing', async () => { const { daSourceGet, env, seen } = await build(); @@ -839,15 +838,24 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // one read each, rather than a second lookup to carry the head - it('reads it alongside the lookup', async () => { + // nothing composes an image, and a config service that is down would answer the 503 the + // handler prefers over the AEM proxy's answer + it('reads nothing for an asset', async () => { const { daSourceGet, env, seen } = await build(); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.heads, 1); + assert.strictEqual(seen.heads, 0); + }); + + it('reads nothing on a HEAD', async () => { + const { daSourceHead, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.heads, 0); }); }); @@ -1188,6 +1196,7 @@ describe('a path the site config gives a template', () => { assert.strictEqual(res.status, 503); assert.match(res.headers.get('x-error'), /preview host failed/); + assert.strictEqual(await res.text(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); }); it('takes the longest matching prefix', async () => { diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 3877feb2..cf69ec49 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -318,6 +318,14 @@ describe('getSiteHead', () => { await assert.rejects(() => getSiteHead(env, daCtx()), /fetch failed/); }); + // a worker deployed without the secret and a rate limit share the status and the body, so + // `x-error` is what tells them apart + it('names the status it got', async () => { + stubFetch(() => new Response('', { status: 401 })); + + await assert.rejects(() => getSiteHead(env, daCtx()), /401/); + }); + it('throws when the body is not JSON', async () => { stubFetch(() => new Response('the edge said no', { status: 200 })); From 403c1604130e594599012974fc72fdfcc9bfde9a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 17:35:25 +0200 Subject: [PATCH 11/49] test: /ping decides the store, the config service decides existence and the head red. the head arrives with the existence answer, so one pipeline read replaces the admin scope, and the source-bus flag comes off admin.hlx.page/ping again. --- src/storage/source-bus.js | 15 ++ test/index.test.js | 2 +- test/routes/da-admin.test.js | 52 +++--- test/routes/source-read.test.js | 279 ++++++++++++++++++------------- test/routes/source-write.test.js | 69 ++++---- test/storage/site.test.js | 205 ++++------------------- test/storage/source-bus.test.js | 194 +++++++++++++++++++++ 7 files changed, 479 insertions(+), 337 deletions(-) create mode 100644 src/storage/source-bus.js create mode 100644 test/storage/source-bus.test.js diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js new file mode 100644 index 00000000..b77223b2 --- /dev/null +++ b/src/storage/source-bus.js @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export default async function isSourceBus() { + throw new Error('not implemented'); +} diff --git a/test/index.test.js b/test/index.test.js index 3ba9d9ba..0bdacba0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -189,7 +189,7 @@ describe('worker fetch handler', () => { // reaches the caller if that rebuild carries it describe('a refused write on a source-bus site', () => { const busWorker = async () => (await esmock('../src/index.js', READ_HANDLER_MOCKS, { - '../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, + '../src/storage/source-bus.js': { default: async () => true }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index b112e196..a8e30904 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -34,6 +34,7 @@ const recorder = () => { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_ADMIN: 'https://admin.hlx.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -44,18 +45,23 @@ const recorder = () => { return { env, fetched }; }; -// stands in for config.aem.page, the only lookup the routes make -// answers that any site exists; `upgraded` lists the `org/site` keys on the source bus -const stubConfig = (upgraded = []) => { +// stands in for the two lookups the routes make: config.aem.page for whether the site exists, +// admin.hlx.page/ping for which store holds it. Answers that any site exists; `upgraded` lists +// the `org/site` keys /ping reports as enrolled +const stubLookups = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const [, site, org] = new URL(url).pathname.split('/')[1].split('--'); - const source = upgraded.includes(`${org}/${site}`) - ? `https://api.aem.live/${org}/sites/${site}/` - : `https://content.da.live/${org}/${site}/`; - const body = JSON.stringify({ content: { source: { url: source, type: 'markup' } } }); + const { pathname } = new URL(url); + if (pathname.startsWith('/ping/')) { + const [, , org, site] = pathname.split('/'); + const headers = upgraded.includes(`${org}/${site}`) + ? { 'x-api-upgrade-available': 'true' } + : {}; + return new Response('', { status: 200, headers }); + } + const body = JSON.stringify({ head: { html: '' } }); return new Response(body, { status: 200 }); }; return asked; @@ -63,9 +69,9 @@ const stubConfig = (upgraded = []) => { const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, onSourceBus: false }), - getSiteHead: async () => '', + default: async () => ({ exists: true, head: '' }), }, + '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -114,13 +120,13 @@ describe('daSourceGet', () => { // `{ headHtml: undefined }` actually simulates a missing head.html, instead // of being masked by the default parameter value. const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; - const site = overrides.site ?? { exists: true, onSourceBus: false }; + const exists = overrides.site?.exists ?? true; calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => site, - getSiteHead: async () => headHtml, + default: async () => ({ exists, head: headHtml }), }, + '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -239,7 +245,7 @@ describe('daSourceGet', () => { }); it('returns a working 404 shell for quick-edit when there is no such site', async () => { - const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); + const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); const daCtx = getDaCtx(req); @@ -254,7 +260,7 @@ describe('daSourceGet', () => { }); it('returns not-found for non-quick-edit when there is no such site', async () => { - const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); + const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const daCtx = getDaCtx(req); @@ -281,9 +287,9 @@ describe('daSourceGet', () => { }); describe('source URLs', () => { - // answers the unmocked lookup with a da-admin source, the legacy store these tests describe + // answers the unmocked lookups with a legacy site, the store these tests describe beforeEach(() => { - stubConfig(); + stubLookups(); }); afterEach(() => { @@ -418,9 +424,9 @@ describe('daSourcePost to a non-HTML path', () => { }); describe('daSourcePost', () => { - // answers the unmocked lookup with a da-admin source, the legacy store these tests describe + // answers the unmocked lookups with a legacy site, the store these tests describe beforeEach(() => { - stubConfig(); + stubLookups(); }); afterEach(() => { @@ -435,7 +441,7 @@ describe('daSourcePost', () => { }; it('is refused with 405 and nothing is written', async () => { - stubConfig(['org/refused']); + stubLookups(['org/refused']); const { env, fetched } = recorder(); const res = await write('refused', env); @@ -448,15 +454,15 @@ describe('daSourcePost', () => { // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes // effect on the next one it('looks the site up once per write', async () => { - const asked = stubConfig(['org/lookedupeach']); + const asked = stubLookups(['org/lookedupeach']); const { env } = recorder(); await write('lookedupeach', env); await write('lookedupeach', env); assert.deepStrictEqual(asked, [ - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://admin.hlx.page/ping/org/lookedupeach', + 'https://admin.hlx.page/ping/org/lookedupeach', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 850cd5a1..fc448600 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,7 +20,8 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the site lookup answers +// what the two lookups answer together: `exists` comes from the config service, `onSourceBus` +// from /ping const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -44,10 +45,10 @@ const build = async (overrides = {}) => { const site = 'site' in overrides ? overrides.site : LEGACY_STORE; const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); const { - headError, templateError, configError, composeError, config = null, + busError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, heads: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, pings: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -71,12 +72,14 @@ const build = async (overrides = {}) => { seen.lookups += 1; // throws for undefined, which is how the lookup reports a failure if (site === undefined) throw lookupError; - return site; + return { exists: site.exists, head: headHtml }; }, - getSiteHead: async () => { - if (headError) throw headError; - seen.heads += 1; - return headHtml; + }, + '../../src/storage/source-bus.js': { + default: async () => { + seen.pings += 1; + if (busError) throw busError; + return site !== undefined && site.onSourceBus; }, }, '../../src/utils/aemCtx.js': { @@ -111,15 +114,17 @@ const build = async (overrides = {}) => { return { ...mod, env, seen }; }; -describe('when the lookup cannot say which store holds the site', () => { +describe('when /ping cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); + const dead = () => new TypeError('fetch failed'); + // picking a store without an answer is a coin flip, and reading the wrong one hands the author // the wrong document at 200 it('refuses an html read with 503 and touches neither store', async () => { - const { daSourceGet, env, seen } = await build({ site: undefined }); + const { daSourceGet, env, seen } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -129,7 +134,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ site: undefined }); + const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -140,7 +145,7 @@ describe('when the lookup cannot say which store holds the site', () => { // the preview iframe renders this body, and the store answered nothing here: it was never // asked, since which store to ask is what could not be determined it('says the store could not be determined, not that it did not answer', async () => { - const { daSourceGet, env } = await build({ site: undefined }); + const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -151,7 +156,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a non-html read too', async () => { - const { daSourceGet, env, seen } = await build({ site: undefined }); + const { daSourceGet, env, seen } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -161,7 +166,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a HEAD with 503 and no body', async () => { - const { daSourceHead, env, seen } = await build({ site: undefined }); + const { daSourceHead, env, seen } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -171,51 +176,49 @@ describe('when the lookup cannot say which store holds the site', () => { assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); - // both 503s share a status and an unparsed body, so only the header separates them - it('names the failed lookup in x-error', async () => { - const { daSourceGet, env } = await build({ site: undefined }); + // the two lookups are two upstreams now, and both 503s share a status and an unparsed body + it('names the probe in x-error', async () => { + const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /store lookup failed/); }); - it('names the failed lookup on a HEAD too', async () => { - const { daSourceHead, env } = await build({ site: undefined }); + it('names the probe on a HEAD too', async () => { + const { daSourceHead, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /store lookup failed/); }); - // a read answers the same 503 and the same body whichever of the two failed, so the header is - // the only thing on the wire that separates a timeout from a dropped connection - it('names the lookup cause, not a category', async () => { + // the header is the only thing on the wire that separates a timeout from a dropped connection + it('names the cause, not a category', async () => { const { daSourceGet, env } = await build({ - site: undefined, - lookupError: new DOMException('timed out', 'TimeoutError'), + busError: new DOMException('timed out', 'TimeoutError'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: TimeoutError: timed out'); }); // rendering a thrown non-Error as "undefined: undefined" would leave the 503 saying nothing it('survives a thrown non-Error', async () => { - const { daSourceGet, env } = await build({ site: undefined, lookupError: 'boom' }); + const { daSourceGet, env } = await build({ busError: 'boom' }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 503); - assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); + assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: Error: boom'); }); - it('tells a lookup failure apart from a store failure', async () => { + it('tells a probe failure apart from a store failure', async () => { const { daSourceGet, env } = await build({ site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, @@ -228,6 +231,97 @@ describe('when the lookup cannot say which store holds the site', () => { }); }); +// the config service answers whether the site exists and what its head.html is, so an outage +// leaves both unknown +describe('when the config service cannot say whether the site exists', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + it('refuses an html read with 503 and touches neither store', async () => { + const { daSourceGet, env, seen } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + // a 404 would say the site is gone, which is #258 again, this time on the lookup + it('does not answer 404', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + + // says what happened, since the preview iframe renders the refusal + it('says the site could not be looked up, not that a store did not answer', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + const body = await res.text(); + assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); + }); + + it('refuses a HEAD with 503 and no body', async () => { + const { daSourceHead, env, seen } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + it('names the failed lookup in x-error', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.match(res.headers.get('x-error'), /site lookup failed/); + }); + + it('names the lookup cause, not a category', async () => { + const { daSourceGet, env } = await build({ + site: undefined, + lookupError: new DOMException('timed out', 'TimeoutError'), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + }); + + it('survives a thrown non-Error', async () => { + const { daSourceGet, env } = await build({ site: undefined, lookupError: 'boom' }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); + }); +}); + describe('reading from the store that holds the site', () => { afterEach(() => { delete globalThis.fetch; @@ -581,9 +675,9 @@ describe('reading from the store that holds the site', () => { }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, onSourceBus: true }), - getSiteHead: async () => '', + default: async () => ({ exists: true, head: '' }), }, + '../../src/storage/source-bus.js': { default: async () => true }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), }, @@ -775,11 +869,11 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // names which failure it was: a site that does not exist has no head.html either - it('reports no such site even when the head read did not answer', async () => { + // the two lookups go out together, and a site that does not exist needs no store + it('reports no such site even when the probe did not answer', async () => { const { daSourceGet, env } = await build({ site: NO_SITE, - headError: new TypeError('Network connection lost'), + busError: new TypeError('Network connection lost'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -788,12 +882,11 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // one service answers both reads, so an outage fails them together, and the store it could - // not name is the more useful of the two failures - it('reports the undetermined store when the head read failed with it', async () => { + // whether there is a site to read at all is the question the other two rest on + it('reports the failed site lookup when the probe failed with it', async () => { const { daSourceGet, env } = await build({ site: undefined, - headError: new TypeError('Network connection lost'), + busError: new TypeError('Network connection lost'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -825,7 +918,7 @@ describe('reading from the store that holds the site', () => { await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.heads, 1); + assert.strictEqual(seen.lookups, 1); assert.deepStrictEqual(seen.aem, []); }); @@ -838,24 +931,39 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // nothing composes an image, and a config service that is down would answer the 503 the - // handler prefers over the AEM proxy's answer - it('reads nothing for an asset', async () => { + // one read of the config service carries both the existence answer and head.html, so a page + // that needs the head pays for no second read + it('reads the config service once for a page', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.pings, 1); + }); + + // nothing composes an image, and the head that arrives with the existence answer is dropped + it('reads the same two lookups for an asset, and no more', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.heads, 0); + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.pings, 1); + assert.deepStrictEqual(seen.head, []); }); - it('reads nothing on a HEAD', async () => { + it('reads the same two lookups on a HEAD, and no more', async () => { const { daSourceHead, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceHead({ env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.heads, 0); + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.pings, 1); + assert.deepStrictEqual(seen.head, []); }); }); @@ -973,60 +1081,6 @@ describe('reading from the store that holds the site', () => { assert.match(await res.text(), /importmap/); }); }); - - // a throw out of the head read used to reach the worker's catch as a 500 with no body - describe('when the head read cannot be reached', () => { - const dead = () => new TypeError('Network connection lost'); - - it('answers 503 rather than throwing', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.strictEqual(res.status, 503); - }); - - it('names the cause in x-error', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.strictEqual(res.headers.get('x-error'), 'page head failed: TypeError: Network connection lost'); - }); - - it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ - headError: new DOMException('The operation timed out', 'TimeoutError'), - }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.ok(Number(res.headers.get('Retry-After')) > 0); - }); - - // says what happened, since the preview iframe renders the refusal - it('says the page head did not arrive, not that the store is undetermined', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.strictEqual(await res.text(), messages.HEAD_UNREACHABLE_HTML_MESSAGE); - }); - - // a 404 would say the site is gone, which is #258 again, this time on the head read - it('does not answer 404', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.notStrictEqual(res.status, 404); - }); - }); }); describe('when the site config cannot be reached', () => { @@ -1091,9 +1145,9 @@ describe('when the site config cannot be reached', () => { }); }); -// getSiteHead is not stubbed here: a stub hides a config service that answers, but with -// something other than a head -describe('when the config service refuses the head read', () => { +// site.js is not stubbed here: a stub hides a config service that answers, but with something +// other than a config +describe('when the config service refuses the lookup', () => { afterEach(() => { delete globalThis.fetch; }); @@ -1108,39 +1162,38 @@ describe('when the config service refuses the head read', () => { daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/site.js': { default: async () => LEGACY_STORE }, + '../../src/storage/source-bus.js': { default: async () => false }, }); // a preview host rather than a UE host, so nothing is instrumented onto the composed page const req = authedReq('https://main--site--org.preview.da.live/folder/content'); return daSourceGet({ req, env, daCtx: getDaCtx(req) }); }; - // a 200 with no head is a page with no stylesheet, no scripts and no entry script - it('refuses a 500 with 503 rather than composing an empty project head', async () => { + // reading a refusal as a missing site would 404 a page that exists, which is #258 again + it('refuses a 500 with 503 rather than calling the site missing', async () => { const res = await readWithConfigStatus(500); assert.strictEqual(res.status, 503); }); - it('names the page head in x-error', async () => { + it('names the site lookup in x-error', async () => { const res = await readWithConfigStatus(500); - assert.match(res.headers.get('x-error'), /page head failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - // the shared secret is the worker's own, so a 401 is a deploy without it rather than a site - // that has no head.html + // the shared secret is the worker's own, so a 401 is a deploy without it rather than an answer it('refuses a 401 with 503', async () => { const res = await readWithConfigStatus(401); assert.strictEqual(res.status, 503); }); - // a ref that was never built has no head.html, which is not a failure - it('composes the page without a head on a 404', async () => { + // the one status that is an answer: the config service knows of no such site + it('answers 404 on a 404', async () => { const res = await readWithConfigStatus(404); - assert.strictEqual(res.status, 200); + assert.strictEqual(res.status, 404); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index cd0b7297..45a8349f 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -19,10 +19,9 @@ import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../.. const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; -// what the site lookup answers -const SOURCE_BUS = { exists: true, onSourceBus: true }; -const LEGACY_STORE = { exists: true, onSourceBus: false }; -const NO_SITE = { exists: false, onSourceBus: false }; +// what /ping answers +const SOURCE_BUS = true; +const LEGACY_STORE = false; /** The shape the Universal Editor Service posts: a `data` blob in a multipart form. */ const uePost = (url, html = DOC) => { @@ -32,12 +31,10 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201 } = overrides; - // `in overrides` rather than a destructured default, so an explicit undefined reaches here - const site = 'site' in overrides ? overrides.site : LEGACY_STORE; - const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); + const { status = 201, busError } = overrides; + const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { - bus: [], legacy: [], lookups: 0, order: [], + bus: [], legacy: [], lookups: 0, probes: 0, order: [], }; const capture = async (request) => { const contentType = request.headers.get('Content-Type'); @@ -73,13 +70,20 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { + // a write asks which store, and nothing else, so a call here is the regression '../../src/storage/site.js': { default: async () => { seen.lookups += 1; seen.order.push('lookup'); - // throws for undefined, which is how the lookup reports a failure - if (site === undefined) throw lookupError; - return site; + return { exists: true, head: undefined }; + }, + }, + '../../src/storage/source-bus.js': { + default: async () => { + seen.probes += 1; + seen.order.push('probe'); + if (busError) throw busError; + return onSourceBus; }, }, }); @@ -133,9 +137,11 @@ describe('writing to the store that holds the site', () => { // a write is the one operation a wrong store cannot be walked back from, so no answer means no // write rather than a guess - describe('when the lookup cannot say which store holds the site', () => { + describe('when the probe cannot say which store holds the site', () => { + const dead = () => new TypeError('fetch failed'); + it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ site: undefined }); + const { res, seen } = await post({ busError: dead() }); assert.strictEqual(res.status, 503); assert.strictEqual(seen.bus.length, 0); @@ -143,41 +149,40 @@ describe('writing to the store that holds the site', () => { }); it('asks the caller to retry, unlike the source-bus refusal', async () => { - const { res } = await post({ site: undefined }); + const { res } = await post({ busError: dead() }); assert.ok(Number(res.headers.get('Retry-After')) > 0); }); it('says which of the two refusals it is', async () => { - const { res } = await post({ site: undefined }); + const { res } = await post({ busError: dead() }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - it('names the failed lookup in x-error', async () => { - const { res } = await post({ site: undefined }); + it('names the failed probe in x-error', async () => { + const { res } = await post({ busError: dead() }); - assert.match(res.headers.get('x-error'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /store lookup failed/); }); - it('names the lookup cause, not a category', async () => { + it('names the cause, not a category', async () => { const { res } = await post({ - site: undefined, - lookupError: new DOMException('timed out', 'TimeoutError'), + busError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: TimeoutError: timed out'); }); }); - // a 404 from the config service says there is no AEM site config, not that the DA org and site - // are bogus. a read of the same path answers 404, so the editor cannot reach this state - describe('a site the lookup says does not exist', () => { - it('is written to da-admin all the same', async () => { - const { res, seen } = await post({ site: NO_SITE }); + // whether the site exists changes nothing about where a write goes, and a read of the same path + // answers 404 first, so the editor cannot reach this state with a site that is not there + describe('what a write asks about the site', () => { + it('asks which store, and not whether the site exists', async () => { + const { res, seen } = await post({}); - assert.strictEqual(seen.bus.length, 0); - assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(seen.probes, 1); + assert.strictEqual(seen.lookups, 0); assert.strictEqual(res.status, 201); }); }); @@ -269,13 +274,13 @@ describe('writing to the store that holds the site', () => { it('happens before anything is sent to a store', async () => { const { seen } = await post({}); - assert.deepStrictEqual(seen.order, ['lookup', 'store']); + assert.deepStrictEqual(seen.order, ['probe', 'store']); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { const { seen } = await post({ site: SOURCE_BUS }); - assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.probes, 1); }); }); diff --git a/test/storage/site.test.js b/test/storage/site.test.js index cf69ec49..6f61b3fb 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -13,7 +13,7 @@ /* eslint-env mocha */ import assert from 'assert'; -const { default: getSite, getSiteHead } = await import('../../src/storage/site.js'); +const { default: getSite } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', @@ -35,21 +35,14 @@ const stubFetch = (respond) => { }; }; -// has the two fields the lookup reads; the service also answers with admin roles and secrets -const config = (sourceUrl) => () => new Response( - JSON.stringify({ content: { source: { url: sourceUrl, type: 'markup' } } }), - { status: 200 }, -); -const legacy = config('https://content.da.live/org/site/'); -const sourceBus = config('https://api.aem.live/org/sites/site/'); -const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); - const STYLESHEET = ''; // the pipeline scope carries the code bus object, under a lastModified the delivery pipeline reads const withHead = (html) => () => new Response( JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), { status: 200 }, ); +const found = withHead(STYLESHEET); +const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); describe('getSite', () => { afterEach(() => { @@ -57,25 +50,26 @@ describe('getSite', () => { }); describe('the request it makes', () => { - it('asks the config service for the admin-scoped site config', async () => { - stubFetch(legacy); + it('asks the config service for the pipeline-scoped config', async () => { + stubFetch(found); await getSite(env, daCtx()); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); }); it('takes the config service from env, so dev can point elsewhere', async () => { - stubFetch(legacy); + stubFetch(found); await getSite({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); + assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=pipeline'); }); + // the code bus holds one head.html per ref, so a branch gets its own it('names the ref the request came in on', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx({ ref: 'feature' })); @@ -83,7 +77,7 @@ describe('getSite', () => { }); it('sends the shared token', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); @@ -92,7 +86,7 @@ describe('getSite', () => { // the edge answers 400 without it, and that failure reads like a bad path it('sends the backend type', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); @@ -101,7 +95,7 @@ describe('getSite', () => { // the author's token has no business at a service-to-service endpoint it('sends no author token', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); @@ -109,50 +103,43 @@ describe('getSite', () => { }); it('gives up rather than hanging', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); assert.ok(calls[0].init.signal); }); - }); - - describe('which store holds the site', () => { - it('reads the source bus off the content source url', async () => { - stubFetch(sourceBus); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: true }); - }); + // the admin scope answers with the site's CDN token and its API key metadata, and one read + // covers both questions this asks + it('reads one scope, and not the admin one', async () => { + stubFetch(found); - it('reads a da-admin site as legacy', async () => { - stubFetch(legacy); + await getSite(env, daCtx()); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: false }); + assert.strictEqual(calls.length, 1); + assert.doesNotMatch(calls[0].url, /scope=admin/); }); + }); - it('tolerates a trailing slash on the configured source bus', async () => { - stubFetch(sourceBus); - - const site = await getSite({ ...env, AEM_API: 'https://api.aem.live/' }, daCtx()); + describe('the head it answers', () => { + it('reads head.html out of the config', async () => { + stubFetch(found); - assert.strictEqual(site.onSourceBus, true); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: STYLESHEET }); }); - // a prefix match on the bare string would take api.aem.live.evil.example for the source bus - it('does not take a lookalike host for the source bus', async () => { - stubFetch(config('https://api.aem.live.evil.example/org/sites/site/')); - - const site = await getSite(env, daCtx()); + // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 + it('answers a site with no head.html for the ref', async () => { + stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); - assert.strictEqual(site.onSourceBus, false); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); }); - it('reads a config with no content source as legacy', async () => { + it('answers a site whose config carries no head at all', async () => { stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); - const site = await getSite(env, daCtx()); - - assert.strictEqual(site.onSourceBus, false); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); }); }); @@ -160,7 +147,7 @@ describe('getSite', () => { it('says so on a 404', async () => { stubFetch(absent); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, onSourceBus: false }); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, head: undefined }); }); // an unparseable hostname leaves org and site undefined, so there is nothing to ask about @@ -174,7 +161,7 @@ describe('getSite', () => { }); }); - // a refusal leaves both answers unknown, and a guess reads the wrong store at 200 + // a refusal leaves existence unknown, and reading that as a missing site 404s a live page describe('when the lookup cannot answer', () => { [401, 403, 429, 500, 502].forEach((status) => { it(`throws on a ${status}`, async () => { @@ -192,13 +179,14 @@ describe('getSite', () => { await assert.rejects(() => getSite(env, daCtx()), /fetch failed/); }); + // a worker deployed without the secret and a rate limit share the status and the body, so + // `x-error` is what tells them apart it('names the status it got', async () => { stubFetch(() => new Response('', { status: 401 })); await assert.rejects(() => getSite(env, daCtx()), /401/); }); - // reads a 200 with an error page as a store it does not know, not as a legacy site it('throws when the body is not JSON', async () => { stubFetch(() => new Response('the edge said no', { status: 200 })); @@ -207,129 +195,10 @@ describe('getSite', () => { // a misconfigured worker gets no answer, and calling that a missing site would 404 the pages it('throws when the config service host is missing, without asking', async () => { - stubFetch(legacy); + stubFetch(found); await assert.rejects(() => getSite({ ...env, HLX_CONFIG_SERVICE: undefined }, daCtx())); assert.strictEqual(calls.length, 0); }); }); }); - -describe('getSiteHead', () => { - afterEach(() => { - delete globalThis.fetch; - }); - - describe('the request it makes', () => { - it('asks the config service for the pipeline-scoped config', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); - }); - - // the code bus holds one head.html per ref, so a branch gets its own - it('names the ref the request came in on', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx({ ref: 'feature' })); - - assert.match(calls[0].url, /\/feature--site--org\//); - }); - - it('sends the shared token', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); - }); - - it('sends the backend type', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); - }); - - it('gives up rather than hanging', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.ok(calls[0].init.signal); - }); - }); - - describe('the head it answers', () => { - it('reads head.html out of the config', async () => { - stubFetch(withHead(STYLESHEET)); - - assert.strictEqual(await getSiteHead(env, daCtx()), STYLESHEET); - }); - - // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 - it('answers nothing when the ref has no head.html', async () => { - stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); - - assert.strictEqual(await getSiteHead(env, daCtx()), undefined); - }); - - it('answers nothing when the config carries no head at all', async () => { - stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); - - assert.strictEqual(await getSiteHead(env, daCtx()), undefined); - }); - - // the lookup answers 404 with the site, and one 404 page is enough - it('answers nothing on a 404', async () => { - stubFetch(absent); - - assert.strictEqual(await getSiteHead(env, daCtx()), undefined); - }); - - it('asks nothing when there is no org or site', async () => { - stubFetch(absent); - - assert.strictEqual(await getSiteHead(env, daCtx({ site: undefined })), undefined); - assert.strictEqual(calls.length, 0); - }); - }); - - // the token is the worker's own, so a refusal is a broken worker rather than a site with no - // head.html, and composing an empty project head would serve that as a page - describe('when the read cannot answer', () => { - [401, 403, 429, 500, 502].forEach((status) => { - it(`throws on a ${status}`, async () => { - stubFetch(() => new Response('', { status })); - - await assert.rejects(() => getSiteHead(env, daCtx()), /502|500|429|403|401/); - }); - }); - - it('throws when the config service cannot be reached', async () => { - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - - await assert.rejects(() => getSiteHead(env, daCtx()), /fetch failed/); - }); - - // a worker deployed without the secret and a rate limit share the status and the body, so - // `x-error` is what tells them apart - it('names the status it got', async () => { - stubFetch(() => new Response('', { status: 401 })); - - await assert.rejects(() => getSiteHead(env, daCtx()), /401/); - }); - - it('throws when the body is not JSON', async () => { - stubFetch(() => new Response('the edge said no', { status: 200 })); - - await assert.rejects(() => getSiteHead(env, daCtx())); - }); - }); -}); diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js new file mode 100644 index 00000000..8093d3fd --- /dev/null +++ b/test/storage/source-bus.test.js @@ -0,0 +1,194 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; + +const { default: isSourceBus } = await import('../../src/storage/source-bus.js'); + +const env = { AEM_API: 'https://api.aem.live', HLX_ADMIN: 'https://admin.hlx.page' }; + +const daCtx = (over = {}) => ({ + org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, +}); + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(input.toString(), init); + }; +}; + +const ping = (headers = {}, status = 200) => new Response('', { status, headers }); +const upgraded = () => ping({ 'x-api-upgrade-available': 'true' }); + +describe('isSourceBus', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks /ping on the admin host', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + }); + + it('takes the admin host from env, so stage can point elsewhere', async () => { + stubFetch(upgraded); + + await isSourceBus({ ...env, HLX_ADMIN: 'https://admin.stage.example' }, daCtx()); + + assert.strictEqual(calls[0].url, 'https://admin.stage.example/ping/org/site'); + }); + + // both stores read one config service and the source is per site, so the branch cannot change + // the answer + it('does not vary by ref', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx({ ref: 'branch' })); + + assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + }); + + // /ping is exempt from authorize() in helix-admin and answers the same with or without a token + it('sends no token, since /ping does not read one', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.strictEqual(new Headers(calls[0].init.headers).get('Authorization'), null); + }); + + it('gives up rather than hanging', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.ok(calls[0].init.signal, 'the probe carries an abort signal'); + }); + }); + + describe('when /ping says the site is upgraded', () => { + it('answers true', async () => { + stubFetch(upgraded); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + + // presence, not value: da-nx tests the same header with `!== null` (nx2/utils/api.js, + // isHlx6), and two clients reading it differently would split one site across two stores + ['false', '', 'TRUE'].forEach((value) => { + it(`counts any value, including ${JSON.stringify(value)}`, async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': value })); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + }); + + // no status test, for the same reason. the edge sets the header from its dictionary, so a + // rate-limited or erroring origin behind it does not make an enrolled site legacy + [429, 500, 503].forEach((status) => { + it(`counts it on a ${status}, since the header is what carries the answer`, async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, status)); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + }); + }); + + describe('when /ping does not say so', () => { + [ + ['the header is absent', {}, 200], + ['the header is absent on a 404', {}, 404], + ['the header is absent on a 405', {}, 405], + ['the header is absent on a 500', {}, 500], + ].forEach(([what, headers, status]) => { + it(`answers false: ${what}`, async () => { + stubFetch(() => ping(headers, status)); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + }); + }); + }); + + // an answer without the header is legacy. no answer is not an answer, and the caller refuses + // rather than picking a store on a coin flip + describe('when /ping cannot answer', () => { + // the cause reaches the caller, which reports it on the 503 as `x-error`. swallowing it here + // would leave a timeout and a dropped connection indistinguishable + it('lets the failure through', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); + }); + + it('lets it through when HLX_ADMIN is unusable, without asking', async () => { + stubFetch(upgraded); + + await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); + assert.strictEqual(calls.length, 0); + }); + + // the distinction the caller acts on: false is a store, a failure is no store + it('is distinguishable from a legacy answer', async () => { + stubFetch(() => ping()); + assert.strictEqual(await isSourceBus(env, daCtx()), false); + + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + await assert.rejects(isSourceBus(env, daCtx())); + }); + }); + + describe('when there is no site to ask about', () => { + // either one missing is enough: a half-parsed request would otherwise build a ping url with + // "undefined" in it + [ + ['neither', { org: undefined, site: undefined }], + ['no org', { org: undefined }], + ['no site', { site: undefined }], + ['an empty org', { org: '' }], + ['an empty site', { site: '' }], + ].forEach(([what, over]) => { + it(`answers false without making a request: ${what}`, async () => { + stubFetch(upgraded); + + assert.strictEqual(await isSourceBus(env, daCtx(over)), false); + assert.strictEqual(calls.length, 0); + }); + }); + }); + + // nothing is remembered between calls, so an enrolment takes effect on the next read and a + // config blip cannot pin a stale answer + it('probes every time it is asked', async () => { + let enrolled = false; + stubFetch(() => (enrolled ? upgraded() : ping())); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + enrolled = true; + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + assert.strictEqual(calls.length, 2); + }); +}); From 4be8875136144f12443a71be379885f62801a01d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 17:37:29 +0200 Subject: [PATCH 12/49] fix: ask /ping which store holds the site, and the config service what exists green. the pipeline scope answers existence and head.html in one read, so the admin scope goes, and with it the CDN token and api key metadata the worker was reading. a write asks /ping only. --- README.md | 6 +- dev/{config-shim.js => lookup-shim.js} | 36 +++++++--- dev/{config-shim.toml => lookup-shim.toml} | 4 +- package.json | 2 +- src/routes/da-admin.js | 57 ++++++++------- src/storage/site.js | 82 ++++++---------------- src/storage/source-bus.js | 25 ++++++- src/utils/constants.js | 2 +- src/utils/upstream.js | 2 +- wrangler.toml | 6 +- 10 files changed, 113 insertions(+), 109 deletions(-) rename dev/{config-shim.js => lookup-shim.js} (60%) rename dev/{config-shim.toml => lookup-shim.toml} (82%) diff --git a/README.md b/README.md index ad1094c0..d3450162 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,20 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -Site lookups go to config.aem.page, which needs a shared secret, so local development points at `dev/config-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404. +A read looks the site up twice: config.aem.page says whether it exists and carries its head.html, and admin.hlx.page/ping says which store holds it. The config service needs a shared secret, so local development points both at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. -1. In a terminal, run `npm run dev:config` to start the stand-in config service on port 4713. +1. In a terminal, run `npm run dev:lookups` to start the stand-in lookups on port 4713. 1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. -Anyone who has the shared secret can point `npm run dev` at config.aem.page instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. +Anyone who has the shared secret can point `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page --var HLX_ADMIN:https://admin.hlx.page`. ### Run on stage diff --git a/dev/config-shim.js b/dev/lookup-shim.js similarity index 60% rename from dev/config-shim.js rename to dev/lookup-shim.js index 734f0cf8..dffce369 100644 --- a/dev/config-shim.js +++ b/dev/lookup-shim.js @@ -10,35 +10,49 @@ * governing permissions and limitations under the License. */ -// stands in for config.aem.page, which needs a shared secret -// answers 200 for a site in SITES, 404 for anything else +// stands in for the two lookups a read makes: config.aem.page, which needs a shared secret, and +// admin.hlx.page/ping. A site in SITES exists, and the source url is what makes it source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; +const SOURCE_BUS = 'https://api.aem.live/'; + // what the code bus holds at {owner}/{repo}/{ref}/head.html, which the pipeline scope carries const HEAD_HTML = '\n\n'; +/** + * Answers /ping the way helix-admin does: the header is set when the site's content source is the + * source bus, and a site it cannot resolve gets a 200 with no header rather than a 404. + */ +function ping(org, site) { + const headers = SITES[`${org}/${site}`]?.startsWith(SOURCE_BUS) + ? { 'x-api-upgrade-available': 'true' } + : {}; + return new Response('', { status: 200, headers }); +} + export default { async fetch(req) { const url = new URL(req.url); + + if (url.pathname.startsWith('/ping/')) { + const [, , pingOrg, pingSite] = url.pathname.split('/'); + return ping(pingOrg, pingSite); + } + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); if (!org || !site) { return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); } - const source = SITES[`${org}/${site}`]; - if (!source) { + if (!SITES[`${org}/${site}`]) { return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); } - const body = url.searchParams.get('scope') === 'pipeline' - ? JSON.stringify({ - ref, site, org, head: { html: HEAD_HTML }, - }) - : JSON.stringify({ - ref, site, org, content: { source: { type: 'markup', url: source } }, - }); + const body = JSON.stringify({ + ref, site, org, head: { html: HEAD_HTML }, + }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, }; diff --git a/dev/config-shim.toml b/dev/lookup-shim.toml similarity index 82% rename from dev/config-shim.toml rename to dev/lookup-shim.toml index d3c4fa48..79e3d4a2 100644 --- a/dev/config-shim.toml +++ b/dev/lookup-shim.toml @@ -1,7 +1,7 @@ # the stand-in gets its own config, so wrangler dev does not read wrangler.toml: no daadmin # binding to connect, and no HLX_CONFIG_SERVICE_TOKEN to warn about -name = "da-ue-config-shim" -main = "config-shim.js" +name = "da-ue-lookup-shim" +main = "lookup-shim.js" compatibility_date = "2023-11-21" [dev] diff --git a/package.json b/package.json index 634708dc..883d9923 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "deploy": "wrangler deploy", "deploy:stage": "wrangler deploy --env stage", "dev": "wrangler dev --local-protocol https --env dev", - "dev:config": "wrangler dev -c dev/config-shim.toml", + "dev:lookups": "wrangler dev -c dev/lookup-shim.toml", "start": "wrangler dev --local-protocol https --env dev", "test": "c8 mocha --spec=test/**/*.test.js", "lint": "eslint ." diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index e0643de9..7d80c2e7 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -26,7 +26,7 @@ import { } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, - HEAD_UNREACHABLE_HTML_MESSAGE, + SITE_UNREACHABLE_HTML_MESSAGE, PREVIEW_UNREACHABLE_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -37,15 +37,16 @@ import { UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; -import getSite, { getSiteHead } from '../storage/site.js'; +import getSite from '../storage/site.js'; +import isSourceBus from '../storage/source-bus.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { CONTENT_STORE, - PAGE_HEAD, PREVIEW_HOST, SITE_CONFIG, SITE_LOOKUP, + STORE_LOOKUP, UpstreamError, reach, } from '../utils/upstream.js'; @@ -58,10 +59,10 @@ const HTML_POST_TYPE = 'text/html'; */ const UNREACHABLE_HTML = { [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, - [SITE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, - [PAGE_HEAD]: HEAD_UNREACHABLE_HTML_MESSAGE, + [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, + [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; -const UNREACHABLE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; +const UNREACHABLE_TEXT = { [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; /** * Only an upstream that could not be reached is retryable. Anything else reaches the worker @@ -128,16 +129,29 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + // two services, one round trip: config.aem.page says whether the site exists and what its + // head.html is, /ping says which store holds it + const [site, onSourceBus] = await Promise.allSettled([ + reach(SITE_LOOKUP, () => getSite(env, daCtx)), + reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), + ]); - if (!site.exists) { + // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. + // drops a failed probe on purpose: a site that does not exist is held by no store + if (site.status === 'fulfilled' && !site.value.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); return { noSuchSite: true }; } + // the site lookup first: whether there is anything to read at all is what the other two rest on + if (site.status === 'rejected') throw site.reason; + if (onSourceBus.status === 'rejected') throw onSourceBus.reason; - const store = getStore(env, daCtx, site.onSourceBus); + const store = getStore(env, daCtx, onSourceBus.value); console.log(`-> ${init.method} ${store.url.toString()}`); - return { response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)) }; + return { + response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)), + head: site.value.head, + }; } async function sourceGet({ req, env, daCtx }) { @@ -168,17 +182,14 @@ async function sourceGet({ req, env, daCtx }) { return response; } - // runs the lookup alongside the head read, since it costs a round trip - // settles both rather than racing, so a failed head read cannot preempt the no-such-site 404 const aemCtx = getAemCtx(env, daCtx); - const [head, source] = await Promise.allSettled([ - reach(PAGE_HEAD, () => getSiteHead(env, daCtx)), - readSource(env, daCtx, { method: 'GET', headers }), - ]); + const { response: sourceResp, noSuchSite, head: headHtml } = await readSource( + env, + daCtx, + { method: 'GET', headers }, + ); - // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops the head failure on purpose: a site that does not exist has no head.html either - if (source.status === 'fulfilled' && source.value.noSuchSite) { + if (noSuchSite) { // quick-edit still needs a working shell (with the import map) so the editor // can load into this page, even when the site does not exist. if (isQuickEdit) { @@ -186,13 +197,7 @@ async function sourceGet({ req, env, daCtx }) { } return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - // the lookup first: one service answers both reads, and the store it could not name is the - // more useful of the two failures - if (source.status === 'rejected') throw source.reason; - if (head.status === 'rejected') throw head.reason; - const headHtml = head.value; - const { response: sourceResp } = source.value; console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // the store is the only thing to see the token, and the authorbus extension recovers off the @@ -308,7 +313,7 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); // the payload is settled, so the only question left is where it goes - const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); if (onSourceBus) { console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); diff --git a/src/storage/site.js b/src/storage/site.js index 230ef6c8..bfe493ac 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -11,79 +11,43 @@ */ const TIMEOUT_MS = 5 * 1000; -const NO_SITE = { exists: false, onSourceBus: false }; - -/** One read of the config service, at the scope the caller needs. */ -function askConfigService(env, daCtx, scope) { - const { org, site, ref } = daCtx; - const url = new URL(`/${ref}--${site}--${org}/config.json?scope=${scope}`, env.HLX_CONFIG_SERVICE); - return fetch(url, { - headers: { - 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, - 'x-backend-type': 'aws', - }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); -} +const NO_SITE = { exists: false, head: undefined }; /** - * Asks the config service whether a site exists and which store holds its content. + * Asks the config service whether a site exists, and reads its head.html from the same answer. * - * `content.source.url` decides the store, and helix-admin sets `x-api-upgrade-available` from - * the same field, so a request to /ping would say the same thing. + * The pipeline scope carries the code bus object the delivery pipeline renders into every page of + * the site. Reading it here rather than from `{ref}--{site}--{org}.aem.page/head.html` answers for + * a site behind Helix authentication, which refuses that path without a site token. The admin + * scope would answer existence too, and carries the site's CDN token and API key metadata with it. * - * Throws on any refusal but a 404, which is the only status that means there is no such site. - * Reads the status and the source url only, since the response also has admin roles and - * resolved secrets. + * Throws on any refusal but a 404, which is the only status that means there is no such site. A + * ref that was never built exists and has no head.html, which is a 200 with an empty head. * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it, `AEM_API` is the source bus the source url is - * compared against + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes and + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it * @param {Object} daCtx - * @returns {Promise<{exists: boolean, onSourceBus: boolean}>} + * @returns {Promise<{exists: boolean, head: string|undefined}>} */ export default async function getSite(env, daCtx) { - const { org, site } = daCtx; + const { + org, site, ref, + } = daCtx; // an unparseable hostname leaves org and site undefined, and there is no site to ask about if (!org || !site) return NO_SITE; - const response = await askConfigService(env, daCtx, 'admin'); + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=pipeline`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); if (response.status === 404) return NO_SITE; if (!response.ok) throw new Error(`the config service answered ${response.status}`); - const { content } = await response.json(); - // the bare prefix would also match a host like api.aem.live.evil.example - const sourceBus = new URL('/', env.AEM_API).href; - return { exists: true, onSourceBus: !!content?.source?.url?.startsWith(sourceBus) }; -} - -/** - * Reads the site's head.html from the config service. - * - * The pipeline scope carries the code bus object the delivery pipeline renders into every page of - * the site, and the admin scope does not carry it at all. Reading it here rather than from - * `{ref}--{site}--{org}.aem.page/head.html` answers for a site behind Helix authentication, which - * refuses that path without a site token. - * - * Throws on any refusal but a 404. The token is the worker's own, so a refusal is a deploy - * without it rather than a site that has no head.html. - * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the read goes and - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it - * @param {Object} daCtx - * @returns {Promise} undefined when the ref has no head.html - */ -export async function getSiteHead(env, daCtx) { - const { org, site } = daCtx; - if (!org || !site) return undefined; - - const response = await askConfigService(env, daCtx, 'pipeline'); - - // getSite answers the missing site, and one 404 for the page is enough - if (response.status === 404) return undefined; - if (!response.ok) throw new Error(`the config service answered ${response.status}`); - const { head } = await response.json(); - return head?.html; + return { exists: true, head: head?.html }; } diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index b77223b2..7d4f98ea 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -10,6 +10,27 @@ * governing permissions and limitations under the License. */ -export default async function isSourceBus() { - throw new Error('not implemented'); +const TIMEOUT_MS = 5 * 1000; +const UPGRADE_HEADER = 'x-api-upgrade-available'; + +/** + * Asks `/ping` whether a site is on the source bus. + * + * An answer without the header is legacy: helix-admin sets it when config resolution succeeded and + * named the API, and a Fastly edge dictionary sets it for a site being moved onto the new API + * ahead of its content. A probe that cannot answer throws, so the caller refuses with the cause + * rather than picking a store. + * + * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes + * @param {Object} daCtx + * @returns {Promise} + */ +export default async function isSourceBus(env, daCtx) { + const { org, site } = daCtx; + // an unparseable hostname leaves org and site undefined, and there is no site to ask about + if (!org || !site) return false; + + const url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); + const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + return response.headers.get(UPGRADE_HEADER) !== null; } diff --git a/src/utils/constants.js b/src/utils/constants.js index 57ca8c2c..ab505f6f 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -53,7 +53,7 @@ export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found< export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; -export const HEAD_UNREACHABLE_HTML_MESSAGE = '

503: Page head unreachable

The site\'s head.html could not be read. Please retry.

'; +export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry.

'; export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry.

'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 66ab125b..f1cffe3a 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -15,7 +15,7 @@ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; export const SITE_CONFIG = 'site config'; export const SITE_LOOKUP = 'site lookup'; -export const PAGE_HEAD = 'page head'; +export const STORE_LOOKUP = 'store lookup'; /** * Renders a failure for the `x-error` header. diff --git a/wrangler.toml b/wrangler.toml index 39d66735..def67d4c 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,7 +2,7 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } @@ -10,14 +10,14 @@ secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } port = 4712 [env.dev] -vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "http://localhost:4713" } +vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "http://localhost:4713", HLX_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] # the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker # with no token and every lookup 401s. running against the stand-in warns that it is missing secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage] -vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } +vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } From dfe6d96063b2657d4c3be61d76a17cedeb3df09d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:07:25 +0200 Subject: [PATCH 13/49] test: a /ping that refuses is no answer, not a legacy site red. header-absent-on-5xx read as legacy, which sends a source-bus write to da-admin where nothing serves it back. --- test/storage/source-bus.test.js | 46 +++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js index 8093d3fd..f3d81b10 100644 --- a/test/storage/source-bus.test.js +++ b/test/storage/source-bus.test.js @@ -102,8 +102,8 @@ describe('isSourceBus', () => { }); }); - // no status test, for the same reason. the edge sets the header from its dictionary, so a - // rate-limited or erroring origin behind it does not make an enrolled site legacy + // the edge sets the header from its dictionary, so a rate-limited or erroring origin behind + // it does not make an enrolled site legacy [429, 500, 503].forEach((status) => { it(`counts it on a ${status}, since the header is what carries the answer`, async () => { stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, status)); @@ -113,19 +113,39 @@ describe('isSourceBus', () => { }); }); - describe('when /ping does not say so', () => { - [ - ['the header is absent', {}, 200], - ['the header is absent on a 404', {}, 404], - ['the header is absent on a 405', {}, 405], - ['the header is absent on a 500', {}, 500], - ].forEach(([what, headers, status]) => { - it(`answers false: ${what}`, async () => { - stubFetch(() => ping(headers, status)); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); + // an answered 200 without the header is the legacy answer. helix-admin sets the header from + // the site's content source, and /ping is 200 for a site it routes at all + describe('when /ping says the site is legacy', () => { + it('answers false on a 200 with no header', async () => { + stubFetch(() => ping()); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + }); + }); + + // a refusal carries no decision, and reading it as legacy sends a source-bus write to da-admin, + // where nothing serves it back + describe('when /ping refuses without the header', () => { + [404, 405, 429, 500, 503].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => ping({}, status)); + + await assert.rejects(() => isSourceBus(env, daCtx()), /404|405|429|500|503/); }); }); + + // the header is read first, so an enrolled site survives an origin the edge is shielding + it('answers true on a 429 that still carries the header', async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, 429)); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + + it('names the status it got', async () => { + stubFetch(() => ping({}, 503)); + + await assert.rejects(() => isSourceBus(env, daCtx()), /503/); + }); }); // an answer without the header is legacy. no answer is not an answer, and the caller refuses From 5f0ff03beb07df32db0611d7160117d71ce542c2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:08:41 +0200 Subject: [PATCH 14/49] fix: refuse when /ping cannot answer, instead of reading it as legacy the header is read ahead of the status, so the edge dictionary still answers for an origin that is rate limited. a refusal without it now throws. --- dev/lookup-shim.js | 7 ++++--- src/routes/da-admin.js | 6 +++--- src/storage/site.js | 8 ++++---- src/storage/source-bus.js | 17 ++++++++++++----- test/routes/source-read.test.js | 10 +++++----- test/routes/source-write.test.js | 2 +- test/storage/site.test.js | 6 +++--- 7 files changed, 32 insertions(+), 24 deletions(-) diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js index dffce369..f73852f4 100644 --- a/dev/lookup-shim.js +++ b/dev/lookup-shim.js @@ -11,19 +11,20 @@ */ // stands in for the two lookups a read makes: config.aem.page, which needs a shared secret, and -// admin.hlx.page/ping. A site in SITES exists, and the source url is what makes it source-bus +// admin.hlx.page/ping. A site in SITES exists, and one with a source url on api.aem.live is +// source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; const SOURCE_BUS = 'https://api.aem.live/'; -// what the code bus holds at {owner}/{repo}/{ref}/head.html, which the pipeline scope carries +// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with const HEAD_HTML = '\n\n'; /** * Answers /ping the way helix-admin does: the header is set when the site's content source is the - * source bus, and a site it cannot resolve gets a 200 with no header rather than a 404. + * source bus, and a site it cannot resolve is answered 200 with no header rather than 404. */ function ping(org, site) { const headers = SITES[`${org}/${site}`]?.startsWith(SOURCE_BUS) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 7d80c2e7..138ea4f7 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -129,7 +129,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // two services, one round trip: config.aem.page says whether the site exists and what its + // both lookups go out together: config.aem.page says whether the site exists and what its // head.html is, /ping says which store holds it const [site, onSourceBus] = await Promise.allSettled([ reach(SITE_LOOKUP, () => getSite(env, daCtx)), @@ -137,12 +137,12 @@ async function readSource(env, daCtx, init) { ]); // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops a failed probe on purpose: a site that does not exist is held by no store + // drops a failed probe on purpose: a site that does not exist needs no store if (site.status === 'fulfilled' && !site.value.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); return { noSuchSite: true }; } - // the site lookup first: whether there is anything to read at all is what the other two rest on + // the site lookup first, since the store answer is no use on its own if (site.status === 'rejected') throw site.reason; if (onSourceBus.status === 'rejected') throw onSourceBus.reason; diff --git a/src/storage/site.js b/src/storage/site.js index bfe493ac..da0be002 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -16,10 +16,10 @@ const NO_SITE = { exists: false, head: undefined }; /** * Asks the config service whether a site exists, and reads its head.html from the same answer. * - * The pipeline scope carries the code bus object the delivery pipeline renders into every page of - * the site. Reading it here rather than from `{ref}--{site}--{org}.aem.page/head.html` answers for - * a site behind Helix authentication, which refuses that path without a site token. The admin - * scope would answer existence too, and carries the site's CDN token and API key metadata with it. + * The pipeline scope has the code bus object the delivery pipeline renders into every page of the + * site. A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` to a + * request with no site token, and the config service does not. The admin scope answers existence + * too, and its answer has the site's CDN token and API key metadata. * * Throws on any refusal but a 404, which is the only status that means there is no such site. A * ref that was never built exists and has no head.html, which is a 200 with an empty head. diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index 7d4f98ea..62d113fd 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -16,10 +16,14 @@ const UPGRADE_HEADER = 'x-api-upgrade-available'; /** * Asks `/ping` whether a site is on the source bus. * - * An answer without the header is legacy: helix-admin sets it when config resolution succeeded and - * named the API, and a Fastly edge dictionary sets it for a site being moved onto the new API - * ahead of its content. A probe that cannot answer throws, so the caller refuses with the cause - * rather than picking a store. + * The header is the answer, and da-nx checks the same header for presence in `isHlx6`. It is read + * ahead of the status, since a Fastly edge dictionary sets it in front of an origin that may be + * rate limited or erroring. A refusal without it is no answer at all. Reading a refusal as legacy + * would send a source-bus write to da-admin, where nothing serves it back, so the read fails. + * + * One answer is ambiguous, and this worker cannot resolve it. helix-admin sets the header from the + * site's content source and swallows a config service failure, so a 200 with no header is either a + * legacy site or an origin that could not resolve one. * * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes * @param {Object} daCtx @@ -32,5 +36,8 @@ export default async function isSourceBus(env, daCtx) { const url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); - return response.headers.get(UPGRADE_HEADER) !== null; + + if (response.headers.get(UPGRADE_HEADER) !== null) return true; + if (!response.ok) throw new Error(`/ping answered ${response.status}`); + return false; } diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index fc448600..c4732a1b 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,8 +20,8 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the two lookups answer together: `exists` comes from the config service, `onSourceBus` -// from /ping +// what the two lookups answer between them: `exists` comes from the config service, +// `onSourceBus` from /ping const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -882,7 +882,7 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // whether there is a site to read at all is the question the other two rest on + // both failed, and the store answer is no use on its own it('reports the failed site lookup when the probe failed with it', async () => { const { daSourceGet, env } = await build({ site: undefined, @@ -931,8 +931,8 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // one read of the config service carries both the existence answer and head.html, so a page - // that needs the head pays for no second read + // one read of the config service has both the existence answer and head.html, so a page that + // needs the head pays for no second read it('reads the config service once for a page', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 45a8349f..44a86809 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -70,7 +70,7 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - // a write asks which store, and nothing else, so a call here is the regression + // a write asks which store and nothing else, so anything reaching this mock is the bug '../../src/storage/site.js': { default: async () => { seen.lookups += 1; diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 6f61b3fb..1af6db51 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -36,7 +36,7 @@ const stubFetch = (respond) => { }; const STYLESHEET = ''; -// the pipeline scope carries the code bus object, under a lastModified the delivery pipeline reads +// the pipeline scope has the code bus object, with a lastModified the delivery pipeline reads const withHead = (html) => () => new Response( JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), { status: 200 }, @@ -67,7 +67,7 @@ describe('getSite', () => { assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=pipeline'); }); - // the code bus holds one head.html per ref, so a branch gets its own + // the code bus has one head.html per ref, so a branch gets its own it('names the ref the request came in on', async () => { stubFetch(found); @@ -111,7 +111,7 @@ describe('getSite', () => { }); // the admin scope answers with the site's CDN token and its API key metadata, and one read - // covers both questions this asks + // is enough for both questions this asks it('reads one scope, and not the admin one', async () => { stubFetch(found); From bd5bc3509cb0d06ff298d9723fa7bb09f5ff748f Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:48:16 +0200 Subject: [PATCH 15/49] test: a save is refused when the config service cannot answer red. /ping answers 200 with no header for a source-bus site in exactly that window, since helix-admin reads the same config and swallows the failure. --- test/routes/source-write.test.js | 64 ++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 44a86809..c7fa1225 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -31,8 +31,9 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201, busError } = overrides; + const { status = 201, busError, lookupError } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; + const exists = 'exists' in overrides ? overrides.exists : true; const seen = { bus: [], legacy: [], lookups: 0, probes: 0, order: [], }; @@ -70,12 +71,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - // a write asks which store and nothing else, so anything reaching this mock is the bug '../../src/storage/site.js': { default: async () => { seen.lookups += 1; seen.order.push('lookup'); - return { exists: true, head: undefined }; + if (lookupError) throw lookupError; + return { exists, head: undefined }; }, }, '../../src/storage/source-bus.js': { @@ -175,14 +176,60 @@ describe('writing to the store that holds the site', () => { }); }); - // whether the site exists changes nothing about where a write goes, and a read of the same path - // answers 404 first, so the editor cannot reach this state with a site that is not there + // a config service that cannot answer is the window where /ping answers 200 with no header for + // a source-bus site: helix-admin sets the header off the same config and swallows the failure. + // so a read that cannot be made is a write that cannot be placed + describe('when the config service cannot answer', () => { + const dead = () => new TypeError('fetch failed'); + + it('is refused with 503 and touches neither store', async () => { + const { res, seen } = await post({ lookupError: dead() }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 0); + }); + + it('asks the caller to retry', async () => { + const { res } = await post({ lookupError: dead() }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + it('names the failed lookup in x-error', async () => { + const { res } = await post({ lookupError: dead() }); + + assert.match(res.headers.get('x-error'), /site lookup failed/); + }); + + // the probe answered, and it is the answer a save cannot be made without + it('is refused even when /ping said legacy', async () => { + const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.legacy.length, 0); + }); + }); + + // a 404 says there is no AEM site config, not that the DA org and site are bogus. the service + // answered, so the store is known, and refusing here would stop saving on a DA-only site + describe('a site the config service does not know', () => { + it('is written to da-admin all the same', async () => { + const { res, seen } = await post({ exists: false }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(seen.bus.length, 0); + }); + }); + describe('what a write asks about the site', () => { - it('asks which store, and not whether the site exists', async () => { + it('asks both lookups, and reaches the store after them', async () => { const { res, seen } = await post({}); assert.strictEqual(seen.probes, 1); - assert.strictEqual(seen.lookups, 0); + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.order[seen.order.length - 1], 'store'); assert.strictEqual(res.status, 201); }); }); @@ -274,7 +321,8 @@ describe('writing to the store that holds the site', () => { it('happens before anything is sent to a store', async () => { const { seen } = await post({}); - assert.deepStrictEqual(seen.order, ['probe', 'store']); + assert.deepStrictEqual(seen.order.slice(-1), ['store']); + assert.ok(seen.order.includes('probe')); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { From a36dab8bc0c5d38c515e22c812e3ea1915325187 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:49:54 +0200 Subject: [PATCH 16/49] test: a save refused on the site lookup says the store is undetermined red. it answered with the store-did-not-answer text, and no store was asked. --- src/routes/da-admin.js | 20 +++++++++++++++----- test/index.test.js | 2 ++ test/routes/da-admin.test.js | 4 +++- test/routes/source-write.test.js | 8 ++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 138ea4f7..e2593a78 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -312,16 +312,26 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes - const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); - - if (onSourceBus) { + // the payload is settled, so the only question left is where it goes. /ping answers that, and + // the config service is read alongside it because helix-admin sets the /ping header off the + // same config and swallows a failure reading it: while the config service is down, a + // source-bus site answers 200 with no header and reads as legacy. so a config service that + // cannot answer refuses the save rather than misplacing it in da-admin, where nothing serves + // it back. a 404 is an answer, and it means no AEM site config rather than no DA site + const [site, onSourceBus] = await Promise.allSettled([ + reach(SITE_LOOKUP, () => getSite(env, daCtx)), + reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), + ]); + if (site.status === 'rejected') throw site.reason; + if (onSourceBus.status === 'rejected') throw onSourceBus.reason; + + if (onSourceBus.value) { console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); return post405(SOURCE_BUS_READ_ONLY_MESSAGE); } // da-admin takes the document as a `data` form part - const store = getStore(env, daCtx, onSourceBus); + const store = getStore(env, daCtx, onSourceBus.value); const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); diff --git a/test/index.test.js b/test/index.test.js index 0bdacba0..aa9c77d7 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -190,6 +190,8 @@ describe('worker fetch handler', () => { describe('a refused write on a source-bus site', () => { const busWorker = async () => (await esmock('../src/index.js', READ_HANDLER_MOCKS, { '../src/storage/source-bus.js': { default: async () => true }, + // a write reads both lookups, and this env carries no config service to reach + '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined }) }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index a8e30904..fe7271ca 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -460,9 +460,11 @@ describe('daSourcePost', () => { await write('lookedupeach', env); await write('lookedupeach', env); - assert.deepStrictEqual(asked, [ + assert.deepStrictEqual(asked.sort(), [ 'https://admin.hlx.page/ping/org/lookedupeach', 'https://admin.hlx.page/ping/org/lookedupeach', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index c7fa1225..29da5cc6 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -202,6 +202,14 @@ describe('writing to the store that holds the site', () => { assert.match(res.headers.get('x-error'), /site lookup failed/); }); + // UES embeds this verbatim, and no store was asked: where the document goes is what could not + // be worked out + it('says the store could not be determined, not that it did not answer', async () => { + const { res } = await post({ lookupError: dead() }); + + assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); + }); + // the probe answered, and it is the answer a save cannot be made without it('is refused even when /ping said legacy', async () => { const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); From 47d66fa5ef0806e1374a27fddff954e1cc7813a2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:53:14 +0200 Subject: [PATCH 17/49] fix: refuse a save when the site lookup cannot answer the store answer comes from the same config, so an outage that hides one hides the other. a wrong store cannot be walked back from, and the 503 now names the destination as undetermined rather than the store as unreachable. --- src/routes/da-admin.js | 17 ++++++++++------- src/storage/source-bus.js | 4 ---- test/index.test.js | 2 +- test/routes/source-write.test.js | 13 ++++++------- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index e2593a78..85546b77 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -62,7 +62,12 @@ const UNREACHABLE_HTML = { [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; -const UNREACHABLE_TEXT = { [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; +// a write asks both lookups and reaches no store without them, so either one failing leaves the +// destination undetermined rather than unreachable +const UNREACHABLE_TEXT = { + [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, + [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, +}; /** * Only an upstream that could not be reached is retryable. Anything else reaches the worker @@ -312,12 +317,10 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes. /ping answers that, and - // the config service is read alongside it because helix-admin sets the /ping header off the - // same config and swallows a failure reading it: while the config service is down, a - // source-bus site answers 200 with no header and reads as legacy. so a config service that - // cannot answer refuses the save rather than misplacing it in da-admin, where nothing serves - // it back. a 404 is an answer, and it means no AEM site config rather than no DA site + // the payload is settled, so the only question left is where it goes. both lookups have to + // answer: the store answer comes from the config the site lookup reads, and a wrong store + // cannot be walked back from. a 404 is an answer, and it means no AEM site config rather + // than no DA site const [site, onSourceBus] = await Promise.allSettled([ reach(SITE_LOOKUP, () => getSite(env, daCtx)), reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index 62d113fd..a908e2af 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -21,10 +21,6 @@ const UPGRADE_HEADER = 'x-api-upgrade-available'; * rate limited or erroring. A refusal without it is no answer at all. Reading a refusal as legacy * would send a source-bus write to da-admin, where nothing serves it back, so the read fails. * - * One answer is ambiguous, and this worker cannot resolve it. helix-admin sets the header from the - * site's content source and swallows a config service failure, so a 200 with no header is either a - * legacy site or an origin that could not resolve one. - * * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes * @param {Object} daCtx * @returns {Promise} diff --git a/test/index.test.js b/test/index.test.js index aa9c77d7..66bb54c0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -190,7 +190,7 @@ describe('worker fetch handler', () => { describe('a refused write on a source-bus site', () => { const busWorker = async () => (await esmock('../src/index.js', READ_HANDLER_MOCKS, { '../src/storage/source-bus.js': { default: async () => true }, - // a write reads both lookups, and this env carries no config service to reach + // a write reads both lookups, and this env names no config service '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined }) }, })).default; diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 29da5cc6..3cdf8fa9 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -176,9 +176,8 @@ describe('writing to the store that holds the site', () => { }); }); - // a config service that cannot answer is the window where /ping answers 200 with no header for - // a source-bus site: helix-admin sets the header off the same config and swallows the failure. - // so a read that cannot be made is a write that cannot be placed + // the store answer comes from the config this read asks for, so a config service that cannot + // answer puts it in doubt as well describe('when the config service cannot answer', () => { const dead = () => new TypeError('fetch failed'); @@ -202,15 +201,15 @@ describe('writing to the store that holds the site', () => { assert.match(res.headers.get('x-error'), /site lookup failed/); }); - // UES embeds this verbatim, and no store was asked: where the document goes is what could not - // be worked out + // UES embeds this verbatim, and no store was asked, so "did not answer" would name the wrong + // failure it('says the store could not be determined, not that it did not answer', async () => { const { res } = await post({ lookupError: dead() }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - // the probe answered, and it is the answer a save cannot be made without + // the probe answered legacy, and that answer is the one in doubt while the config is down it('is refused even when /ping said legacy', async () => { const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); @@ -220,7 +219,7 @@ describe('writing to the store that holds the site', () => { }); // a 404 says there is no AEM site config, not that the DA org and site are bogus. the service - // answered, so the store is known, and refusing here would stop saving on a DA-only site + // answered, so nothing is in doubt, and refusing here would stop saving on a DA-only site describe('a site the config service does not know', () => { it('is written to da-admin all the same', async () => { const { res, seen } = await post({ exists: false }); From b490ca3a4561605d3568d44bd03fb58301af4459 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 13 Aug 2026 21:21:58 +0200 Subject: [PATCH 18/49] test: the store comes from the config service content source, not /ping --- test/storage/source-bus.test.js | 155 +++++++++++++++++--------------- 1 file changed, 82 insertions(+), 73 deletions(-) diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js index f3d81b10..e0397ea8 100644 --- a/test/storage/source-bus.test.js +++ b/test/storage/source-bus.test.js @@ -15,7 +15,11 @@ import assert from 'assert'; const { default: isSourceBus } = await import('../../src/storage/source-bus.js'); -const env = { AEM_API: 'https://api.aem.live', HLX_ADMIN: 'https://admin.hlx.page' }; +const env = { + AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', +}; const daCtx = (over = {}) => ({ org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, @@ -31,8 +35,13 @@ const stubFetch = (respond) => { }; }; -const ping = (headers = {}, status = 200) => new Response('', { status, headers }); -const upgraded = () => ping({ 'x-api-upgrade-available': 'true' }); +const config = (source, status = 200) => new Response( + JSON.stringify({ content: { source, contentBusId: 'abc' } }), + { status, headers: { 'content-type': 'application/json' } }, +); + +const onBus = () => config({ type: 'markup', url: 'https://api.aem.live/org/sites/site/source' }); +const onDa = () => config({ type: 'markup', url: 'https://content.da.live/org/site/' }); describe('isSourceBus', () => { afterEach(() => { @@ -40,36 +49,37 @@ describe('isSourceBus', () => { }); describe('the request it makes', () => { - it('asks /ping on the admin host', async () => { - stubFetch(upgraded); + it('asks the config service for the admin scope', async () => { + stubFetch(onBus); await isSourceBus(env, daCtx()); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); }); - it('takes the admin host from env, so stage can point elsewhere', async () => { - stubFetch(upgraded); + it('takes the config service from env, so dev can point at the shim', async () => { + stubFetch(onBus); - await isSourceBus({ ...env, HLX_ADMIN: 'https://admin.stage.example' }, daCtx()); + await isSourceBus({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - assert.strictEqual(calls[0].url, 'https://admin.stage.example/ping/org/site'); + assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); }); - // both stores read one config service and the source is per site, so the branch cannot change - // the answer - it('does not vary by ref', async () => { - stubFetch(upgraded); + it('sends the shared secret, which the config service requires', async () => { + stubFetch(onBus); - await isSourceBus(env, daCtx({ ref: 'branch' })); + await isSourceBus(env, daCtx()); - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + const headers = new Headers(calls[0].init.headers); + assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); + assert.strictEqual(headers.get('x-backend-type'), 'aws'); }); - // /ping is exempt from authorize() in helix-admin and answers the same with or without a token - it('sends no token, since /ping does not read one', async () => { - stubFetch(upgraded); + // an author's IMS token is refused by the config service, and sending it as well would only + // hand a user credential to a service that has no use for it + it('sends no author token', async () => { + stubFetch(onBus); await isSourceBus(env, daCtx()); @@ -77,47 +87,62 @@ describe('isSourceBus', () => { }); it('gives up rather than hanging', async () => { - stubFetch(upgraded); + stubFetch(onBus); await isSourceBus(env, daCtx()); - assert.ok(calls[0].init.signal, 'the probe carries an abort signal'); + assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); }); }); - describe('when /ping says the site is upgraded', () => { + // the source is per site, so the ref in the url is only there to address the site + describe('when the content source is on the source bus', () => { it('answers true', async () => { - stubFetch(upgraded); + stubFetch(onBus); assert.strictEqual(await isSourceBus(env, daCtx()), true); }); - // presence, not value: da-nx tests the same header with `!== null` (nx2/utils/api.js, - // isHlx6), and two clients reading it differently would split one site across two stores - ['false', '', 'TRUE'].forEach((value) => { - it(`counts any value, including ${JSON.stringify(value)}`, async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': value })); + it('answers the same for any ref', async () => { + stubFetch(onBus); - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); + assert.strictEqual(await isSourceBus(env, daCtx({ ref: 'branch' })), true); }); - // the edge sets the header from its dictionary, so a rate-limited or erroring origin behind - // it does not make an enrolled site legacy - [429, 500, 503].forEach((status) => { - it(`counts it on a ${status}, since the header is what carries the answer`, async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, status)); + // helix-admin tests the same prefix, so two clients cannot split one site across two stores + it('reads the url, not the type, since both stores are markup', async () => { + stubFetch(() => config({ type: 'markup', url: 'https://api.aem.live/o/sites/s/source' })); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + + it('takes the source bus origin from env', async () => { + stubFetch(() => config({ type: 'markup', url: 'https://api.stage.example/o/sites/s/source' })); + + assert.strictEqual(await isSourceBus({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()), true); + }); + }); - assert.strictEqual(await isSourceBus(env, daCtx()), true); + describe('when the content source is da-admin', () => { + it('answers false', async () => { + stubFetch(onDa); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + }); + + ['https://content.da.live/org/site/', 'https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { + it(`answers false for ${new URL(url).host}`, async () => { + stubFetch(() => config({ type: 'markup', url })); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); }); }); }); - // an answered 200 without the header is the legacy answer. helix-admin sets the header from - // the site's content source, and /ping is 200 for a site it routes at all - describe('when /ping says the site is legacy', () => { - it('answers false on a 200 with no header', async () => { - stubFetch(() => ping()); + // 404 is the one status that means there is no such site, and the site lookup answers that + describe('when there is no such site', () => { + it('answers false', async () => { + stubFetch(() => new Response('', { status: 404 })); assert.strictEqual(await isSourceBus(env, daCtx()), false); }); @@ -125,32 +150,29 @@ describe('isSourceBus', () => { // a refusal carries no decision, and reading it as legacy sends a source-bus write to da-admin, // where nothing serves it back - describe('when /ping refuses without the header', () => { - [404, 405, 429, 500, 503].forEach((status) => { + describe('when the config service refuses', () => { + [401, 403, 429, 500, 503].forEach((status) => { it(`throws on a ${status}`, async () => { - stubFetch(() => ping({}, status)); + stubFetch(() => new Response('', { status })); - await assert.rejects(() => isSourceBus(env, daCtx()), /404|405|429|500|503/); + await assert.rejects(() => isSourceBus(env, daCtx()), new RegExp(`${status}`)); }); }); - // the header is read first, so an enrolled site survives an origin the edge is shielding - it('answers true on a 429 that still carries the header', async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, 429)); + it('throws when the answer has no content source', async () => { + stubFetch(() => new Response(JSON.stringify({ ref: 'main' }), { status: 200 })); - assert.strictEqual(await isSourceBus(env, daCtx()), true); + await assert.rejects(() => isSourceBus(env, daCtx()), /content source/); }); - it('names the status it got', async () => { - stubFetch(() => ping({}, 503)); + it('throws when the answer is not json', async () => { + stubFetch(() => new Response('', { status: 200 })); - await assert.rejects(() => isSourceBus(env, daCtx()), /503/); + await assert.rejects(() => isSourceBus(env, daCtx())); }); }); - // an answer without the header is legacy. no answer is not an answer, and the caller refuses - // rather than picking a store on a coin flip - describe('when /ping cannot answer', () => { + describe('when the config service cannot answer', () => { // the cause reaches the caller, which reports it on the 503 as `x-error`. swallowing it here // would leave a timeout and a dropped connection indistinguishable it('lets the failure through', async () => { @@ -161,8 +183,8 @@ describe('isSourceBus', () => { await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); }); - it('lets it through when HLX_ADMIN is unusable, without asking', async () => { - stubFetch(upgraded); + it('lets it through when the config service is unusable, without asking', async () => { + stubFetch(onBus); await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); assert.strictEqual(calls.length, 0); @@ -170,7 +192,7 @@ describe('isSourceBus', () => { // the distinction the caller acts on: false is a store, a failure is no store it('is distinguishable from a legacy answer', async () => { - stubFetch(() => ping()); + stubFetch(onDa); assert.strictEqual(await isSourceBus(env, daCtx()), false); stubFetch(() => { @@ -181,7 +203,7 @@ describe('isSourceBus', () => { }); describe('when there is no site to ask about', () => { - // either one missing is enough: a half-parsed request would otherwise build a ping url with + // either one missing is enough: a half-parsed request would otherwise build a config url with // "undefined" in it [ ['neither', { org: undefined, site: undefined }], @@ -191,24 +213,11 @@ describe('isSourceBus', () => { ['an empty site', { site: '' }], ].forEach(([what, over]) => { it(`answers false without making a request: ${what}`, async () => { - stubFetch(upgraded); + stubFetch(onBus); assert.strictEqual(await isSourceBus(env, daCtx(over)), false); assert.strictEqual(calls.length, 0); }); }); }); - - // nothing is remembered between calls, so an enrolment takes effect on the next read and a - // config blip cannot pin a stale answer - it('probes every time it is asked', async () => { - let enrolled = false; - stubFetch(() => (enrolled ? upgraded() : ping())); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - enrolled = true; - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - assert.strictEqual(calls.length, 2); - }); }); From c2162a47f5c179407bf0c61921cec129725c5e9c Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 13 Aug 2026 21:26:09 +0200 Subject: [PATCH 19/49] fix: take the store from the config service content source, and drop admin.hlx.page --- README.md | 4 ++-- dev/lookup-shim.js | 33 ++++++++++----------------------- src/storage/source-bus.js | 36 +++++++++++++++++++++--------------- test/routes/da-admin.test.js | 26 +++++++++++++------------- wrangler.toml | 6 +++--- 5 files changed, 49 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index d3450162..98864586 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -A read looks the site up twice: config.aem.page says whether it exists and carries its head.html, and admin.hlx.page/ping says which store holds it. The config service needs a shared secret, so local development points both at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. +A read asks config.aem.page twice: the pipeline scope says whether the site exists and has its head.html, and the admin scope carries the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. To run da-universal locally: @@ -24,7 +24,7 @@ To run da-universal locally: Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. -Anyone who has the shared secret can point `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page --var HLX_ADMIN:https://admin.hlx.page`. +with the shared secret, use `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. ### Run on stage diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js index f73852f4..286933a5 100644 --- a/dev/lookup-shim.js +++ b/dev/lookup-shim.js @@ -10,49 +10,36 @@ * governing permissions and limitations under the License. */ -// stands in for the two lookups a read makes: config.aem.page, which needs a shared secret, and -// admin.hlx.page/ping. A site in SITES exists, and one with a source url on api.aem.live is -// source-bus +// stands in for config.aem.page, which needs a shared secret. a read asks it twice: the pipeline +// scope for whether the site exists and its head.html, the admin scope for the content source. a +// site in SITES exists, and one with a source url on api.aem.live is source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; -const SOURCE_BUS = 'https://api.aem.live/'; - // what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with const HEAD_HTML = '\n\n'; -/** - * Answers /ping the way helix-admin does: the header is set when the site's content source is the - * source bus, and a site it cannot resolve is answered 200 with no header rather than 404. - */ -function ping(org, site) { - const headers = SITES[`${org}/${site}`]?.startsWith(SOURCE_BUS) - ? { 'x-api-upgrade-available': 'true' } - : {}; - return new Response('', { status: 200, headers }); -} - export default { async fetch(req) { const url = new URL(req.url); - if (url.pathname.startsWith('/ping/')) { - const [, , pingOrg, pingSite] = url.pathname.split('/'); - return ping(pingOrg, pingSite); - } - const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); if (!org || !site) { return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); } - if (!SITES[`${org}/${site}`]) { + const source = SITES[`${org}/${site}`]; + if (!source) { return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); } + // both stores are `type: markup`, so only the url separates them + const answer = url.searchParams.get('scope') === 'admin' + ? { content: { source: { type: 'markup', url: source } } } + : { head: { html: HEAD_HTML } }; const body = JSON.stringify({ - ref, site, org, head: { html: HEAD_HTML }, + ref, site, org, ...answer, }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index a908e2af..59b63af2 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -11,29 +11,35 @@ */ const TIMEOUT_MS = 5 * 1000; -const UPGRADE_HEADER = 'x-api-upgrade-available'; /** - * Asks `/ping` whether a site is on the source bus. + * Asks the config service which store holds a site's content based on `content.source` + * A 404 means there is no such site, which the site lookup reports. Any other refusal is no + * answer at all: reading it as legacy would send a source-bus write to da-admin * - * The header is the answer, and da-nx checks the same header for presence in `isHlx6`. It is read - * ahead of the status, since a Fastly edge dictionary sets it in front of an origin that may be - * rate limited or erroring. A refusal without it is no answer at all. Reading a refusal as legacy - * would send a source-bus write to da-admin, where nothing serves it back, so the read fails. - * - * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus * @param {Object} daCtx * @returns {Promise} */ export default async function isSourceBus(env, daCtx) { - const { org, site } = daCtx; - // an unparseable hostname leaves org and site undefined, and there is no site to ask about + const { org, site, ref } = daCtx; if (!org || !site) return false; - const url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); - const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (response.status === 404) return false; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); - if (response.headers.get(UPGRADE_HEADER) !== null) return true; - if (!response.ok) throw new Error(`/ping answered ${response.status}`); - return false; + const { content } = await response.json(); + const source = content?.source?.url; + if (!source) throw new Error('the config service named no content source'); + return source.startsWith(`${env.AEM_API}/`); } diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index fe7271ca..30200c77 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -34,7 +34,6 @@ const recorder = () => { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', HLX_CONFIG_SERVICE: 'https://config.aem.page', - HLX_ADMIN: 'https://admin.hlx.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -45,21 +44,22 @@ const recorder = () => { return { env, fetched }; }; -// stands in for the two lookups the routes make: config.aem.page for whether the site exists, -// admin.hlx.page/ping for which store holds it. Answers that any site exists; `upgraded` lists -// the `org/site` keys /ping reports as enrolled +// stands in for the two config service reads the routes make: the pipeline scope for whether the +// site exists and its head.html, the admin scope for which store holds it. Answers that any site +// exists; `upgraded` lists the `org/site` keys whose content source is the source bus const stubLookups = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const { pathname } = new URL(url); - if (pathname.startsWith('/ping/')) { - const [, , org, site] = pathname.split('/'); - const headers = upgraded.includes(`${org}/${site}`) - ? { 'x-api-upgrade-available': 'true' } - : {}; - return new Response('', { status: 200, headers }); + const { pathname, searchParams } = new URL(url); + const [, site, org] = (pathname.split('/')[1] ?? '').split('--'); + if (searchParams.get('scope') === 'admin') { + const source = upgraded.includes(`${org}/${site}`) + ? `https://api.aem.live/${org}/sites/${site}/source` + : `https://content.da.live/${org}/${site}/`; + const body = JSON.stringify({ content: { source: { type: 'markup', url: source } } }); + return new Response(body, { status: 200 }); } const body = JSON.stringify({ head: { html: '' } }); return new Response(body, { status: 200 }); @@ -461,8 +461,8 @@ describe('daSourcePost', () => { await write('lookedupeach', env); assert.deepStrictEqual(asked.sort(), [ - 'https://admin.hlx.page/ping/org/lookedupeach', - 'https://admin.hlx.page/ping/org/lookedupeach', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); diff --git a/wrangler.toml b/wrangler.toml index def67d4c..39d66735 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,7 +2,7 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page", HLX_CONFIG_SERVICE = "https://config.aem.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } @@ -10,14 +10,14 @@ secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } port = 4712 [env.dev] -vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "http://localhost:4713", HLX_CONFIG_SERVICE = "http://localhost:4713" } +vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] # the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker # with no token and every lookup 401s. running against the stand-in warns that it is missing secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage] -vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page", HLX_CONFIG_SERVICE = "https://config.aem.page" } +vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } From e3e9ecfeab0e77c93ed54c9e486f6731bde1668e Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 13 Aug 2026 21:56:37 +0200 Subject: [PATCH 20/49] chore: name the two config scopes, not /ping, in the comments and tests --- src/routes/da-admin.js | 4 ++-- test/routes/source-read.test.js | 22 +++++++++++----------- test/routes/source-write.test.js | 7 ++++--- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 85546b77..16be3429 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -134,8 +134,8 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // both lookups go out together: config.aem.page says whether the site exists and what its - // head.html is, /ping says which store holds it + // both lookups go out together: the pipeline scope says whether the site exists and what its + // head.html is, the admin scope says which store holds it const [site, onSourceBus] = await Promise.allSettled([ reach(SITE_LOOKUP, () => getSite(env, daCtx)), reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index c4732a1b..d56cffa1 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,8 +20,8 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the two lookups answer between them: `exists` comes from the config service, -// `onSourceBus` from /ping +// what the two lookups answer between them: `exists` comes from the pipeline scope, +// `onSourceBus` from the admin scope const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -48,7 +48,7 @@ const build = async (overrides = {}) => { busError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, pings: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, storeLookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -77,7 +77,7 @@ const build = async (overrides = {}) => { }, '../../src/storage/source-bus.js': { default: async () => { - seen.pings += 1; + seen.storeLookups += 1; if (busError) throw busError; return site !== undefined && site.onSourceBus; }, @@ -114,7 +114,7 @@ const build = async (overrides = {}) => { return { ...mod, env, seen }; }; -describe('when /ping cannot say which store holds the site', () => { +describe('when the store lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); @@ -931,16 +931,16 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // one read of the config service has both the existence answer and head.html, so a page that - // needs the head pays for no second read - it('reads the config service once for a page', async () => { + // the pipeline scope answers existence and head.html together, so a page that needs the head + // pays for no third read + it('reads each lookup once for a page', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.pings, 1); + assert.strictEqual(seen.storeLookups, 1); }); // nothing composes an image, and the head that arrives with the existence answer is dropped @@ -951,7 +951,7 @@ describe('reading from the store that holds the site', () => { await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.pings, 1); + assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); @@ -962,7 +962,7 @@ describe('reading from the store that holds the site', () => { await daSourceHead({ env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.pings, 1); + assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 3cdf8fa9..fde52b60 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -19,7 +19,7 @@ import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../.. const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; -// what /ping answers +// what the store lookup answers const SOURCE_BUS = true; const LEGACY_STORE = false; @@ -209,8 +209,9 @@ describe('writing to the store that holds the site', () => { assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - // the probe answered legacy, and that answer is the one in doubt while the config is down - it('is refused even when /ping said legacy', async () => { + // the store lookup answered legacy, and that answer is the one in doubt while the site + // lookup is down + it('is refused even when the store lookup said legacy', async () => { const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); assert.strictEqual(res.status, 503); From dc0fcec9cb2c1938fc7922ab414461160d6e2a72 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:21:54 +0200 Subject: [PATCH 21/49] test: a write asks the store lookup only --- test/routes/da-admin.test.js | 2 -- test/routes/source-write.test.js | 53 ++++---------------------------- 2 files changed, 6 insertions(+), 49 deletions(-) diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 30200c77..10669370 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -463,8 +463,6 @@ describe('daSourcePost', () => { assert.deepStrictEqual(asked.sort(), [ 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index fde52b60..984dcc53 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -176,51 +176,8 @@ describe('writing to the store that holds the site', () => { }); }); - // the store answer comes from the config this read asks for, so a config service that cannot - // answer puts it in doubt as well - describe('when the config service cannot answer', () => { - const dead = () => new TypeError('fetch failed'); - - it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ lookupError: dead() }); - - assert.strictEqual(res.status, 503); - assert.strictEqual(seen.bus.length, 0); - assert.strictEqual(seen.legacy.length, 0); - }); - - it('asks the caller to retry', async () => { - const { res } = await post({ lookupError: dead() }); - - assert.ok(Number(res.headers.get('Retry-After')) > 0); - }); - - it('names the failed lookup in x-error', async () => { - const { res } = await post({ lookupError: dead() }); - - assert.match(res.headers.get('x-error'), /site lookup failed/); - }); - - // UES embeds this verbatim, and no store was asked, so "did not answer" would name the wrong - // failure - it('says the store could not be determined, not that it did not answer', async () => { - const { res } = await post({ lookupError: dead() }); - - assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); - }); - - // the store lookup answered legacy, and that answer is the one in doubt while the site - // lookup is down - it('is refused even when the store lookup said legacy', async () => { - const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); - - assert.strictEqual(res.status, 503); - assert.strictEqual(seen.legacy.length, 0); - }); - }); - - // a 404 says there is no AEM site config, not that the DA org and site are bogus. the service - // answered, so nothing is in doubt, and refusing here would stop saving on a DA-only site + // the store lookup answers 404 for a site the config service does not know, and 404 is an + // answer: it means no AEM site config rather than no DA site, so the write goes to da-admin describe('a site the config service does not know', () => { it('is written to da-admin all the same', async () => { const { res, seen } = await post({ exists: false }); @@ -232,11 +189,13 @@ describe('writing to the store that holds the site', () => { }); describe('what a write asks about the site', () => { - it('asks both lookups, and reaches the store after them', async () => { + // the site lookup and the store lookup read the same service, and a write never reads the + // pipeline scope's answer, so asking it twice buys nothing + it('asks the store lookup only, and reaches the store after it', async () => { const { res, seen } = await post({}); assert.strictEqual(seen.probes, 1); - assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.lookups, 0); assert.strictEqual(seen.order[seen.order.length - 1], 'store'); assert.strictEqual(res.status, 201); }); From 11ae4583309c98898e44abe65dd4b454f02955d1 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:22:30 +0200 Subject: [PATCH 22/49] fix: ask one lookup on a write, since the write never reads the other's answer --- src/routes/da-admin.js | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 16be3429..21511fd0 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -62,10 +62,9 @@ const UNREACHABLE_HTML = { [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; -// a write asks both lookups and reaches no store without them, so either one failing leaves the -// destination undetermined rather than unreachable +// a write reaches no store until the lookup answers, so a failed lookup leaves the destination +// undetermined rather than unreachable const UNREACHABLE_TEXT = { - [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, }; @@ -317,24 +316,18 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes. both lookups have to - // answer: the store answer comes from the config the site lookup reads, and a wrong store - // cannot be walked back from. a 404 is an answer, and it means no AEM site config rather - // than no DA site - const [site, onSourceBus] = await Promise.allSettled([ - reach(SITE_LOOKUP, () => getSite(env, daCtx)), - reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), - ]); - if (site.status === 'rejected') throw site.reason; - if (onSourceBus.status === 'rejected') throw onSourceBus.reason; - - if (onSourceBus.value) { + // the payload is settled, so the only question left is where it goes + // a 404 is an answer, and it means no AEM site config rather than no DA + // site, so the write goes to da-admin + const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); + + if (onSourceBus) { console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); return post405(SOURCE_BUS_READ_ONLY_MESSAGE); } // da-admin takes the document as a `data` form part - const store = getStore(env, daCtx, onSourceBus.value); + const store = getStore(env, daCtx, onSourceBus); const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); From 46b5c5f5454c606c20f93f9de741380222ec418d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:31:14 +0200 Subject: [PATCH 23/49] test: drop the knobs a write no longer reaches, and name what the assertion proves --- test/routes/da-admin.test.js | 2 +- test/routes/source-write.test.js | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 10669370..29cc9159 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -453,7 +453,7 @@ describe('daSourcePost', () => { // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes // effect on the next one - it('looks the site up once per write', async () => { + it('looks the store up once per write, and asks nothing else', async () => { const asked = stubLookups(['org/lookedupeach']); const { env } = recorder(); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 984dcc53..15ed4495 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -31,9 +31,8 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201, busError, lookupError } = overrides; + const { status = 201, busError } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; - const exists = 'exists' in overrides ? overrides.exists : true; const seen = { bus: [], legacy: [], lookups: 0, probes: 0, order: [], }; @@ -71,12 +70,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { + // still mocked, so a write that reached for it would be counted rather than hitting the network '../../src/storage/site.js': { default: async () => { seen.lookups += 1; seen.order.push('lookup'); - if (lookupError) throw lookupError; - return { exists, head: undefined }; + return { exists: true, head: undefined }; }, }, '../../src/storage/source-bus.js': { @@ -176,11 +175,11 @@ describe('writing to the store that holds the site', () => { }); }); - // the store lookup answers 404 for a site the config service does not know, and 404 is an - // answer: it means no AEM site config rather than no DA site, so the write goes to da-admin + // the store lookup answers false for a site the config service does not know, since a 404 means + // no AEM site config rather than no DA site describe('a site the config service does not know', () => { it('is written to da-admin all the same', async () => { - const { res, seen } = await post({ exists: false }); + const { res, seen } = await post({ site: LEGACY_STORE }); assert.strictEqual(res.status, 201); assert.strictEqual(seen.legacy.length, 1); From 81c566ccd7587251dafe2c1d9ff3b4608a4139fd Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:36:17 +0200 Subject: [PATCH 24/49] test: drop the site lookup stub from the write case, which no longer reaches it --- test/index.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index 66bb54c0..0bdacba0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -190,8 +190,6 @@ describe('worker fetch handler', () => { describe('a refused write on a source-bus site', () => { const busWorker = async () => (await esmock('../src/index.js', READ_HANDLER_MOCKS, { '../src/storage/source-bus.js': { default: async () => true }, - // a write reads both lookups, and this env names no config service - '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined }) }, })).default; const uePost = (origin) => { From c646d216653d4c452e0fbecc55fb2eecfed9c5dc Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:07:59 +0200 Subject: [PATCH 25/49] test: cover the cookie route, and point the exchange at the source bus api --- test/routes/cookie.test.js | 152 +++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 test/routes/cookie.test.js diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js new file mode 100644 index 00000000..e0ad43f5 --- /dev/null +++ b/test/routes/cookie.test.js @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; + +const { getCookie } = await import('../../src/routes/cookie.js'); + +const env = { AEM_API: 'https://api.aem.live' }; + +const daCtx = (over = {}) => ({ org: 'org', site: 'site', ...over }); + +const req = (over = {}) => new Request('https://main--site--org.ue.da.live/gimme_cookie', { + headers: { + Origin: over.origin ?? 'https://da.live', + ...(over.noAuth ? {} : { Authorization: 'Bearer thetoken' }), + }, +}); + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(); + }; +}; + +const noAuthNeeded = () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); +const mints = () => new Response( + JSON.stringify({ siteToken: 'sitetok', siteTokenExpiry: Date.now() + 3600_000 }), + { status: 200, headers: { 'content-type': 'application/json' } }, +); + +const cookies = (res) => res.headers.getSetCookie(); + +describe('getCookie', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the exchange it asks for', () => { + // one service behind two hostnames: minted in the same second, admin.hlx.page and api.aem.live + // answer a byte-identical token, so the worker asks the one it uses for everything else + it('goes to the source bus api', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://api.aem.live/auth/adobe/exchange'); + }); + + it('takes the api from env, so stage can point elsewhere', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env: { AEM_API: 'https://api.stage.example' }, daCtx: daCtx() }); + + assert.strictEqual(calls[0].url, 'https://api.stage.example/auth/adobe/exchange'); + }); + + it('sends the org, the site and the caller token', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.deepStrictEqual(JSON.parse(calls[0].init.body), { + org: 'org', site: 'site', accessToken: 'thetoken', + }); + }); + }); + + describe('what it sets', () => { + it('sets the auth cookie for a site that needs no site token', async () => { + stubFetch(noAuthNeeded); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 200); + const set = cookies(res); + assert.strictEqual(set.length, 1); + assert.ok(set[0].startsWith('auth_token=thetoken;')); + }); + + it('adds the site token for a site behind helix authentication', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + const set = cookies(res); + assert.strictEqual(set.length, 2); + assert.ok(set[1].startsWith('site_token=sitetok;')); + assert.match(set[1], /Max-Age=\d+/); + }); + + // the author is signed in either way, so a refused exchange keeps the auth cookie + it('keeps the auth cookie when the exchange refuses', async () => { + stubFetch(() => new Response('', { status: 403 })); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(cookies(res).length, 1); + }); + + it('keeps the auth cookie when the exchange cannot be reached', async () => { + stubFetch(() => { throw new TypeError('fetch failed'); }); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(cookies(res).length, 1); + }); + + it('asks for no exchange when the caller already has a site token', async () => { + stubFetch(mints); + + await getCookie({ req: req(), env, daCtx: daCtx({ siteToken: 'already' }) }); + + assert.strictEqual(calls.length, 0); + }); + }); + + describe('what it refuses', () => { + it('refuses an untrusted origin', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req({ origin: 'https://evil.example' }), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(calls.length, 0); + }); + + it('answers 401 without an Authorization header', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req({ noAuth: true }), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(calls.length, 0); + }); + }); +}); From 8c01ab9c5135fe3a728e1f05a7c7c4673c4b1fa9 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:08:34 +0200 Subject: [PATCH 26/49] fix: exchange the site token on the source bus api, from env --- src/handlers/get.js | 2 +- src/routes/cookie.js | 8 ++++---- test/routes/cookie.test.js | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/handlers/get.js b/src/handlers/get.js index 85e8f02f..df429f3d 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -21,7 +21,7 @@ export default async function getHandler({ req, env, daCtx }) { if (path.startsWith('/favicon.ico')) return get404(); if (path.startsWith('/robots.txt')) return getRobots(); - if (path.startsWith('/gimme_cookie')) return getCookie({ req, daCtx }); + if (path.startsWith('/gimme_cookie')) return getCookie({ req, env, daCtx }); const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { diff --git a/src/routes/cookie.js b/src/routes/cookie.js index 352f348f..1559ecf7 100644 --- a/src/routes/cookie.js +++ b/src/routes/cookie.js @@ -12,9 +12,9 @@ import { daResp, get401 } from '../responses/index.js'; import { isTrustedOrigin } from '../utils/constants.js'; -async function exchangeSiteToken(org, site, accessToken) { +async function exchangeSiteToken(env, org, site, accessToken) { try { - const response = await fetch('https://admin.hlx.page/auth/adobe/exchange', { + const response = await fetch(new URL('/auth/adobe/exchange', env.AEM_API), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,7 +48,7 @@ async function exchangeSiteToken(org, site, accessToken) { } } -export async function getCookie({ req, daCtx }) { +export async function getCookie({ req, env, daCtx }) { const { headers } = req; if (!isTrustedOrigin(headers.get('Origin'))) return daResp({ body: '403 Forbidden', status: 403, contentType: 'text/plain' }); @@ -67,7 +67,7 @@ export async function getCookie({ req, daCtx }) { // Try to exchange for site token if (org && site && !daCtx.siteToken) { - const siteTokenData = await exchangeSiteToken(org, site, cookieValue); + const siteTokenData = await exchangeSiteToken(env, org, site, cookieValue); if (siteTokenData) { // Calculate Max-Age based on token expiry time (siteTokenExpiry is in milliseconds) const now = Date.now(); diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js index e0ad43f5..a5a69f75 100644 --- a/test/routes/cookie.test.js +++ b/test/routes/cookie.test.js @@ -113,7 +113,9 @@ describe('getCookie', () => { }); it('keeps the auth cookie when the exchange cannot be reached', async () => { - stubFetch(() => { throw new TypeError('fetch failed'); }); + stubFetch(() => { + throw new TypeError('fetch failed'); + }); const res = await getCookie({ req: req(), env, daCtx: daCtx() }); From 708c0d188e9f5cbf93ba7854892f78c1f09a42d2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:56:06 +0200 Subject: [PATCH 27/49] test: one config read answers existence, head.html and the store --- test/storage/site.test.js | 197 +++++++++++++--------------- test/storage/source-bus.test.js | 223 -------------------------------- 2 files changed, 90 insertions(+), 330 deletions(-) delete mode 100644 test/storage/source-bus.test.js diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 1af6db51..8da98aa0 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -18,31 +18,32 @@ const { default: getSite } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', HLX_CONFIG_SERVICE: 'https://config.aem.page', - HLX_CONFIG_SERVICE_TOKEN: 'shared-token', + HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', }; const daCtx = (over = {}) => ({ - org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, + org: 'org', site: 'site', ref: 'main', ...over, }); +const HEAD = ''; + let calls; const stubFetch = (respond) => { calls = []; globalThis.fetch = async (input, init) => { calls.push({ url: input.toString(), init }); - return respond(input.toString(), init); + return respond(); }; }; -const STYLESHEET = ''; -// the pipeline scope has the code bus object, with a lastModified the delivery pipeline reads -const withHead = (html) => () => new Response( - JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), - { status: 200 }, -); -const found = withHead(STYLESHEET); -const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); +const config = (over = {}) => new Response(JSON.stringify({ + head: { html: HEAD }, + contentSource: { type: 'markup', url: 'https://content.da.live/org/site/' }, + ...over, +}), { status: 200, headers: { 'content-type': 'application/json' } }); + +const onBus = () => config({ contentSource: { type: 'markup', url: 'https://api.aem.live/org/sites/site/source' } }); describe('getSite', () => { afterEach(() => { @@ -50,8 +51,8 @@ describe('getSite', () => { }); describe('the request it makes', () => { - it('asks the config service for the pipeline-scoped config', async () => { - stubFetch(found); + it('asks the config service once, for the pipeline scope', async () => { + stubFetch(config); await getSite(env, daCtx()); @@ -59,146 +60,128 @@ describe('getSite', () => { assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); }); - it('takes the config service from env, so dev can point elsewhere', async () => { - stubFetch(found); - - await getSite({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - - assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=pipeline'); - }); - - // the code bus has one head.html per ref, so a branch gets its own - it('names the ref the request came in on', async () => { - stubFetch(found); - - await getSite(env, daCtx({ ref: 'feature' })); - - assert.match(calls[0].url, /\/feature--site--org\//); - }); - - it('sends the shared token', async () => { - stubFetch(found); + it('sends the shared secret', async () => { + stubFetch(config); await getSite(env, daCtx()); - assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); + const headers = new Headers(calls[0].init.headers); + assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); + assert.strictEqual(headers.get('x-backend-type'), 'aws'); }); - // the edge answers 400 without it, and that failure reads like a bad path - it('sends the backend type', async () => { - stubFetch(found); - - await getSite(env, daCtx()); - - assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); - }); - - // the author's token has no business at a service-to-service endpoint - it('sends no author token', async () => { - stubFetch(found); + it('gives up rather than hanging', async () => { + stubFetch(config); await getSite(env, daCtx()); - assert.strictEqual(calls[0].init.headers.Authorization, undefined); + assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); }); + }); - it('gives up rather than hanging', async () => { - stubFetch(found); + describe('what one answer carries', () => { + it('answers existence, head.html and the store together', async () => { + stubFetch(onBus); - await getSite(env, daCtx()); - - assert.ok(calls[0].init.signal); + assert.deepStrictEqual(await getSite(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: true, + }); }); - // the admin scope answers with the site's CDN token and its API key metadata, and one read - // is enough for both questions this asks - it('reads one scope, and not the admin one', async () => { - stubFetch(found); + it('reads the url, not the type, since both stores are markup', async () => { + stubFetch(config); - await getSite(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.doesNotMatch(calls[0].url, /scope=admin/); + const { onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(onSourceBus, false); }); - }); - describe('the head it answers', () => { - it('reads head.html out of the config', async () => { - stubFetch(found); + it('takes the source bus origin from env', async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url: 'https://api.stage.example/o/sites/s/source' } })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: STYLESHEET }); + const { onSourceBus } = await getSite({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); + assert.strictEqual(onSourceBus, true); }); - // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 - it('answers a site with no head.html for the ref', async () => { - stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); + ['https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { + it(`answers legacy for ${new URL(url).host}`, async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url } })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); + const { onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); }); - it('answers a site whose config carries no head at all', async () => { - stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); + // a ref that was never built exists and has no head.html + it('answers a missing head as undefined, and still names the store', async () => { + stubFetch(() => config({ head: undefined })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); + const { exists, head, onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(exists, true); + assert.strictEqual(head, undefined); + assert.strictEqual(onSourceBus, false); }); }); describe('when there is no such site', () => { - it('says so on a 404', async () => { - stubFetch(absent); + it('answers no-site on a 404', async () => { + stubFetch(() => new Response('', { status: 404 })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, head: undefined }); + assert.deepStrictEqual(await getSite(env, daCtx()), { + exists: false, head: undefined, onSourceBus: false, + }); }); - // an unparseable hostname leaves org and site undefined, so there is nothing to ask about - it('says so without asking when there is no org or site', async () => { - stubFetch(absent); + [ + ['neither', { org: undefined, site: undefined }], + ['no org', { org: undefined }], + ['no site', { site: undefined }], + ['an empty org', { org: '' }], + ['an empty site', { site: '' }], + ].forEach(([what, over]) => { + it(`answers no-site without asking: ${what}`, async () => { + stubFetch(config); + + assert.deepStrictEqual(await getSite(env, daCtx(over)), { + exists: false, head: undefined, onSourceBus: false, + }); + assert.strictEqual(calls.length, 0); + }); + }); + }); - const site = await getSite(env, daCtx({ site: undefined })); + // the config service has served `contentSource` since 2026-08-13, and a site whose config has not + // been rewritten since then is answered from a cache that predates it. guessing a store from a + // config that does not name one would send a source-bus write to da-admin + describe('when the answer names no content source', () => { + it('throws rather than guessing', async () => { + stubFetch(() => config({ contentSource: undefined })); - assert.strictEqual(site.exists, false); - assert.strictEqual(calls.length, 0); + await assert.rejects(() => getSite(env, daCtx()), /content source/); }); }); - // a refusal leaves existence unknown, and reading that as a missing site 404s a live page - describe('when the lookup cannot answer', () => { - [401, 403, 429, 500, 502].forEach((status) => { + describe('when the config service refuses', () => { + [401, 403, 429, 500, 503].forEach((status) => { it(`throws on a ${status}`, async () => { stubFetch(() => new Response('', { status })); - await assert.rejects(() => getSite(env, daCtx()), /502|500|429|403|401/); + await assert.rejects(() => getSite(env, daCtx()), new RegExp(`${status}`)); }); }); - it('throws when the config service cannot be reached', async () => { - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - - await assert.rejects(() => getSite(env, daCtx()), /fetch failed/); - }); - - // a worker deployed without the secret and a rate limit share the status and the body, so - // `x-error` is what tells them apart - it('names the status it got', async () => { - stubFetch(() => new Response('', { status: 401 })); - - await assert.rejects(() => getSite(env, daCtx()), /401/); - }); - - it('throws when the body is not JSON', async () => { - stubFetch(() => new Response('the edge said no', { status: 200 })); + it('throws when the answer is not json', async () => { + stubFetch(() => new Response('', { status: 200 })); await assert.rejects(() => getSite(env, daCtx())); }); - // a misconfigured worker gets no answer, and calling that a missing site would 404 the pages - it('throws when the config service host is missing, without asking', async () => { - stubFetch(found); + // the cause reaches the caller, which reports it on the 503 as `x-error` + it('lets a failure through', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); - await assert.rejects(() => getSite({ ...env, HLX_CONFIG_SERVICE: undefined }, daCtx())); - assert.strictEqual(calls.length, 0); + await assert.rejects(getSite(env, daCtx()), { message: 'fetch failed' }); }); }); }); diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js deleted file mode 100644 index e0397ea8..00000000 --- a/test/storage/source-bus.test.js +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ -import assert from 'assert'; - -const { default: isSourceBus } = await import('../../src/storage/source-bus.js'); - -const env = { - AEM_API: 'https://api.aem.live', - HLX_CONFIG_SERVICE: 'https://config.aem.page', - HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', -}; - -const daCtx = (over = {}) => ({ - org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, -}); - -let calls; - -const stubFetch = (respond) => { - calls = []; - globalThis.fetch = async (input, init) => { - calls.push({ url: input.toString(), init }); - return respond(input.toString(), init); - }; -}; - -const config = (source, status = 200) => new Response( - JSON.stringify({ content: { source, contentBusId: 'abc' } }), - { status, headers: { 'content-type': 'application/json' } }, -); - -const onBus = () => config({ type: 'markup', url: 'https://api.aem.live/org/sites/site/source' }); -const onDa = () => config({ type: 'markup', url: 'https://content.da.live/org/site/' }); - -describe('isSourceBus', () => { - afterEach(() => { - delete globalThis.fetch; - }); - - describe('the request it makes', () => { - it('asks the config service for the admin scope', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); - }); - - it('takes the config service from env, so dev can point at the shim', async () => { - stubFetch(onBus); - - await isSourceBus({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - - assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); - }); - - it('sends the shared secret, which the config service requires', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - const headers = new Headers(calls[0].init.headers); - assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); - assert.strictEqual(headers.get('x-backend-type'), 'aws'); - }); - - // an author's IMS token is refused by the config service, and sending it as well would only - // hand a user credential to a service that has no use for it - it('sends no author token', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(new Headers(calls[0].init.headers).get('Authorization'), null); - }); - - it('gives up rather than hanging', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); - }); - }); - - // the source is per site, so the ref in the url is only there to address the site - describe('when the content source is on the source bus', () => { - it('answers true', async () => { - stubFetch(onBus); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - - it('answers the same for any ref', async () => { - stubFetch(onBus); - - assert.strictEqual(await isSourceBus(env, daCtx({ ref: 'branch' })), true); - }); - - // helix-admin tests the same prefix, so two clients cannot split one site across two stores - it('reads the url, not the type, since both stores are markup', async () => { - stubFetch(() => config({ type: 'markup', url: 'https://api.aem.live/o/sites/s/source' })); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - - it('takes the source bus origin from env', async () => { - stubFetch(() => config({ type: 'markup', url: 'https://api.stage.example/o/sites/s/source' })); - - assert.strictEqual(await isSourceBus({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()), true); - }); - }); - - describe('when the content source is da-admin', () => { - it('answers false', async () => { - stubFetch(onDa); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - - ['https://content.da.live/org/site/', 'https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { - it(`answers false for ${new URL(url).host}`, async () => { - stubFetch(() => config({ type: 'markup', url })); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - }); - }); - - // 404 is the one status that means there is no such site, and the site lookup answers that - describe('when there is no such site', () => { - it('answers false', async () => { - stubFetch(() => new Response('', { status: 404 })); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - }); - - // a refusal carries no decision, and reading it as legacy sends a source-bus write to da-admin, - // where nothing serves it back - describe('when the config service refuses', () => { - [401, 403, 429, 500, 503].forEach((status) => { - it(`throws on a ${status}`, async () => { - stubFetch(() => new Response('', { status })); - - await assert.rejects(() => isSourceBus(env, daCtx()), new RegExp(`${status}`)); - }); - }); - - it('throws when the answer has no content source', async () => { - stubFetch(() => new Response(JSON.stringify({ ref: 'main' }), { status: 200 })); - - await assert.rejects(() => isSourceBus(env, daCtx()), /content source/); - }); - - it('throws when the answer is not json', async () => { - stubFetch(() => new Response('', { status: 200 })); - - await assert.rejects(() => isSourceBus(env, daCtx())); - }); - }); - - describe('when the config service cannot answer', () => { - // the cause reaches the caller, which reports it on the 503 as `x-error`. swallowing it here - // would leave a timeout and a dropped connection indistinguishable - it('lets the failure through', async () => { - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - - await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); - }); - - it('lets it through when the config service is unusable, without asking', async () => { - stubFetch(onBus); - - await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); - assert.strictEqual(calls.length, 0); - }); - - // the distinction the caller acts on: false is a store, a failure is no store - it('is distinguishable from a legacy answer', async () => { - stubFetch(onDa); - assert.strictEqual(await isSourceBus(env, daCtx()), false); - - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - await assert.rejects(isSourceBus(env, daCtx())); - }); - }); - - describe('when there is no site to ask about', () => { - // either one missing is enough: a half-parsed request would otherwise build a config url with - // "undefined" in it - [ - ['neither', { org: undefined, site: undefined }], - ['no org', { org: undefined }], - ['no site', { site: undefined }], - ['an empty org', { org: '' }], - ['an empty site', { site: '' }], - ].forEach(([what, over]) => { - it(`answers false without making a request: ${what}`, async () => { - stubFetch(onBus); - - assert.strictEqual(await isSourceBus(env, daCtx(over)), false); - assert.strictEqual(calls.length, 0); - }); - }); - }); -}); From b8b63eecbe9efd47ae504909053bb298261ea930 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:59:42 +0200 Subject: [PATCH 28/49] fix: take existence, head.html and the store from one config read --- README.md | 2 +- dev/lookup-shim.js | 15 ++++---- src/routes/da-admin.js | 30 +++++----------- src/storage/site.js | 38 +++++++++++--------- src/storage/source-bus.js | 45 ------------------------ src/utils/constants.js | 12 +++---- src/utils/upstream.js | 1 - test/index.test.js | 2 +- test/routes/da-admin.test.js | 36 +++++++++---------- test/routes/source-read.test.js | 59 +++++++++----------------------- test/routes/source-write.test.js | 30 ++++++---------- 11 files changed, 88 insertions(+), 182 deletions(-) delete mode 100644 src/storage/source-bus.js diff --git a/README.md b/README.md index 98864586..201ee602 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -A read asks config.aem.page twice: the pipeline scope says whether the site exists and has its head.html, and the admin scope carries the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. +A read asks config.aem.page once, at the pipeline scope, which says whether the site exists, has its head.html and names the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. To run da-universal locally: diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js index 286933a5..5bbaf0dc 100644 --- a/dev/lookup-shim.js +++ b/dev/lookup-shim.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ -// stands in for config.aem.page, which needs a shared secret. a read asks it twice: the pipeline -// scope for whether the site exists and its head.html, the admin scope for the content source. a -// site in SITES exists, and one with a source url on api.aem.live is source-bus +// stands in for config.aem.page, which needs a shared secret. one read of the pipeline scope +// answers whether the site exists, its head.html and which store holds it. a site in SITES exists, +// and one with a source url on api.aem.live is source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; @@ -35,11 +35,12 @@ export default { } // both stores are `type: markup`, so only the url separates them - const answer = url.searchParams.get('scope') === 'admin' - ? { content: { source: { type: 'markup', url: source } } } - : { head: { html: HEAD_HTML } }; const body = JSON.stringify({ - ref, site, org, ...answer, + ref, + site, + org, + head: { html: HEAD_HTML }, + contentSource: { type: 'markup', url: source }, }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 21511fd0..99f232b0 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -30,7 +30,6 @@ import { PREVIEW_UNREACHABLE_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, - SOURCE_UNDETERMINED_HTML_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, SOURCE_UNREACHABLE_HTML_MESSAGE, SOURCE_UNREACHABLE_MESSAGE, @@ -38,7 +37,6 @@ import { } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; import getSite from '../storage/site.js'; -import isSourceBus from '../storage/source-bus.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { @@ -46,7 +44,6 @@ import { PREVIEW_HOST, SITE_CONFIG, SITE_LOOKUP, - STORE_LOOKUP, UpstreamError, reach, } from '../utils/upstream.js'; @@ -60,12 +57,11 @@ const HTML_POST_TYPE = 'text/html'; const UNREACHABLE_HTML = { [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, - [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; // a write reaches no store until the lookup answers, so a failed lookup leaves the destination // undetermined rather than unreachable const UNREACHABLE_TEXT = { - [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, + [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, }; /** @@ -133,28 +129,20 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // both lookups go out together: the pipeline scope says whether the site exists and what its - // head.html is, the admin scope says which store holds it - const [site, onSourceBus] = await Promise.allSettled([ - reach(SITE_LOOKUP, () => getSite(env, daCtx)), - reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), - ]); - - // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops a failed probe on purpose: a site that does not exist needs no store - if (site.status === 'fulfilled' && !site.value.exists) { + // one read of the pipeline scope answers whether the site exists, what its head.html is and + // which store holds it + const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + + if (!site.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); return { noSuchSite: true }; } - // the site lookup first, since the store answer is no use on its own - if (site.status === 'rejected') throw site.reason; - if (onSourceBus.status === 'rejected') throw onSourceBus.reason; - const store = getStore(env, daCtx, onSourceBus.value); + const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); return { response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)), - head: site.value.head, + head: site.head, }; } @@ -319,7 +307,7 @@ async function sourcePost({ req, env, daCtx }) { // the payload is settled, so the only question left is where it goes // a 404 is an answer, and it means no AEM site config rather than no DA // site, so the write goes to da-admin - const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); + const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); if (onSourceBus) { console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); diff --git a/src/storage/site.js b/src/storage/site.js index da0be002..6d9ab7ac 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -11,29 +11,25 @@ */ const TIMEOUT_MS = 5 * 1000; -const NO_SITE = { exists: false, head: undefined }; +const NO_SITE = { exists: false, head: undefined, onSourceBus: false }; /** - * Asks the config service whether a site exists, and reads its head.html from the same answer. + * Asks the config service whether a site exists, what its head.html is and which store holds it. * - * The pipeline scope has the code bus object the delivery pipeline renders into every page of the - * site. A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` to a - * request with no site token, and the config service does not. The admin scope answers existence - * too, and its answer has the site's CDN token and API key metadata. + * The pipeline scope answers all three. A site behind Helix authentication refuses + * `{ref}--{site}--{org}.aem.page/head.html` without a site token, and the config service does not. + * `contentSource.url` names the store, since both stores are `type: markup`. * - * Throws on any refusal but a 404, which is the only status that means there is no such site. A - * ref that was never built exists and has no head.html, which is a 200 with an empty head. + * Throws on any refusal but a 404, which is the only status that means there is no such site. A ref + * that was never built exists and has no head.html, which is a 200 with an empty head. * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes and - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus * @param {Object} daCtx - * @returns {Promise<{exists: boolean, head: string|undefined}>} + * @returns {Promise<{exists: boolean, head: string|undefined, onSourceBus: boolean}>} */ export default async function getSite(env, daCtx) { - const { - org, site, ref, - } = daCtx; - // an unparseable hostname leaves org and site undefined, and there is no site to ask about + const { org, site, ref } = daCtx; if (!org || !site) return NO_SITE; const url = new URL(`/${ref}--${site}--${org}/config.json?scope=pipeline`, env.HLX_CONFIG_SERVICE); @@ -48,6 +44,14 @@ export default async function getSite(env, daCtx) { if (response.status === 404) return NO_SITE; if (!response.ok) throw new Error(`the config service answered ${response.status}`); - const { head } = await response.json(); - return { exists: true, head: head?.html }; + const { head, contentSource } = await response.json(); + // a config cached from before the service served contentSource names no store, and guessing one + // would send a source-bus write to da-admin + if (!contentSource?.url) throw new Error('the config service named no content source'); + + return { + exists: true, + head: head?.html, + onSourceBus: contentSource.url.startsWith(`${env.AEM_API}/`), + }; } diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js deleted file mode 100644 index 59b63af2..00000000 --- a/src/storage/source-bus.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -const TIMEOUT_MS = 5 * 1000; - -/** - * Asks the config service which store holds a site's content based on `content.source` - * A 404 means there is no such site, which the site lookup reports. Any other refusal is no - * answer at all: reading it as legacy would send a source-bus write to da-admin - * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus - * @param {Object} daCtx - * @returns {Promise} - */ -export default async function isSourceBus(env, daCtx) { - const { org, site, ref } = daCtx; - if (!org || !site) return false; - - const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); - const response = await fetch(url, { - headers: { - 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, - 'x-backend-type': 'aws', - }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - - if (response.status === 404) return false; - if (!response.ok) throw new Error(`the config service answered ${response.status}`); - - const { content } = await response.json(); - const source = content?.source?.url; - if (!source) throw new Error('the config service named no content source'); - return source.startsWith(`${env.AEM_API}/`); -} diff --git a/src/utils/constants.js b/src/utils/constants.js index ab505f6f..4336b500 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,17 +51,15 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; -export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; +export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry, or contact your project admin if it persists.

'; -export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry.

'; +export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry.

'; +export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNDETERMINED_HTML_MESSAGE = '

503: Content store undetermined

Which store holds this document could not be determined. Please retry.

'; +export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; -export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry.'; - -export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry.'; +export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry, or contact your project admin if it persists.'; export const SOURCE_BUS_READ_ONLY_MESSAGE = 'This site is on the source bus, which this proxy only reads. Nothing was written, and retrying will not help.'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index f1cffe3a..eff0bd50 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -15,7 +15,6 @@ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; export const SITE_CONFIG = 'site config'; export const SITE_LOOKUP = 'site lookup'; -export const STORE_LOOKUP = 'store lookup'; /** * Renders a failure for the `x-error` header. diff --git a/test/index.test.js b/test/index.test.js index 0bdacba0..ccc058cd 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -189,7 +189,7 @@ describe('worker fetch handler', () => { // reaches the caller if that rebuild carries it describe('a refused write on a source-bus site', () => { const busWorker = async () => (await esmock('../src/index.js', READ_HANDLER_MOCKS, { - '../src/storage/source-bus.js': { default: async () => true }, + '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined, onSourceBus: true }) }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 29cc9159..817397cc 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -44,24 +44,22 @@ const recorder = () => { return { env, fetched }; }; -// stands in for the two config service reads the routes make: the pipeline scope for whether the -// site exists and its head.html, the admin scope for which store holds it. Answers that any site -// exists; `upgraded` lists the `org/site` keys whose content source is the source bus +// stands in for the one config service read the routes make: the pipeline scope answers whether +// the site exists, its head.html and which store holds it. Answers that any site exists; +// `upgraded` lists the `org/site` keys whose content source is the source bus const stubLookups = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const { pathname, searchParams } = new URL(url); - const [, site, org] = (pathname.split('/')[1] ?? '').split('--'); - if (searchParams.get('scope') === 'admin') { - const source = upgraded.includes(`${org}/${site}`) - ? `https://api.aem.live/${org}/sites/${site}/source` - : `https://content.da.live/${org}/${site}/`; - const body = JSON.stringify({ content: { source: { type: 'markup', url: source } } }); - return new Response(body, { status: 200 }); - } - const body = JSON.stringify({ head: { html: '' } }); + const [, site, org] = ((new URL(url)).pathname.split('/')[1] ?? '').split('--'); + const source = upgraded.includes(`${org}/${site}`) + ? `https://api.aem.live/${org}/sites/${site}/source` + : `https://content.da.live/${org}/${site}/`; + const body = JSON.stringify({ + head: { html: '' }, + contentSource: { type: 'markup', url: source }, + }); return new Response(body, { status: 200 }); }; return asked; @@ -69,9 +67,8 @@ const stubLookups = (upgraded = []) => { const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, head: '' }), + default: async () => ({ exists: true, head: '', onSourceBus: false }), }, - '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -124,9 +121,8 @@ describe('daSourceGet', () => { calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists, head: headHtml }), + default: async () => ({ exists, head: headHtml, onSourceBus: false }), }, - '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -453,7 +449,7 @@ describe('daSourcePost', () => { // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes // effect on the next one - it('looks the store up once per write, and asks nothing else', async () => { + it('looks the site up once per write, and asks nothing else', async () => { const asked = stubLookups(['org/lookedupeach']); const { env } = recorder(); @@ -461,8 +457,8 @@ describe('daSourcePost', () => { await write('lookedupeach', env); assert.deepStrictEqual(asked.sort(), [ - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index d56cffa1..26b0981f 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -48,7 +48,7 @@ const build = async (overrides = {}) => { busError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, storeLookups: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -72,14 +72,8 @@ const build = async (overrides = {}) => { seen.lookups += 1; // throws for undefined, which is how the lookup reports a failure if (site === undefined) throw lookupError; - return { exists: site.exists, head: headHtml }; - }, - }, - '../../src/storage/source-bus.js': { - default: async () => { - seen.storeLookups += 1; if (busError) throw busError; - return site !== undefined && site.onSourceBus; + return { exists: site.exists, head: headHtml, onSourceBus: site.onSourceBus }; }, }, '../../src/utils/aemCtx.js': { @@ -114,7 +108,7 @@ const build = async (overrides = {}) => { return { ...mod, env, seen }; }; -describe('when the store lookup cannot say which store holds the site', () => { +describe('when the lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); @@ -142,9 +136,9 @@ describe('when the store lookup cannot say which store holds the site', () => { assert.ok(Number(res.headers.get('Retry-After')) > 0); }); - // the preview iframe renders this body, and the store answered nothing here: it was never - // asked, since which store to ask is what could not be determined - it('says the store could not be determined, not that it did not answer', async () => { + // the preview iframe renders this body, and no store was asked: the one read that names the + // store is what failed + it('says the site could not be looked up, not that a store did not answer', async () => { const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -152,7 +146,7 @@ describe('when the store lookup cannot say which store holds the site', () => { const body = await res.text(); assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(body, messages.SOURCE_UNDETERMINED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); }); it('refuses a non-html read too', async () => { @@ -177,22 +171,22 @@ describe('when the store lookup cannot say which store holds the site', () => { }); // the two lookups are two upstreams now, and both 503s share a status and an unparsed body - it('names the probe in x-error', async () => { + it('names the lookup in x-error', async () => { const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /store lookup failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('names the probe on a HEAD too', async () => { + it('names the lookup on a HEAD too', async () => { const { daSourceHead, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /store lookup failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); // the header is the only thing on the wire that separates a timeout from a dropped connection @@ -204,7 +198,7 @@ describe('when the store lookup cannot say which store holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); }); // rendering a thrown non-Error as "undefined: undefined" would leave the 503 saying nothing @@ -215,7 +209,7 @@ describe('when the store lookup cannot say which store holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 503); - assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: Error: boom'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); }); it('tells a probe failure apart from a store failure', async () => { @@ -675,9 +669,8 @@ describe('reading from the store that holds the site', () => { }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, head: '' }), + default: async () => ({ exists: true, head: '', onSourceBus: true }), }, - '../../src/storage/source-bus.js': { default: async () => true }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), }, @@ -870,18 +863,6 @@ describe('reading from the store that holds the site', () => { }); // the two lookups go out together, and a site that does not exist needs no store - it('reports no such site even when the probe did not answer', async () => { - const { daSourceGet, env } = await build({ - site: NO_SITE, - busError: new TypeError('Network connection lost'), - }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.strictEqual(res.status, 404); - }); - // both failed, and the store answer is no use on its own it('reports the failed site lookup when the probe failed with it', async () => { const { daSourceGet, env } = await build({ @@ -931,16 +912,14 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // the pipeline scope answers existence and head.html together, so a page that needs the head - // pays for no third read - it('reads each lookup once for a page', async () => { + // the pipeline scope answers existence, head.html and the store together + it('reads the config service once for a page', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.storeLookups, 1); }); // nothing composes an image, and the head that arrives with the existence answer is dropped @@ -951,7 +930,6 @@ describe('reading from the store that holds the site', () => { await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); @@ -962,7 +940,6 @@ describe('reading from the store that holds the site', () => { await daSourceHead({ env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); }); @@ -1161,9 +1138,7 @@ describe('when the config service refuses the lookup', () => { HLX_CONFIG_SERVICE_TOKEN: 'shared-token', daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, }; - const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { default: async () => false }, - }); + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', {}); // a preview host rather than a UE host, so nothing is instrumented onto the composed page const req = authedReq('https://main--site--org.preview.da.live/folder/content'); return daSourceGet({ req, env, daCtx: getDaCtx(req) }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 15ed4495..75c6c5b9 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -34,7 +34,7 @@ const build = async (overrides = {}) => { const { status = 201, busError } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { - bus: [], legacy: [], lookups: 0, probes: 0, order: [], + bus: [], legacy: [], probes: 0, order: [], }; const capture = async (request) => { const contentType = request.headers.get('Content-Type'); @@ -70,20 +70,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - // still mocked, so a write that reached for it would be counted rather than hitting the network '../../src/storage/site.js': { - default: async () => { - seen.lookups += 1; - seen.order.push('lookup'); - return { exists: true, head: undefined }; - }, - }, - '../../src/storage/source-bus.js': { default: async () => { seen.probes += 1; - seen.order.push('probe'); + seen.order.push('lookup'); if (busError) throw busError; - return onSourceBus; + return { exists: true, head: undefined, onSourceBus }; }, }, }); @@ -163,7 +155,7 @@ describe('writing to the store that holds the site', () => { it('names the failed probe in x-error', async () => { const { res } = await post({ busError: dead() }); - assert.match(res.headers.get('x-error'), /store lookup failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); it('names the cause, not a category', async () => { @@ -171,7 +163,7 @@ describe('writing to the store that holds the site', () => { busError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); }); }); @@ -188,13 +180,11 @@ describe('writing to the store that holds the site', () => { }); describe('what a write asks about the site', () => { - // the site lookup and the store lookup read the same service, and a write never reads the - // pipeline scope's answer, so asking it twice buys nothing - it('asks the store lookup only, and reaches the store after it', async () => { + // one read of the pipeline scope answers where the document goes + it('asks one lookup, and reaches the store after it', async () => { const { res, seen } = await post({}); assert.strictEqual(seen.probes, 1); - assert.strictEqual(seen.lookups, 0); assert.strictEqual(seen.order[seen.order.length - 1], 'store'); assert.strictEqual(res.status, 201); }); @@ -281,14 +271,14 @@ describe('writing to the store that holds the site', () => { }); }); - describe('the store lookup on a write', () => { + describe('the lookup on a write', () => { // a legacy write is the case that can tell the two orderings apart: the store is reached // either way, so only the sequence says whether the write went out before it was placed it('happens before anything is sent to a store', async () => { const { seen } = await post({}); assert.deepStrictEqual(seen.order.slice(-1), ['store']); - assert.ok(seen.order.includes('probe')); + assert.ok(seen.order.includes('lookup')); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { @@ -336,7 +326,7 @@ describe('writing to the store that holds the site', () => { const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 415); - assert.strictEqual(seen.lookups, 0); + assert.strictEqual(seen.probes, 0); assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); }); From 2bc12f408c1ee7085918c4930fe71ad604540775 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 11:03:57 +0200 Subject: [PATCH 29/49] test: a config that names no content source reads as legacy, and says so --- test/storage/site.test.js | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 8da98aa0..54f2eea4 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -149,14 +149,39 @@ describe('getSite', () => { }); }); - // the config service has served `contentSource` since 2026-08-13, and a site whose config has not - // been rewritten since then is answered from a cache that predates it. guessing a store from a - // config that does not name one would send a source-bus write to da-admin describe('when the answer names no content source', () => { - it('throws rather than guessing', async () => { + it('reads as legacy, since that is where a site without one has always been', async () => { stubFetch(() => config({ contentSource: undefined })); - await assert.rejects(() => getSite(env, daCtx()), /content source/); + assert.deepStrictEqual(await getSite(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: false, + }); + }); + + // a source-bus site read as legacy is served the wrong document and written to the wrong store, + // so the site says so in the log rather than only in an author's lost edits + it('names the site in a warning', async () => { + stubFetch(() => config({ contentSource: undefined })); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getSite(env, daCtx()); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /org\/site/); + assert.match(warnings[0], /content source/); + }); + + it('answers the same when the source carries no url', async () => { + stubFetch(() => config({ contentSource: { type: 'markup' } })); + + const { onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(onSourceBus, false); }); }); From 461236ec318660542442289f3aedb69ef713d378 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 11:04:13 +0200 Subject: [PATCH 30/49] fix: read a config with no content source as legacy, and warn --- src/storage/site.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/storage/site.js b/src/storage/site.js index 6d9ab7ac..aabee841 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -16,9 +16,9 @@ const NO_SITE = { exists: false, head: undefined, onSourceBus: false }; /** * Asks the config service whether a site exists, what its head.html is and which store holds it. * - * The pipeline scope answers all three. A site behind Helix authentication refuses - * `{ref}--{site}--{org}.aem.page/head.html` without a site token, and the config service does not. - * `contentSource.url` names the store, since both stores are `type: markup`. + * A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` without a + * site token, and the config service does not. `contentSource.url` names the store, since both + * stores are `type: markup`. * * Throws on any refusal but a 404, which is the only status that means there is no such site. A ref * that was never built exists and has no head.html, which is a 200 with an empty head. @@ -45,13 +45,16 @@ export default async function getSite(env, daCtx) { if (!response.ok) throw new Error(`the config service answered ${response.status}`); const { head, contentSource } = await response.json(); - // a config cached from before the service served contentSource names no store, and guessing one - // would send a source-bus write to da-admin - if (!contentSource?.url) throw new Error('the config service named no content source'); + // a config that names no store is read as legacy, which is where a site without one has always + // been. the warning is there because a source-bus site read that way is written to the store the + // site does not serve + if (!contentSource?.url) { + console.warn(`${url} named no content source, reading ${org}/${site} as legacy`); + } return { exists: true, head: head?.html, - onSourceBus: contentSource.url.startsWith(`${env.AEM_API}/`), + onSourceBus: Boolean(contentSource?.url?.startsWith(`${env.AEM_API}/`)), }; } From 39b57305479798cfae6d23ca6c1d9ee71dc4910d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 11:17:16 +0200 Subject: [PATCH 31/49] chore: plainer wording for the config read in the readme --- README.md | 4 +++- src/routes/da-admin.js | 5 ----- src/storage/site.js | 4 +--- test/routes/da-admin.test.js | 2 +- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 201ee602..a6f5ef49 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -A read asks config.aem.page once, at the pipeline scope, which says whether the site exists, has its head.html and names the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. +One read of config.aem.page, pipeline scope, answers existence, head.html and `contentSource`. Its url names the store. + +The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to its `SITES` table. A site missing from the table is answered 404. A source url on api.aem.live reads as source-bus. To run da-universal locally: diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 99f232b0..0ff56050 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -129,8 +129,6 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // one read of the pipeline scope answers whether the site exists, what its head.html is and - // which store holds it const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); if (!site.exists) { @@ -304,9 +302,6 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes - // a 404 is an answer, and it means no AEM site config rather than no DA - // site, so the write goes to da-admin const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); if (onSourceBus) { diff --git a/src/storage/site.js b/src/storage/site.js index aabee841..fd55df7a 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -45,9 +45,7 @@ export default async function getSite(env, daCtx) { if (!response.ok) throw new Error(`the config service answered ${response.status}`); const { head, contentSource } = await response.json(); - // a config that names no store is read as legacy, which is where a site without one has always - // been. the warning is there because a source-bus site read that way is written to the store the - // site does not serve + // a config that names no store is read as legacy if (!contentSource?.url) { console.warn(`${url} named no content source, reading ${org}/${site} as legacy`); } diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 817397cc..03286b75 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -44,7 +44,7 @@ const recorder = () => { return { env, fetched }; }; -// stands in for the one config service read the routes make: the pipeline scope answers whether +// substitute for the config service read the routes make: the pipeline scope answers whether // the site exists, its head.html and which store holds it. Answers that any site exists; // `upgraded` lists the `org/site` keys whose content source is the source bus const stubLookups = (upgraded = []) => { From f7de96e8c730ffc2183ffd4a2ce73faf179c8649 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 13:58:59 +0200 Subject: [PATCH 32/49] test: pin the ctx get.js hands to getCookie --- test/handlers/get.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/handlers/get.test.js b/test/handlers/get.test.js index 86c7634f..dd385729 100644 --- a/test/handlers/get.test.js +++ b/test/handlers/get.test.js @@ -63,13 +63,18 @@ describe('GET handler', () => { describe('gimme_cookie', () => { let getHandler; + let getCookieArgs; beforeEach(async () => { + getCookieArgs = undefined; getHandler = (await esmock('../../src/handlers/get.js', { '../../src/routes/da-admin.js': { daSourceGet: async () => new Response() }, '../../src/routes/aem-proxy.js': { handleAEMProxyRequest: async () => new Response() }, '../../src/routes/cookie.js': { - getCookie: async () => new Response('cookie-set', { status: 200 }), + getCookie: async (args) => { + getCookieArgs = args; + return new Response('cookie-set', { status: 200 }); + }, }, })).default; }); @@ -83,6 +88,9 @@ describe('GET handler', () => { assert.strictEqual(res.status, 200); assert.strictEqual(await res.text(), 'cookie-set'); + assert.strictEqual(getCookieArgs.req, req); + assert.strictEqual(getCookieArgs.env, env); + assert.strictEqual(getCookieArgs.daCtx, daCtx); }); }); From 0fcd36373bb6b8596626cbe626812a274a42c502 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 13:58:59 +0200 Subject: [PATCH 33/49] chore: say what throws an UpstreamError --- src/routes/da-admin.js | 4 ++-- src/utils/upstream.js | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 0ff56050..4c3de03e 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -65,8 +65,8 @@ const UNREACHABLE_TEXT = { }; /** - * Only an upstream that could not be reached is retryable. Anything else reaches the worker - * boundary in src/index.js, which logs it and answers 500. + * An UpstreamError is retryable, so it is answered 503. Anything else reaches the worker boundary + * in src/index.js, which logs it and answers 500. */ function refuseUnreachable(e, method, sourcePath) { if (!(e instanceof UpstreamError)) throw e; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index eff0bd50..56e87924 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -28,10 +28,8 @@ export function causeOf(e) { } /** - * Thrown when an upstream could not be reached at all. - * - * Not a refusal: an upstream that answered 401 or 404 has answered, and the route decides what - * that means. An UpstreamError means there is no answer to read, and it is retryable. + * Thrown when a read has no answer to use: the upstream was not reached, or answered a status the + * reader threw on. A store response is handed back whatever its status, and the route decides. * * @property {string} upstream one of the names at the top of this file */ From fba8ff604e639081add3cb961fbcf383608aa3a9 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 13:59:31 +0200 Subject: [PATCH 34/49] fix: a 503 heading says the read failed, not that the upstream was unreachable --- src/utils/constants.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/constants.js b/src/utils/constants.js index 4336b500..51d6246a 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,11 +51,11 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; -export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry, or contact your project admin if it persists.

'; +export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; -export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; +export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry, or contact your project admin if it persists.

'; +export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; From d9d9df227b95eaf2bb26052fddf324d820bc0770 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:00:28 +0200 Subject: [PATCH 35/49] refactor: name the 503 path for a failed read, not an unreachable upstream --- src/routes/da-admin.js | 42 ++++++++++++++++----------------- src/utils/constants.js | 8 +++---- test/routes/source-read.test.js | 18 +++++++------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 4c3de03e..79cd0045 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -26,13 +26,13 @@ import { } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, - SITE_UNREACHABLE_HTML_MESSAGE, - PREVIEW_UNREACHABLE_HTML_MESSAGE, + SITE_LOOKUP_FAILED_HTML_MESSAGE, + PREVIEW_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, - SOURCE_UNREACHABLE_HTML_MESSAGE, - SOURCE_UNREACHABLE_MESSAGE, + SOURCE_FAILED_HTML_MESSAGE, + SOURCE_FAILED_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; @@ -54,13 +54,13 @@ const HTML_POST_TYPE = 'text/html'; * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off * da-admin, so it takes the default body and `x-error` is what tells the two reads apart. */ -const UNREACHABLE_HTML = { - [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, - [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, +const UPSTREAM_FAILURE_HTML = { + [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, + [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, }; -// a write reaches no store until the lookup answers, so a failed lookup leaves the destination -// undetermined rather than unreachable -const UNREACHABLE_TEXT = { +// a write reaches no store until the lookup answers, so a failed lookup says the destination is +// undetermined, not that the store failed +const UPSTREAM_FAILURE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, }; @@ -68,14 +68,14 @@ const UNREACHABLE_TEXT = { * An UpstreamError is retryable, so it is answered 503. Anything else reaches the worker boundary * in src/index.js, which logs it and answers 500. */ -function refuseUnreachable(e, method, sourcePath) { +function refuseUpstreamFailure(e, method, sourcePath) { if (!(e instanceof UpstreamError)) throw e; console.warn(`503 ${method} ${sourcePath}, ${e.message}`); if (method === 'HEAD') return head503(e.message); if (method === 'POST') { - return post503(UNREACHABLE_TEXT[e.upstream] ?? SOURCE_UNREACHABLE_MESSAGE, e.message); + return post503(UPSTREAM_FAILURE_TEXT[e.upstream] ?? SOURCE_FAILED_MESSAGE, e.message); } - return get503(UNREACHABLE_HTML[e.upstream] ?? SOURCE_UNREACHABLE_HTML_MESSAGE, e.message); + return get503(UPSTREAM_FAILURE_HTML[e.upstream] ?? SOURCE_FAILED_HTML_MESSAGE, e.message); } export function isHtmlPostType(type) { @@ -94,7 +94,7 @@ function getTextBody(data) { } async function getPageTemplate(env, daCtx, aemCtx) { - // answers null for a site with no config, so a store that refuses or is unreachable throws + // answers null for a site with no config, so any other failure throws const config = await reach(SITE_CONFIG, () => getSiteConfig(env, daCtx)); // Search whether a template is configured for this path @@ -126,7 +126,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * Sets `noSuchSite` when there is no such site, `response` otherwise. * * @returns {Promise<{response?: Response, noSuchSite?: boolean}>} - * @throws {UpstreamError} when the lookup or the store could not be reached + * @throws {UpstreamError} when the lookup or the store fails */ async function readSource(env, daCtx, init) { const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); @@ -234,12 +234,12 @@ async function sourceGet({ req, env, daCtx }) { }); } -/** Wraps sourceGet, turning an unreachable upstream into a 503 in HTML the editor renders. */ +/** Wraps sourceGet, turning a failed upstream read into a 503 in HTML the editor renders. */ export async function daSourceGet({ req, env, daCtx }) { try { return await sourceGet({ req, env, daCtx }); } catch (e) { - return refuseUnreachable(e, 'GET', daCtx.sourcePath); + return refuseUpstreamFailure(e, 'GET', daCtx.sourcePath); } } @@ -259,12 +259,12 @@ async function sourceHead({ env, daCtx }) { return new Response(null, { status: response.status, headers: response.headers }); } -/** Wraps sourceHead, turning an unreachable upstream into a bodyless 503. */ +/** Wraps sourceHead, turning a failed upstream read into a bodyless 503. */ export async function daSourceHead({ env, daCtx }) { try { return await sourceHead({ env, daCtx }); } catch (e) { - return refuseUnreachable(e, 'HEAD', daCtx.sourcePath); + return refuseUpstreamFailure(e, 'HEAD', daCtx.sourcePath); } } @@ -327,13 +327,13 @@ async function sourcePost({ req, env, daCtx }) { } /** - * Wraps sourcePost, turning an unreachable upstream into the plain text the editor shows the + * Wraps sourcePost, turning a failed upstream read into the plain text the editor shows the * author. */ export async function daSourcePost({ req, env, daCtx }) { try { return await sourcePost({ req, env, daCtx }); } catch (e) { - return refuseUnreachable(e, 'POST', daCtx.sourcePath); + return refuseUpstreamFailure(e, 'POST', daCtx.sourcePath); } } diff --git a/src/utils/constants.js b/src/utils/constants.js index 51d6246a..d064362b 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,13 +51,13 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; -export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; +export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; -export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; +export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; +export const SOURCE_FAILED_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; +export const SOURCE_FAILED_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry, or contact your project admin if it persists.'; diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 26b0981f..4bdab303 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -145,8 +145,8 @@ describe('when the lookup cannot say which store holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); const body = await res.text(); - assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); + assert.notStrictEqual(body, messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_LOOKUP_FAILED_HTML_MESSAGE); }); it('refuses a non-html read too', async () => { @@ -269,8 +269,8 @@ describe('when the config service cannot say whether the site exists', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); const body = await res.text(); - assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); + assert.notStrictEqual(body, messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_LOOKUP_FAILED_HTML_MESSAGE); }); it('refuses a HEAD with 503 and no body', async () => { @@ -444,8 +444,8 @@ describe('reading from the store that holds the site', () => { const htmlRes = await daSourceGet({ req: html, env, daCtx: getDaCtx(html) }); const assetRes = await daSourceGet({ req: asset, env, daCtx: getDaCtx(asset) }); - assert.strictEqual(await htmlRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(await assetRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await htmlRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(await assetRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); }); it('answers 503 with no body on a HEAD', async () => { @@ -1089,7 +1089,7 @@ describe('when the site config cannot be reached', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(await res.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await res.text(), messages.SOURCE_FAILED_HTML_MESSAGE); }); it('names the site config in x-error', async () => { @@ -1224,7 +1224,7 @@ describe('a path the site config gives a template', () => { assert.strictEqual(res.status, 503); assert.match(res.headers.get('x-error'), /preview host failed/); - assert.strictEqual(await res.text(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); }); it('takes the longest matching prefix', async () => { @@ -1270,7 +1270,7 @@ describe('when the worker itself has a bug', () => { delete globalThis.fetch; }); - // only an unreachable upstream is retryable, and a 503 would keep the throw out of the log + // only a failed upstream read is retryable, and a 503 would keep the throw out of the log // the worker boundary writes it('lets the throw through rather than rendering it as a 503', async () => { const { daSourceGet, env } = await build({ composeError: new TypeError('tree is not iterable') }); From f0fd2bd126e71d6eb4d935212f9ede853ae26021 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:47:03 +0200 Subject: [PATCH 36/49] test: a write is refused for a site the config service does not know --- test/routes/source-write.test.js | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 75c6c5b9..2da31cf3 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -31,7 +31,7 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201, busError } = overrides; + const { status = 201, busError, exists = true } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { bus: [], legacy: [], probes: 0, order: [], @@ -75,7 +75,7 @@ const build = async (overrides = {}) => { seen.probes += 1; seen.order.push('lookup'); if (busError) throw busError; - return { exists: true, head: undefined, onSourceBus }; + return { exists, head: undefined, onSourceBus }; }, }, }); @@ -167,16 +167,32 @@ describe('writing to the store that holds the site', () => { }); }); - // the store lookup answers false for a site the config service does not know, since a 404 means - // no AEM site config rather than no DA site + // a read of the same path answers 404, and a write the reader cannot get back is worse than a + // refusal the author sees describe('a site the config service does not know', () => { - it('is written to da-admin all the same', async () => { - const { res, seen } = await post({ site: LEGACY_STORE }); + it('is refused with 404 and touches neither store', async () => { + const { res, seen } = await post({ exists: false }); - assert.strictEqual(res.status, 201); - assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(res.status, 404); + assert.strictEqual(seen.legacy.length, 0); assert.strictEqual(seen.bus.length, 0); }); + + it('says what happened in plain text', async () => { + const { res } = await post({ exists: false }); + + assert.match(res.headers.get('Content-Type'), /^text\/plain/); + assert.strictEqual( + await res.text(), + 'There is no site at this address, so nothing was written.', + ); + }); + + it('does not ask the caller to retry, since the site will not appear', async () => { + const { res } = await post({ exists: false }); + + assert.strictEqual(res.headers.get('Retry-After'), null); + }); }); describe('what a write asks about the site', () => { From 3cafda2db7bb65bdd25b1909a3bdc7c9c84425e9 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:47:35 +0200 Subject: [PATCH 37/49] fix: refuse a write for a site the config service does not know --- src/responses/index.js | 4 ++++ src/routes/da-admin.js | 10 ++++++++-- src/utils/constants.js | 2 ++ test/routes/source-write.test.js | 11 ++++++----- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/responses/index.js b/src/responses/index.js index 41758933..c07a3abb 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -77,6 +77,10 @@ export function post503(message = '', error = '') { }); } +export function post404(message = '') { + return daResp({ body: message, status: 404, contentType: 'text/plain; charset=utf-8' }); +} + // RFC 9110 requires an Allow header on a 405, and reads are what is left once the write is gone. export function post405(message = '') { return daResp({ diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 79cd0045..de1fcde4 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,13 +22,14 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head404, head503, post405, post503, + daResp, get401, get404, get415, get503, head401, head404, head503, post404, post405, post503, } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, SITE_LOOKUP_FAILED_HTML_MESSAGE, PREVIEW_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, + SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, SOURCE_FAILED_HTML_MESSAGE, @@ -302,7 +303,12 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + + if (!exists) { + console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return post404(SITE_NOT_FOUND_MESSAGE); + } if (onSourceBus) { console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); diff --git a/src/utils/constants.js b/src/utils/constants.js index d064362b..aa27bcfb 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,6 +51,8 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; +export const SITE_NOT_FOUND_MESSAGE = 'There is no site at this address, so nothing was written.'; + export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 2da31cf3..ca255f2e 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -14,7 +14,11 @@ import assert from 'assert'; import esmock from 'esmock'; import { getDaCtx } from '../../src/utils/daCtx.js'; -import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../../src/utils/constants.js'; +import { + SITE_NOT_FOUND_MESSAGE, + SOURCE_BUS_READ_ONLY_MESSAGE, + SOURCE_UNDETERMINED_MESSAGE, +} from '../../src/utils/constants.js'; const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; @@ -182,10 +186,7 @@ describe('writing to the store that holds the site', () => { const { res } = await post({ exists: false }); assert.match(res.headers.get('Content-Type'), /^text\/plain/); - assert.strictEqual( - await res.text(), - 'There is no site at this address, so nothing was written.', - ); + assert.strictEqual(await res.text(), SITE_NOT_FOUND_MESSAGE); }); it('does not ask the caller to retry, since the site will not appear', async () => { From 46ed7ecc5df0630019afd5655281ff1c1fa02efd Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:47:57 +0200 Subject: [PATCH 38/49] test: a failed site config read says so, instead of naming the store --- test/routes/source-read.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 4bdab303..52217e92 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -1081,15 +1081,18 @@ describe('when the site config cannot be reached', () => { assert.strictEqual(res.status, 503); }); - // da-admin serves the config as well as the document, so the store is what did not answer. - // x-error is what separates the two reads - it('says the store did not answer', async () => { + // the config is read off da-admin while the document can be on the source bus, so naming the + // store would name a system that answered + it('says the site config failed, not the store', async () => { const { daSourceGet, env } = await build(missing()); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(await res.text(), messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual( + await res.text(), + '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

', + ); }); it('names the site config in x-error', async () => { From 411ac04df992d09323b63d28de1f529b64a8194c Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:48:29 +0200 Subject: [PATCH 39/49] fix: name the site config in its own 503 body --- src/routes/da-admin.js | 7 +++---- src/utils/constants.js | 2 ++ test/routes/source-read.test.js | 5 +---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index de1fcde4..159449e0 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -28,6 +28,7 @@ import { DEFAULT_HTML_TEMPLATE, SITE_LOOKUP_FAILED_HTML_MESSAGE, PREVIEW_FAILED_HTML_MESSAGE, + SITE_CONFIG_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -51,12 +52,10 @@ import { const HTML_POST_TYPE = 'text/html'; -/** - * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off - * da-admin, so it takes the default body and `x-error` is what tells the two reads apart. - */ +/** Names the upstream that failed, since the default body names the store. */ const UPSTREAM_FAILURE_HTML = { [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, + [SITE_CONFIG]: SITE_CONFIG_FAILED_HTML_MESSAGE, [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, }; // a write reaches no store until the lookup answers, so a failed lookup says the destination is diff --git a/src/utils/constants.js b/src/utils/constants.js index aa27bcfb..de496586 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -55,6 +55,8 @@ export const SITE_NOT_FOUND_MESSAGE = 'There is no site at this address, so noth export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; +export const SITE_CONFIG_FAILED_HTML_MESSAGE = '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

'; + export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; export const SOURCE_FAILED_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 52217e92..29ecd28f 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -1089,10 +1089,7 @@ describe('when the site config cannot be reached', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual( - await res.text(), - '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

', - ); + assert.strictEqual(await res.text(), messages.SITE_CONFIG_FAILED_HTML_MESSAGE); }); it('names the site config in x-error', async () => { From 1e73ef331d83aef7fb73c83b043131efe1bcaddf Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:49:39 +0200 Subject: [PATCH 40/49] fix: ignore the wrangler build dir the dev shim creates under dev/ --- eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 1c5ff742..abbd1c37 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ import { defineConfig, globalIgnores } from '@eslint/config-helpers'; import { recommended, source, test } from '@adobe/eslint-config-helix'; export default defineConfig([ - globalIgnores(['.vscode/*', '.wrangler/*', 'coverage/*']), + globalIgnores(['.vscode/*', '**/.wrangler/**', 'coverage/*']), { languageOptions: { ...recommended.languageOptions, From 62ec448e0b4b2efc885dc4e6217b96d0effeeeeb Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:51:43 +0200 Subject: [PATCH 41/49] test: say what the one lookup answers, and drop the knob for the probe that is gone --- test/routes/source-read.test.js | 54 ++++++++++++--------------------- 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 29ecd28f..c8245284 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,8 +20,7 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the two lookups answer between them: `exists` comes from the pipeline scope, -// `onSourceBus` from the admin scope +// what the one read of the pipeline scope answers const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -43,9 +42,8 @@ const build = async (overrides = {}) => { ? overrides.templateHtml : 'from the template'; const site = 'site' in overrides ? overrides.site : LEGACY_STORE; - const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); const { - busError, templateError, configError, composeError, config = null, + lookupError, templateError, configError, composeError, config = null, } = overrides; const seen = { bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, @@ -70,15 +68,16 @@ const build = async (overrides = {}) => { '../../src/storage/site.js': { default: async () => { seen.lookups += 1; - // throws for undefined, which is how the lookup reports a failure - if (site === undefined) throw lookupError; - if (busError) throw busError; + // the lookup reports a failure by throwing + if (lookupError) throw lookupError; + if (site === undefined) throw new TypeError('fetch failed'); return { exists: site.exists, head: headHtml, onSourceBus: site.onSourceBus }; }, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), - // the template is the only preview host read left on this path + // the template is the preview host read this stub owns. composeHtml reads metadata.json off + // the same host, and it is stubbed below, so the suite never sees that one getAEMHtml: async (aemCtx, path) => { if (templateError) throw templateError; seen.aem.push(path); @@ -118,7 +117,7 @@ describe('when the lookup cannot say which store holds the site', () => { // picking a store without an answer is a coin flip, and reading the wrong one hands the author // the wrong document at 200 it('refuses an html read with 503 and touches neither store', async () => { - const { daSourceGet, env, seen } = await build({ busError: dead() }); + const { daSourceGet, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -128,7 +127,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ busError: dead() }); + const { daSourceGet, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -139,7 +138,7 @@ describe('when the lookup cannot say which store holds the site', () => { // the preview iframe renders this body, and no store was asked: the one read that names the // store is what failed it('says the site could not be looked up, not that a store did not answer', async () => { - const { daSourceGet, env } = await build({ busError: dead() }); + const { daSourceGet, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -150,7 +149,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a non-html read too', async () => { - const { daSourceGet, env, seen } = await build({ busError: dead() }); + const { daSourceGet, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -160,7 +159,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a HEAD with 503 and no body', async () => { - const { daSourceHead, env, seen } = await build({ busError: dead() }); + const { daSourceHead, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -170,9 +169,9 @@ describe('when the lookup cannot say which store holds the site', () => { assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); - // the two lookups are two upstreams now, and both 503s share a status and an unparsed body + // every 503 shares a status and an unparsed body, so x-error is what names the upstream it('names the lookup in x-error', async () => { - const { daSourceGet, env } = await build({ busError: dead() }); + const { daSourceGet, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -181,7 +180,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('names the lookup on a HEAD too', async () => { - const { daSourceHead, env } = await build({ busError: dead() }); + const { daSourceHead, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -192,7 +191,7 @@ describe('when the lookup cannot say which store holds the site', () => { // the header is the only thing on the wire that separates a timeout from a dropped connection it('names the cause, not a category', async () => { const { daSourceGet, env } = await build({ - busError: new DOMException('timed out', 'TimeoutError'), + lookupError: new DOMException('timed out', 'TimeoutError'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -203,7 +202,7 @@ describe('when the lookup cannot say which store holds the site', () => { // rendering a thrown non-Error as "undefined: undefined" would leave the 503 saying nothing it('survives a thrown non-Error', async () => { - const { daSourceGet, env } = await build({ busError: 'boom' }); + const { daSourceGet, env } = await build({ lookupError: 'boom' }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -862,21 +861,6 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // the two lookups go out together, and a site that does not exist needs no store - // both failed, and the store answer is no use on its own - it('reports the failed site lookup when the probe failed with it', async () => { - const { daSourceGet, env } = await build({ - site: undefined, - busError: new TypeError('Network connection lost'), - }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.strictEqual(res.status, 503); - assert.match(res.headers.get('x-error'), /site lookup failed/); - }); - it('reports an unreachable store on a site with no head.html', async () => { const { daSourceGet, env } = await build({ headHtml: undefined, @@ -923,7 +907,7 @@ describe('reading from the store that holds the site', () => { }); // nothing composes an image, and the head that arrives with the existence answer is dropped - it('reads the same two lookups for an asset, and no more', async () => { + it('reads the config service once for an asset, and no more', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -933,7 +917,7 @@ describe('reading from the store that holds the site', () => { assert.deepStrictEqual(seen.head, []); }); - it('reads the same two lookups on a HEAD, and no more', async () => { + it('reads the config service once on a HEAD, and no more', async () => { const { daSourceHead, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); From e473c889e3a1a043dab2a3f9f770f10dbf54161b Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 15:31:35 +0200 Subject: [PATCH 42/49] chore: drop getOrgConfig, which nothing imports --- src/storage/config.js | 4 --- test/storage/config.test.js | 67 ------------------------------------- 2 files changed, 71 deletions(-) diff --git a/src/storage/config.js b/src/storage/config.js index 1b8816af..5d163793 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -37,7 +37,3 @@ async function fetchConfig(env, daCtx, path) { export async function getSiteConfig(env, daCtx) { return fetchConfig(env, daCtx, `/config/${daCtx.org}/${daCtx.site}`); } - -export async function getOrgConfig(env, daCtx) { - return fetchConfig(env, daCtx, `/config/${daCtx.org}`); -} diff --git a/test/storage/config.test.js b/test/storage/config.test.js index a0336a1a..b042189d 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -159,73 +159,6 @@ describe('Config Module', () => { }); }); - describe('getOrgConfig', () => { - it('should fetch org config successfully (single-sheet)', async () => { - const mockData = [ - { key: 'editor.ue.template', value: '/content=/templates' }, - { key: 'editor.ue.template', value: '/components=/blocks' }, - { key: 'editor.ue.template', value: '/assets=/media' }, - ]; - setMockResponse({ data: mockData }); - - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - - const expectedCall = { - url: 'https://admin.da.live/config/test-org', - opts: { - headers: new Headers({ - authorization: 'test-token', - }), - }, - }; - assert.strictEqual(mockFetch.lastCall.url, expectedCall.url); - assert.deepStrictEqual( - Object.fromEntries(mockFetch.lastCall.opts.headers.entries()), - Object.fromEntries(expectedCall.opts.headers.entries()), - ); - assert.deepStrictEqual(result, mockData); - }); - - it('should fetch org config successfully (multi-sheet)', async () => { - const multiSheet = { - data: { - total: 2, - limit: 2, - offset: 0, - data: [ - { key: 'org.setting', value: 'org-value' }, - { key: 'editor.ue.template', value: '/org=/org-templates.html' }, - ], - }, - library: { - total: 1, - limit: 1, - offset: 0, - data: [ - { - title: 'Org Blocks', path: 'https://content.da.live/org/library/blocks.json', format: '', ref: '', icon: '', experience: '', - }, - ], - }, - ':names': ['data', 'library'], - ':version': 3, - ':type': 'multi-sheet', - }; - setMockResponse(multiSheet); - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - // Should return only the first sheet's data array - assert.deepStrictEqual(result, multiSheet.data.data); - }); - - it('should return null when there is no config', async () => { - mockFetch.nextResponse = { ok: false, status: 404 }; - - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - - assert.strictEqual(result, null); - }); - }); - describe('Authorization handling', () => { it('should not include authorization header when authToken is not provided', async () => { const ctxWithoutToken = { org: 'test-org', site: 'test-site' }; From 6bd7be1df33dde0344dcc0efd2e13fd1fdf37b8e Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 15:50:01 +0200 Subject: [PATCH 43/49] test: the editor config names itself when it fails --- src/routes/da-admin.js | 10 ++++----- src/storage/config.js | 2 +- src/storage/site.js | 2 +- test/routes/da-admin.test.js | 4 ++-- test/routes/source-read.test.js | 16 +++++++-------- test/storage/config.test.js | 14 ++++++------- test/storage/site.test.js | 36 ++++++++++++++++----------------- 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 159449e0..9439e194 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -37,8 +37,8 @@ import { SOURCE_FAILED_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; -import { getSiteConfig } from '../storage/config.js'; -import getSite from '../storage/site.js'; +import { getEditorConfig } from '../storage/config.js'; +import getSiteConfig from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { @@ -95,7 +95,7 @@ function getTextBody(data) { async function getPageTemplate(env, daCtx, aemCtx) { // answers null for a site with no config, so any other failure throws - const config = await reach(SITE_CONFIG, () => getSiteConfig(env, daCtx)); + const config = await reach(SITE_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -129,7 +129,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store fails */ async function readSource(env, daCtx, init) { - const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const site = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); if (!site.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); @@ -302,7 +302,7 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); if (!exists) { console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); diff --git a/src/storage/config.js b/src/storage/config.js index 5d163793..7147054c 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -34,6 +34,6 @@ async function fetchConfig(env, daCtx, path) { return data; } -export async function getSiteConfig(env, daCtx) { +export async function getEditorConfig(env, daCtx) { return fetchConfig(env, daCtx, `/config/${daCtx.org}/${daCtx.site}`); } diff --git a/src/storage/site.js b/src/storage/site.js index fd55df7a..79626c45 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -28,7 +28,7 @@ const NO_SITE = { exists: false, head: undefined, onSourceBus: false }; * @param {Object} daCtx * @returns {Promise<{exists: boolean, head: string|undefined, onSourceBus: boolean}>} */ -export default async function getSite(env, daCtx) { +export default async function getSiteConfig(env, daCtx) { const { org, site, ref } = daCtx; if (!org || !site) return NO_SITE; diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 03286b75..75840c8a 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -144,8 +144,8 @@ describe('daSourceGet', () => { buildQuickEditCookie: (p) => `da-quick-edit=${encodeURIComponent(p)}; Path=/`, }, '../../src/storage/config.js': { - // da-admin answers a site with no config with a 404, which getSiteConfig reports as null - getSiteConfig: async () => null, + // da-admin answers a site with no config with a 404, which getEditorConfig reports as null + getEditorConfig: async () => null, }, })).daSourceGet; }; diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index c8245284..4df056cd 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -97,8 +97,8 @@ const build = async (overrides = {}) => { applyUEInstrumentation: async () => { seen.ue += 1; }, }, '../../src/storage/config.js': { - // da-admin answers a site with no config with a 404, which getSiteConfig reports as null - getSiteConfig: async () => { + // da-admin answers a site with no config with a 404, which getEditorConfig reports as null + getEditorConfig: async () => { if (configError) throw configError; return config; }, @@ -1044,7 +1044,7 @@ describe('reading from the store that holds the site', () => { }); }); -describe('when the site config cannot be reached', () => { +describe('when the editor config cannot be reached', () => { afterEach(() => { delete globalThis.fetch; }); @@ -1067,22 +1067,22 @@ describe('when the site config cannot be reached', () => { // the config is read off da-admin while the document can be on the source bus, so naming the // store would name a system that answered - it('says the site config failed, not the store', async () => { + it('says the editor config failed, not the store', async () => { const { daSourceGet, env } = await build(missing()); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(await res.text(), messages.SITE_CONFIG_FAILED_HTML_MESSAGE); + assert.strictEqual(await res.text(), '

503: Editor config failed

The editor configuration for this site could not be read. Please retry, or contact your project admin if it persists.

'); }); - it('names the site config in x-error', async () => { + it('names the editor config in x-error', async () => { const { daSourceGet, env } = await build(missing()); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'site config failed: TypeError: fetch failed'); + assert.strictEqual(res.headers.get('x-error'), 'editor config failed: TypeError: fetch failed'); }); it('asks the caller to retry', async () => { @@ -1174,7 +1174,7 @@ describe('a read that carries no token', () => { }); }); -describe('a path the site config gives a template', () => { +describe('a path the editor config gives a template', () => { afterEach(() => { delete globalThis.fetch; }); diff --git a/test/storage/config.test.js b/test/storage/config.test.js index b042189d..73549234 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -67,7 +67,7 @@ describe('Config Module', () => { } }; - describe('getSiteConfig', () => { + describe('getEditorConfig', () => { it('should fetch site config successfully (single-sheet)', async () => { const mockData = [ { key: 'editor.ue.template', value: '/content=/templates' }, @@ -75,7 +75,7 @@ describe('Config Module', () => { ]; setMockResponse({ data: mockData }); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', @@ -122,7 +122,7 @@ describe('Config Module', () => { ':type': 'multi-sheet', }; setMockResponse(multiSheet); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); // Should return only the first sheet's data array assert.deepStrictEqual(result, multiSheet.data.data); }); @@ -130,7 +130,7 @@ describe('Config Module', () => { it('should return null when there is no config', async () => { mockFetch.nextResponse = { ok: false, status: 404 }; - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); assert.strictEqual(result, null); }); @@ -141,7 +141,7 @@ describe('Config Module', () => { it(`should return null when the store answers ${status}`, async () => { mockFetch.nextResponse = { ok: false, status }; - assert.strictEqual(await configModule.getSiteConfig(mockEnv, mockDaCtx), null); + assert.strictEqual(await configModule.getEditorConfig(mockEnv, mockDaCtx), null); }); }); @@ -152,7 +152,7 @@ describe('Config Module', () => { mockFetch.nextResponse = { ok: false, status }; await assert.rejects( - () => configModule.getSiteConfig(mockEnv, mockDaCtx), + () => configModule.getEditorConfig(mockEnv, mockDaCtx), new RegExp(String(status)), ); }); @@ -166,7 +166,7 @@ describe('Config Module', () => { { key: 'editor.ue.template', value: '/content=/templates' }, ]); - await configModule.getSiteConfig(mockEnv, ctxWithoutToken); + await configModule.getEditorConfig(mockEnv, ctxWithoutToken); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 54f2eea4..78ae49c1 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -13,7 +13,7 @@ /* eslint-env mocha */ import assert from 'assert'; -const { default: getSite } = await import('../../src/storage/site.js'); +const { default: getSiteConfig } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', @@ -45,7 +45,7 @@ const config = (over = {}) => new Response(JSON.stringify({ const onBus = () => config({ contentSource: { type: 'markup', url: 'https://api.aem.live/org/sites/site/source' } }); -describe('getSite', () => { +describe('getSiteConfig', () => { afterEach(() => { delete globalThis.fetch; }); @@ -54,7 +54,7 @@ describe('getSite', () => { it('asks the config service once, for the pipeline scope', async () => { stubFetch(config); - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); assert.strictEqual(calls.length, 1); assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); @@ -63,7 +63,7 @@ describe('getSite', () => { it('sends the shared secret', async () => { stubFetch(config); - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); const headers = new Headers(calls[0].init.headers); assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); @@ -73,7 +73,7 @@ describe('getSite', () => { it('gives up rather than hanging', async () => { stubFetch(config); - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); }); @@ -83,7 +83,7 @@ describe('getSite', () => { it('answers existence, head.html and the store together', async () => { stubFetch(onBus); - assert.deepStrictEqual(await getSite(env, daCtx()), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { exists: true, head: HEAD, onSourceBus: true, }); }); @@ -91,14 +91,14 @@ describe('getSite', () => { it('reads the url, not the type, since both stores are markup', async () => { stubFetch(config); - const { onSourceBus } = await getSite(env, daCtx()); + const { onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(onSourceBus, false); }); it('takes the source bus origin from env', async () => { stubFetch(() => config({ contentSource: { type: 'markup', url: 'https://api.stage.example/o/sites/s/source' } })); - const { onSourceBus } = await getSite({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); + const { onSourceBus } = await getSiteConfig({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); assert.strictEqual(onSourceBus, true); }); @@ -106,7 +106,7 @@ describe('getSite', () => { it(`answers legacy for ${new URL(url).host}`, async () => { stubFetch(() => config({ contentSource: { type: 'markup', url } })); - const { onSourceBus } = await getSite(env, daCtx()); + const { onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(onSourceBus, false); }); }); @@ -115,7 +115,7 @@ describe('getSite', () => { it('answers a missing head as undefined, and still names the store', async () => { stubFetch(() => config({ head: undefined })); - const { exists, head, onSourceBus } = await getSite(env, daCtx()); + const { exists, head, onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(exists, true); assert.strictEqual(head, undefined); assert.strictEqual(onSourceBus, false); @@ -126,7 +126,7 @@ describe('getSite', () => { it('answers no-site on a 404', async () => { stubFetch(() => new Response('', { status: 404 })); - assert.deepStrictEqual(await getSite(env, daCtx()), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { exists: false, head: undefined, onSourceBus: false, }); }); @@ -141,7 +141,7 @@ describe('getSite', () => { it(`answers no-site without asking: ${what}`, async () => { stubFetch(config); - assert.deepStrictEqual(await getSite(env, daCtx(over)), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx(over)), { exists: false, head: undefined, onSourceBus: false, }); assert.strictEqual(calls.length, 0); @@ -153,7 +153,7 @@ describe('getSite', () => { it('reads as legacy, since that is where a site without one has always been', async () => { stubFetch(() => config({ contentSource: undefined })); - assert.deepStrictEqual(await getSite(env, daCtx()), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { exists: true, head: HEAD, onSourceBus: false, }); }); @@ -167,7 +167,7 @@ describe('getSite', () => { console.warn = (m) => warnings.push(m); try { - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); } finally { console.warn = saved; } @@ -180,7 +180,7 @@ describe('getSite', () => { it('answers the same when the source carries no url', async () => { stubFetch(() => config({ contentSource: { type: 'markup' } })); - const { onSourceBus } = await getSite(env, daCtx()); + const { onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(onSourceBus, false); }); }); @@ -190,14 +190,14 @@ describe('getSite', () => { it(`throws on a ${status}`, async () => { stubFetch(() => new Response('', { status })); - await assert.rejects(() => getSite(env, daCtx()), new RegExp(`${status}`)); + await assert.rejects(() => getSiteConfig(env, daCtx()), new RegExp(`${status}`)); }); }); it('throws when the answer is not json', async () => { stubFetch(() => new Response('', { status: 200 })); - await assert.rejects(() => getSite(env, daCtx())); + await assert.rejects(() => getSiteConfig(env, daCtx())); }); // the cause reaches the caller, which reports it on the 503 as `x-error` @@ -206,7 +206,7 @@ describe('getSite', () => { throw new TypeError('fetch failed'); }); - await assert.rejects(getSite(env, daCtx()), { message: 'fetch failed' }); + await assert.rejects(getSiteConfig(env, daCtx()), { message: 'fetch failed' }); }); }); }); From e96aae5f81358af6e39aa43921b4d2669d525f54 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 15:50:21 +0200 Subject: [PATCH 44/49] fix: name the editor config read for what it reads --- src/routes/da-admin.js | 8 ++++---- src/utils/constants.js | 2 +- src/utils/upstream.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 9439e194..491b722a 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -28,7 +28,7 @@ import { DEFAULT_HTML_TEMPLATE, SITE_LOOKUP_FAILED_HTML_MESSAGE, PREVIEW_FAILED_HTML_MESSAGE, - SITE_CONFIG_FAILED_HTML_MESSAGE, + EDITOR_CONFIG_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -44,7 +44,7 @@ import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { CONTENT_STORE, PREVIEW_HOST, - SITE_CONFIG, + EDITOR_CONFIG, SITE_LOOKUP, UpstreamError, reach, @@ -55,7 +55,7 @@ const HTML_POST_TYPE = 'text/html'; /** Names the upstream that failed, since the default body names the store. */ const UPSTREAM_FAILURE_HTML = { [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, - [SITE_CONFIG]: SITE_CONFIG_FAILED_HTML_MESSAGE, + [EDITOR_CONFIG]: EDITOR_CONFIG_FAILED_HTML_MESSAGE, [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, }; // a write reaches no store until the lookup answers, so a failed lookup says the destination is @@ -95,7 +95,7 @@ function getTextBody(data) { async function getPageTemplate(env, daCtx, aemCtx) { // answers null for a site with no config, so any other failure throws - const config = await reach(SITE_CONFIG, () => getEditorConfig(env, daCtx)); + const config = await reach(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config diff --git a/src/utils/constants.js b/src/utils/constants.js index de496586..5093c3a5 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -55,7 +55,7 @@ export const SITE_NOT_FOUND_MESSAGE = 'There is no site at this address, so noth export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; -export const SITE_CONFIG_FAILED_HTML_MESSAGE = '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

'; +export const EDITOR_CONFIG_FAILED_HTML_MESSAGE = '

503: Editor config failed

The editor configuration for this site could not be read. Please retry, or contact your project admin if it persists.

'; export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 56e87924..ec83b926 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -13,7 +13,7 @@ /** Names the upstream in the worker log and in `x-error`. */ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; -export const SITE_CONFIG = 'site config'; +export const EDITOR_CONFIG = 'editor config'; export const SITE_LOOKUP = 'site lookup'; /** From c2268bc5718ceb3ae5773cddfd371004d76b429a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:28:19 +0200 Subject: [PATCH 45/49] chore: clarify naming Co-authored-by: Tobias Bocanegra --- src/utils/upstream.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/upstream.js b/src/utils/upstream.js index ec83b926..f1ac8f5f 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -42,16 +42,16 @@ export class UpstreamError extends Error { } /** - * Runs `read` and rethrows anything it throws as an UpstreamError naming `upstream`. + * Runs `call` and rethrows anything it throws as an UpstreamError naming `upstream`. * * @param {string} upstream one of the names at the top of this file - * @param {() => Promise} read + * @param {() => Promise} call * @returns {Promise} * @template T */ -export async function reach(upstream, read) { +export async function withUpstream(upstream, call) { try { - return await read(); + return await call(); } catch (e) { // keeps the inner upstream name rather than overwriting it if (e instanceof UpstreamError) throw e; From 52637bdcd5a8fb330750d3be3b82c5efef1f05f8 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:42:36 +0200 Subject: [PATCH 46/49] fix: keep latin-1 characters in x-error instead of blanking them Co-authored-by: Tobias Bocanegra --- src/utils/upstream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/upstream.js b/src/utils/upstream.js index f1ac8f5f..5dad13ba 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -21,7 +21,7 @@ export const SITE_LOOKUP = 'site lookup'; */ export function causeOf(e) { return `${e?.name ?? 'Error'}: ${e?.message ?? e}` - .replace(/[^\x20-\x7e]/g, ' ') + .replace(/[^\t\u0020-\u007E\u0080-\u00FF]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 1024); From c2364ea3eadbb5748d521019c188d10a41ab18ac Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:53:03 +0200 Subject: [PATCH 47/49] fix: finish the withUpstream rename in da-admin, the branch did not load --- src/routes/da-admin.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 491b722a..931424cb 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -47,7 +47,7 @@ import { EDITOR_CONFIG, SITE_LOOKUP, UpstreamError, - reach, + withUpstream, } from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; @@ -95,7 +95,7 @@ function getTextBody(data) { async function getPageTemplate(env, daCtx, aemCtx) { // answers null for a site with no config, so any other failure throws - const config = await reach(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); + const config = await withUpstream(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -112,7 +112,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { } const templatePath = matchingTemplates[0].template; - const templateHtml = await reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); + const templateHtml = await withUpstream(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); if (templateHtml) { return templateHtml; } @@ -129,7 +129,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store fails */ async function readSource(env, daCtx, init) { - const site = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); + const site = await withUpstream(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); if (!site.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); @@ -139,7 +139,7 @@ async function readSource(env, daCtx, init) { const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); return { - response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)), + response: await withUpstream(CONTENT_STORE, () => store.fetch(store.url, init)), head: site.head, }; } @@ -302,7 +302,10 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); + const { exists, onSourceBus } = await withUpstream( + SITE_LOOKUP, + () => getSiteConfig(env, daCtx), + ); if (!exists) { console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); @@ -319,7 +322,7 @@ async function sourcePost({ req, env, daCtx }) { const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); - const response = await reach(CONTENT_STORE, () => store.fetch(new Request(store.url, { + const response = await withUpstream(CONTENT_STORE, () => store.fetch(new Request(store.url, { method: 'POST', body, headers: { Authorization: authToken }, From 6f2363ff0c4bcd194647cce503c465abd16e76ec Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:53:03 +0200 Subject: [PATCH 48/49] test: causeOf keeps latin-1 and blanks the controls --- test/utils/upstream.test.js | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/utils/upstream.test.js diff --git a/test/utils/upstream.test.js b/test/utils/upstream.test.js new file mode 100644 index 00000000..20d7698f --- /dev/null +++ b/test/utils/upstream.test.js @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import assert from 'assert'; +import { describe, it } from 'mocha'; +import { causeOf } from '../../src/utils/upstream.js'; + +describe('causeOf', () => { + it('names the error and its message', () => { + assert.strictEqual(causeOf(new TypeError('Network lost')), 'TypeError: Network lost'); + }); + + it('keeps latin-1 characters, which a header value can carry', () => { + assert.strictEqual(causeOf(new Error('café ÿ')), 'Error: café ÿ'); + }); + + it('replaces a character a header value cannot carry', () => { + assert.strictEqual(causeOf(new Error('a ’ b')), 'Error: a b'); + }); + + it('replaces the controls that are not a header value character', () => { + // tab, newline, carriage return, NUL, DEL and a C1 control + const controls = [0x09, 0x0a, 0x0d, 0x00, 0x7f, 0x85] + .map((c) => String.fromCharCode(c)) + .join(''); + assert.strictEqual(causeOf(new Error(`a${controls}b`)), 'Error: a b'); + }); + + it('caps the length', () => { + assert.strictEqual(causeOf(new Error('x'.repeat(2000))).length, 1024); + }); + + it('renders what is thrown when it is not an Error', () => { + assert.strictEqual(causeOf('boom'), 'Error: boom'); + assert.strictEqual(causeOf(undefined), 'Error: undefined'); + }); + + // the point of the sanitizing: whatever comes back is settable, so no failure path throws while + // reporting a failure + it('answers a string that Headers accepts, for any code point', () => { + for (let i = 0; i <= 0x2fff; i += 1) { + const value = causeOf(new Error(`a${String.fromCharCode(i)}b`)); + assert.doesNotThrow(() => new Headers({ 'x-error': value }), `code point 0x${i.toString(16)}`); + } + }); +}); From be3a7bb28e516d035489b6001edeaf10bee51ae6 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:53:03 +0200 Subject: [PATCH 49/49] fix: blank the c1 controls, invisible in x-error --- src/utils/upstream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 5dad13ba..cf73bb0e 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -21,7 +21,7 @@ export const SITE_LOOKUP = 'site lookup'; */ export function causeOf(e) { return `${e?.name ?? 'Error'}: ${e?.message ?? e}` - .replace(/[^\t\u0020-\u007E\u0080-\u00FF]/g, ' ') + .replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 1024);