From 9cc00187086b623584e887a77e8489a39a47aa48 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 18:11:21 -0400 Subject: [PATCH] Add customer names on the ledger, staff session login, and exclusive jobs.events. 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. --- packages/overview/02-modules-and-repos.md | 1 + packages/verae-access-staff/src/names.js | 25 +++++ packages/verae-access-staff/src/server.js | 20 +++- packages/verae-fleet/public/index.html | 7 +- packages/verae-jobs-events/NATS.md | 2 + packages/verae-jobs-events/src/server.js | 39 ++++++- packages/verae-jobs-events/src/subjects.js | 2 + packages/verae-staff-session/NATS.md | 3 + packages/verae-staff-session/README.md | 9 ++ packages/verae-staff-session/SUMMARY.md | 3 + packages/verae-staff-session/package.json | 11 ++ packages/verae-staff-session/src/gate.js | 14 +++ packages/verae-staff-session/src/server.js | 100 ++++++++++++++++++ packages/verae-staff-session/src/token.js | 28 +++++ .../verae-staff-session/test/token.test.js | 11 ++ .../src/workers/webhookWorker.js | 31 +++--- packages/zapier-decisions/LOG.md | 7 ++ packages/zapier-decisions/TODO.md | 4 +- packages/zappier-account-balance/src/books.js | 31 ++++-- .../zappier-account-balance/src/server.js | 9 +- .../zappier-account-balance/src/subjects.js | 1 + .../test/books.test.js | 8 ++ .../test/health.test.js | 6 +- .../zappier-accounting-export/src/names.js | 25 +++++ .../zappier-accounting-export/src/server.js | 24 +++-- .../zappier-customer-service/src/names.js | 25 +++++ .../zappier-customer-service/src/server.js | 15 ++- packages/zappier-sales-pricing/src/names.js | 25 +++++ packages/zappier-sales-pricing/src/server.js | 23 ++-- packages/zappier/portal/app.js | 30 +++--- packages/zappier/src/admin.ts | 10 +- packages/zappier/src/billing-nats.ts | 2 + packages/zappier/src/ledger.ts | 17 ++- packages/zappier/src/portal.ts | 7 +- packages/zappier/src/statement.ts | 2 + scripts/build-docs-site.py | 21 ++-- scripts/gen-module-docs.py | 1 + scripts/push-module-repos.sh | 2 + 38 files changed, 533 insertions(+), 68 deletions(-) create mode 100644 packages/verae-access-staff/src/names.js create mode 100644 packages/verae-staff-session/NATS.md create mode 100644 packages/verae-staff-session/README.md create mode 100644 packages/verae-staff-session/SUMMARY.md create mode 100644 packages/verae-staff-session/package.json create mode 100644 packages/verae-staff-session/src/gate.js create mode 100644 packages/verae-staff-session/src/server.js create mode 100644 packages/verae-staff-session/src/token.js create mode 100644 packages/verae-staff-session/test/token.test.js create mode 100644 packages/zappier-accounting-export/src/names.js create mode 100644 packages/zappier-customer-service/src/names.js create mode 100644 packages/zappier-sales-pricing/src/names.js diff --git a/packages/overview/02-modules-and-repos.md b/packages/overview/02-modules-and-repos.md index f8c8fdd..8701aff 100644 --- a/packages/overview/02-modules-and-repos.md +++ b/packages/overview/02-modules-and-repos.md @@ -33,6 +33,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`, | **verae-zapier-simulator** | `packages/verae-zapier-simulator` | Trace console before `zapier-platform push` | | **zapier-user-docs** | `packages/zapier-user-docs` | Customer signup → register → lookup | | **UI-Docs** | `packages/ui-docs` | Operator/staff/portal walkthrough, screenshots, review PDF | +| **verae-staff-session** | `packages/verae-staff-session` | Shared staff cookie login for department HTML | | **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` | | **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services | diff --git a/packages/verae-access-staff/src/names.js b/packages/verae-access-staff/src/names.js new file mode 100644 index 0000000..d265ba0 --- /dev/null +++ b/packages/verae-access-staff/src/names.js @@ -0,0 +1,25 @@ +/** Join ledger ids to zappier-edge customer display names. */ +export async function withCustomerName(st, idOrName, edge, key) { + const out = { ...(st || {}) }; + if (out.name && out.customerId) return out; + try { + const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, { + headers: { 'x-admin-key': key }, + }); + const { customers } = await r.json(); + const want = String(idOrName || out.customerId || '').toLowerCase(); + const c = (customers || []).find( + (x) => + x.id === idOrName || + x.id === out.customerId || + String(x.name || '').toLowerCase() === want, + ); + if (c) { + out.name = c.name; + out.customerId = c.id; + } + } catch { + /* edge optional */ + } + return out; +} diff --git a/packages/verae-access-staff/src/server.js b/packages/verae-access-staff/src/server.js index a736d1a..0920346 100644 --- a/packages/verae-access-staff/src/server.js +++ b/packages/verae-access-staff/src/server.js @@ -4,11 +4,14 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { withCustomerName } from './names.js'; const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); const PORT = Number(process.env.PORT || 3025); const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, ''); const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, ''); +const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); +const KEY = process.env.ZAPPIER_ADMIN_KEY || 'admin-dev-key'; const PLANE = 'staff'; async function check(subject, extra = {}) { @@ -28,6 +31,15 @@ const server = http.createServer(async (req, res) => { }; try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + if (!chk || !chk.ok) { + res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); + res.end(); + return; + } + } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(fs.readFileSync(path.join(PUBLIC, 'index.html'))); return; @@ -37,10 +49,12 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { - const gate = await check('verae.billing.statement.get', { principal: review[1] }); + const id = decodeURIComponent(review[1]); + const gate = await check('verae.billing.statement.get', { principal: id }); if (!gate.allow) return json(403, gate); - const r = await fetch(`${BOOKS}/statement/${review[1]}`); - return json(r.status, { ...(await r.json()), plane: PLANE, source: 'account-balance' }); + const r = await fetch(`${BOOKS}/statement/${encodeURIComponent(id)}`); + const body = await withCustomerName({ ...(await r.json()), plane: PLANE, source: 'account-balance' }, id, EDGE, KEY); + return json(r.status, body); } if (req.method === 'POST' && url.pathname === '/credits') { const chunks = []; diff --git a/packages/verae-fleet/public/index.html b/packages/verae-fleet/public/index.html index 9342efa..253cbd6 100644 --- a/packages/verae-fleet/public/index.html +++ b/packages/verae-fleet/public/index.html @@ -47,6 +47,7 @@ .card.operational { background:var(--ok-bg); border-color:#c8efd4; } .card.degraded { background:var(--warn-bg); border-color:#ffe08a; } .card.down { background:var(--err-bg); border-color:#f3c0c0; } + .card.disabled { background:#f1f5f9; border-color:#e5e7f0; } .card h3 { margin:0 0 .25rem; font-size:14px; } .muted { color:var(--muted); font-size:12px; } .pill { font:700 10px system-ui; letter-spacing:.06em; text-transform:uppercase; padding:.12rem .4rem; border-radius:999px; } @@ -258,8 +259,8 @@ function spark(r) {
RTT ms ${r.minMs} / ${r.avgMs} / ${r.p50Ms} / ${r.p90Ms} n=${r.count}
`; } function pill(health) { - const label = health === 'operational' ? 'operational' : health === 'degraded' ? 'degraded' : 'not operational'; - const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : 'bad'; + const label = health === 'operational' ? 'operational' : health === 'degraded' ? 'degraded' : health === 'disabled' ? 'disabled' : 'not operational'; + const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : health === 'disabled' ? '' : 'bad'; return `${label}`; } function moreMenu(items) { @@ -303,7 +304,7 @@ async function drawFleet() { $('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '') + ' · HTTP 0.0.0.0:3850'; const machines = s.machines || []; $('machines').innerHTML = machines.length ? machines.map((m) => { - const health = !m.enabled ? 'down' : m.running > 0 ? 'operational' : 'degraded'; + const health = !m.enabled ? 'disabled' : m.running > 0 ? 'operational' : 'degraded'; return `

${m.id} ${pill(health)}

${m.title || ''}
diff --git a/packages/verae-jobs-events/NATS.md b/packages/verae-jobs-events/NATS.md index 0996e14..cd40e3f 100644 --- a/packages/verae-jobs-events/NATS.md +++ b/packages/verae-jobs-events/NATS.md @@ -1,3 +1,5 @@ # NATS — verae-jobs-events IN `verae.zapier.jobs.events` queue `jobs-events`. + +When `JOBS_EVENTS_EXCLUSIVE=1`, this process is the durable JetStream consumer `jobs-events-exclusive` on `ZAPIER_EVENTS` and republishes to `verae.internal.jobs.events`. Middleware must set the same env so it does not also consume `verae.zapier.jobs.events`. diff --git a/packages/verae-jobs-events/src/server.js b/packages/verae-jobs-events/src/server.js index 2bbdda6..e079086 100644 --- a/packages/verae-jobs-events/src/server.js +++ b/packages/verae-jobs-events/src/server.js @@ -14,14 +14,40 @@ async function startNats() { const { connect, StringCodec } = await import('nats'); const nc = await connect({ servers: url.split(','), name: 'verae-jobs-events' }); const sc = StringCodec(); - for await (const m of nc.subscribe(SUBJECTS.IN, { queue: SUBJECTS.QUEUE })) { + const exclusive = process.env.JOBS_EVENTS_EXCLUSIVE === '1'; + const deliver = async (m) => { processed += 1; try { last = JSON.parse(sc.decode(m.data) || '{}'); } catch { last = {}; } - if (m.reply) m.respond(sc.encode(JSON.stringify({ ok: true, processed }))); + if (exclusive) { + nc.publish(SUBJECTS.INTERNAL, m.data); + } + if (m.reply) m.respond(sc.encode(JSON.stringify({ ok: true, processed, exclusive }))); + if (typeof m.ack === 'function') await m.ack(); + }; + if (exclusive) { + try { + const js = nc.jetstream(); + const jsm = await nc.jetstreamManager(); + await jsm.consumers.add('ZAPIER_EVENTS', { + durable_name: SUBJECTS.DURABLE, + ack_policy: 'explicit', + filter_subject: SUBJECTS.IN, + max_deliver: 10, + }).catch(() => {}); + const consumer = await js.consumers.get('ZAPIER_EVENTS', SUBJECTS.DURABLE); + const messages = await consumer.consume({ max_messages: 10 }); + for await (const m of messages) await deliver(m); + return; + } catch (err) { + process.stderr.write(`js exclusive fallback core sub: ${err.message}\n`); + } + } + for await (const m of nc.subscribe(SUBJECTS.IN, { queue: SUBJECTS.QUEUE })) { + await deliver(m); } } @@ -29,7 +55,14 @@ const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); if (url.pathname === '/health') { res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ ok: true, role: 'verae-jobs-events', processed, subject: SUBJECTS.IN, lastEvent: last?.event || null })); + res.end(JSON.stringify({ + ok: true, + role: 'verae-jobs-events', + processed, + subject: SUBJECTS.IN, + exclusive: process.env.JOBS_EVENTS_EXCLUSIVE === '1', + lastEvent: last?.event || null, + })); return; } res.writeHead(404, { 'content-type': 'application/json' }); diff --git a/packages/verae-jobs-events/src/subjects.js b/packages/verae-jobs-events/src/subjects.js index b9d826d..b320bf3 100644 --- a/packages/verae-jobs-events/src/subjects.js +++ b/packages/verae-jobs-events/src/subjects.js @@ -1,4 +1,6 @@ export const SUBJECTS = { IN: 'verae.zapier.jobs.events', + INTERNAL: 'verae.internal.jobs.events', QUEUE: 'jobs-events', + DURABLE: 'jobs-events-exclusive', }; diff --git a/packages/verae-staff-session/NATS.md b/packages/verae-staff-session/NATS.md new file mode 100644 index 0000000..c03cc4b --- /dev/null +++ b/packages/verae-staff-session/NATS.md @@ -0,0 +1,3 @@ +# NATS + +No subjects. HTTP cookie only. diff --git a/packages/verae-staff-session/README.md b/packages/verae-staff-session/README.md new file mode 100644 index 0000000..38fb73c --- /dev/null +++ b/packages/verae-staff-session/README.md @@ -0,0 +1,9 @@ +# verae-staff-session + +Shared cookie login for CS / sales / accounting / access-staff HTML. + +**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-session + +Port `:3027`. Set `STAFF_AUTH=1` on the department servers and `STAFF_SESSION_URL=http://127.0.0.1:3027`. Cookie host is the browser host (ports share `127.0.0.1`). JSON APIs stay open unless you also send `x-staff-key`. + +Default key: `STAFF_KEY` or `ADMIN_KEY` or `admin-dev-key`. diff --git a/packages/verae-staff-session/SUMMARY.md b/packages/verae-staff-session/SUMMARY.md new file mode 100644 index 0000000..7ecac15 --- /dev/null +++ b/packages/verae-staff-session/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-staff-session + +HMAC staff cookie (`staff_session`) so CS, sales, accounting, and access-staff HTML share one login on the operator host. diff --git a/packages/verae-staff-session/package.json b/packages/verae-staff-session/package.json new file mode 100644 index 0000000..e378952 --- /dev/null +++ b/packages/verae-staff-session/package.json @@ -0,0 +1,11 @@ +{ + "name": "verae-staff-session", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Shared staff cookie login for CS / sales / accounting / access-staff HTML", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/packages/verae-staff-session/src/gate.js b/packages/verae-staff-session/src/gate.js new file mode 100644 index 0000000..25e34a6 --- /dev/null +++ b/packages/verae-staff-session/src/gate.js @@ -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; +} diff --git a/packages/verae-staff-session/src/server.js b/packages/verae-staff-session/src/server.js new file mode 100644 index 0000000..525d3cc --- /dev/null +++ b/packages/verae-staff-session/src/server.js @@ -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 = ` + + + + + Staff sign-in + + + +
+
+

Staff sign-in

+

One cookie covers CS, sales, accounting, and the staff plane on this host.

+ + + +

+ +
+
+ + +`; + +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`); +}); diff --git a/packages/verae-staff-session/src/token.js b/packages/verae-staff-session/src/token.js new file mode 100644 index 0000000..580e52b --- /dev/null +++ b/packages/verae-staff-session/src/token.js @@ -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); +} diff --git a/packages/verae-staff-session/test/token.test.js b/packages/verae-staff-session/test/token.test.js new file mode 100644 index 0000000..503ed7b --- /dev/null +++ b/packages/verae-staff-session/test/token.test.js @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sessionToken, cookieOk, headerOk } from '../src/token.js'; + +test('cookie matches HMAC of staff key', () => { + const tok = sessionToken(); + assert.equal(tok.length, 64); + assert.equal(cookieOk({ headers: { cookie: `staff_session=${tok}` } }), true); + assert.equal(cookieOk({ headers: { cookie: 'staff_session=nope' } }), false); + assert.equal(headerOk({ headers: { 'x-staff-key': process.env.STAFF_KEY || 'admin-dev-key' } }), true); +}); diff --git a/packages/verae-zapier-middleware/src/workers/webhookWorker.js b/packages/verae-zapier-middleware/src/workers/webhookWorker.js index 726821d..b567f67 100644 --- a/packages/verae-zapier-middleware/src/workers/webhookWorker.js +++ b/packages/verae-zapier-middleware/src/workers/webhookWorker.js @@ -68,16 +68,17 @@ export async function startWebhookWorker() { const { js, jsm } = await connectNats(); await ensureStreams(jsm); - // Events consumer → deliver - try { - await jsm.consumers.add(STREAMS.ZAPIER_EVENTS, { - durable_name: CONSUMERS.EVENT_WEBHOOK_ROUTER, - ack_policy: 'explicit', - filter_subject: SUBJECTS.JOBS_EVENTS, - max_deliver: 10, - }); - } catch (err) { - log.debug('events consumer may exist', { error: err.message }); + if (process.env.JOBS_EVENTS_EXCLUSIVE !== '1') { + try { + await jsm.consumers.add(STREAMS.ZAPIER_EVENTS, { + durable_name: CONSUMERS.EVENT_WEBHOOK_ROUTER, + ack_policy: 'explicit', + filter_subject: SUBJECTS.JOBS_EVENTS, + max_deliver: 10, + }); + } catch (err) { + log.debug('events consumer may exist', { error: err.message }); + } } try { @@ -117,9 +118,13 @@ export async function startWebhookWorker() { } }; - runConsumer(STREAMS.ZAPIER_EVENTS, CONSUMERS.EVENT_WEBHOOK_ROUTER).catch((err) => - log.error('events consumer failed', { error: err.message }), - ); + if (process.env.JOBS_EVENTS_EXCLUSIVE === '1') { + log.info('JOBS_EVENTS_EXCLUSIVE=1 — skipping event-webhook-router; verae-jobs-events owns ZAPIER_EVENTS'); + } else { + runConsumer(STREAMS.ZAPIER_EVENTS, CONSUMERS.EVENT_WEBHOOK_ROUTER).catch((err) => + log.error('events consumer failed', { error: err.message }), + ); + } runConsumer(STREAMS.ZAPIER_WEBHOOKS, CONSUMERS.WEBHOOK_DELIVER).catch((err) => log.error('webhooks consumer failed', { error: err.message }), ); diff --git a/packages/zapier-decisions/LOG.md b/packages/zapier-decisions/LOG.md index a08b36e..1c15474 100644 --- a/packages/zapier-decisions/LOG.md +++ b/packages/zapier-decisions/LOG.md @@ -32,6 +32,13 @@ - Disable lan-134 unless `FLEET_ENABLE_LAN134=1`. - SSH spawn timeout 8s; failed hosts skipped. +## 2026-09-11 — names, staff session, exclusive jobs.events + +- Account-balance stores display names (`customer.put` + lookup by name). Edge writes names on customer create/edit; CS/sales/accounting/staff join from edge if the ledger has no name. +- New repo `verae-staff-session` (:3027). Department HTML redirects when `STAFF_AUTH=1`. +- `JOBS_EVENTS_EXCLUSIVE=1` makes `verae-jobs-events` the durable consumer; middleware webhook router backs off. +- Catalog index is cards. Disabled fleet machines are grey, not degraded yellow. + ## 2026-09-11 — UI design-system pass - Restyled CS/sales/accounting/access-staff to portal indigo; dollars + names. diff --git a/packages/zapier-decisions/TODO.md b/packages/zapier-decisions/TODO.md index 068fd3f..a928b72 100644 --- a/packages/zapier-decisions/TODO.md +++ b/packages/zapier-decisions/TODO.md @@ -2,7 +2,7 @@ - [ ] Live `api.veraetime.net` with `MOCK_VERAE=false` and admin bind credentials. - [ ] NATS nkeys/mTLS on a real three-node cluster (accounts file is the lab stand-in). -- [ ] Exclusive JetStream consumer for `verae.zapier.jobs.events` on `verae-jobs-events` (middleware still also listens). -- [ ] Auth on CS/sales/accounting HTML (staff plane is the intended door). +- [x] Exclusive JetStream consumer for `verae.zapier.jobs.events` on `verae-jobs-events` (`JOBS_EVENTS_EXCLUSIVE=1`; middleware skips the router). +- [x] Auth on CS/sales/accounting HTML via `verae-staff-session` (`STAFF_AUTH=1`). - [ ] Zapier Platform `push` of a private app. - [ ] Move portal static files fully into `verae-access-web` (today it proxies `/portal` to loopback edge). diff --git a/packages/zappier-account-balance/src/books.js b/packages/zappier-account-balance/src/books.js index 91119f4..5dee26b 100644 --- a/packages/zappier-account-balance/src/books.js +++ b/packages/zappier-account-balance/src/books.js @@ -7,21 +7,24 @@ export class AccountBooks { this.prepaid = {}; /** @type {Record} */ this.veraeUserIds = {}; + /** @type {Record} */ + this.names = {}; this.credits = []; this.usage = []; this.payments = []; } - remember(customerId, veraeUserId) { + remember(customerId, veraeUserId, name) { if (customerId && veraeUserId) this.veraeUserIds[customerId] = veraeUserId; + if (customerId && name) this.names[customerId] = name; } prepaidCents(customerId) { return this.prepaid[customerId] ?? 0; } - adjust({ customerId, cents, reason, agent, kind = 'credit', veraeUserId }) { - this.remember(customerId, veraeUserId); + adjust({ customerId, cents, reason, agent, kind = 'credit', veraeUserId, name }) { + this.remember(customerId, veraeUserId, name); const delta = Math.trunc(Number(cents) || 0); const next = this.prepaidCents(customerId) + delta; this.prepaid[customerId] = next; @@ -41,7 +44,7 @@ export class AccountBooks { } recordUsage(entry) { - this.remember(entry.customerId, entry.veraeUserId); + this.remember(entry.customerId, entry.veraeUserId, entry.name); const cents = Number(entry.cents) || 0; const customerId = entry.customerId; const next = this.prepaidCents(customerId) - cents; @@ -67,10 +70,20 @@ export class AccountBooks { }); } + lookup(idOrName) { + if (this.names[idOrName] || this.prepaid[idOrName] != null || this.veraeUserIds[idOrName]) { + return this.statement(idOrName); + } + const want = String(idOrName || '').toLowerCase(); + const hit = Object.entries(this.names).find(([, n]) => String(n).toLowerCase() === want); + return this.statement(hit ? hit[0] : idOrName); + } + statement(customerId) { const match = (rows) => rows.filter((r) => r.customerId === customerId).slice(0, 100); return { customerId, + name: this.names[customerId], veraeUserId: this.veraeUserIds[customerId], prepaidCents: this.prepaidCents(customerId), credits: match(this.credits), @@ -83,6 +96,7 @@ export class AccountBooks { return { prepaid: this.prepaid, veraeUserIds: this.veraeUserIds, + names: this.names, credits: this.credits, usage: this.usage, payments: this.payments, @@ -93,6 +107,7 @@ export class AccountBooks { if (!raw || typeof raw !== 'object') return this; this.prepaid = raw.prepaid || {}; this.veraeUserIds = raw.veraeUserIds || {}; + this.names = raw.names || {}; this.credits = Array.isArray(raw.credits) ? raw.credits : []; this.usage = Array.isArray(raw.usage) ? raw.usage : []; this.payments = Array.isArray(raw.payments) ? raw.payments : []; @@ -102,9 +117,13 @@ export class AccountBooks { export function handle(subject, payload, books) { const p = payload || {}; + if (subject.endsWith('customer.put')) { + books.remember(p.customerId, p.veraeUserId, p.name); + return books.lookup(p.customerId); + } if (subject.endsWith('balance.get') || subject.endsWith('statement.get')) { - books.remember(p.customerId, p.veraeUserId); - return books.statement(p.customerId); + books.remember(p.customerId, p.veraeUserId, p.name); + return books.lookup(p.customerId); } if (subject.endsWith('balance.adjust') || subject.endsWith('credit.applied')) { return books.adjust(p); diff --git a/packages/zappier-account-balance/src/server.js b/packages/zappier-account-balance/src/server.js index b20d567..c3cd64e 100644 --- a/packages/zappier-account-balance/src/server.js +++ b/packages/zappier-account-balance/src/server.js @@ -43,6 +43,9 @@ async function startNats() { reply(nc.subscribe(SUBJECTS.BALANCE_ADJUST, { queue: SUBJECTS.QUEUE }), (p) => apply(SUBJECTS.BALANCE_ADJUST, p), ); + reply(nc.subscribe(SUBJECTS.CUSTOMER_PUT, { queue: SUBJECTS.QUEUE }), (p) => + apply(SUBJECTS.CUSTOMER_PUT, p), + ); (async () => { for await (const m of nc.subscribe(SUBJECTS.USAGE_RECORDED)) { apply(SUBJECTS.USAGE_RECORDED, JSON.parse(sc.decode(m.data) || '{}')); @@ -80,7 +83,11 @@ const server = http.createServer(async (req, res) => { } const st = url.pathname.match(/^\/statement\/([^/]+)$/); if (req.method === 'GET' && st) { - return json(200, books.statement(st[1])); + return json(200, books.lookup(decodeURIComponent(st[1]))); + } + if (req.method === 'POST' && url.pathname === '/customer') { + const body = await readBody(req); + return json(200, apply(SUBJECTS.CUSTOMER_PUT, body)); } if (req.method === 'POST' && url.pathname === '/adjust') { const body = await readBody(req); diff --git a/packages/zappier-account-balance/src/subjects.js b/packages/zappier-account-balance/src/subjects.js index 45e6bb2..07d0c0c 100644 --- a/packages/zappier-account-balance/src/subjects.js +++ b/packages/zappier-account-balance/src/subjects.js @@ -6,5 +6,6 @@ export const SUBJECTS = { USAGE_RECORDED: 'verae.billing.usage.recorded', PAYMENT_RECORDED: 'verae.billing.payment.recorded', CREDIT_APPLIED: 'verae.billing.credit.applied', + CUSTOMER_PUT: 'verae.billing.customer.put', QUEUE: 'account-balance', }; diff --git a/packages/zappier-account-balance/test/books.test.js b/packages/zappier-account-balance/test/books.test.js index 230fc55..4eb5cc1 100644 --- a/packages/zappier-account-balance/test/books.test.js +++ b/packages/zappier-account-balance/test/books.test.js @@ -24,6 +24,14 @@ test('adjust credits prepaid and statement lists credits usage payments', () => assert.equal(st.payments[0].kind, 'payment'); }); +test('customer.put stores display name and lookup by name', () => { + const books = new AccountBooks(); + handle(SUBJECTS.CUSTOMER_PUT, { customerId: 'cust_1', name: 'Ada (free)' }, books); + const st = handle(SUBJECTS.STATEMENT_GET, { customerId: 'Ada (free)' }, books); + assert.equal(st.customerId, 'cust_1'); + assert.equal(st.name, 'Ada (free)'); +}); + test('persist round-trip keeps prepaid', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'books-')); process.env.BOOKS_PATH = path.join(dir, 'books.json'); diff --git a/packages/zappier-account-balance/test/health.test.js b/packages/zappier-account-balance/test/health.test.js index d3b5e06..49bcc20 100644 --- a/packages/zappier-account-balance/test/health.test.js +++ b/packages/zappier-account-balance/test/health.test.js @@ -1,6 +1,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,9 +10,10 @@ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); test('account-balance health and statement', async () => { const port = 18010; + const books = path.join(os.tmpdir(), `books-health-${Date.now()}.json`); const child = spawn(process.execPath, ['src/server.js'], { cwd: root, - env: { ...process.env, PORT: String(port) }, + env: { ...process.env, PORT: String(port), BOOKS_PATH: books }, stdio: ['ignore', 'pipe', 'pipe'], }); await new Promise((r) => setTimeout(r, 400)); @@ -26,5 +29,6 @@ test('account-balance health and statement', async () => { assert.equal(st.prepaidCents, 200); } finally { child.kill('SIGTERM'); + fs.rmSync(books, { force: true }); } }); diff --git a/packages/zappier-accounting-export/src/names.js b/packages/zappier-accounting-export/src/names.js new file mode 100644 index 0000000..d265ba0 --- /dev/null +++ b/packages/zappier-accounting-export/src/names.js @@ -0,0 +1,25 @@ +/** Join ledger ids to zappier-edge customer display names. */ +export async function withCustomerName(st, idOrName, edge, key) { + const out = { ...(st || {}) }; + if (out.name && out.customerId) return out; + try { + const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, { + headers: { 'x-admin-key': key }, + }); + const { customers } = await r.json(); + const want = String(idOrName || out.customerId || '').toLowerCase(); + const c = (customers || []).find( + (x) => + x.id === idOrName || + x.id === out.customerId || + String(x.name || '').toLowerCase() === want, + ); + if (c) { + out.name = c.name; + out.customerId = c.id; + } + } catch { + /* edge optional */ + } + return out; +} diff --git a/packages/zappier-accounting-export/src/server.js b/packages/zappier-accounting-export/src/server.js index 564f9e0..e60bdf3 100644 --- a/packages/zappier-accounting-export/src/server.js +++ b/packages/zappier-accounting-export/src/server.js @@ -5,6 +5,7 @@ import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SUBJECTS, billingRequest } from './nats-billing.js'; +import { withCustomerName } from './names.js'; const PORT = Number(process.env.PORT || 3013); const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); @@ -26,6 +27,15 @@ const server = http.createServer(async (req, res) => { const json = (code, obj) => send(code, 'application/json', JSON.stringify(obj)); try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + if (!chk || !chk.ok) { + res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); + res.end(); + return; + } + } return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(PUBLIC, 'index.html'))); } if (req.method === 'GET' && url.pathname === '/health') { @@ -33,12 +43,14 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { - const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: review[1] }); - if (nats) return json(200, { ...nats, source: 'nats' }); - const r = await fetch(`${BOOKS}/statement/${review[1]}`); - if (r.ok) return json(200, { ...(await r.json()), source: 'account-balance' }); - const e = await edge(`/admin/api/statement/${review[1]}`); - return send(e.status, 'application/json', await e.text()); + const id = decodeURIComponent(review[1]); + const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id }); + if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY)); + const r = await fetch(`${BOOKS}/statement/${encodeURIComponent(id)}`); + if (r.ok) return json(200, await withCustomerName({ ...(await r.json()), source: 'account-balance' }, id, EDGE, KEY)); + const e = await edge(`/admin/api/statement/${encodeURIComponent(id)}`); + const body = await e.json().catch(() => ({})); + return json(e.status, await withCustomerName(body, id, EDGE, KEY)); } const period = url.searchParams.get('period'); const q = period ? `?period=${encodeURIComponent(period)}` : ''; diff --git a/packages/zappier-customer-service/src/names.js b/packages/zappier-customer-service/src/names.js new file mode 100644 index 0000000..d265ba0 --- /dev/null +++ b/packages/zappier-customer-service/src/names.js @@ -0,0 +1,25 @@ +/** Join ledger ids to zappier-edge customer display names. */ +export async function withCustomerName(st, idOrName, edge, key) { + const out = { ...(st || {}) }; + if (out.name && out.customerId) return out; + try { + const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, { + headers: { 'x-admin-key': key }, + }); + const { customers } = await r.json(); + const want = String(idOrName || out.customerId || '').toLowerCase(); + const c = (customers || []).find( + (x) => + x.id === idOrName || + x.id === out.customerId || + String(x.name || '').toLowerCase() === want, + ); + if (c) { + out.name = c.name; + out.customerId = c.id; + } + } catch { + /* edge optional */ + } + return out; +} diff --git a/packages/zappier-customer-service/src/server.js b/packages/zappier-customer-service/src/server.js index 28b99ba..cdcdf5d 100644 --- a/packages/zappier-customer-service/src/server.js +++ b/packages/zappier-customer-service/src/server.js @@ -7,6 +7,7 @@ import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SUBJECTS, billingRequest } from './nats-billing.js'; +import { withCustomerName } from './names.js'; const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); @@ -32,6 +33,16 @@ const server = http.createServer(async (req, res) => { }; try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + if (!chk || !chk.ok) { + const next = `http://${req.headers.host || '127.0.0.1'}/`; + res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent(next)}` }); + res.end(); + return; + } + } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(fs.readFileSync(path.join(PUBLIC, 'index.html'))); return; @@ -41,7 +52,9 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { - const out = await statement(review[1]); + const id = decodeURIComponent(review[1]); + const out = await statement(id); + out.body = await withCustomerName(out.body, id, EDGE, KEY); return json(out.status, out.body); } if (req.method === 'POST' && url.pathname === '/credits') { diff --git a/packages/zappier-sales-pricing/src/names.js b/packages/zappier-sales-pricing/src/names.js new file mode 100644 index 0000000..d265ba0 --- /dev/null +++ b/packages/zappier-sales-pricing/src/names.js @@ -0,0 +1,25 @@ +/** Join ledger ids to zappier-edge customer display names. */ +export async function withCustomerName(st, idOrName, edge, key) { + const out = { ...(st || {}) }; + if (out.name && out.customerId) return out; + try { + const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, { + headers: { 'x-admin-key': key }, + }); + const { customers } = await r.json(); + const want = String(idOrName || out.customerId || '').toLowerCase(); + const c = (customers || []).find( + (x) => + x.id === idOrName || + x.id === out.customerId || + String(x.name || '').toLowerCase() === want, + ); + if (c) { + out.name = c.name; + out.customerId = c.id; + } + } catch { + /* edge optional */ + } + return out; +} diff --git a/packages/zappier-sales-pricing/src/server.js b/packages/zappier-sales-pricing/src/server.js index 0e5e2cb..b19a09c 100644 --- a/packages/zappier-sales-pricing/src/server.js +++ b/packages/zappier-sales-pricing/src/server.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SUBJECTS, billingRequest } from './nats-billing.js'; +import { withCustomerName } from './names.js'; const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); @@ -36,6 +37,15 @@ const server = http.createServer(async (req, res) => { }; try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + if (!chk || !chk.ok) { + res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); + res.end(); + return; + } + } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(fs.readFileSync(path.join(PUBLIC, 'index.html'))); return; @@ -45,12 +55,13 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { - const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: review[1] }); - if (nats) return json(200, { ...nats, source: 'nats' }); - const r = await fetch(`${BOOKS}/statement/${review[1]}`); - if (r.ok) return json(200, { ...(await r.json()), source: 'account-balance' }); - const e = await edge(`/admin/api/statement/${review[1]}`); - return json(e.status, e.body); + const id = decodeURIComponent(review[1]); + const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id }); + if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY)); + const r = await fetch(`${BOOKS}/statement/${encodeURIComponent(id)}`); + if (r.ok) return json(200, await withCustomerName({ ...(await r.json()), source: 'account-balance' }, id, EDGE, KEY)); + const e = await edge(`/admin/api/statement/${encodeURIComponent(id)}`); + return json(e.status, await withCustomerName(e.body, id, EDGE, KEY)); } const quote = url.pathname.match(/^\/quotes\/([^/]+)$/); if (req.method === 'GET' && quote) { diff --git a/packages/zappier/portal/app.js b/packages/zappier/portal/app.js index 385d1fe..82d9bbb 100644 --- a/packages/zappier/portal/app.js +++ b/packages/zappier/portal/app.js @@ -226,10 +226,10 @@ async function viewInvoice(id) { function renderStatement() { const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] }; + const emptyCard = (title) => + `
${EMPTY_SVG}

${title}

Nothing recorded yet.

`; const row = (list, cols) => { - if (!list || !list.length) { - return `
${EMPTY_SVG}

Nothing here yet

`; - } + if (!list || !list.length) return ''; return list .map( (r) => @@ -251,15 +251,21 @@ function renderStatement() {
Usage events
${st.usage?.length || 0}
Payments
${st.payments?.length || 0}
-

Credits

- - ${row(st.credits, ['cents', 'reason', 'agent', 'at'])}
AmountReasonAgentWhen
-

Usage

- - ${row(st.usage, ['endpointId', 'cents', 'at'])}
EndpointAmountWhen
-

Payments

- - ${row(st.payments, ['cents', 'kind', 'reason', 'at'])}
AmountKindReasonWhen
`; +

Credits

${ + st.credits?.length + ? `${row(st.credits, ['cents', 'reason', 'agent', 'at'])}
AmountReasonAgentWhen
` + : emptyCard('No credits yet') + }
+

Usage

${ + st.usage?.length + ? `${row(st.usage, ['endpointId', 'cents', 'at'])}
EndpointAmountWhen
` + : emptyCard('No usage yet') + }
+

Payments

${ + st.payments?.length + ? `${row(st.payments, ['cents', 'kind', 'reason', 'at'])}
AmountKindReasonWhen
` + : emptyCard('No payments yet') + }
`; } /* ---------------- billing ---------------- */ diff --git a/packages/zappier/src/admin.ts b/packages/zappier/src/admin.ts index 9bf6692..79f4b04 100644 --- a/packages/zappier/src/admin.ts +++ b/packages/zappier/src/admin.ts @@ -14,7 +14,7 @@ import { CreditLedger } from './credits'; import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export'; import { composeStatement } from './statement'; import { BILLING_SUBJECTS, natsPublish } from './billing-nats'; -import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger'; +import { booksConfigured, ledgerAdjust, ledgerPutCustomer, ledgerStatement } from './ledger'; // Issued login tokens (in-memory; a restart simply requires logging in again). const sessions = new Map(); @@ -197,6 +197,7 @@ export function adminRouter( apiKey: `key-${randomBytes(12).toString('hex')}`, }; customers.save(customer); + void ledgerPutCustomer({ customerId: customer.id, name: customer.name }); res.status(201).json(customer); }); @@ -216,7 +217,7 @@ export function adminRouter( res.status(400).json({ error: 'billingType must be stripe or purchase_order' }); return; } - customers.save({ + const next = { ...existing, ...(name !== undefined ? { name } : {}), ...(tierId !== undefined ? { tierId } : {}), @@ -224,7 +225,9 @@ export function adminRouter( ...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}), ...(billingType !== undefined ? { billingType } : {}), ...(email !== undefined ? { email } : {}), - }); + }; + customers.save(next); + void ledgerPutCustomer({ customerId: next.id, name: next.name, veraeUserId: next.veraeUserId }); res.json({ ok: true }); }); @@ -286,6 +289,7 @@ export function adminRouter( res.json({ ...composeStatement({ customerId: customer.id, + name: customer.name, veraeUserId: customer.veraeUserId, prepaidCents: customer.balanceCents ?? 0, credits: credits.list(customer.id), diff --git a/packages/zappier/src/billing-nats.ts b/packages/zappier/src/billing-nats.ts index a22a1f9..d09b0f5 100644 --- a/packages/zappier/src/billing-nats.ts +++ b/packages/zappier/src/billing-nats.ts @@ -8,6 +8,7 @@ export const BILLING_SUBJECTS = { USAGE_RECORDED: 'verae.billing.usage.recorded', PAYMENT_RECORDED: 'verae.billing.payment.recorded', CREDIT_APPLIED: 'verae.billing.credit.applied', + CUSTOMER_PUT: 'verae.billing.customer.put', }; export const AUTHZ_CHECK = 'verae.access.authz.check'; @@ -15,6 +16,7 @@ export type AccessPlane = 'zapier' | 'web' | 'api' | 'leaf' | 'staff'; export type BillingStatement = { customerId: string; + name?: string; veraeUserId?: string; prepaidCents: number; credits: unknown[]; diff --git a/packages/zappier/src/ledger.ts b/packages/zappier/src/ledger.ts index 5905f24..df8957e 100644 --- a/packages/zappier/src/ledger.ts +++ b/packages/zappier/src/ledger.ts @@ -2,7 +2,7 @@ * Prepaid mutations. Account-balance is the writer when NATS or HTTP books exist. * Tests (no NATS_URL, no ACCOUNT_BALANCE_URL) keep a local cache only. */ -import { natsAdjust, natsStatement } from './billing-nats'; +import { natsAdjust, natsPublish, natsStatement } from './billing-nats'; import type { AccessPlane } from './billing-nats'; export type PrepaidRow = { @@ -39,6 +39,21 @@ export async function ledgerAdjust(row: PrepaidRow, plane: AccessPlane): Promise return (await r.json()) as PrepaidRow; } +export async function ledgerPutCustomer(row: { + customerId: string; + name?: string; + veraeUserId?: string; +}): Promise { + natsPublish('verae.billing.customer.put', row, 'staff'); + const base = booksUrl(); + if (!base) return; + await fetch(`${base}/customer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(row), + }).catch(() => {}); +} + export async function ledgerStatement(customerId: string, plane: AccessPlane, veraeUserId?: string) { const nats = await natsStatement(customerId, plane, veraeUserId); if (nats) return nats; diff --git a/packages/zappier/src/portal.ts b/packages/zappier/src/portal.ts index 740e256..cb51f97 100644 --- a/packages/zappier/src/portal.ts +++ b/packages/zappier/src/portal.ts @@ -212,12 +212,17 @@ export function portalRouter(deps: PortalDeps): Router { router.get('/statement', async (req, res) => { const fromBooks = await ledgerStatement(req.customer!.id, 'web', req.customer!.veraeUserId); if (fromBooks) { - res.json({ ...fromBooks, source: fromBooks.source || 'account-balance' }); + res.json({ + ...fromBooks, + name: req.customer!.name, + source: fromBooks.source || 'account-balance', + }); return; } res.json({ ...composeStatement({ customerId: req.customer!.id, + name: req.customer!.name, veraeUserId: req.customer!.veraeUserId, prepaidCents: req.customer!.balanceCents ?? 0, credits: (deps.credits || new CreditLedger()).list(req.customer!.id), diff --git a/packages/zappier/src/statement.ts b/packages/zappier/src/statement.ts index d3d29a3..ca6941d 100644 --- a/packages/zappier/src/statement.ts +++ b/packages/zappier/src/statement.ts @@ -4,6 +4,7 @@ import { UsageEntry } from './usage'; export function composeStatement(args: { customerId: string; + name?: string; veraeUserId?: string; prepaidCents: number; credits: CreditAdjustment[]; @@ -22,6 +23,7 @@ export function composeStatement(args: { })); return { customerId: args.customerId, + name: args.name, veraeUserId: args.veraeUserId, prepaidCents: args.prepaidCents, credits: args.credits.filter((c) => c.customerId === args.customerId), diff --git a/scripts/build-docs-site.py b/scripts/build-docs-site.py index bb2fe19..abd7329 100755 --- a/scripts/build-docs-site.py +++ b/scripts/build-docs-site.py @@ -286,6 +286,13 @@ def page_shell(now: str, body: str, switch_to_md: bool, title: str) -> str: .badge {{ font:700 10px system-ui; letter-spacing:.04em; text-transform:uppercase; background:var(--soft); color:var(--accent); padding:.1rem .35rem; border-radius:4px; margin-left:.25rem; }} input {{ width:100%; padding:.55rem .7rem; font:16px system-ui; border:1px solid var(--line); border-radius:6px; }} + .cards {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:.75rem; }} + .card {{ background:#fff; border:1px solid var(--line); border-radius:12px; padding:.85rem .95rem; + box-shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06); }} + .card h3 {{ margin:0 0 .35rem; font-size:14px; }} + .card p {{ margin:0 0 .7rem; color:var(--muted); font-size:13px; }} + .card a.act {{ display:inline-block; background:var(--accent); color:#fff; text-decoration:none; + border-radius:8px; padding:.28rem .6rem; font:650 12px system-ui; }} @@ -305,8 +312,8 @@ def page_shell(now: str, body: str, switch_to_md: bool, title: str) -> str: const q = document.getElementById('q'); q.addEventListener('input', () => {{ const v = q.value.toLowerCase(); - document.querySelectorAll('li').forEach(li => {{ - li.style.display = li.textContent.toLowerCase().includes(v) ? '' : 'none'; + document.querySelectorAll('.card').forEach(card => {{ + card.style.display = card.textContent.toLowerCase().includes(v) ? '' : 'none'; }}); }}); @@ -357,6 +364,8 @@ def main() -> None: "verae-jobs-events", "verae-nats-accounts", "zapier-decisions", + "ui-docs", + "verae-staff-session", ): pkg_root = ROOT / "packages" / pkg if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}: @@ -491,11 +500,11 @@ def main() -> None: href = html_href if (SITE / html_href).exists() else path badge = "MD" if src.suffix.lower() == ".md" else src.suffix.lstrip(".").upper() or "FILE" lis.append( - f'
  • {label}' - f'{badge}' - f'{path}
  • ' + f'' ) - out.append(f"

    {title}

      {''.join(lis)}
    ") + out.append(f'

    {title}

    {"".join(lis)}
    ') return "".join(out) git_repos = [ diff --git a/scripts/gen-module-docs.py b/scripts/gen-module-docs.py index b08cf13..9acf1a4 100644 --- a/scripts/gen-module-docs.py +++ b/scripts/gen-module-docs.py @@ -68,6 +68,7 @@ REPO_READMES = [ "verae-nats-accounts", "zapier-decisions", "ui-docs", + "verae-staff-session", ] SKIP_PARTS = {"test", "tests", "node_modules", "dist"} diff --git a/scripts/push-module-repos.sh b/scripts/push-module-repos.sh index 621d24b..1187b7b 100755 --- a/scripts/push-module-repos.sh +++ b/scripts/push-module-repos.sh @@ -65,6 +65,7 @@ create verae-jobs-events "Mailbox for verae.zapier.jobs.events" create verae-nats-accounts "NATS INTERNAL vs LEAF account policy" create zapier-decisions "Architecture decisions and action log" create UI-Docs "UI walkthrough, screenshots, and review PDF" +create verae-staff-session "Shared staff cookie login for department HTML" push_dir "$ROOT/packages/zappier" zappier-edge push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware @@ -96,5 +97,6 @@ push_dir "$ROOT/packages/verae-jobs-events" verae-jobs-events push_dir "$ROOT/packages/verae-nats-accounts" verae-nats-accounts push_dir "$ROOT/packages/zapier-decisions" zapier-decisions push_dir "$ROOT/packages/ui-docs" UI-Docs +push_dir "$ROOT/packages/verae-staff-session" verae-staff-session echo ALL_MODULE_REPOS_PUSHED