From 19caa8f5d4166bb56bf6d68abf7bfbccf087a1b0 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 17:17:07 -0400 Subject: [PATCH] Initial import of verae-access-staff from zapier monorepo --- NATS.md | 3 +++ README.md | 7 ++++++ package.json | 11 +++++++++ src/server.js | 57 +++++++++++++++++++++++++++++++++++++++++++++ test/health.test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+) create mode 100644 NATS.md create mode 100644 README.md create mode 100644 package.json 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..f34039a --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# verae-access-staff + +Staff access plane for CS / sales / accounting. Credits and statement review after `verae.access.authz.check` with plane `staff`. + +**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/src/server.js b/src/server.js new file mode 100644 index 0000000..cf4ef1d --- /dev/null +++ b/src/server.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** Staff access plane (CS / sales / accounting). Not Zapier, not customer API. */ +import http from 'node:http'; + +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 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 === '/health') { + return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE }); + } + const review = url.pathname.match(/^\/review\/([^/]+)$/); + if (req.method === 'GET' && review) { + const gate = await check('verae.billing.statement.get', { principal: review[1] }); + 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' }); + } + 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..0125538 --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,56 @@ +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 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'); + } +});