Add customer names on the ledger, staff session login, and exclusive jobs.events.
Some checks are pending
offline / test (push) Waiting to run

Account-balance stores display names and looks up by name. Edge writes
names on customer create/edit; staff UIs join from edge when needed.
New verae-staff-session issues a host cookie; department HTML redirects
when STAFF_AUTH=1. JOBS_EVENTS_EXCLUSIVE lets jobs-events own the durable
consumer. Catalog index is cards; disabled fleet machines are grey.
This commit is contained in:
George Lambert 2026-09-11 18:11:21 -04:00
parent cb07f5b321
commit 9cc0018708
38 changed files with 533 additions and 68 deletions

View file

@ -0,0 +1,14 @@
import { allowed } from './token.js';
/** Redirect HTML to the staff login when STAFF_AUTH=1. JSON APIs stay open unless STAFF_AUTH_JSON=1. */
export function staffHtmlGuard(req, res, url) {
if (process.env.STAFF_AUTH !== '1') return false;
const html = req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html');
if (!html) return false;
if (allowed(req)) return false;
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
const next = `http://${req.headers.host || '127.0.0.1'}${url.pathname}`;
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent(next)}` });
res.end();
return true;
}

View file

@ -0,0 +1,100 @@
#!/usr/bin/env node
import http from 'node:http';
import { cookieHeader, sessionToken, staffKey } from './token.js';
const PORT = Number(process.env.PORT || 3027);
const LOGIN = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Staff sign-in</title>
<style>
:root { --accent:#4f46e5; --bg:#f4f5fb; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
#auth { min-height:100vh; display:grid; place-items:center; background:linear-gradient(160deg,#312e81 0%,#4f46e5 55%,#7c74f0 100%); }
.card { width:360px; background:#fff; border-radius:16px; padding:2rem; box-shadow:0 24px 64px rgba(17,12,60,.35); }
h1 { margin:0 0 .25rem; font-size:1.3rem; }
p { color:var(--muted); margin:0 0 1.2rem; }
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
button { width:100%; margin-top:1.2rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
.err { color:#dc2626; min-height:1.2em; }
</style>
</head>
<body>
<section id="auth">
<form class="card" method="post" action="/login">
<h1>Staff sign-in</h1>
<p>One cookie covers CS, sales, accounting, and the staff plane on this host.</p>
<input type="hidden" name="next" id="next"/>
<label for="password">Staff key</label>
<input id="password" name="password" type="password" autocomplete="current-password" required/>
<p class="err" id="err"></p>
<button type="submit">Sign in</button>
</form>
</section>
<script>
const q = new URLSearchParams(location.search);
document.getElementById('next').value = q.get('next') || '';
if (q.get('error')) document.getElementById('err').textContent = 'Wrong key.';
</script>
</body>
</html>`;
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
const json = (code, obj) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(obj));
};
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'verae-staff-session' });
}
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(LOGIN);
}
if (req.method === 'GET' && url.pathname === '/check') {
const raw = req.headers.cookie || '';
const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
return json(m && m[1] === sessionToken() ? 200 : 401, { ok: Boolean(m && m[1] === sessionToken()) });
}
if (req.method === 'POST' && url.pathname === '/login') {
const chunks = [];
for await (const c of req) chunks.push(c);
const text = Buffer.concat(chunks).toString('utf8');
let password = '';
let next = '/';
if ((req.headers['content-type'] || '').includes('json')) {
const body = JSON.parse(text || '{}');
password = body.password || '';
next = body.next || '/';
} else {
const params = new URLSearchParams(text);
password = params.get('password') || '';
next = params.get('next') || '/';
}
if (password !== staffKey()) {
res.writeHead(302, { location: '/login?error=1' });
return res.end();
}
const loc = next.startsWith('http') || next.startsWith('/') ? next : '/';
res.writeHead(302, { 'set-cookie': cookieHeader(), location: loc });
return res.end();
}
if (req.method === 'POST' && url.pathname === '/logout') {
res.writeHead(302, {
'set-cookie': 'staff_session=; Path=/; Max-Age=0',
location: '/login',
});
return res.end();
}
json(404, { error: 'not found' });
});
server.listen(PORT, '0.0.0.0', () => {
process.stdout.write(`verae-staff-session http://0.0.0.0:${PORT}/\n`);
});

View file

@ -0,0 +1,28 @@
import crypto from 'node:crypto';
export function staffKey() {
return process.env.STAFF_KEY || process.env.ADMIN_KEY || 'admin-dev-key';
}
export function sessionToken() {
return crypto.createHmac('sha256', staffKey()).update('verae-staff').digest('hex');
}
export function cookieHeader() {
return `staff_session=${sessionToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`;
}
export function cookieOk(req) {
const raw = req.headers?.cookie || '';
const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
return Boolean(m && m[1] === sessionToken());
}
export function headerOk(req) {
const k = req.headers?.['x-staff-key'];
return k === staffKey();
}
export function allowed(req) {
return cookieOk(req) || headerOk(req);
}