Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
c31263d
test: a missing site is the 404, and an upstream that cannot answer i…
benpeter Aug 11, 2026
4686062
fix: ask the config service what exists, and stop guessing what silen…
benpeter Aug 11, 2026
b33f8cc
chore: point at the config service, and deploy with the wrangler the …
benpeter Aug 11, 2026
e5a14ce
ci: bump wrangler-action to v4, which knows the required-secrets decl…
benpeter Aug 11, 2026
0a29287
test: the page head comes from the config service, and a refused read…
benpeter Aug 12, 2026
1ffeb81
fix: read head.html from the config service, not the preview host
benpeter Aug 12, 2026
bd96daa
test: the store the lookup could not name outranks a failed head read
benpeter Aug 12, 2026
01082cd
fix: report the store the lookup could not name before a failed head …
benpeter Aug 12, 2026
fd17d55
fix: declare the config service token in the dev env, so .dev.vars.de…
benpeter Aug 12, 2026
ce03d15
test: pin the head read to the html GET, and keep the preview host 50…
benpeter Aug 12, 2026
403c160
test: /ping decides the store, the config service decides existence a…
benpeter Aug 12, 2026
4be8875
fix: ask /ping which store holds the site, and the config service wha…
benpeter Aug 12, 2026
dfe6d96
test: a /ping that refuses is no answer, not a legacy site
benpeter Aug 12, 2026
5f0ff03
fix: refuse when /ping cannot answer, instead of reading it as legacy
benpeter Aug 12, 2026
bd5bc35
test: a save is refused when the config service cannot answer
benpeter Aug 12, 2026
a36dab8
test: a save refused on the site lookup says the store is undetermined
benpeter Aug 12, 2026
47d66fa
fix: refuse a save when the site lookup cannot answer
benpeter Aug 12, 2026
b490ca3
test: the store comes from the config service content source, not /ping
benpeter Aug 13, 2026
c2162a4
fix: take the store from the config service content source, and drop …
benpeter Aug 13, 2026
e3e9ecf
chore: name the two config scopes, not /ping, in the comments and tests
benpeter Aug 13, 2026
dc0fcec
test: a write asks the store lookup only
benpeter Aug 14, 2026
11ae458
fix: ask one lookup on a write, since the write never reads the other…
benpeter Aug 14, 2026
46b5c5f
test: drop the knobs a write no longer reaches, and name what the ass…
benpeter Aug 14, 2026
81c566c
test: drop the site lookup stub from the write case, which no longer …
benpeter Aug 14, 2026
c646d21
test: cover the cookie route, and point the exchange at the source bu…
benpeter Aug 14, 2026
8c01ab9
fix: exchange the site token on the source bus api, from env
benpeter Aug 14, 2026
708c0d1
test: one config read answers existence, head.html and the store
benpeter Aug 14, 2026
b8b63ee
fix: take existence, head.html and the store from one config read
benpeter Aug 14, 2026
2bc12f4
test: a config that names no content source reads as legacy, and says so
benpeter Aug 14, 2026
461236e
fix: read a config with no content source as legacy, and warn
benpeter Aug 14, 2026
39b5730
chore: plainer wording for the config read in the readme
benpeter Aug 14, 2026
f7de96e
test: pin the ctx get.js hands to getCookie
benpeter Aug 14, 2026
0fcd363
chore: say what throws an UpstreamError
benpeter Aug 14, 2026
fba8ff6
fix: a 503 heading says the read failed, not that the upstream was un…
benpeter Aug 14, 2026
d9d9df2
refactor: name the 503 path for a failed read, not an unreachable ups…
benpeter Aug 14, 2026
f0fd2bd
test: a write is refused for a site the config service does not know
benpeter Aug 14, 2026
3cafda2
fix: refuse a write for a site the config service does not know
benpeter Aug 14, 2026
46ed7ec
test: a failed site config read says so, instead of naming the store
benpeter Aug 14, 2026
411ac04
fix: name the site config in its own 503 body
benpeter Aug 14, 2026
1e73ef3
fix: ignore the wrangler build dir the dev shim creates under dev/
benpeter Aug 14, 2026
62ec448
test: say what the one lookup answers, and drop the knob for the prob…
benpeter Aug 14, 2026
e473c88
chore: drop getOrgConfig, which nothing imports
benpeter Aug 14, 2026
6bd7be1
test: the editor config names itself when it fails
benpeter Aug 14, 2026
e96aae5
fix: name the editor config read for what it reads
benpeter Aug 14, 2026
c2268bc
chore: clarify naming
benpeter Aug 17, 2026
52637bd
fix: keep latin-1 characters in x-error instead of blanking them
benpeter Aug 17, 2026
c2364ea
fix: finish the withUpstream rename in da-admin, the branch did not load
benpeter Aug 17, 2026
6f2363f
test: causeOf keeps latin-1 and blanks the controls
benpeter Aug 17, 2026
be3a7bb
fix: blank the c1 controls, invisible in x-error
benpeter Aug 17, 2026
8a315e8
Merge remote-tracking branch 'origin/main' into headhtml
benpeter Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ jobs:

- name: Deploy to Cloudflare Workers (production)
if: github.ref_name == 'main'
uses: cloudflare/wrangler-action@v3
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'
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jspm_packages/
.env.production.local
.env.local

.dev.vars
.dev.vars*
.wrangler/
.DS_Store
.cursor
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,26 @@ 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.

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:

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: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.

with the shared secret, use `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN="<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.
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
47 changes: 47 additions & 0 deletions dev/lookup-shim.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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. 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/',
};

// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with
const HEAD_HTML = '<link rel="stylesheet" href="/styles/styles.css"/>\n<script src="/scripts/scripts.js" type="module"></script>\n';

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.' } });
}

// both stores are `type: markup`, so only the url separates them
const body = JSON.stringify({
ref,
site,
org,
head: { html: HEAD_HTML },
contentSource: { type: 'markup', url: source },
});
return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
},
};
9 changes: 9 additions & 0 deletions dev/lookup-shim.toml
Original file line number Diff line number Diff line change
@@ -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-lookup-shim"
main = "lookup-shim.js"
compatibility_date = "2023-11-21"

[dev]
port = 4713
inspector_port = 9234
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"deploy": "wrangler deploy",
"deploy:stage": "wrangler deploy --env stage",
"dev": "wrangler dev --local-protocol https --env dev",
"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 ."
Expand Down
12 changes: 9 additions & 3 deletions src/handlers/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/responses/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
8 changes: 4 additions & 4 deletions src/routes/cookie.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' });
Expand All @@ -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();
Expand Down
Loading