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:
10
.env.example
10
.env.example
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
152
tests/e7-2-auth.test.ts
Normal 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user