← 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
M README.mdM VERSIONM server.jsM test/README.mdA test/auth.spec.js
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 →