[object Object]

← back to Costa Rica

costa-rica: fail-closed admin guard when site gate unset (TK-10346, Steve-approved option B)

3031607764c328943783a38abc96e8fa9c017219 · 2026-09-26 08:23:30 -0700 · Steve

/api/admin, /admin, /api/build, /build, and /api/logo-agent have no auth of
their own -- they ride the same site-wide BASIC_AUTH_* switch as the public
directory. docs/GO-LIVE.md SS8 plans to remove BASIC_AUTH_* to open the
public directory, which would also silently strip admin's only lock
(host-claim approvals + traveler PII, the ops dashboard, and the logo tool).

Add a small requireAdminGate{Json,Html} middleware so those admin-only
surfaces respond 503 {error:'admin_gate_unconfigured'} (HTML routes: plain
503 text) when BASIC_AUTH_USER/PASS are unset, instead of serving openly.
When the gate IS configured this is a no-op -- unauthenticated requests are
already rejected 401 by the existing basic-auth gate before reaching these
routes, so behavior is unchanged in the configured case. Public directory
routes (/api/map, /api/places, etc.) are untouched.

Adds test/admin-gate-fail-closed.test.js (red case: gate unset -> 503 on
every admin surface, public directory unaffected) and
test/admin-gate-configured.test.js (gate set -> 401 as before, guard is a
no-op). Full suite: 240 -> 251 passing, 0 failures.

Also hardens docs/GO-LIVE.md SS8 with a HARD pre-launch requirement: a
separate cookie-session admin auth (SameSite=Strict, own ADMIN_* secret,
decoupled from BASIC_AUTH_*) must ship before BASIC_AUTH_* is removed; until
then this fail-closed guard makes admin go dark, not open, if it is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuMfhSKGfQhQLMrdVKD7MR

Files touched

Diff

commit 3031607764c328943783a38abc96e8fa9c017219
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Sep 26 08:23:30 2026 -0700

    costa-rica: fail-closed admin guard when site gate unset (TK-10346, Steve-approved option B)
    
    /api/admin, /admin, /api/build, /build, and /api/logo-agent have no auth of
    their own -- they ride the same site-wide BASIC_AUTH_* switch as the public
    directory. docs/GO-LIVE.md SS8 plans to remove BASIC_AUTH_* to open the
    public directory, which would also silently strip admin's only lock
    (host-claim approvals + traveler PII, the ops dashboard, and the logo tool).
    
    Add a small requireAdminGate{Json,Html} middleware so those admin-only
    surfaces respond 503 {error:'admin_gate_unconfigured'} (HTML routes: plain
    503 text) when BASIC_AUTH_USER/PASS are unset, instead of serving openly.
    When the gate IS configured this is a no-op -- unauthenticated requests are
    already rejected 401 by the existing basic-auth gate before reaching these
    routes, so behavior is unchanged in the configured case. Public directory
    routes (/api/map, /api/places, etc.) are untouched.
    
    Adds test/admin-gate-fail-closed.test.js (red case: gate unset -> 503 on
    every admin surface, public directory unaffected) and
    test/admin-gate-configured.test.js (gate set -> 401 as before, guard is a
    no-op). Full suite: 240 -> 251 passing, 0 failures.
    
    Also hardens docs/GO-LIVE.md SS8 with a HARD pre-launch requirement: a
    separate cookie-session admin auth (SameSite=Strict, own ADMIN_* secret,
    decoupled from BASIC_AUTH_*) must ship before BASIC_AUTH_* is removed; until
    then this fail-closed guard makes admin go dark, not open, if it is.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01EuMfhSKGfQhQLMrdVKD7MR
---
 docs/GO-LIVE.md                     |  2 +
 server.js                           | 34 +++++++++++---
 test/admin-gate-configured.test.js  | 57 +++++++++++++++++++++++
 test/admin-gate-fail-closed.test.js | 92 +++++++++++++++++++++++++++++++++++++
 4 files changed, 179 insertions(+), 6 deletions(-)

diff --git a/docs/GO-LIVE.md b/docs/GO-LIVE.md
index 79789c7..f9b8340 100644
--- a/docs/GO-LIVE.md
+++ b/docs/GO-LIVE.md
@@ -86,3 +86,5 @@ ASC app 6799240433; build #13 on TestFlight. Remaining: attach build + `store/li
 
 ## 8. Optional — public web directory
 The web directory pages are basic-auth gated (`admin`/`DW2024!`); the app API (`/api/app`) + `/webhooks` are already public. Dropping the web gate exposes ~34k listings publicly — a customer-facing publish of scraped third-party business data; decide deliberately before removing `BASIC_AUTH_*`.
+
+**HARD pre-launch requirement (TK-10346, Cody audit, Steve-approved 2026-09-24):** `/api/admin` (host-claim approvals + traveler PII/bookings), `/admin`, `/api/build` (ops dashboard), and `/api/logo-agent` have NO auth of their own — they ride the SAME `BASIC_AUTH_*` switch as this public directory. Removing `BASIC_AUTH_*` per this section, on its own, also strips admin's only lock. A **separate cookie-session admin auth** (its own `ADMIN_*` secret, `SameSite=Strict`, fully decoupled from `BASIC_AUTH_*`) MUST be built and shipped BEFORE `BASIC_AUTH_*` is removed. Until that ships, a stopgap fail-closed guard (`server.js`) is in place: with `BASIC_AUTH_*` unset, those admin surfaces respond `503 {error:'admin_gate_unconfigured'}` instead of serving openly — so removing `BASIC_AUTH_*` today makes admin go DARK (safe), not OPEN (unsafe). Don't mistake the 503 stopgap for the real fix; it buys time, it doesn't replace the decoupled admin auth.
diff --git a/server.js b/server.js
index 0f7956a..20697aa 100644
--- a/server.js
+++ b/server.js
@@ -95,16 +95,38 @@ if (BA_USER && BA_PASS) {
   console.warn('[boot] BASIC_AUTH_USER/PASS not set — site is OPEN');
 }
 
+// Fail-CLOSED guard for the admin-only curation surfaces below (/api/admin, /admin,
+// /api/build, /build, /api/logo-agent, /logo-agent). None of them carry their own
+// auth — they rely entirely on the site-wide gate above. When BA_USER/BA_PASS are
+// unset, that gate is a no-op ("site is OPEN"), which would silently expose
+// host-claim approvals + bookings PII (admin), the ops dashboard (build), and the
+// logo tool the moment BASIC_AUTH_* is removed per docs/GO-LIVE.md §8 — the exact
+// collision Cody's audit flagged (TK-10346-costa-admin-auth-coupling, option B,
+// Steve-approved 2026-09-24). This does NOT touch the public directory (/api/map,
+// /api/places, etc.) — only the admin-only routes mounted below opt in. When
+// BA_USER/BA_PASS ARE set, both guards are a no-op (unauthenticated requests are
+// already rejected 401 by the basicAuth gate above before reaching these routes at
+// all), so behavior is byte-for-byte unchanged in the configured case.
+const ADMIN_GATE_CONFIGURED = !!(BA_USER && BA_PASS);
+function requireAdminGateJson(_req, res, next) {
+  if (!ADMIN_GATE_CONFIGURED) return res.status(503).json({ error: 'admin_gate_unconfigured' });
+  next();
+}
+function requireAdminGateHtml(_req, res, next) {
+  if (!ADMIN_GATE_CONFIGURED) return res.status(503).type('text/plain').send('admin gate not configured');
+  next();
+}
+
 app.get('/health', (_req, res) => res.json({ ok: true, site: SITE_NAME, ts: new Date().toISOString() }));
 
 // Admin (behind the basic-auth gate) — host-claim approvals + bookings oversight.
-app.use('/api/admin', harden(require('./routes/admin')));
-app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
-app.use('/api/build', harden(require('./routes/build')));
-app.get('/build', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'build.html')));
+app.use('/api/admin', requireAdminGateJson, harden(require('./routes/admin')));
+app.get('/admin', requireAdminGateHtml, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
+app.use('/api/build', requireAdminGateJson, harden(require('./routes/build')));
+app.get('/build', requireAdminGateHtml, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'build.html')));
 // Logo Agent — hot-or-not tournament brand/logo builder (admin-gated curation tool)
-app.use('/api/logo-agent', harden(require('./routes/logo-agent')));
-app.get('/logo-agent', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'logo-agent.html')));
+app.use('/api/logo-agent', requireAdminGateJson, harden(require('./routes/logo-agent')));
+app.get('/logo-agent', requireAdminGateHtml, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'logo-agent.html')));
 // Map of all geocoded places (desktop viewer)
 app.get('/api/map', async (req, res) => {
   try {
diff --git a/test/admin-gate-configured.test.js b/test/admin-gate-configured.test.js
new file mode 100644
index 0000000..9b4f103
--- /dev/null
+++ b/test/admin-gate-configured.test.js
@@ -0,0 +1,57 @@
+'use strict';
+// Companion to admin-gate-fail-closed.test.js (TK-10346, Steve-approved option B):
+// this is the GREEN/unchanged case. When BASIC_AUTH_USER/PASS ARE set (today's real
+// config), the new fail-closed guard must be a total no-op — an unauthenticated
+// admin request is rejected 401 by the pre-existing site-wide basic-auth gate
+// exactly as before, and never even reaches the new middleware's 503 branch.
+//
+// Pin BASIC_AUTH_USER/PASS BEFORE requiring server.js (same pattern as
+// test/server-routes.test.js) so this wins over whatever the real .env has.
+process.env.BASIC_AUTH_USER = 'testuser';
+process.env.BASIC_AUTH_PASS = 'testpass';
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+const app = require('../server'); // exported app, does NOT listen on import
+
+const AUTH = 'Basic ' + Buffer.from('testuser:testpass').toString('base64');
+
+let server, base;
+before(async () => {
+  await new Promise(r => { server = app.listen(0, r); });
+  base = `http://127.0.0.1:${server.address().port}`;
+});
+after(async () => {
+  server && server.close();
+  try { await app.locals.pool.end(); } catch { /* already closed */ }
+});
+
+function get(path, headers) {
+  return new Promise((resolve, reject) => {
+    http.get(base + path, { headers: headers || {} }, res => {
+      let b = ''; res.on('data', c => b += c);
+      res.on('end', () => resolve({ status: res.statusCode, body: b }));
+    }).on('error', reject);
+  });
+}
+
+test('GET /api/admin/stats WITHOUT credentials -> 401 as before (gate configured, unchanged)', async () => {
+  const r = await get('/api/admin/stats');
+  assert.equal(r.status, 401);
+});
+
+test('GET /admin WITHOUT credentials -> 401 as before (gate configured, unchanged)', async () => {
+  const r = await get('/admin');
+  assert.equal(r.status, 401);
+});
+
+test('GET /api/admin/stats WITH valid credentials reaches the router (not blocked by the new guard)', async () => {
+  const r = await get('/api/admin/stats', { authorization: AUTH });
+  // Real dev DB: either the query succeeds (200) or the app's generic 500 handler
+  // fires — either way it must NOT be 401 (creds accepted) or 503 (guard is a no-op
+  // once the gate is configured); 503 is reserved for the unconfigured case only.
+  assert.notEqual(r.status, 401, 'valid credentials must pass the basic-auth gate');
+  assert.notEqual(r.status, 503, 'the fail-closed guard must be a no-op when BASIC_AUTH_* is set');
+});
diff --git a/test/admin-gate-fail-closed.test.js b/test/admin-gate-fail-closed.test.js
new file mode 100644
index 0000000..0daaf1e
--- /dev/null
+++ b/test/admin-gate-fail-closed.test.js
@@ -0,0 +1,92 @@
+'use strict';
+// Fail-CLOSED guard for the admin-only curation surfaces when the site-wide
+// basic-auth gate is unconfigured (TK-10346, Cody audit, Steve-approved option B —
+// see ~/.claude/yolo-queue/pending-approval/2026-09-24-TK-10346-costa-admin-auth-
+// coupling-DECISION.md). Before this fix, unsetting BASIC_AUTH_USER/PASS (as
+// docs/GO-LIVE.md §8 plans, to open the public directory) also silently opened
+// /api/admin (host-claim approvals + traveler PII), /api/build (ops dashboard),
+// and /api/logo-agent. This is the RED case: gate unconfigured -> those surfaces
+// must refuse (503), not serve.
+//
+// Pin BASIC_AUTH_USER/PASS to EMPTY strings BEFORE requiring server.js — dotenv's
+// config() does not override a key that already exists in process.env (even ''),
+// so this wins over the real .env (which has real creds for local dev) and
+// deterministically reproduces the "site is OPEN" boot path server.js warns about.
+process.env.BASIC_AUTH_USER = '';
+process.env.BASIC_AUTH_PASS = '';
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+const app = require('../server'); // exported app, does NOT listen on import
+
+let server, base;
+before(async () => {
+  await new Promise(r => { server = app.listen(0, r); });
+  base = `http://127.0.0.1:${server.address().port}`;
+});
+after(async () => {
+  server && server.close();
+  try { await app.locals.pool.end(); } catch { /* already closed */ }
+});
+
+function get(path) {
+  return new Promise((resolve, reject) => {
+    http.get(base + path, res => {
+      let b = '';
+      res.on('data', c => b += c);
+      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
+    }).on('error', reject);
+  });
+}
+
+test('GET /api/admin/stats -> 503 admin_gate_unconfigured (JSON) when the site gate is unset', async () => {
+  const r = await get('/api/admin/stats');
+  assert.equal(r.status, 503);
+  assert.match(r.headers['content-type'] || '', /application\/json/);
+  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
+});
+
+test('GET /api/admin/bookings -> 503 admin_gate_unconfigured (no DB read of traveler PII)', async () => {
+  const r = await get('/api/admin/bookings');
+  assert.equal(r.status, 503);
+  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
+});
+
+test('GET /admin (HTML page) -> 503 plain text when the site gate is unset', async () => {
+  const r = await get('/admin');
+  assert.equal(r.status, 503);
+  assert.match(r.headers['content-type'] || '', /text\/plain/);
+});
+
+test('GET /api/build/status -> 503 admin_gate_unconfigured (ops dashboard API)', async () => {
+  const r = await get('/api/build/status');
+  assert.equal(r.status, 503);
+  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
+});
+
+test('GET /build (HTML page) -> 503 plain text when the site gate is unset', async () => {
+  const r = await get('/build');
+  assert.equal(r.status, 503);
+  assert.match(r.headers['content-type'] || '', /text\/plain/);
+});
+
+test('GET /api/logo-agent/* -> 503 admin_gate_unconfigured', async () => {
+  const r = await get('/api/logo-agent/state');
+  assert.equal(r.status, 503);
+  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
+});
+
+test('GET /logo-agent (HTML page) -> 503 plain text when the site gate is unset', async () => {
+  const r = await get('/logo-agent');
+  assert.equal(r.status, 503);
+  assert.match(r.headers['content-type'] || '', /text\/plain/);
+});
+
+test('the PUBLIC directory is unaffected by the admin guard (still open, not 503)', async () => {
+  const health = await get('/health');
+  assert.equal(health.status, 200, '/health is not an admin surface -> unchanged');
+  const map = await get('/api/map');
+  assert.notEqual(map.status, 503, '/api/map (public directory) must not inherit the admin fail-closed guard');
+});

← cce97e4 cycle 30 docs: YOLO_NOTES ledger — WhatsApp inbound cost gua  ·  back to Costa Rica  ·  YOLO_NOTES: Steve 2026-09-26 park ruling (Q1-Q4) — TK-10346 22c104f →