diff --git a/lith/Caddyfile b/lith/Caddyfile index 2247f7c739..1c219f2c39 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -97,14 +97,8 @@ } } -# lith production Caddyfile — full subdomain routing -# Cloudflare handles TLS. Caddy serves HTTP on :80. - -# --- papers.aesthetic.computer --- -# (touched 2026-04-29 to re-fire webhook after the install-Caddyfile-before-reload fix) -:443 { - import machine_discovery - tls /etc/caddy/origin-cert.pem /etc/caddy/origin-key.pem +# Shared access sink for proxied and explicitly opted-in direct-origin hosts. +(lith_access_log) { log { output file /var/log/caddy/access.log { roll_size 50MiB @@ -114,6 +108,17 @@ format json level INFO } +} + +# lith production Caddyfile — full subdomain routing +# Cloudflare handles TLS. Caddy serves HTTP on :80. + +# --- papers.aesthetic.computer --- +# (touched 2026-04-29 to re-fire webhook after the install-Caddyfile-before-reload fix) +:443 { + import machine_discovery + tls /etc/caddy/origin-cert.pem /etc/caddy/origin-key.pem + import lith_access_log # --- Global performance headers --- # Code modules change every deploy — short TTL so rollouts are visible fast. # (lib/disk .mjs are STATIC sub-imports without the ?v= cache-bust boot.mjs @@ -1275,6 +1280,7 @@ www.oskiewar.com { # /classic stays the permanent home of the preserved 2021 Construct build, # and /gallery + the per-painting archive record pages keep serving here. nopaint.art { + import lith_access_log import machine_discovery # Pin issuance to LE production (Caddy demoted this host to LE staging # after early post-DNS-flip failures; staging's resolvers held the stale @@ -1375,6 +1381,7 @@ nopaint.art { } www.nopaint.art { + import lith_access_log # Same serving requirement as the apex block above: without a distinct # TLS setting, the catch-all's pinned origin cert answers this SNI too. tls { diff --git a/toolchain/aespatchercron/README.md b/toolchain/aespatchercron/README.md index 3572ece0a9..1bf1e716b5 100644 --- a/toolchain/aespatchercron/README.md +++ b/toolchain/aespatchercron/README.md @@ -121,6 +121,14 @@ aliases. `cloudflare.zones` can select up to eight known studio properties; client domains are rejected. An inaccessible zone is reported individually. Set `cloudflare.enabled: false` to disable this comparison. +`nopaint.art` uses DigitalOcean DNS and connects directly to Lith. Its apex and +`www` requests use the shared Caddy access log; Cloudflare coverage is marked +`disabled: direct-origin`, while Lith counts and findings remain available. +`cloudflare.directOriginZones` defaults to `["nopaint.art"]`; set it to `[]` +after a verified migration behind Cloudflare. This setting does not change DNS. +No Paint access counts begin when the logging configuration is deployed; +earlier requests cannot be recovered from these logs. + The reader requests `httpRequestsAdaptiveGroups` for eyeball requests, grouped by UTC hour, hostname, edge status and origin status. Its returned counts are already [estimates](https://developers.cloudflare.com/analytics/graphql-api/sampling/); diff --git a/toolchain/aespatchercron/edge.mjs b/toolchain/aespatchercron/edge.mjs index 180a81484a..3ab792a107 100644 --- a/toolchain/aespatchercron/edge.mjs +++ b/toolchain/aespatchercron/edge.mjs @@ -10,12 +10,16 @@ const integer = value => Number.isSafeInteger(value) && value >= 0; export function edgeScope(config, window) { const zones = config.cloudflare?.zones || edgeZones; + const directOriginZones = config.cloudflare?.directOriginZones ?? ["nopaint.art"]; if (!Array.isArray(zones) || !zones.length || zones.length > 8 || new Set(zones).size !== zones.length || + !Array.isArray(directOriginZones) || directOriginZones.length > 8 || + directOriginZones.some(zone => !Object.hasOwn(VISIT_PROPERTIES, zone) || visitGroup(zone) !== "studio") || zones.some(zone => !Object.hasOwn(VISIT_PROPERTIES, zone) || visitGroup(zone) !== "studio") || !Number.isFinite(Date.parse(window.start)) || !Number.isFinite(Date.parse(window.end)) || Date.parse(window.end) <= Date.parse(window.start) || Date.parse(window.end) - Date.parse(window.start) > 168 * 3600000) throw new Error("Invalid edge correlation scope"); - return { ...window, zones, hosts: [...new Set(zones.flatMap(zone => [zone, ...VISIT_PROPERTIES[zone]]))], limit: 1000 }; + return { ...window, zones, directOriginZones: directOriginZones.filter(zone => zones.includes(zone)), + hosts: [...new Set(zones.flatMap(zone => [zone, ...VISIT_PROPERTIES[zone]]))], limit: 1000 }; } async function readCloudflare(path, headers, fetchImpl, body) { @@ -73,10 +77,12 @@ export function edgeRows(result, hosts, scope) { export async function collectCloudflare(scope, { fetchImpl = globalThis.fetch, auth = credentials, env = process.env } = {}) { const token = env.AESPATCHER_CLOUDFLARE_API_TOKEN; - const { email, apiKey } = token ? {} : auth(); - if (!token && (!email || !apiKey)) return { rows: [], coverage: scope.zones.map(zone => ({ zone, status: "unavailable", reason: "missing-credential" })) }; + const direct = zone => scope.directOriginZones?.includes(zone); + const { email, apiKey } = token || scope.zones.every(direct) ? {} : auth(); const headers = { "content-type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : { "X-Auth-Email": email, "X-Auth-Key": apiKey }) }; const zones = await Promise.all(scope.zones.map(async zone => { + if (direct(zone)) return { rows: [], coverage: { zone, status: "disabled", reason: "direct-origin; Lith access logs only" } }; + if (!token && (!email || !apiKey)) return { rows: [], coverage: { zone, status: "unavailable", reason: "missing-credential" } }; try { const result = await readCloudflare(`/zones?name=${encodeURIComponent(zone)}`, headers, fetchImpl); const matches = result.success && Array.isArray(result.result) ? result.result.filter(row => row.name === zone && row.status === "active") : []; diff --git a/toolchain/aespatchercron/test/edge.test.mjs b/toolchain/aespatchercron/test/edge.test.mjs index c60f27be6c..8d091d5885 100644 --- a/toolchain/aespatchercron/test/edge.test.mjs +++ b/toolchain/aespatchercron/test/edge.test.mjs @@ -76,6 +76,26 @@ test("one inaccessible zone cannot erase another zone's evidence", async () => { assert.doesNotMatch(JSON.stringify(out), /private-key|raw error/); }); +test("direct-origin No Paint retains Lith evidence without a Cloudflare credential or request", async () => { + const nopaint = { cloudflare: { zones: ["nopaint.art"] } }; + const out = await collectEdge(nopaint, window, { + env: {}, auth: () => { throw Error("Unexpected credential read"); }, + fetchImpl: () => { throw Error("Unexpected Cloudflare request"); }, + readAccess: async (_config, scope) => { + assert.deepEqual(scope.hosts, ["nopaint.art", "www.nopaint.art"]); + return { ...access(), rows: [{ ...access().rows[0], host: "nopaint.art" }] }; + }, + }); + assert.equal(out.correlation.cloudflare[0].status, "disabled"); + assert.match(out.correlation.cloudflare[0].reason, /direct-origin/); + assert.equal(out.correlation.buckets[0].edge, null); + assert.equal(out.correlation.buckets[0].lith.errors5xx, 3); + assert.equal(out.signals.leads[0].source, "lith-access"); + validateSignals(out.signals, []); + assert.deepEqual(edgeScope({ cloudflare: { zones: ["nopaint.art"], directOriginZones: [] } }, window).directOriginZones, []); + assert.throws(() => edgeScope({ cloudflare: { directOriginZones: ["false.work"] } }, window), /scope/); +}); + test("correlation requires the same host and UTC hour and never auto-patches a timing overlap", () => { const cf = { ...edgeRows(result([group(500, 500)]), scope.hosts, scope), coverage }; const joined = correlateEdge(scope, cf, access());