From b8370678e3fa1227dd21a4994580ed11c88ecbb4 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 18:13:18 -0400 Subject: [PATCH] Initial import of verae-access-staff from zapier monorepo --- NATS.md | 3 + README.md | 9 +++ package.json | 11 ++++ public/index.html | 134 ++++++++++++++++++++++++++++++++++++++++++++ src/names.js | 25 +++++++++ src/server.js | 80 ++++++++++++++++++++++++++ test/health.test.js | 61 ++++++++++++++++++++ 7 files changed, 323 insertions(+) create mode 100644 NATS.md create mode 100644 README.md create mode 100644 package.json create mode 100644 public/index.html create mode 100644 src/names.js create mode 100644 src/server.js create mode 100644 test/health.test.js diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..f33adff --- /dev/null +++ b/NATS.md @@ -0,0 +1,3 @@ +# NATS — verae-access-staff + +Plane `staff`. Authz then `verae.billing.statement.get` / `balance.adjust`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6850731 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# verae-access-staff + +Staff access plane for CS / sales / accounting. Credits and statement review after `verae.access.authz.check` with plane `staff`. + +HTTP UI at **http://0.0.0.0:3025/** (indigo shell, dollars and names). JSON still at `/health`, `/review/:id`, `POST /credits`. + +**Forgejo:** https://git.georgelambert.org/marchon/verae-access-staff + +Port `:3025`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..d306082 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "verae-access-staff", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Staff access plane (CS/sales/accounting)", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..30df6eb --- /dev/null +++ b/public/index.html @@ -0,0 +1,134 @@ + + + + + + Staff access — account review + + + + + +
+
staff plane · after authz
+

Staff access

+

CS / sales / accounting door. Review and credit go through verae.access.authz.check, then account-balance. Amounts are dollars; the customer name is shown when the ledger has it.

+
+
+
+
+
+ + +
+ +
+
+
+

Apply credit

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + + diff --git a/src/names.js b/src/names.js new file mode 100644 index 0000000..d265ba0 --- /dev/null +++ b/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/src/server.js b/src/server.js new file mode 100644 index 0000000..0920346 --- /dev/null +++ b/src/server.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** Staff access plane (CS / sales / accounting). Not Zapier, not customer API. */ +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 = {}) { + const r = await fetch(`${AUTHZ}/check`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ plane: PLANE, subject, ...extra }), + }); + return r.json(); +} + +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)); + }; + 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; + } + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE }); + } + const review = url.pathname.match(/^\/review\/([^/]+)$/); + if (req.method === 'GET' && review) { + 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/${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 = []; + for await (const c of req) chunks.push(c); + const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + const gate = await check('verae.billing.balance.adjust', { kind: 'credit', principal: body.agent }); + if (!gate.allow) return json(403, gate); + const r = await fetch(`${BOOKS}/adjust`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...body, kind: 'credit' }), + }); + return json(r.status, { ...(await r.json()), plane: PLANE }); + } + json(404, { error: 'not found' }); + } catch (err) { + json(502, { error: err.message }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`verae-access-staff http://0.0.0.0:${PORT}/ plane=${PLANE}\n`); +}); diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..bb73cc6 --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const authzRoot = path.join(root, '..', 'verae-access-authz'); +const booksRoot = path.join(root, '..', 'zappier-account-balance'); + +test('staff plane can credit after authz; zapier cannot', async () => { + const authzPort = 18031; + const booksPort = 18032; + const staffPort = 18033; + const authz = spawn(process.execPath, ['src/server.js'], { + cwd: authzRoot, + env: { ...process.env, PORT: String(authzPort) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const books = spawn(process.execPath, ['src/server.js'], { + cwd: booksRoot, + env: { ...process.env, PORT: String(booksPort), BOOKS_PATH: `/tmp/staff-books-${Date.now()}.json` }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const staff = spawn(process.execPath, ['src/server.js'], { + cwd: root, + env: { + ...process.env, + PORT: String(staffPort), + AUTHZ_URL: `http://127.0.0.1:${authzPort}`, + ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((r) => setTimeout(r, 600)); + try { + const h = await (await fetch(`http://127.0.0.1:${staffPort}/health`)).json(); + assert.equal(h.plane, 'staff'); + const page = await fetch(`http://127.0.0.1:${staffPort}/`); + assert.match(page.headers.get('content-type') || '', /text\/html/); + const html = await page.text(); + assert.match(html, /Staff access/); + assert.match(html, /Amount \(USD\)/); + const add = await fetch(`http://127.0.0.1:${staffPort}/credits`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ customerId: 'c-staff', cents: 50, reason: 'test', agent: 'cs' }), + }); + assert.equal(add.status, 200); + const deny = await fetch(`http://127.0.0.1:${authzPort}/check`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ plane: 'zapier', subject: 'verae.billing.balance.adjust' }), + }); + assert.equal(deny.status, 403); + } finally { + staff.kill('SIGTERM'); + books.kill('SIGTERM'); + authz.kill('SIGTERM'); + } +});