feat: add free-tier composition origin failover asset
Select a ready origin before dispatch, preserve authentication requests without replay, and document Cloudflare compatibility and activation gates. Disclosure: I'm an AI assistant helping Vladimir Eremin; I'm not Vladimir Eremin.
This commit is contained in:
1 parent
3e26df259f
commit
dabfd8dbc8
3 files changed
+441
No files matched your search
@@ -0,0 +1,87 @@
|
|||||||
|
# Readiness-gated origin routing on Workers Free
|
||||||
|
|
||||||
|
This local ES-module Worker asset selects between two origins for one public HTTPS hostname. It implements the ingress part of the [composition replication RFC](../../docs/rfcs/composition-replication.md). It is not deployed and does not establish production high availability. Activate it only after backend HA and authenticated application readiness are verified.
|
||||||
|
|
||||||
|
The module has no runtime dependencies, paid bindings, KV, R2, storage or scheduled jobs. This directory does not enable billing, enroll an account, upload a Worker or change DNS/routes.
|
||||||
|
|
||||||
|
## Bindings
|
||||||
|
|
||||||
|
Set these string bindings through the operator's chosen Cloudflare deployment process. Keep the readiness token in a Worker secret, never a committed variable, URL or log.
|
||||||
|
|
||||||
|
| Binding | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| `APEX_PUBLIC_HOST` | Existing public SSO hostname, for example `sso.example.com` |
|
||||||
|
| `APEX_ZONE` | The actual Cloudflare DNS zone, for example `example.com` |
|
||||||
|
| `APEX_ORIGIN_PRIMARY` | Dedicated DNS-only primary origin hostname in that zone |
|
||||||
|
| `APEX_ORIGIN_SECONDARY` | Dedicated DNS-only secondary origin hostname in that zone |
|
||||||
|
| `APEX_READY_PATH` | Dedicated absolute readiness path, for example `/.well-known/apex-ready` |
|
||||||
|
| `APEX_READY_TOKEN` | High-entropy secret shared only by this Worker and both readiness adapters |
|
||||||
|
|
||||||
|
Hostnames accept ordinary ASCII DNS labels, without a scheme, port, trailing dot, wildcard or IP literal. Case is normalized. Public and origin hostnames must be distinct and within the declared zone. Readiness paths accept ASCII letters, digits, `.`, `_`, `~`, `-` and `/`; queries, fragments, escapes, repeated slashes and dot traversal segments are rejected. The secret must be nonempty visible ASCII without whitespace. Invalid/missing bindings produce a generic no-store 503 before any fetch. Syntax validation cannot establish actual DNS ownership or prevent an operator from naming a separately delegated zone; deployment must verify both origins are in the actual same Cloudflare zone.
|
||||||
|
|
||||||
|
## Routing and readiness contract
|
||||||
|
|
||||||
|
Create two dedicated DNS-only origin records pointing to the participating servers; do not point either at the public hostname or a Worker. Retain the existing proxied public SSO DNS record. Use a **Worker Route** such as `sso.example.com/*` on that existing hostname, not a Worker Custom Domain. Both origins must serve HTTPS with a valid certificate and virtual host for the public name. Configure strict origin TLS verification. Disable any Workers Cache in front of this Worker and any cache policy that would bypass its invocation. [Cloudflare Routes](https://developers.cloudflare.com/workers/configuration/routing/routes/)
|
||||||
|
|
||||||
|
Require `global_fetch_private_origin` in the Worker compatibility flags and prohibit `global_fetch_strictly_public`. The strictly-public flag sends same-zone global fetches through Cloudflare's public routing layer and can re-enter this Worker; a readiness probe would then hit its public-path rejection instead of the origin. Private-origin behavior routes same-zone subrequests to the origin, bypassing mapped Workers and Cloudflare security settings, so the origin adapter must still enforce its own authentication. [Global fetch compatibility](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public)
|
||||||
|
|
||||||
|
Set `compatibility_date` to **2024-11-11 or later** and do not set `cache_option_disabled`. `cache_option_enabled` became the default on that date; the example also enables it explicitly. Without this behavior, constructing requests with `cache: "no-store"` throws instead of dispatching. [Request cache compatibility](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-cache-no-store-http-standard-api)
|
||||||
|
|
||||||
|
The required non-secret upload metadata fields are:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"main_module": "worker.mjs",
|
||||||
|
"compatibility_date": "2026-10-10",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"global_fetch_private_origin",
|
||||||
|
"cache_option_enabled"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this as the multipart API `metadata` portion, with the ES-module file part named `worker.mjs`. Bindings and the readiness secret are configured separately through the operator-owned enrollment process; they are deliberately absent from this example, as are paid bindings. This metadata is not a complete enrollment or route activation command. Verify the resulting effective settings before activation, including after later uploads. [Multipart upload metadata](https://developers.cloudflare.com/workers/configuration/multipart-upload-metadata/)
|
||||||
|
|
||||||
|
Every accepted HTTPS request first probes the primary with a GET at `https://APEX_PUBLIC_HOST/APEX_READY_PATH`. The request carries only `X-APEX-Ready-Token`, rather than client cookies or authorization. `cf.resolveOverride` selects the DNS-only origin while the URL remains the public hostname. Only status 204 is ready; redirects are manual and never accepted. Each probe has a two-second deadline. Unread response bodies are canceled, including responses arriving after timeout. Failed primary readiness leads to a secondary probe; neither ready produces a no-store 503. There is no readiness cache or background health state.
|
||||||
|
|
||||||
|
The dedicated origin adapter must authenticate the token, reject its absence/incorrect value and prove that this application instance can safely serve traffic: required dependencies, compatible configuration, current write authority/quorum, session state and any application-specific prerequisites. Authelia `/api/health`, an nginx 204, a TCP check or a running container alone is insufficient. Protect adapter aliases and normalize paths consistently at the origin. The Worker rejects public requests to the configured readiness path (also with a query or percent-encoded spelling), and removes every client-supplied readiness header before application dispatch. This asset does not implement or enroll the backend adapter.
|
||||||
|
|
||||||
|
Cloudflare documents `resolveOverride` as DNS substitution while retaining the URL's Host, subject to both names belonging to the same zone. The asset retains the HTTPS public URL for routing; actual origin TLS SNI/certificate selection and routing must be demonstrated natively before activation. No local Node test proves Cloudflare DNS/TLS behavior. [Request API](https://developers.cloudflare.com/workers/runtime-apis/request/)
|
||||||
|
|
||||||
|
## Application request behavior
|
||||||
|
|
||||||
|
After selection, the original method, URL/path/query, body, cookies and authorization are forwarded once. The body is streamed; no request-body buffering or replay copy is made. The client readiness header is removed. Application redirects remain manual, and response status, body, Location and separate Set-Cookie headers are preserved.
|
||||||
|
|
||||||
|
**No application request is replayed after an origin failure, including GET callbacks.** A fetch exception returns a generic no-store 502; an origin HTTP error is returned with its original status/body. There is no second probe or alternative-origin dispatch after this point. Readiness can become stale between probe and dispatch; failover applies to subsequent requests. Application requests have no additional Worker timeout so streaming is not cut off; platform/client deadlines still apply.
|
||||||
|
|
||||||
|
All probe and application fetches use `cache: "no-store"`, `cacheEverything: false` and negative `cacheTtlByStatus`. Ordinary responses override browser/CDN cache headers to no-store. There is no Cache API use. Verify native cache bypass with cookies, authorization, redirects and authentication error responses before enrollment. [Fetch caching](https://developers.cloudflare.com/workers/runtime-apis/fetch/)
|
||||||
|
|
||||||
|
A platform status-101 response is returned unchanged to preserve Cloudflare's WebSocket upgrade object. The Worker does not terminate or inspect WebSocket messages, migrate existing connections, reconnect clients or claim protocol HA. Ordinary response bodies remain streams. Native WebSocket behavior is an optional deployment test if the application needs it; the local test only checks unchanged forwarding of the platform response. XHTTP and XHTTP-over-Cloudflare are excluded from this SSO HA scope. [Workers Response API](https://developers.cloudflare.com/workers/runtime-apis/response/)
|
||||||
|
|
||||||
|
Other hostnames, non-default ports and non-HTTPS requests receive no-store 421. The public route must already have the intended HTTPS policy. Client errors and unavailable responses contain no secret diagnostics; the module emits no logs.
|
||||||
|
|
||||||
|
## Free-tier deployment gates
|
||||||
|
|
||||||
|
Workers Free currently provides **100,000 incoming requests per day per account**, reset at midnight UTC, and **10 ms CPU per HTTP request**. Quota is shared with the account's other Workers. This asset performs two fetch subrequests normally (one probe plus application dispatch), or three when primary readiness fails. Waiting for network I/O does not count toward CPU time, but actual Worker CPU consumption must be measured; Node wall time is not that measurement. [Cloudflare Workers limits](https://developers.cloudflare.com/workers/platform/limits/)
|
||||||
|
|
||||||
|
Keep the account on Workers Free. Do not enable a paid plan, paid load balancer or paid bindings for this asset. An operator must inspect existing account usage and peak traffic to confirm the free quota is sufficient. This is a no-added-cost deployment candidate, not unlimited availability.
|
||||||
|
|
||||||
|
Set the Worker Route to **fail closed**. On Free quota exhaustion, Cloudflare documents a 1027 error page for this mode; fail open bypasses the Worker and is unacceptable here. The module cannot enforce the platform route setting or prevent quota exhaustion. Record and verify that setting before activation.
|
||||||
|
|
||||||
|
Operator-owned enrollment gates:
|
||||||
|
|
||||||
|
1. Validate backend HA, adapter authentication/readiness, dependency loss and write-authority withdrawal independently of this Worker.
|
||||||
|
2. Confirm account Free plan/quota, same-zone DNS-only origin records, public proxied Route, strict TLS, native SNI/Host at each origin, required private-origin/cache compatibility flags and date, and no Worker/route recursion or pre-Worker caching.
|
||||||
|
3. Test native primary/secondary readiness, both-unready failure, redirects, cookies, cache bypass, POST and GET callback non-replay, streaming and any required WebSockets. Measure Worker CPU and account request headroom.
|
||||||
|
4. Verify fail-closed quota handling and exercise location loss, network partitions, stale/rejoining origins and recovery. Record observed recovery time and session behavior before making an HA claim.
|
||||||
|
5. Activate only after those gates pass with the intended backend HA. Maintain a documented operator rollback that does not silently route to an unsafe origin.
|
||||||
|
|
||||||
|
## Local verification
|
||||||
|
|
||||||
|
From the shared APEX repository root, with Node available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --test assets/composition-failover/worker.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Tested with Node v24.11.1 using its built-in test runner and Web APIs. Tests call the exported handler and replace only the external fetch boundary, with fake timers for probe deadlines. They do not call Cloudflare or use credentials. No npm install, new dependency or change to the default Python test workflow is required. Native Cloudflare deployment is deliberately outside this local test's proof.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
const READY_HEADER = 'X-APEX-Ready-Token';
|
||||||
|
const PROBE_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
|
function hostname(value) {
|
||||||
|
if (typeof value !== 'string' || value.length > 253 || !value.includes('.')) return null;
|
||||||
|
const name = value.toLowerCase();
|
||||||
|
if (!name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) return null;
|
||||||
|
if (/^[0-9.]+$/.test(name)) return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuration(env) {
|
||||||
|
if (!env) return null;
|
||||||
|
const zone = hostname(env.APEX_ZONE);
|
||||||
|
const publicHost = hostname(env.APEX_PUBLIC_HOST);
|
||||||
|
const primary = hostname(env.APEX_ORIGIN_PRIMARY);
|
||||||
|
const secondary = hostname(env.APEX_ORIGIN_SECONDARY);
|
||||||
|
if (!zone || ![publicHost, primary, secondary].every((name) =>
|
||||||
|
name && (name === zone || name.endsWith('.' + zone)))) return null;
|
||||||
|
if (new Set([publicHost, primary, secondary]).size !== 3) return null;
|
||||||
|
const path = env.APEX_READY_PATH;
|
||||||
|
if (typeof path !== 'string' || !/^\/[a-zA-Z0-9._~/-]*$/.test(path)
|
||||||
|
|| path.includes('//') || path.split('/').some((part) => part === '.' || part === '..')) return null;
|
||||||
|
const token = env.APEX_READY_TOKEN;
|
||||||
|
if (typeof token !== 'string' || !/^[\x21-\x7e]+$/.test(token)) return null;
|
||||||
|
return { publicHost, primary, secondary, path, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable(status = 503, message = 'Service unavailable') {
|
||||||
|
return new Response(message, { status, headers: { 'Cache-Control': 'private, no-store' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(origin) {
|
||||||
|
return {
|
||||||
|
redirect: 'manual',
|
||||||
|
cache: 'no-store',
|
||||||
|
cf: { resolveOverride: origin, cacheEverything: false, cacheTtlByStatus: { '100-599': -1 } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard(response) {
|
||||||
|
// Do not let a slow body cancellation extend the probe deadline.
|
||||||
|
if (response.body) response.body.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready(config, origin) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer;
|
||||||
|
const deadline = new Promise((resolve) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
resolve(false);
|
||||||
|
}, PROBE_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const probe = new Request(`https://${config.publicHost}${config.path}`, {
|
||||||
|
headers: { [READY_HEADER]: config.token },
|
||||||
|
redirect: 'manual',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const inspection = fetch(probe, { ...options(origin), signal: controller.signal })
|
||||||
|
.then((response) => {
|
||||||
|
const accepted = !controller.signal.aborted && response.status === 204;
|
||||||
|
discard(response);
|
||||||
|
return accepted;
|
||||||
|
}, () => false);
|
||||||
|
return await Promise.race([inspection, deadline]);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const config = configuration(env);
|
||||||
|
if (!config) return unavailable();
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.hostname !== config.publicHost || url.port || url.protocol !== 'https:') {
|
||||||
|
return unavailable(421, 'Misdirected request');
|
||||||
|
}
|
||||||
|
let path;
|
||||||
|
try {
|
||||||
|
path = decodeURIComponent(url.pathname);
|
||||||
|
} catch {
|
||||||
|
return unavailable(400, 'Invalid path');
|
||||||
|
}
|
||||||
|
if (path === config.path) return unavailable(404, 'Not found');
|
||||||
|
|
||||||
|
let origin;
|
||||||
|
if (await ready(config, config.primary)) origin = config.primary;
|
||||||
|
else if (await ready(config, config.secondary)) origin = config.secondary;
|
||||||
|
else return unavailable();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.delete(READY_HEADER);
|
||||||
|
const forwarded = new Request(request, { headers, redirect: 'manual', cache: 'no-store' });
|
||||||
|
// This is the only application dispatch. Its failure never triggers replay.
|
||||||
|
const response = await fetch(forwarded, options(origin));
|
||||||
|
if (response.status === 101) return response;
|
||||||
|
const result = new Response(response.body, response);
|
||||||
|
result.headers.set('Cache-Control', 'private, no-store');
|
||||||
|
result.headers.set('CDN-Cache-Control', 'no-store');
|
||||||
|
result.headers.set('Cloudflare-CDN-Cache-Control', 'no-store');
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return unavailable(502, 'Origin request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import worker from './worker.mjs';
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
APEX_PUBLIC_HOST: 'sso.example.com',
|
||||||
|
APEX_ZONE: 'example.com',
|
||||||
|
APEX_ORIGIN_PRIMARY: 'primary.example.com',
|
||||||
|
APEX_ORIGIN_SECONDARY: 'secondary.example.com',
|
||||||
|
APEX_READY_PATH: '/.well-known/apex-ready',
|
||||||
|
APEX_READY_TOKEN: 'test-only-readiness-secret',
|
||||||
|
};
|
||||||
|
const url = 'https://sso.example.com/api/callback?state=a%2Fb';
|
||||||
|
const ready = () => new Response(null, { status: 204 });
|
||||||
|
|
||||||
|
function intercept(t, handler) {
|
||||||
|
const calls = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (request, options) => {
|
||||||
|
calls.push({ request, options });
|
||||||
|
return handler(request, options, calls.length);
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkProbe(call, origin) {
|
||||||
|
assert.equal(call.request.url, 'https://sso.example.com/.well-known/apex-ready');
|
||||||
|
assert.equal(call.request.method, 'GET');
|
||||||
|
assert.equal(call.request.headers.get('X-APEX-Ready-Token'), env.APEX_READY_TOKEN);
|
||||||
|
assert.equal(call.request.headers.get('Cookie'), null);
|
||||||
|
assert.equal(call.request.headers.get('Authorization'), null);
|
||||||
|
assert.equal(call.options.cf.resolveOverride, origin);
|
||||||
|
assert.equal(call.options.redirect, 'manual');
|
||||||
|
assert.equal(call.options.cache, 'no-store');
|
||||||
|
assert.deepEqual(call.options.cf.cacheTtlByStatus, { '100-599': -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ready primary receives the original POST exactly once with credentials and body', async (t) => {
|
||||||
|
const calls = intercept(t, async (request, options, count) => {
|
||||||
|
if (count === 1) return ready();
|
||||||
|
assert.equal(await request.text(), 'code=one-use&state=a%2Fb');
|
||||||
|
return new Response('accepted', { status: 201 });
|
||||||
|
});
|
||||||
|
const request = new Request(url, {
|
||||||
|
method: 'POST', body: 'code=one-use&state=a%2Fb',
|
||||||
|
headers: { Cookie: 'session=original', Authorization: 'Bearer original',
|
||||||
|
'X-APEX-Ready-Token': 'client-supplied', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
const response = await worker.fetch(request, env);
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
assert.equal(await response.text(), 'accepted');
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
checkProbe(calls[0], env.APEX_ORIGIN_PRIMARY);
|
||||||
|
const forwarded = calls[1];
|
||||||
|
assert.equal(forwarded.request.url, url);
|
||||||
|
assert.equal(forwarded.request.method, 'POST');
|
||||||
|
assert.equal(forwarded.request.headers.get('Cookie'), 'session=original');
|
||||||
|
assert.equal(forwarded.request.headers.get('Authorization'), 'Bearer original');
|
||||||
|
assert.equal(forwarded.request.headers.get('X-APEX-Ready-Token'), null);
|
||||||
|
assert.equal(forwarded.options.cf.resolveOverride, env.APEX_ORIGIN_PRIMARY);
|
||||||
|
assert.equal(forwarded.options.redirect, 'manual');
|
||||||
|
assert.equal(forwarded.options.cache, 'no-store');
|
||||||
|
assert.match(response.headers.get('Cache-Control'), /no-store/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unready primary cancels unread body and selects ready secondary', async (t) => {
|
||||||
|
let canceled = false;
|
||||||
|
const calls = intercept(t, (_, __, count) => count === 1
|
||||||
|
? new Response(new ReadableStream({ cancel() { canceled = true; } }), { status: 503 })
|
||||||
|
: count === 2 ? ready() : new Response('secondary'));
|
||||||
|
const response = await worker.fetch(new Request(url), env);
|
||||||
|
assert.equal(await response.text(), 'secondary');
|
||||||
|
assert.equal(canceled, true);
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
checkProbe(calls[1], env.APEX_ORIGIN_SECONDARY);
|
||||||
|
assert.equal(calls[2].options.cf.resolveOverride, env.APEX_ORIGIN_SECONDARY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probe deadline aborts at two seconds even if fetch ignores cancellation', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
let finishPrimary;
|
||||||
|
let canceled = false;
|
||||||
|
const calls = intercept(t, (_, __, count) => count === 1
|
||||||
|
? new Promise((resolve) => { finishPrimary = resolve; })
|
||||||
|
: count === 2 ? ready() : new Response('secondary'));
|
||||||
|
const pending = worker.fetch(new Request(url), env);
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(1999);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
const response = await pending;
|
||||||
|
assert.equal(await response.text(), 'secondary');
|
||||||
|
assert.equal(calls[0].options.signal.aborted, true);
|
||||||
|
finishPrimary(new Response(new ReadableStream({ cancel() { canceled = true; } })));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(canceled, true);
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two unavailable origins return no-store 503 without client dispatch', async (t) => {
|
||||||
|
const calls = intercept(t, () => new Response('down', { status: 503 }));
|
||||||
|
const response = await worker.fetch(new Request(url), env);
|
||||||
|
assert.equal(response.status, 503);
|
||||||
|
assert.match(response.headers.get('Cache-Control'), /no-store/);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.ok(calls.every(({ request }) => new URL(request.url).pathname === env.APEX_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readiness redirect is never accepted or followed', async (t) => {
|
||||||
|
const calls = intercept(t, (_, __, count) => count === 1
|
||||||
|
? new Response(null, { status: 302, headers: { Location: 'https://evil.example/' } })
|
||||||
|
: count === 2 ? ready() : new Response('secondary'));
|
||||||
|
assert.equal(await (await worker.fetch(new Request(url), env)).text(), 'secondary');
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
assert.equal(calls[0].options.redirect, 'manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const method of ['GET', 'POST']) {
|
||||||
|
test(`failed ${method} dispatch is never replayed on another origin`, async (t) => {
|
||||||
|
const calls = intercept(t, (_, __, count) => {
|
||||||
|
if (count === 1) return ready();
|
||||||
|
throw new DOMException('origin timed out', 'TimeoutError');
|
||||||
|
});
|
||||||
|
const response = await worker.fetch(new Request(url, { method,
|
||||||
|
...(method === 'POST' ? { body: 'one-use' } : {}) }), env);
|
||||||
|
assert.equal(response.status, 502);
|
||||||
|
assert.match(response.headers.get('Cache-Control'), /no-store/);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('origin errors preserve status and body without probing secondary', async (t) => {
|
||||||
|
const calls = intercept(t, (_, __, count) => count === 1 ? ready() : new Response('origin error', { status: 500 }));
|
||||||
|
const response = await worker.fetch(new Request(url), env);
|
||||||
|
assert.equal(response.status, 500);
|
||||||
|
assert.equal(await response.text(), 'origin error');
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth redirect retains distinct Set-Cookie headers and Location but cannot cache', async (t) => {
|
||||||
|
const cookies = ['session=first; Secure; HttpOnly', 'state=second; Expires=Wed, 21 Oct 2030 07:28:00 GMT'];
|
||||||
|
const headers = new Headers({ Location: '/continue?state=a%2Fb', 'Cache-Control': 'public, max-age=3600' });
|
||||||
|
for (const cookie of cookies) headers.append('Set-Cookie', cookie);
|
||||||
|
const calls = intercept(t, (_, __, count) => count === 1 ? ready() : new Response(null, { status: 302, headers }));
|
||||||
|
const response = await worker.fetch(new Request(url), env);
|
||||||
|
assert.equal(response.status, 302);
|
||||||
|
assert.equal(response.headers.get('Location'), '/continue?state=a%2Fb');
|
||||||
|
assert.deepEqual(response.headers.getSetCookie(), cookies);
|
||||||
|
assert.match(response.headers.get('Cache-Control'), /no-store/);
|
||||||
|
assert.equal(response.headers.get('Cloudflare-CDN-Cache-Control'), 'no-store');
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hostile hosts and public readiness requests never reach origins', async (t) => {
|
||||||
|
const calls = intercept(t, () => { throw new Error('must not dispatch'); });
|
||||||
|
for (const target of ['https://evil.example/', 'https://sso.example.com.evil.example/', 'https://sso.example.com:444/']) {
|
||||||
|
assert.equal((await worker.fetch(new Request(target), env)).status, 421);
|
||||||
|
}
|
||||||
|
for (const path of [env.APEX_READY_PATH, env.APEX_READY_PATH + '?probe=1', '/.well-known/%61pex-ready']) {
|
||||||
|
const response = await worker.fetch(new Request('https://sso.example.com' + path), env);
|
||||||
|
assert.equal(response.status, 404);
|
||||||
|
assert.match(response.headers.get('Cache-Control'), /no-store/);
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing and invalid bindings fail closed without diagnostics or probes', async (t) => {
|
||||||
|
const calls = intercept(t, () => { throw new Error('must not dispatch'); });
|
||||||
|
const invalid = Object.keys(env).map((name) => ({ ...env, [name]: undefined }));
|
||||||
|
for (const [name, value] of [
|
||||||
|
['APEX_ZONE', 'com'], ['APEX_PUBLIC_HOST', 'https://sso.example.com'],
|
||||||
|
['APEX_ORIGIN_PRIMARY', 'primary.other.example'], ['APEX_ORIGIN_PRIMARY', env.APEX_PUBLIC_HOST],
|
||||||
|
['APEX_ORIGIN_PRIMARY', env.APEX_ORIGIN_SECONDARY], ['APEX_ORIGIN_SECONDARY', '127.0.0.1'],
|
||||||
|
['APEX_ORIGIN_SECONDARY', '-invalid.example.com'], ['APEX_ORIGIN_PRIMARY', 'a..example.com'],
|
||||||
|
['APEX_READY_PATH', '//evil.example/'], ['APEX_READY_PATH', '/ready?query=yes'],
|
||||||
|
['APEX_READY_PATH', '/ready#fragment'], ['APEX_READY_PATH', '/a/../ready'],
|
||||||
|
['APEX_READY_PATH', '/%72eady'], ['APEX_READY_TOKEN', 'secret\r\ninjected: yes'],
|
||||||
|
]) invalid.push({ ...env, [name]: value });
|
||||||
|
invalid.push(null);
|
||||||
|
for (const bindings of invalid) {
|
||||||
|
const response = await worker.fetch(new Request(url), bindings);
|
||||||
|
assert.equal(response.status, 503);
|
||||||
|
assert.match(response.headers.get('Cache-Control'), /no-store/);
|
||||||
|
assert.equal(await response.text(), 'Service unavailable');
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('response remains streaming without buffering the body', async (t) => {
|
||||||
|
let controller;
|
||||||
|
const body = new ReadableStream({ start(value) { controller = value; } });
|
||||||
|
intercept(t, (_, __, count) => count === 1 ? ready() : new Response(body));
|
||||||
|
const response = await worker.fetch(new Request(url), env);
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
controller.enqueue(new TextEncoder().encode('first'));
|
||||||
|
assert.equal(new TextDecoder().decode((await reader.read()).value), 'first');
|
||||||
|
controller.close();
|
||||||
|
assert.equal((await reader.read()).done, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed readiness fetch selects secondary without exposing exception details', async (t) => {
|
||||||
|
const calls = intercept(t, (_, __, count) => {
|
||||||
|
if (count === 1) throw new Error(env.APEX_READY_TOKEN);
|
||||||
|
return count === 2 ? ready() : new Response('secondary');
|
||||||
|
});
|
||||||
|
const response = await worker.fetch(new Request(url), env);
|
||||||
|
assert.equal(await response.text(), 'secondary');
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('secondary probe has its own two-second deadline and no client dispatch follows', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const calls = intercept(t, () => new Promise(() => {}));
|
||||||
|
const pending = worker.fetch(new Request(url), env);
|
||||||
|
t.mock.timers.tick(2000);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[0].options.signal.aborted, true);
|
||||||
|
assert.equal(calls[1].options.signal.aborted, false);
|
||||||
|
t.mock.timers.tick(2000);
|
||||||
|
const response = await pending;
|
||||||
|
assert.equal(response.status, 503);
|
||||||
|
assert.equal(calls[1].options.signal.aborted, true);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unread probe cancellation cannot stall selection', async (t) => {
|
||||||
|
const calls = intercept(t, (_, __, count) => count === 1
|
||||||
|
? new Response(new ReadableStream({ cancel() { return new Promise(() => {}); } }), { status: 500 })
|
||||||
|
: count === 2 ? ready() : new Response('secondary'));
|
||||||
|
assert.equal(await (await worker.fetch(new Request(url), env)).text(), 'secondary');
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform WebSocket upgrade response is returned unchanged', async (t) => {
|
||||||
|
// Node cannot construct status 101; the fetch boundary represents Workers' response.
|
||||||
|
const upgrade = { status: 101, webSocket: {}, headers: new Headers({ Upgrade: 'websocket' }) };
|
||||||
|
const calls = intercept(t, (_, __, count) => count === 1 ? ready() : upgrade);
|
||||||
|
const response = await worker.fetch(new Request(url, { headers: { Upgrade: 'websocket' } }), env);
|
||||||
|
assert.equal(response, upgrade);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[1].request.headers.get('Upgrade'), 'websocket');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user