[object Object]

← back to Studio Zero

DTD-B: optional HTTP Basic-Auth gate behind default-OFF SZ_BASIC_AUTH

bdd814b365a0f48d4d3124e314ee7f305131122e · 2026-07-26 19:44:57 -0700 · Steve Abrams

Stages the internal-only app for a future public launch. While SZ_BASIC_AUTH is
unset the app is byte-for-byte open (no auth) so the live :9761 service and the
/deploy /api/config smoke test are unaffected; set it to 1/on and every request
(incl. /api/* + health) requires Basic-Auth. Creds default admin/DW2024!
(SZ_BASIC_USER/SZ_BASIC_PASS override), constant-time compare, zero new deps.
Adds zero-dependency test/auth.spec.js (7 checks, green). v0.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit bdd814b365a0f48d4d3124e314ee7f305131122e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 19:44:57 2026 -0700

    DTD-B: optional HTTP Basic-Auth gate behind default-OFF SZ_BASIC_AUTH
    
    Stages the internal-only app for a future public launch. While SZ_BASIC_AUTH is
    unset the app is byte-for-byte open (no auth) so the live :9761 service and the
    /deploy /api/config smoke test are unaffected; set it to 1/on and every request
    (incl. /api/* + health) requires Basic-Auth. Creds default admin/DW2024!
    (SZ_BASIC_USER/SZ_BASIC_PASS override), constant-time compare, zero new deps.
    Adds zero-dependency test/auth.spec.js (7 checks, green). v0.4.0.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 README.md         |  9 ++++++++
 VERSION           |  2 +-
 server.js         | 36 ++++++++++++++++++++++++++++--
 test/README.md    |  9 ++++++++
 test/auth.spec.js | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 119 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index cc24285..b270ec8 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,15 @@ PORT=4173 node server.js                 # open http://localhost:4173
 Each generate shows its **estimated + actual $ cost**; the header keeps a running
 session total.
 
+## Auth (optional, default OFF)
+The app ships open (firewalled/internal-only). To stage it for a future public
+launch, set `SZ_BASIC_AUTH=1` and the whole app — including `/api/*` and the
+health check — requires HTTP Basic-Auth. Credentials default `admin` / `DW2024!`,
+overridable via `SZ_BASIC_USER` / `SZ_BASIC_PASS`. While the flag is unset,
+behavior is byte-for-byte unchanged (no auth), so it never affects the current
+`:9761` service or the `/deploy` `/api/config` smoke test. Guarded by
+`test/auth.spec.js`.
+
 ## Layout
 - `server.js` — static host + `POST /api/generate` (Anthropic proxy, cost
   accounting, demo fallback) + `/api/config` + `/api/prompts`.
diff --git a/VERSION b/VERSION
index 9325c3c..60a2d3e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.0
\ No newline at end of file
+0.4.0
\ No newline at end of file
diff --git a/server.js b/server.js
index cce19e4..3a49e82 100644
--- a/server.js
+++ b/server.js
@@ -4,7 +4,34 @@
 // 'api' (metered Anthropic HTTP API), or 'auto' (cli if the CLI is present, else api).
 // Every path falls back to a canned demo deliverable so the prototype is always clickable.
 const http = require('http'), fs = require('fs'), path = require('path'), https = require('https');
-const os = require('os'), { spawn, execSync } = require('child_process');
+const os = require('os'), crypto = require('crypto'), { spawn, execSync } = require('child_process');
+
+// --- Optional HTTP Basic-Auth gate (DEFAULT OFF) ---------------------------------
+// Staged for a future public launch. While SZ_BASIC_AUTH is unset/0/off the app is wide
+// open exactly as before (firewalled, internal-only) — byte-for-byte unchanged, so it never
+// affects the current :9761 service or the /deploy /api/config smoke test. Flip it on
+// (=1/on/true/yes) and EVERY request — including /api/* and the health check — requires
+// Basic-Auth. Creds default admin/DW2024!, overridable via SZ_BASIC_USER / SZ_BASIC_PASS.
+// Zero new dependencies (crypto is built-in); credential compare is constant-time.
+const AUTH_ON = /^(1|on|true|yes)$/i.test(process.env.SZ_BASIC_AUTH || '');
+const AUTH_USER = process.env.SZ_BASIC_USER || 'admin';
+const AUTH_PASS = process.env.SZ_BASIC_PASS || 'DW2024!';
+function tseq(a, b) {                                   // constant-time string compare
+  const ab = Buffer.from(String(a)), bb = Buffer.from(String(b));
+  if (ab.length !== bb.length) return false;
+  return crypto.timingSafeEqual(ab, bb);
+}
+function authOK(req) {
+  if (!AUTH_ON) return true;
+  const m = (req.headers['authorization'] || '').match(/^Basic\s+(.+)$/i);
+  if (!m) return false;
+  let dec = ''; try { dec = Buffer.from(m[1], 'base64').toString('utf8'); } catch (_) { return false; }
+  const i = dec.indexOf(':');
+  if (i < 0) return false;
+  const uok = tseq(dec.slice(0, i), AUTH_USER);          // evaluate both (no short-circuit) to
+  const pok = tseq(dec.slice(i + 1), AUTH_PASS);         // avoid leaking which half was wrong
+  return uok && pok;
+}
 
 const PORT = parseInt(process.env.PORT || '0', 10);
 const DIR = __dirname;
@@ -138,6 +165,10 @@ function send(res, code, obj) {
 const LIVE = (PROVIDER === 'cli' && HAS_CLI) || (PROVIDER === 'api' && HAS_KEY);
 
 const server = http.createServer((req, res) => {
+  if (!authOK(req)) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Studio Zero", charset="UTF-8"', 'Content-Type': 'text/plain' });
+    return res.end('Authentication required');
+  }
   const u = new URL(req.url, 'http://x');
 
   if (u.pathname === '/api/config') {
@@ -195,5 +226,6 @@ const server = http.createServer((req, res) => {
 
 server.listen(PORT, function () {
   const mode = LIVE ? (PROVIDER === 'cli' ? 'LIVE · Max plan (' + CLI_MODEL + ')' : 'LIVE · API ' + MODEL) : 'DEMO mode';
-  console.log('[studio-zero] http://localhost:' + this.address().port + '  (' + mode + ')');
+  const auth = AUTH_ON ? ' · BasicAuth ON (' + AUTH_USER + ')' : ' · no auth (internal)';
+  console.log('[studio-zero] http://localhost:' + this.address().port + '  (' + mode + auth + ')');
 });
diff --git a/test/README.md b/test/README.md
index d66b299..fb4a66c 100644
--- a/test/README.md
+++ b/test/README.md
@@ -22,3 +22,12 @@ navigation) so fabricated demo output is never mistaken for a real result.
 ```bash
 NODE_PATH=~/Projects/animals/node_modules node fallback.spec.js   # spawns + tears down its own server
 ```
+
+`auth.spec.js` is a **zero-dependency** pure-HTTP regression guard (7 checks) for the
+optional Basic-Auth gate (`SZ_BASIC_AUTH`): asserts the flag is default-OFF (`/` and
+`/api/config` open, byte-for-byte unchanged) and, when on, rejects missing/wrong
+credentials with `401` + a `WWW-Authenticate` challenge while admitting the correct ones.
+No browser needed.
+```bash
+node auth.spec.js                 # spawns two instances (off + on), tears them down
+```
diff --git a/test/auth.spec.js b/test/auth.spec.js
new file mode 100644
index 0000000..e084eac
--- /dev/null
+++ b/test/auth.spec.js
@@ -0,0 +1,66 @@
+// Regression guard for the optional Basic-Auth gate (SZ_BASIC_AUTH, DTD-B refinement, 2026-07-26).
+// The gate MUST be default-OFF (byte-for-byte open, so it never affects the internal :9761
+// service or the /deploy /api/config smoke test) and, when flipped on, MUST reject missing/wrong
+// credentials with 401 + a WWW-Authenticate challenge and admit the correct ones.
+//
+// Pure-HTTP, zero dependencies (no browser/playwright). Spawns its own studio-zero instances.
+// Run:  node test/auth.spec.js
+const http = require('http');
+const { spawn } = require('child_process');
+const path = require('path');
+
+function get(port, pathname, creds) {
+  return new Promise((resolve, reject) => {
+    const headers = {};
+    if (creds) headers.Authorization = 'Basic ' + Buffer.from(creds).toString('base64');
+    const r = http.get({ host: '127.0.0.1', port, path: pathname, headers }, res => {
+      res.resume();
+      resolve({ code: res.statusCode, wwwAuth: res.headers['www-authenticate'] || '' });
+    });
+    r.on('error', reject);
+  });
+}
+function waitReady(port, ms) {
+  const t0 = Date.now();
+  return new Promise((resolve, reject) => {
+    (function ping() {
+      const r = http.get({ host: '127.0.0.1', port, path: '/' }, res => { res.resume(); resolve(); });
+      r.on('error', () => { if (Date.now() - t0 > ms) reject(new Error('server never came up on ' + port)); else setTimeout(ping, 120); });
+    })();
+  });
+}
+function boot(port, extraEnv) {
+  return spawn('node', ['server.js'], {
+    cwd: path.join(__dirname, '..'),
+    env: { ...process.env, PORT: String(port), SZ_PROVIDER: 'demo', ...extraEnv },
+    stdio: 'ignore',
+  });
+}
+
+(async () => {
+  const log = []; const ok = (c, m) => log.push((c ? 'PASS ' : 'FAIL ') + m);
+  const OFF = 4188, ON = 4189;
+  const s1 = boot(OFF, {});                       // default: flag unset → OPEN
+  const s2 = boot(ON, { SZ_BASIC_AUTH: '1' });    // gated
+  try {
+    await Promise.all([waitReady(OFF, 8000), waitReady(ON, 8000)]);
+
+    // --- default OFF: unchanged, fully open ---
+    ok((await get(OFF, '/api/config')).code === 200, 'default (flag unset): /api/config open (200, unchanged)');
+    ok((await get(OFF, '/')).code === 200, 'default (flag unset): / open (200, unchanged)');
+
+    // --- flag ON: gated ---
+    const noCreds = await get(ON, '/api/config');
+    ok(noCreds.code === 401, 'gated: no creds → 401');
+    ok(/^Basic /i.test(noCreds.wwwAuth), 'gated: 401 carries a Basic WWW-Authenticate challenge');
+    ok((await get(ON, '/api/config', 'admin:wrongpass')).code === 401, 'gated: wrong creds → 401');
+    ok((await get(ON, '/api/config', 'admin:DW2024!')).code === 200, 'gated: correct creds → 200');
+    ok((await get(ON, '/', 'admin:DW2024!')).code === 200, 'gated: correct creds admit the app too');
+  } catch (e) {
+    ok(false, 'HARNESS: ' + e.message);
+  } finally {
+    s1.kill('SIGKILL'); s2.kill('SIGKILL');
+  }
+  console.log(log.join('\n'));
+  process.exit(log.some(l => l.startsWith('FAIL')) ? 1 : 0);
+})();

← 87f86c8 DTD-A: persistent honesty banner when a live call falls back  ·  back to Studio Zero  ·  chore: version 0.4.0 → 0.5.0 (session close — first Kamatera d3a352d →