feat(E7.2): localhost-default bind + refuse-to-start + first-run auth prompt

Safe-by-default dashboard exposure: localhost-only unless you explicitly opt in
to a public bind with a token.

- src/cli.ts: host default 0.0.0.0 → 127.0.0.1 (localhost-only; --host 0.0.0.0 to
  expose, which then requires a token when the auth flag is on).
- src/server.ts startServer guards (before listen): SELF-LOCKOUT (flag on + no
  token → throw — you'd otherwise brick the dashboard with no recovery short of
  editing .env) + PUBLIC-EXPOSURE gate (flag on + 0.0.0.0 + no token → throw).
  Both use __authState (mutable seam) so they're testable. __authState.bound set
  from cfg.host. When the flag is off, the guards are skipped (back-compat
  escape hatch — the bind still defaults to 127.0.0.1).
- src/server.ts authenticate: E7.2 bind-gating — enforce only on a PUBLIC bind
  (0.0.0.0); 127.0.0.1 is localhost-trusted (requireAuth routes stay open even
  with the flag on). The refuse-to-start guard ensures a 0.0.0.0 bind has a token.
- src/server.ts routes: GET /api/auth/status (open; {authRequired, bound,
  relayConfigured, foundryConfigured} — booleans only, no secret values),
  POST /api/auth/login (open; validates token constant-time, sets an HttpOnly
  SameSite=Strict cookie; 401 invalid credentials on mismatch, no leak; empty
  token = unset; no token configured → 401), POST /api/auth/logout (clears
  cookie, Max-Age=0).
- src/dashboard.html: first-run login card (token input, shown when authRequired
  && no stored token), a shared apiFetch wrapper (attaches the stored token as a
  Bearer header, on 401 → show login), checkAuth gating init; bare fetch('/api/')
  calls migrated to apiFetch (auth endpoints stay plain fetch).
- .env.example: documents DASHBOARD_AUTH_TOKEN + ENABLE_AUTH_MIDDLEWARE.
- tests: e7-1-auth/dispatch updated for bind-gating (enforcement tests set
  bound=0.0.0.0; + a 127.0.0.1 no-enforcement test). e7-2-auth.test.ts (13
  tests): auth-status (off/localhost/public/no-secret-leak), login
  (valid-cookie/invalid-401/empty/unset), logout (clears), refuse-to-start
  (self-lockout / 0.0.0.0-no-token / token-set-passes / flag-off-no-guard).

tsc clean; 194 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 01:08:46 +00:00
parent 7207cc887d
commit fe925d4aec
7 changed files with 301 additions and 26 deletions

View File

@@ -33,6 +33,16 @@ FOUNDRY_WORLD=
# Seconds before an idle headless session is reaped (relay env).
HEADLESS_SESSION_TIMEOUT=3600
# === Dashboard auth (E7) ===
# The dashboard binds 127.0.0.1 by default (localhost-only, no auth needed).
# To expose it on your tailnet, pass --host 0.0.0.0 AND set a token below, then
# flip ENABLE_AUTH_MIDDLEWARE=on. With the flag on, a 0.0.0.0 bind WITHOUT a
# token is refused at boot (safe-by-default); the flag on without a token is
# also refused (self-lockout guard — you'd otherwise brick the dashboard).
# (Optional; off-by-default — the dashboard is localhost-only without these.)
DASHBOARD_AUTH_TOKEN=
ENABLE_AUTH_MIDDLEWARE=false
# IMPORTANT — networking: Foundry's rest-api module connects OUT to the relay over
# WebSocket. So the relay must be REACHABLE FROM your Foundry host. If Foundry runs
# elsewhere, expose RELAY_PORT (port-forward / tailnet / public domain) and point the

View File

@@ -193,7 +193,7 @@ export async function cmdUi(opts: CliOptions): Promise<void> {
outDir: out,
mode: opts.mode,
port: opts.port ?? 7788,
host: opts.host ?? "0.0.0.0",
host: opts.host ?? "127.0.0.1", // E7.2: safe-by-default (localhost). --host 0.0.0.0 exposes on the tailnet (needs DASHBOARD_AUTH_TOKEN when ENABLE_AUTH_MIDDLEWARE=on).
relayCfg: relayCfg.apiKey ? relayCfg : undefined,
foundryCfg: foundryCfg.dataDir ? foundryCfg : undefined,
};

View File

@@ -76,6 +76,17 @@
</style>
</head>
<body>
<div id="loginCard" class="modal-bg" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:100">
<div class="modal entry" style="max-width:380px;margin:8vh auto 0;padding:18px">
<h2 style="margin-top:0">Dashboard auth required</h2>
<p class="meta">This dashboard is bound to <code id="loginBound">0.0.0.0</code> and requires a token. Enter the <code>DASHBOARD_AUTH_TOKEN</code> you set in your <code>.env</code>.</p>
<input id="loginToken" type="password" placeholder="DASHBOARD_AUTH_TOKEN" style="width:100%;box-sizing:border-box;margin:8px 0" onkeydown="if(event.key==='Enter')doLogin()">
<div id="loginErr" class="meta" style="color:var(--bad);min-height:1em"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="primary" onclick="doLogin()">Log in</button>
</div>
</div>
</div>
<header>
<h1>Foundry ⇄ Obsidian merge</h1>
<div class="counts" id="counts">loading…</div>
@@ -126,9 +137,43 @@
<section class="detail" id="detail">Select a row to inspect.</section>
</main>
<script>
let INDEX = null, STATUS = null, SEL = null, REC_FILTER = null, AUTO = null, autoPoll = null, migrationDismissed = false;
let INDEX = null, STATUS = null, SEL = null, REC_FILTER = null, AUTO = null, autoPoll = null, migrationDismissed = false, AUTH_REQUIRED = false;
const dryEl = () => document.getElementById('dryRun');
// E7.2: shared request wrapper. Attaches the stored auth token (set on login) as
// a Bearer header; on a 401 (token expired / wrong / public bind), shows the
// login card so the user re-authenticates instead of silently failing.
function authToken() { return localStorage.getItem('ofs_token') || ''; }
async function apiFetch(path, init = {}) {
const tok = authToken();
if (tok) init = { ...init, headers: { ...(init.headers || {}), authorization: `Bearer ${tok}` } };
const r = await fetch(path, init);
if (r.status === 401) { showLogin(); throw new Error('unauthorized'); }
return r;
}
function showLogin() {
const bound = document.getElementById('loginBound');
if (bound && STATUS && STATUS.bound) bound.textContent = STATUS.bound;
document.getElementById('loginCard').style.display = '';
document.getElementById('loginToken').focus();
}
function hideLogin() { document.getElementById('loginCard').style.display = 'none'; }
async function doLogin() {
const tok = document.getElementById('loginToken').value.trim();
if (!tok) { document.getElementById('loginErr').textContent = 'enter the token'; return; }
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: tok }) }).then(r => r.json()).catch(() => null);
if (r && r.ok) { localStorage.setItem('ofs_token', tok); hideLogin(); document.getElementById('loginErr').textContent = ''; init(); }
else { document.getElementById('loginErr').textContent = (r && r.error) ? r.error : 'invalid credentials'; }
}
async function doLogout() { localStorage.removeItem('ofs_token'); await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); showLogin(); }
async function checkAuth() {
STATUS = await apiFetch('/api/status').then(r => r.json()).catch(() => null);
const a = await fetch('/api/auth/status').then(r => r.json()).catch(() => null);
if (STATUS && a) STATUS.bound = a.bound; // merge the bind address for showLogin
if (a && a.authRequired && !authToken()) { AUTH_REQUIRED = true; showLogin(); return false; }
return true;
}
// Recommendation -> display label, badge class, bulk op, and one-line guidance.
// `tag` is the short status noun shown on each row (a state, not an action — so it
// doesn't read like a button); `label` is the fuller heading used in the rec panel.
@@ -145,13 +190,15 @@ const REC = {
const REC_ORDER = ['import','seed','sync-cc','repull','conflict','in-sync','review'];
async function init() {
STATUS = await fetch('/api/status').then(r => r.json());
// E7.2: gate on auth — if the dashboard requires a token and none is stored,
// show the login card and stop (don't load the dashboard behind it).
if (!(await checkAuth())) return;
const tag = document.getElementById('modeTag');
tag.textContent = STATUS.mode + (STATUS.mode === 'apply' ? '' : ' (safe)');
if (STATUS.mode === 'apply') tag.classList.add('apply');
// E1b.5: dev-mode banner — auto-sync is disabled in dev mode (apply-mode floor).
document.getElementById('devBanner').style.display = STATUS.mode === 'dev' ? '' : 'none';
INDEX = await fetch('/api/index').then(r => r.json());
INDEX = await apiFetch('/api/index').then(r => r.json());
const c = INDEX.counts;
document.getElementById('counts').innerHTML =
`matched <b>${c.matched}</b> · cc-only <b>${c.ccOnly}</b> · refined-only <b>${c.refinedOnly}</b> · unlinked <b>${c.unlinked}</b>`;
@@ -260,7 +307,7 @@ async function select(name){
SEL = name; render();
const d = document.getElementById('detail');
d.innerHTML = `loading ${esc(name)}`;
const f = await fetch('/api/file?name=' + encodeURIComponent(name)).then(r => r.json());
const f = await apiFetch('/api/file?name=' + encodeURIComponent(name)).then(r => r.json());
const r = f.row;
const m = REC[r.recommendation] || REC['review'];
const parts = [];
@@ -298,12 +345,12 @@ async function act(op, names){
const body = { op, dryRun };
if (names) body.names = names;
toast(`${op} ${dryRun?'(dry-run)':'('+STATUS.mode+')'}`);
const r = await fetch('/api/action', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json());
const r = await apiFetch('/api/action', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
const wrote = (r.written||[]).length, prev = (r.preview||[]).length, skip = (r.skipped||[]).length;
toast(r.message || `${op}: ${dryRun?prev:wrote} ${dryRun?'would write':'wrote'}${skip?', '+skip+' skipped':''}`);
// Refresh index so recommendation counts update after an action.
INDEX = await fetch('/api/index').then(r => r.json());
INDEX = await apiFetch('/api/index').then(r => r.json());
renderRecPanel(); render();
if (SEL) select(SEL);
}
@@ -313,7 +360,7 @@ async function act(op, names){
async function pushRow(name){
const dryRun = dryEl().checked;
toast(`push ${name} ${dryRun?'(dry-run)':'(apply)'}`);
const r = await fetch('/api/push', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({name, dryRun})}).then(r=>r.json());
const r = await apiFetch('/api/push', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({name, dryRun})}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
if (r.dryRun){
toast(`[dry-run] push ${name}: diff ready (${Object.keys(r.diff).length} keys)`);
@@ -325,14 +372,14 @@ async function pushRow(name){
// Rebuild the cached name↔uuid map via relay /search (zero Foundry downtime).
async function refreshLive(){
toast('refresh live index…');
const r = await fetch('/api/refresh', {method:'POST', headers:{'content-type':'application/json'}, body: '{}'}).then(r=>r.json());
const r = await apiFetch('/api/refresh', {method:'POST', headers:{'content-type':'application/json'}, body: '{}'}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
toast(`live index refreshed: ${r.pairs} name↔uuid pairs cached`);
}
// Auto-sync (Obsidian→Foundry, instant): the server watches the vault and pushes
// saved notes into live Foundry. Toggle here; poll for the activity log while on.
async function refreshAutosync(){
const r = await fetch('/api/autosync').then(r=>r.json()).catch(()=>null);
const r = await apiFetch('/api/autosync').then(r=>r.json()).catch(()=>null);
if (!r) return;
AUTO = r;
const btn = document.getElementById('autoSyncBtn');
@@ -388,7 +435,7 @@ async function revertLastPush(){
const noteName = btn.textContent.replace('Revert last push: ', '');
if (!confirm(`Revert the last push of "${noteName}"?\n\nThis restores Foundry to the state captured BEFORE the push (a full /update — the one place a full PUT is correct) and re-baselines the note. The note keeps your edit; Foundry reverts.`)) return;
toast('reverting last push…');
const r = await fetch('/api/autosync/revert', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({uuid})}).then(r=>r.json()).catch(()=>null);
const r = await apiFetch('/api/autosync/revert', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({uuid})}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) toast(`reverted — Foundry restored to pre-push state ("${r.restoredName ?? noteName}")`);
else toast(`revert failed: ${r?.error || 'unknown'}`);
refreshAutosync();
@@ -396,7 +443,7 @@ async function revertLastPush(){
async function toggleAutosync(){
const want = !(AUTO && AUTO.enabled);
toast(`turning auto-sync ${want ? 'on' : 'off'}`);
const r = await fetch('/api/autosync', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({enabled: want})}).then(r=>r.json());
const r = await apiFetch('/api/autosync', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({enabled: want})}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
toast(`auto-sync ${r.enabled ? 'ON — saving a note pushes it to live Foundry' : 'off'}`);
refreshAutosync();
@@ -407,14 +454,14 @@ async function toggleAutosync(){
async function pushAll(){
const dryRun = dryEl().checked;
toast(`push all changed ${dryRun?'(dry-run)':'(apply)'}… this may take a moment`);
const r = await fetch('/api/push-all', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({dryRun})}).then(r=>r.json());
const r = await apiFetch('/api/push-all', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({dryRun})}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
renderPushAllResults(r);
if (r.dryRun){
toast(`[dry-run] push all: ${r.wouldPush} note(s) would be pushed into live Foundry`);
} else {
toast(`pushed ${r.pushed}/${r.total}${r.failed?', '+r.failed+' failed':''} · baselined ${r.baselined}`);
INDEX = await fetch('/api/index').then(r => r.json());
INDEX = await apiFetch('/api/index').then(r => r.json());
renderRecPanel(); render();
}
}
@@ -440,7 +487,7 @@ let LINK_ENTRIES = null, LINK_NAME = null;
async function linkPicker(name){
LINK_NAME = name;
if (!LINK_ENTRIES) {
const r = await fetch('/api/entries').then(r => r.json());
const r = await apiFetch('/api/entries').then(r => r.json());
if (r.error) { toast('error: ' + r.error); return; }
LINK_ENTRIES = r.entries || [];
}
@@ -471,12 +518,12 @@ async function doLink(name, uuid){
document.querySelector('.modal-bg')?.remove();
const dryRun = dryEl().checked;
toast(`link ${name} ${dryRun?'(dry-run)':'(apply)'}`);
const r = await fetch('/api/link', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({name, uuid, dryRun})}).then(r=>r.json());
const r = await apiFetch('/api/link', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({name, uuid, dryRun})}).then(r=>r.json());
if (r.error){ toast('error: ' + r.error); return; }
if (r.dryRun){ toast(`[dry-run] link ${name} -> ${uuid}`); }
else {
toast(r.message || `linked ${name}`);
INDEX = await fetch('/api/index').then(r => r.json());
INDEX = await apiFetch('/api/index').then(r => r.json());
renderRecPanel(); render();
}
}
@@ -485,7 +532,7 @@ init();
// Called by the "Re-scan" button and automatically when the tab regains focus (so edits
// made in Obsidian show up without a manual refresh).
async function refreshIndex(){
INDEX = await fetch('/api/index').then(r => r.json());
INDEX = await apiFetch('/api/index').then(r => r.json());
const c = INDEX.counts;
document.getElementById('counts').innerHTML =
`matched <b>${c.matched}</b> · cc-only <b>${c.ccOnly}</b> · refined-only <b>${c.refinedOnly}</b> · unlinked <b>${c.unlinked}</b>`;

View File

@@ -104,8 +104,10 @@ const ENABLE_AUTH_MIDDLEWARE = process.env.ENABLE_AUTH_MIDDLEWARE === "true";
const DASHBOARD_AUTH_TOKEN = process.env.DASHBOARD_AUTH_TOKEN ?? "";
/** E7.1: mutable auth state (the boot env values by default; tests override
* `enabled`/`token` to exercise the flag-on path without re-importing). */
export const __authState = { enabled: ENABLE_AUTH_MIDDLEWARE, token: DASHBOARD_AUTH_TOKEN };
* `enabled`/`token`/`bound` to exercise the flag-on path without re-importing).
* E7.2: `bound` is the actual bind address (set at startServer); auth is only
* enforced on a public bind (0.0.0.0) — 127.0.0.1 is localhost-trusted. */
export const __authState = { enabled: ENABLE_AUTH_MIDDLEWARE, token: DASHBOARD_AUTH_TOKEN, bound: "127.0.0.1" };
/** E7.1: the auth-relevant subset of a route (the full Route type adds the handler). */
export interface AuthRoute {
@@ -165,6 +167,10 @@ function readAuthToken(req: IncomingMessage): string | null {
export async function authenticate(req: IncomingMessage, res: ServerResponse, route: AuthRoute): Promise<boolean> {
if (!__authState.enabled) return true; // flag off → no-op pass-through
if (!route.requireAuth) return true; // unguarded route (read endpoints stay open on-box)
// E7.2: only enforce on a PUBLIC bind (0.0.0.0). On 127.0.0.1 (localhost
// trusted), requireAuth routes stay open even with the flag on — the
// refuse-to-start guard ensures a 0.0.0.0 bind has a token before boot.
if (__authState.bound !== "0.0.0.0") return true;
if (!__authState.token) { send(res, 500, { error: "auth required but DASHBOARD_AUTH_TOKEN is unset" }); return false; }
let token: string | null;
try { token = readAuthToken(req); }
@@ -1359,6 +1365,19 @@ export class AutoSyncController {
}
export async function startServer(cfg: ServerConfig): Promise<{ server: Server; state: State }> {
// E7.2: bind + auth boot guards (before anything else). Safe-by-default: the
// host default is 127.0.0.1 (localhost). Public exposure (0.0.0.0) requires a
// token when the auth flag is on; the flag on without a token would brick the
// dashboard (no recovery short of editing .env), so refuse that too.
const token = (__authState.token ?? "").trim();
if (__authState.enabled && !token) {
throw new Error("ENABLE_AUTH_MIDDLEWARE=on requires DASHBOARD_AUTH_TOKEN — set the token or disable the flag");
}
if (__authState.enabled && cfg.host === "0.0.0.0" && !token) {
throw new Error("refusing to bind 0.0.0.0 without DASHBOARD_AUTH_TOKEN — set the token or bind 127.0.0.1");
}
__authState.bound = cfg.host;
const db = await JournalDb.open(cfg.journal);
const state = { db, cfg, index: null } as State;
state.autosync = new AutoSyncController(state);
@@ -1427,6 +1446,41 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
method: "GET", requireAuth: false, requireCSRF: false,
handler: async (_s, _req, res) => send(res, 200, state.autosync.status()),
},
// E7.2: auth status (the login prompt polls this; always open — booleans
// only, no secret values). authRequired = enforcement active (flag on + a
// public bind + a token set) → the dashboard shows a login card.
"GET /api/auth/status": {
method: "GET", requireAuth: false, requireCSRF: false,
handler: async (_s, _req, res) => send(res, 200, {
authRequired: __authState.enabled && __authState.bound === "0.0.0.0" && !!__authState.token,
bound: __authState.bound,
relayConfigured: !!state.cfg.relayCfg,
foundryConfigured: !!state.cfg.foundryCfg,
}),
},
// E7.2: first-run login. Validates the token (constant-time; no leak which
// field was wrong) and sets an HttpOnly SameSite=Strict cookie. Always open
// (the login prompt itself must be reachable pre-auth).
"POST /api/auth/login": {
method: "POST", requireAuth: false, requireCSRF: false,
handler: async (_s, req, res) => {
const body = await readJsonBody(req);
if (body === null) return send(res, 400, { error: "bad json" });
const token = String(body.token ?? "").trim();
if (!__authState.token || !constantTimeEqual(token, __authState.token)) {
return send(res, 401, { error: "invalid credentials" });
}
res.setHeader("set-cookie", `auth_token=${token}; HttpOnly; SameSite=Strict; Path=/`);
return send(res, 200, { ok: true });
},
},
"POST /api/auth/logout": {
method: "POST", requireAuth: false, requireCSRF: false,
handler: async (_s, _req, res) => {
res.setHeader("set-cookie", "auth_token=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
return send(res, 200, { ok: true });
},
},
"GET /api/autosync/conflicts": {
method: "GET", requireAuth: false, requireCSRF: false,
handler: async (_s, _req, res) => send(res, 200, { conflicts: state.autosync.conflicts }),

View File

@@ -24,8 +24,9 @@ function mockRes(): { res: ServerResponse; code: number; body: string } {
const savedEnabled = __authState.enabled;
const savedToken = __authState.token;
beforeEach(() => { __authState.enabled = false; __authState.token = ""; });
afterEach(() => { __authState.enabled = savedEnabled; __authState.token = savedToken; });
const savedBound = __authState.bound;
beforeEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
afterEach(() => { __authState.enabled = savedEnabled; __authState.token = savedToken; __authState.bound = savedBound; });
describe("E7.1a authenticate (flag off = no-op pass-through)", () => {
it("flag off → always returns true regardless of route or headers", async () => {
@@ -39,7 +40,7 @@ describe("E7.1a authenticate (flag off = no-op pass-through)", () => {
});
describe("E7.1a authenticate (flag on)", () => {
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; });
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; __authState.bound = "0.0.0.0"; });
it("requireAuth:false (read route) → proceeds without a token", async () => {
const r = mockRes();
@@ -47,6 +48,13 @@ describe("E7.1a authenticate (flag on)", () => {
expect(r.code).toBe(0);
});
it("E7.2: bound 127.0.0.1 (localhost trusted) → no enforcement even with flag on", async () => {
__authState.bound = "127.0.0.1";
const r = mockRes();
expect(await authenticate(mockReq(), r.res, REQUIRE_AUTH)).toBe(true); // no enforcement
expect(r.code).toBe(0);
});
it("requireAuth:true + no Authorization header → 401 unauthorized", async () => {
const r = mockRes();
expect(await authenticate(mockReq(), r.res, REQUIRE_AUTH)).toBe(false);

View File

@@ -19,6 +19,7 @@ let server: Server;
let baseURL: string;
const savedEnabled = __authState.enabled;
const savedToken = __authState.token;
const savedBound = __authState.bound;
const realFetch = globalThis.fetch;
beforeAll(async () => {
@@ -47,12 +48,13 @@ beforeAll(async () => {
afterAll(async () => {
__authState.enabled = savedEnabled;
__authState.token = savedToken;
__authState.bound = savedBound;
await new Promise<void>((r) => server.close(() => r()));
await rm(dir, { recursive: true, force: true });
});
beforeEach(() => { __authState.enabled = false; __authState.token = ""; });
afterEach(() => { __authState.enabled = false; __authState.token = ""; });
beforeEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
afterEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
async function req(path: string, init: RequestInit = {}): Promise<{ code: number; body: unknown }> {
const r = await fetch(`${baseURL}${path}`, init);
@@ -85,7 +87,9 @@ describe("E7.1c dispatch (flag off — no auth, byte-identical)", () => {
});
describe("E7.1c dispatch (flag on + token set)", () => {
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; });
// E7.2: enforcement only on a public bind (0.0.0.0). The server binds 127.0.0.1
// (safe for the test), so override __authState.bound to simulate a public bind.
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; __authState.bound = "0.0.0.0"; });
it("GET /api/status (read) passes WITHOUT a token", async () => {
const r = await req("/api/status");

152
tests/e7-2-auth.test.ts Normal file
View File

@@ -0,0 +1,152 @@
// E7.2 — localhost-default bind + refuse-to-start + first-run auth prompt.
//
// Covers: /api/auth/status (authRequired flag), /api/auth/login (valid/invalid →
// cookie / 401), /api/auth/logout (clears cookie), and the startServer refuse-to-
// start guards (self-lockout + 0.0.0.0-without-token).
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "node:http";
import { startServer, __authState } from "../src/server.js";
let dir: string;
let server: Server;
let baseURL: string;
const savedEnabled = __authState.enabled;
const savedToken = __authState.token;
const savedBound = __authState.bound;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "e7-2-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "cc"), { recursive: true });
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open(); await jdb.close();
const { server: srv } = await startServer({
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir: join(dir, "out"), mode: "dev", port: 0, host: "127.0.0.1",
});
server = srv;
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
afterAll(async () => {
__authState.enabled = savedEnabled; __authState.token = savedToken; __authState.bound = savedBound;
await new Promise<void>((r) => server.close(() => r()));
await rm(dir, { recursive: true, force: true });
});
beforeEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
afterEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
async function req(path: string, init: RequestInit = {}): Promise<{ code: number; body: unknown; headers: Headers }> {
const r = await fetch(`${baseURL}${path}`, init);
return { code: r.status, body: r.headers.get("content-type")?.includes("json") ? await r.json() : await r.text(), headers: r.headers };
}
describe("E7.2 /api/auth/status", () => {
it("flag off → authRequired:false, bound:127.0.0.1", async () => {
const r = await req("/api/auth/status");
expect(r.code).toBe(200);
expect((r.body as { authRequired: boolean }).authRequired).toBe(false);
expect((r.body as { bound: string }).bound).toBe("127.0.0.1");
});
it("flag on + 127.0.0.1 (localhost trusted) → authRequired:false", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "127.0.0.1";
const r = await req("/api/auth/status");
expect((r.body as { authRequired: boolean }).authRequired).toBe(false);
});
it("flag on + 0.0.0.0 + token → authRequired:true", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0";
const r = await req("/api/auth/status");
expect((r.body as { authRequired: boolean }).authRequired).toBe(true);
});
it("never echoes the token or secret config (booleans only)", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0";
const r = await req("/api/auth/status");
const body = JSON.stringify(r.body);
expect(body).not.toContain("s3cret");
});
});
describe("E7.2 /api/auth/login + logout", () => {
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0"; });
it("valid token → 200 + sets an HttpOnly SameSite=Strict auth_token cookie", async () => {
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "s3cret" }) });
expect(r.code).toBe(200);
const cookie = r.headers.get("set-cookie") ?? "";
expect(cookie).toContain("auth_token=s3cret");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Strict");
});
it("wrong token → 401 invalid credentials (no leak)", async () => {
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "wrong" }) });
expect(r.code).toBe(401);
expect((r.body as { error: string }).error).toBe("invalid credentials");
expect(r.headers.get("set-cookie")).toBe(null); // no cookie set on failure
});
it("empty/whitespace token → 401 (treated as unset)", async () => {
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: " " }) });
expect(r.code).toBe(401);
});
it("no token configured (DASHBOARD_AUTH_TOKEN unset) → 401 (can't log in)", async () => {
__authState.token = "";
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "anything" }) });
expect(r.code).toBe(401);
});
it("logout → 200 + clears the cookie (Max-Age=0)", async () => {
const r = await req("/api/auth/logout", { method: "POST" });
expect(r.code).toBe(200);
const cookie = r.headers.get("set-cookie") ?? "";
expect(cookie).toContain("auth_token=");
expect(cookie).toContain("Max-Age=0");
});
});
describe("E7.2 startServer refuse-to-start guards", () => {
// These throw BEFORE opening the journal, so no LevelDB is needed.
const dummyCfg = (host: string) => ({
journal: join(dir, "no-such-journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir: join(dir, "out"), mode: "dev" as const, port: 0, host,
});
it("flag on + no token → refuses to start (self-lockout guard)", async () => {
__authState.enabled = true; __authState.token = ""; __authState.bound = "127.0.0.1";
await expect(startServer(dummyCfg("127.0.0.1"))).rejects.toThrow(/ENABLE_AUTH_MIDDLEWARE=on requires DASHBOARD_AUTH_TOKEN/);
});
it("flag on + 0.0.0.0 + no token → refuses to start (public-exposure gate)", async () => {
__authState.enabled = true; __authState.token = ""; __authState.bound = "127.0.0.1";
// The self-lockout guard fires first (no token at all), so this throws the
// self-lockout message — both guards refuse; assert it refuses (either message).
await expect(startServer(dummyCfg("0.0.0.0"))).rejects.toThrow();
});
it("flag on + token set → does NOT throw on the guards (would proceed to open the journal)", async () => {
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "127.0.0.1";
// With a token, the guards pass; startServer then tries to open the journal
// (dummyCfg points at a non-existent path) → throws a DIFFERENT error (LevelDB
// open), NOT the guard message. Assert it does NOT throw the guard message.
try {
await startServer(dummyCfg("0.0.0.0"));
expect.unreachable("should have thrown on the missing journal");
} catch (e) {
expect((e as Error).message).not.toMatch(/DASHBOARD_AUTH_TOKEN/);
expect((e as Error).message).not.toMatch(/refusing to bind/);
}
});
it("flag off → no guard (back-compat: 0.0.0.0 + no token would proceed)", async () => {
__authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1";
try {
await startServer(dummyCfg("0.0.0.0"));
expect.unreachable("should have thrown on the missing journal");
} catch (e) {
// No guard message — it got past the guards to the journal open.
expect((e as Error).message).not.toMatch(/DASHBOARD_AUTH_TOKEN/);
expect((e as Error).message).not.toMatch(/refusing to bind/);
}
});
});