Clean prepaid SoT, identity mailbox, public access planes, leaf policy, fleet spawn
Some checks are pending
offline / test (push) Waiting to run

Persist account-balance books; edge caches prepaid from books. Add zappier-identity, verae-nats-accounts, verae-jobs-events, verae-access-staff, zapier-decisions. Edge binds loopback; lan-134 stays off; HTTP services prefer local spawn.
This commit is contained in:
George Lambert 2026-09-11 17:15:16 -04:00
parent 345aeeead9
commit ddf772454b
153 changed files with 2236 additions and 116 deletions

View file

@ -0,0 +1,3 @@
# NATS — verae-access-staff
Plane `staff`. Authz then billing statement/adjust.

View file

@ -0,0 +1,3 @@
# verae-access-staff
Staff plane for credits and statement review.

View file

@ -0,0 +1,3 @@
# NATS — verae-jobs-events
IN `verae.zapier.jobs.events` queue `jobs-events`.

View file

@ -0,0 +1,3 @@
# verae-jobs-events
Mailbox for `verae.zapier.jobs.events` (passthrough + count).

View file

@ -0,0 +1,3 @@
# NATS — verae-nats-accounts
Policy only. See `policy.json`.

View file

@ -0,0 +1,3 @@
# verae-nats-accounts
INTERNAL vs LEAF. Leaf deny `verae.billing.>`.

View file

@ -0,0 +1,3 @@
# NATS — zapier-decisions
None.

View file

@ -0,0 +1,3 @@
# zapier-decisions
Architecture decisions and action log for the lab cleanup (prepaid SoT, identity, access planes, leaf policy, fleet).

View file

@ -0,0 +1,3 @@
# NATS — zappier-identity
`verae.identity.bind` / `verae.identity.lookup` queue `identity`.

View file

@ -0,0 +1,3 @@
# zappier-identity
Bind and lookup `veraeUserId`. JWTs never stored.

View file

@ -15,15 +15,15 @@ Verae Time proves a SHA-256 existed at a given time. Zapier lets customers regis
| Direct customer API | `verae-access-api` `x-api-key` | HTTPS `:3022` |
| S2S leaf | `verae-access-leaf` | NATS `verae.access.leaf.in` / HTTPS `:3023` |
| Authz | `verae-access-authz` | `verae.access.authz.check` before any internal subject |
| Commercial edge | `zappier-edge` portal, admin, Stripe (legacy combined) | Public HTTPS; NATS billing after authz |
| Commercial edge | `zappier-edge` portal, admin, Stripe | Loopback `:3000`; public doors are access-* |
| Account balance | `zappier-account-balance` prepaid SoT | NATS `verae.billing.*` + HTTP `:3010` |
| CS / sales / accounting | `zappier-customer-service`, `zappier-sales-pricing`, `zappier-accounting-export` | Private HTTP; NATS statement/adjust |
| Middleware HTTP | `/zapier/v1/*` job id + wait | Public HTTPS from edge only |
| Middleware HTTP | `/zapier/v1/*` job id + wait | HTTPS from access-zapier / loopback edge |
| NATS cluster | JetStream subjects under `verae.*` | Private; loopback or SSH tunnel |
| Workers | poller, webhook-deliver, aggregator | NATS + HTTPS to chain or Zapier hooks |
| Archives | WORM + tree nodes | NATS broadcast query; bloom miss = silence |
| Chain | Verae timestamping | HTTPS `api.veraetime.net` or MOCK |
| Control | `verae-fleet` | Operator loopback `:3850`; SSH to extra machines |
| Control | `verae-fleet` | Operator HTTP `:3850` on `0.0.0.0`; SSH to extra machines |
## Request in one sentence

View file

@ -13,6 +13,11 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
| **verae-access-api** | `packages/verae-access-api` | Direct customer API plane (not Zapier) |
| **verae-access-leaf** | `packages/verae-access-leaf` | Server-to-server NATS leaf plane |
| **verae-access-zapier** | `packages/verae-access-zapier` | Zapier Platform plane only |
| **verae-access-staff** | `packages/verae-access-staff` | Staff CS/sales/accounting plane |
| **zappier-identity** | `packages/zappier-identity` | Bind/lookup `veraeUserId` |
| **verae-jobs-events** | `packages/verae-jobs-events` | Mailbox `verae.zapier.jobs.events` |
| **verae-nats-accounts** | `packages/verae-nats-accounts` | INTERNAL vs LEAF subject policy |
| **zapier-decisions** | `packages/zapier-decisions` | Decision log |
| **zappier-account-balance** | `packages/zappier-account-balance` | NATS source of truth for prepaid balances |
| **zappier-customer-service** | `packages/zappier-customer-service` | CS goodwill credits onto prepaid balances |
| **zappier-sales-pricing** | `packages/zappier-sales-pricing` | Sales per-customer tier / multiplier |

View file

@ -54,7 +54,7 @@ Operator console (loopback): http://127.0.0.1:3850/ · [CONSOLE.pdf](https://zap
Prefix: `https://git.georgelambert.org/marchon/`
overview · verae-nats-process · verae-ops · master-zapier-plan-draft · zappier-edge · verae-access-authz · verae-access-web · verae-access-api · verae-access-leaf · verae-access-zapier · verae-middleware · verae-zapier-app · verae-activate · verae-request-splitter · verae-archive-worm · verae-archive-aggregator · verae-tree-node · verae-fleet · verae-zapier-simulator · zapier-user-docs · zapier-docs-master
overview · verae-nats-process · verae-ops · master-zapier-plan-draft · zappier-edge · verae-access-authz · verae-access-web · verae-access-api · verae-access-leaf · verae-access-zapier · verae-access-staff · zappier-identity · verae-jobs-events · verae-nats-accounts · zapier-decisions · verae-middleware · verae-zapier-app · verae-activate · verae-request-splitter · verae-archive-worm · verae-archive-aggregator · verae-tree-node · verae-fleet · verae-zapier-simulator · zapier-user-docs · zapier-docs-master
## Per-module contracts

View file

@ -0,0 +1,28 @@
import fs from 'node:fs';
const FALLBACK = [
'verae.archive.put',
'verae.archive.query',
'verae.archive.reply.',
'verae.zapier.jobs.watch',
'verae.zapier.jobs.events',
'verae.zapier.webhooks.deliver',
];
export function leafAllowList() {
const p = process.env.NATS_POLICY_PATH;
if (p && fs.existsSync(p)) {
try {
return JSON.parse(fs.readFileSync(p, 'utf8')).leafAllow || FALLBACK;
} catch {
return FALLBACK;
}
}
return FALLBACK;
}
export function leafAllowed(subject) {
return leafAllowList().some((p) =>
p.endsWith('.') ? subject.startsWith(p) : subject === p || subject.startsWith(`${p}.`),
);
}

View file

@ -5,6 +5,7 @@
* then forwards only allowed internal subjects (archive + jobs, never billing).
*/
import http from 'node:http';
import { leafAllowed } from './allow.js';
const PORT = Number(process.env.PORT || 3023);
const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, '');
@ -30,6 +31,9 @@ async function forward(body) {
return { status: 401, body: { allow: false, reason: 'bad leaf token' } };
}
const target = body.target || body.subject;
if (!leafAllowed(target)) {
return { status: 403, body: { allow: false, reason: `leaf policy denies ${target}` } };
}
const gate = await check(target, body.principal || 'leaf');
if (!gate.allow) return { status: 403, body: gate };
const url = process.env.NATS_URL;

View file

@ -0,0 +1,3 @@
# NATS — verae-access-staff
Plane `staff`. Authz then `verae.billing.statement.get` / `balance.adjust`.

View file

@ -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`.

View file

@ -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"
}
}

View file

@ -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`);
});

View file

@ -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');
}
});

View file

@ -13,7 +13,7 @@ Operator control plane for Verae Time × Zapier **runtime** services:
cd packages/verae-fleet
npm test
node src/cli.js list
node src/cli.js serve # http://127.0.0.1:3850/
node src/cli.js serve # http://0.0.0.0:3850/
```
Against a running daemon:

View file

@ -368,9 +368,10 @@
<h1 class="title">Operator console</h1>
</header>
<h1 id="operator-console">Operator console</h1>
<p>Loopback UI at <strong>http://127.0.0.1:3850/</strong> — Fleet, Trace
(simulator), and Docs in one shell. Not a public site. NATS stays
private; SSH identity files stay on disk.</p>
<p>Operator UI at <strong>http://0.0.0.0:3850/</strong> (all
interfaces). Fleet, Trace (simulator), and Docs in one shell. NATS stays
private (<code>127.0.0.1:4222</code>); SSH identity files stay on
disk.</p>
<div class="sourceCode" id="cb1"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="bu">cd</span> packages/verae-fleet</span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a><span class="ex">node</span> src/cli.js serve</span></code></pre></div>

View file

@ -1,6 +1,6 @@
# Operator console
Loopback UI at **http://127.0.0.1:3850/** — Fleet, Trace (simulator), and Docs in one shell. Not a public site. NATS stays private; SSH identity files stay on disk.
Operator UI at **http://0.0.0.0:3850/** (all interfaces). Fleet, Trace (simulator), and Docs in one shell. NATS stays private (`127.0.0.1:4222`); SSH identity files stay on disk.
```bash
cd packages/verae-fleet

View file

@ -1,7 +1,7 @@
{
"apiVersion": "verae.fleet/v1",
"control": {
"bind": "127.0.0.1",
"bind": "0.0.0.0",
"port": 3850,
"probeIntervalMs": 750,
"unhealthyAfterMs": 2500,
@ -20,6 +20,9 @@
"access-api": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"access-leaf": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"access-zapier": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"access-staff": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"identity": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"jobs-events": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"account-balance": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"customer-service": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"sales-pricing": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },

View file

@ -17,6 +17,7 @@
"PORT": "3023",
"AUTHZ_URL": "http://127.0.0.1:3020",
"LEAF_TOKEN": "leaf-dev-token",
"NATS_POLICY_PATH": "../verae-nats-accounts/policy.json",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {

View file

@ -0,0 +1,22 @@
{
"id": "access-staff",
"title": "Staff access plane",
"kind": "http",
"package": "verae-access-staff",
"role": "access-staff",
"managed": true,
"runtime": "HTTP :3025 plane=staff",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3025 },
"spawn": {
"cwd": "../verae-access-staff",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3025",
"AUTHZ_URL": "http://127.0.0.1:3020",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010"
},
"nats": { "in": [], "out": ["verae.access.authz.check"] }
}

View file

@ -0,0 +1,24 @@
{
"id": "identity",
"title": "Verae user-id bind/lookup",
"kind": "http",
"package": "zappier-identity",
"role": "identity",
"managed": true,
"runtime": "HTTP :3026 + NATS verae.identity.*",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3026 },
"spawn": {
"cwd": "../zappier-identity",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3026",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {
"in": ["verae.identity.bind", "verae.identity.lookup"],
"out": []
}
}

View file

@ -0,0 +1,24 @@
{
"id": "jobs-events",
"title": "jobs.events mailbox",
"kind": "http",
"package": "verae-jobs-events",
"role": "jobs-events",
"managed": true,
"runtime": "HTTP :3030 + NATS verae.zapier.jobs.events",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3030 },
"spawn": {
"cwd": "../verae-jobs-events",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3030",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {
"in": ["verae.zapier.jobs.events"],
"out": []
}
}

View file

@ -5,7 +5,7 @@
"package": "zappier",
"role": "zappier-edge",
"managed": true,
"runtime": "HTTPS :3000 (portal, admin, meter, proxy to middleware)",
"runtime": "loopback :3000 (portal, admin, Stripe; public doors are access-*)",
"health": { "type": "http", "path": "/health", "timeoutMs": 4000 },
"ports": { "healthBase": 3000 },
"spawn": {
@ -15,12 +15,15 @@
},
"env": {
"PORT": "3000",
"BIND": "127.0.0.1",
"ZAPPIER_UPSTREAM": "http://127.0.0.1:3100",
"CS_SERVICE_URL": "http://127.0.0.1:3011",
"SALES_PRICING_URL": "http://127.0.0.1:3012",
"ACCOUNTING_EXPORT_URL": "http://127.0.0.1:3013",
"NATS_URL": "nats://127.0.0.1:4222",
"AUTHZ_URL": "http://127.0.0.1:3020"
"AUTHZ_URL": "http://127.0.0.1:3020",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"IDENTITY_URL": "http://127.0.0.1:3026"
},
"nats": {
"in": [],
@ -32,5 +35,5 @@
"verae.billing.balance.adjust"
]
},
"notes": "Zapier x-api-key lands here. HTTPS to middleware. NATS billing to account-balance."
"notes": "Loopback only. Public Zapier traffic hits access-zapier :3024."
}

View file

@ -92,15 +92,15 @@ async function main() {
}
if (cmd === 'serve' || cmd === 'monitor') {
const sup = new Supervisor({ loaded });
const mon = new Monitor(sup);
startControlServer(sup, mon);
print(`fleet ${cmd} pid=${process.pid}\nUI http://${loaded.control.bind || '0.0.0.0'}:${loaded.control.port}/\nSIGINT stops all managed replicas`);
for (const id of Object.keys(loaded.services)) {
const s = loaded.services[id];
if (s.managed && s.enabled && s.min > 0) await sup.startService(id);
}
const mon = new Monitor(sup);
await mon.tick();
mon.start();
startControlServer(sup, mon);
print(`fleet ${cmd} pid=${process.pid}\nUI ${BASE}/\nSIGINT stops all managed replicas`);
process.on('SIGINT', async () => {
mon.stop();
await sup.stopAll();

View file

@ -41,7 +41,11 @@ export function loadMachines(root, overlayPath) {
if (extra && typeof extra === 'object') Object.assign(m, extra);
}
}
return list.map(normalizeMachine);
return list.map((m) => {
const n = normalizeMachine(m);
if (n.id === 'lan-134' && process.env.FLEET_ENABLE_LAN134 !== '1') n.enabled = false;
return n;
});
}
export function normalizeMachine(m) {
@ -86,9 +90,13 @@ export function canHost(machine, serviceId, role) {
* @param {string} serviceId
* @param {string} [role]
*/
export function pickMachine(machines, instances, serviceId, role, exclude = []) {
export function pickMachine(machines, instances, serviceId, role, exclude = [], opts = {}) {
const skip = new Set(exclude);
const eligible = machines.filter((m) => canHost(m, serviceId, role) && !skip.has(m.id));
let eligible = machines.filter((m) => canHost(m, serviceId, role) && !skip.has(m.id));
if (opts.preferLocal) {
const local = eligible.filter((m) => m.kind === 'local');
if (local.length) eligible = local;
}
if (!eligible.length) return null;
const scored = eligible.map((m) => {
const running = instances.filter((i) => i.machine === m.id && i.pid).length;

View file

@ -1,6 +1,6 @@
/**
* Operator HTTP: list, status, pause/resume/stop/start/restart.
* Binds 127.0.0.1 only.
* HTTP bind from fleet.json control.bind (default 0.0.0.0).
* @module server
*/
@ -23,7 +23,7 @@ function json(res, code, obj) {
* @param {import('./monitor.js').Monitor} [mon]
*/
export function startControlServer(sup, mon) {
const bind = sup.loaded.control?.bind || '127.0.0.1';
const bind = process.env.FLEET_BIND || sup.loaded.control?.bind || '0.0.0.0';
const port = Number(sup.loaded.control?.port || 3850);
const sim = new Simulator();

View file

@ -57,7 +57,7 @@ export function scpBaseArgs(machine) {
return args;
}
export function sshExec(machine, remoteCommand, { timeoutMs = 20000 } = {}) {
export function sshExec(machine, remoteCommand, { timeoutMs = 8000 } = {}) {
return new Promise((resolve, reject) => {
const args = [...sshBaseArgs(machine), sshTarget(machine), remoteCommand];
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
@ -96,7 +96,7 @@ export function scpTo(machine, localFiles, remoteDir) {
const t = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('scp timeout'));
}, 30000);
}, 8000);
child.on('close', (code) => {
clearTimeout(t);
if (code !== 0) reject(new Error(Buffer.concat(err).toString('utf8') || `scp exit ${code}`));

View file

@ -153,6 +153,7 @@ export class Supervisor {
serviceId,
spec.role || serviceId,
opts.excludeMachines || [],
{ preferLocal: Boolean(spec.spawn?.command) },
);
if (!machine) {
this.log('skip', { service: serviceId, reason: 'no-machine-capacity' });

View file

@ -58,6 +58,9 @@ describe('verae-fleet', () => {
'access-api',
'access-leaf',
'access-zapier',
'access-staff',
'identity',
'jobs-events',
'account-balance',
'customer-service',
'sales-pricing',
@ -78,6 +81,8 @@ describe('verae-fleet', () => {
assert.equal(tree.keepFloor, true);
assert.equal(tree.configPath, 'services/tree-node.json');
assert.equal(loaded.fleetPath, 'fleet.json');
const lan = loaded.machines.find((m) => m.id === 'lan-134');
if (lan) assert.equal(lan.enabled, false);
});
it('starts the tree-node floor (3 available copies)', async () => {

View file

@ -53,4 +53,19 @@ describe('SSH remote machine config', () => {
const pick = pickMachine(machines, instances, 'tree-node', 'tree-node');
assert.equal(pick.id, 'ns1');
});
it('preferLocal keeps HTTP apps on the control host', () => {
const machines = [
normalizeMachine({ id: 'local', kind: 'local', host: '127.0.0.1', capacity: 32, roles: ['*'] }),
normalizeMachine({
id: 'ns1',
kind: 'ssh',
host: '70.88.205.138',
capacity: 24,
roles: ['*'],
}),
];
const pick = pickMachine(machines, [], 'account-balance', 'account-balance', [], { preferLocal: true });
assert.equal(pick.id, 'local');
});
});

View file

@ -0,0 +1,3 @@
# NATS — verae-jobs-events
IN `verae.zapier.jobs.events` queue `jobs-events`.

View file

@ -0,0 +1,7 @@
# verae-jobs-events
Mailbox process for `verae.zapier.jobs.events`. Today it counts events (passthrough). Webhook routing still lives in middleware until this process takes the exclusive consumer.
**Forgejo:** https://git.georgelambert.org/marchon/verae-jobs-events
Port `:3030`.

View file

@ -0,0 +1,14 @@
{
"name": "verae-jobs-events",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Mailbox for verae.zapier.jobs.events (passthrough)",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"nats": "^2.28.2"
}
}

View file

@ -0,0 +1,42 @@
#!/usr/bin/env node
/** Mailbox for verae.zapier.jobs.events. Passthrough: count + optional fan-out later. */
import http from 'node:http';
import { SUBJECTS } from './subjects.js';
const PORT = Number(process.env.PORT || process.env.FLEET_HEALTH_PORT || 3030);
const BIND = process.env.FLEET_HEALTH_BIND || '0.0.0.0';
let processed = 0;
let last = null;
async function startNats() {
const url = process.env.NATS_URL;
if (!url) return;
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 })) {
processed += 1;
try {
last = JSON.parse(sc.decode(m.data) || '{}');
} catch {
last = {};
}
if (m.reply) m.respond(sc.encode(JSON.stringify({ ok: true, processed })));
}
}
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 }));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
server.listen(PORT, BIND, () => {
process.stdout.write(`verae-jobs-events http://${BIND}:${PORT}/\n`);
});
startNats().catch((err) => process.stderr.write(`nats optional: ${err.message}\n`));

View file

@ -0,0 +1,4 @@
export const SUBJECTS = {
IN: 'verae.zapier.jobs.events',
QUEUE: 'jobs-events',
};

View file

@ -0,0 +1,23 @@
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)), '..');
test('jobs-events health', async () => {
const port = 18030;
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: { ...process.env, PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 400));
try {
const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json();
assert.equal(h.role, 'verae-jobs-events');
} finally {
child.kill('SIGTERM');
}
});

View file

@ -0,0 +1,3 @@
# NATS — verae-nats-accounts
Not a subscriber. Defines INTERNAL vs LEAF. Leaf deny `verae.billing.>`.

View file

@ -0,0 +1,8 @@
# verae-nats-accounts
NATS **account + leaf allow-list**. Leaf nodes cannot publish `verae.billing.*`.
**Forgejo:** https://git.georgelambert.org/marchon/verae-nats-accounts
- `policy.json` — machine-readable allow/deny (used by access-leaf).
- `nats.conf` — sketch for a future accounts-enabled nats-server (loopback).

View file

@ -0,0 +1,21 @@
# Lab accounts sketch. Loopback only. Do not publish 4222.
# INTERNAL: middleware, account-balance, authz, identity.
# LEAF: access-leaf and remote leaf nodes — no billing.
listen: 127.0.0.1:4222
http: 127.0.0.1:8222
jetstream {}
accounts {
INTERNAL {
users = [ { user: internal, password: internal-dev } ]
jetstream: enabled
}
LEAF {
users = [ { user: leaf, password: leaf-dev } ]
exports = []
imports = []
}
}
# Subject restrictions for LEAF are enforced in policy.json + access-leaf
# until this nats-server is started with full auth callout.

View file

@ -0,0 +1,10 @@
{
"name": "verae-nats-accounts",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "NATS account and leaf subject policy (INTERNAL vs LEAF)",
"scripts": {
"test": "node --test test/*.test.js"
}
}

View file

@ -0,0 +1,35 @@
{
"accounts": {
"INTERNAL": {
"publish": [">"],
"subscribe": [">"]
},
"LEAF": {
"publish": [
"verae.access.leaf.in",
"verae.archive.put",
"verae.archive.query",
"verae.archive.reply.*",
"verae.zapier.jobs.watch",
"verae.zapier.jobs.events",
"verae.zapier.webhooks.deliver"
],
"subscribe": [
"verae.access.leaf.in",
"verae.archive.>",
"verae.zapier.jobs.>",
"verae.zapier.webhooks.deliver"
],
"deny_publish": ["verae.billing.>", "verae.access.authz.>"],
"deny_subscribe": ["verae.billing.>"]
}
},
"leafAllow": [
"verae.archive.put",
"verae.archive.query",
"verae.archive.reply.",
"verae.zapier.jobs.watch",
"verae.zapier.jobs.events",
"verae.zapier.webhooks.deliver"
]
}

View file

@ -0,0 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'policy.json');
export function loadPolicy() {
return JSON.parse(fs.readFileSync(FILE, 'utf8'));
}
export function leafAllowed(subject) {
const allow = loadPolicy().leafAllow || [];
return allow.some((p) => (p.endsWith('.') ? subject.startsWith(p) : subject === p || subject.startsWith(`${p}.`)));
}

View file

@ -0,0 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { leafAllowed, loadPolicy } from '../src/policy.js';
test('leaf may archive and jobs, never billing', () => {
assert.equal(leafAllowed('verae.archive.put'), true);
assert.equal(leafAllowed('verae.archive.reply.abc'), true);
assert.equal(leafAllowed('verae.zapier.jobs.watch'), true);
assert.equal(leafAllowed('verae.billing.balance.adjust'), false);
assert.equal(leafAllowed('verae.billing.statement.get'), false);
assert.ok(loadPolicy().accounts.LEAF.deny_publish.includes('verae.billing.>'));
});

View file

@ -11,8 +11,10 @@ This is the **top document** for install, first-up, and monitoring. Platform-spe
| Piece | Job | Default |
|-------|-----|---------|
| **access-authz** | NATS authorization step for every access plane | `:3020` |
| **access-web / api / leaf / zapier** | Separated ingress (browser, x-api-key, S2S, Zapier) | `:3021:3024` |
| **zappier-edge** | Billing, API keys, customer portal, admin, Stripe; meters then **proxies** to middleware | `:3000` public HTTPS |
| **access-web / api / leaf / zapier / staff** | Public HTTP doors | `:3021:3025` |
| **identity** | `veraeUserId` bind/lookup | `:3026` |
| **zappier-edge** | Identity cache, Stripe, admin, portal (loopback only) | `127.0.0.1:3000` |
| **jobs-events** | Mailbox `verae.zapier.jobs.events` | `:3030` |
| **account-balance** | NATS prepaid balances (SoT) | `:3010` |
| **customer-service** | CS credit additions + review | `:3011` |
| **sales-pricing** | Per-customer sales quotes | `:3012` |
@ -20,9 +22,9 @@ This is the **top document** for install, first-up, and monitoring. Platform-spe
| **verae-middleware** | Zapier `/zapier/v1`, chain, NATS | `:3100` |
| **NATS JetStream** | Private jobs / archive | `:4222` **not public** |
| **worm ×3, tree-node ×3** | Off-chain files and Merkle leaves | fleet floor |
| **fleet** | Replica floors, SSH hosts, Trace, Docs | `:3850` loopback |
| **fleet** | Replica floors, SSH hosts, Trace, Docs | `:3850` on `0.0.0.0` |
Zapier cloud talks **only** to zappier-edge over HTTPS.
Zapier cloud talks **only** to **access-zapier** (`:3024`) over HTTPS. Browsers use access-web. Customer API keys use access-api. Staff use access-staff. zappier-edge is loopback.
The billing and user platform is the standalone **zappier** project (`/Users/marchon/zappier`, Forgejo [zappier-edge](https://git.georgelambert.org/marchon/zappier-edge)): portal signup, TOTP, API keys, rate card, Stripe meter, PO invoices, admin users. Verae middleware does **not** replace it.

View file

@ -7,7 +7,7 @@ Use it to validate messaging and to catch errors, delays, failures, and recoveri
```bash
cd packages/verae-zapier-simulator
npm test
npm start # http://127.0.0.1:3847/
npm start # http://0.0.0.0:3847/
```
## What it simulates

View file

@ -1,6 +1,6 @@
/**
* Local Zapier interface simulator + trace console.
* Default http://127.0.0.1:3847
* Default http://0.0.0.0:3847
*/
import http from 'node:http';
import fs from 'node:fs';
@ -11,6 +11,7 @@ import { ACTIONS, Simulator } from './pipeline.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC = path.join(__dirname, '..', 'public');
const PORT = Number(process.env.SIM_PORT || 3847);
const BIND = process.env.SIM_BIND || '0.0.0.0';
const world = new Simulator();
const mime = {
@ -81,6 +82,6 @@ const server = http.createServer(async (req, res) => {
}
});
server.listen(PORT, '127.0.0.1', () => {
process.stdout.write(`Zapier simulator http://127.0.0.1:${PORT}/\n`);
server.listen(PORT, BIND, () => {
process.stdout.write(`Zapier simulator http://${BIND}:${PORT}/\n`);
});

View file

@ -0,0 +1,40 @@
# Action log
## 2026-09-11 — bootstrap
- Created this repo to record decisions while executing cleanup items 15 (and a second pass on 25).
- Assumption: single developer, lab only; breaking changes allowed.
## Step 1 — account-balance is the prepaid source of truth
- Persist books to `BOOKS_PATH` (atomic JSON).
- Mutations go through `balance.adjust` / `usage.recorded`; edge cache `balanceCents` is updated from the books reply only.
- Local cache mutate only when `NATS_URL` and `ACCOUNT_BALANCE_URL` are both unset (tests).
## Step 2 — identity join
- `zappier-identity` mailbox: `verae.identity.bind` / `verae.identity.lookup`.
- Portal + middleware tenants store `veraeUserId`; JWT never public.
## Step 3 — access planes are the public doors
- zappier-edge binds loopback (`127.0.0.1:3000`).
- Public: access-web `:3021`, access-api `:3022`, access-leaf `:3023`, access-zapier `:3024`, access-staff `:3025`.
## Step 4 — leaf hardening
- `verae-nats-accounts` holds nats.conf + subject policy.
- Leaf token required; billing subjects denied in policy file and access-leaf.
## Step 5 — fleet spawn
- Prefer local machines for HTTP `spawn.command` services.
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
- SSH spawn timeout 8s; failed hosts skipped.
## Second pass (25)
- Identity used from portal when `IDENTITY_URL` set.
- Leaf loads allow-list from nats-accounts `policy.json`.
- Staff plane is a first-class mailbox (`verae-access-staff`).
- jobs.events mailbox process `verae-jobs-events` (passthrough + count).

View file

@ -0,0 +1,3 @@
# NATS — zapier-decisions
None. Documentation repo.

View file

@ -0,0 +1,13 @@
# zapier-decisions
Lab log of architecture decisions, actions taken, and open todos while cleaning the Verae × Zapier stack.
**Forgejo:** https://git.georgelambert.org/marchon/zapier-decisions
| File | What |
|------|------|
| [LOG.md](LOG.md) | Chronological actions |
| [TODO.md](TODO.md) | Open items |
| [decisions/](decisions/) | One file per decision |
No runtime. Not a NATS mailbox.

View file

@ -0,0 +1,8 @@
# Open todos
- [ ] 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).
- [ ] Zapier Platform `push` of a private app.
- [ ] Move portal static files fully into `verae-access-web` (today it proxies `/portal` to loopback edge).

View file

@ -0,0 +1,6 @@
# D001 — Account-balance is the only prepaid writer
**Status:** accepted
**Step:** 1
Prepaid cents, credits, usage, and payments persist in `zappier-account-balance` (`books.json`). zappier-edge `balanceCents` is a cache filled from the books reply. Tests without NATS/HTTP books still mutate the cache so unit tests stay hermetic.

View file

@ -0,0 +1,6 @@
# D002 — veraeUserId is the join key, not the JWT
**Status:** accepted
**Step:** 2
Public token remains the zappier API key. Chain JWT stays server-side (`tokenRef`). `zappier-identity` is the mailbox for bind/lookup so portal and middleware share one map.

View file

@ -0,0 +1,6 @@
# D003 — Access planes are the only public HTTP doors
**Status:** accepted
**Step:** 3
Zapier → `:3024`, browser → `:3021`, customer API → `:3022`, leaf → `:3023`, staff → `:3025`. zappier-edge listens on `127.0.0.1:3000` for identity, Stripe, admin, and as upstream for those planes.

View file

@ -0,0 +1,6 @@
# D004 — Leaf cannot touch billing, even if authz is skipped
**Status:** accepted
**Step:** 4
`verae-nats-accounts/policy.json` is the allow-list. access-leaf refuses any target not in the leaf set. nats.conf documents INTERNAL vs LEAF accounts for when the lab cluster is replaced with a real accounts-enabled nats-server.

View file

@ -0,0 +1,6 @@
# D005 — HTTP services spawn locally; lan-134 stays off
**Status:** accepted
**Step:** 5
SSH to `70.88.205.134` blocked keepFloor. HTTP apps (`spawn.command`) prefer `kind=local`. `lan-134` is disabled unless `FLEET_ENABLE_LAN134=1`. Failed SSH hosts are skipped after 8s.

View file

@ -0,0 +1,6 @@
{
"name": "zapier-decisions",
"version": "0.1.0",
"private": true,
"description": "Architecture decisions and action log for the Verae × Zapier stack"
}

View file

@ -1,6 +1,6 @@
# zappier-account-balance
Source of truth for **customer prepaid balances**. Internal traffic is **NATS request-reply**; HTTP is health plus a fallback.
Source of truth for **customer prepaid balances** (persisted `books.json`). Internal traffic is **NATS request-reply**; HTTP is health plus a fallback. zappier-edge `balanceCents` is a cache of this book.
**Forgejo:** https://git.georgelambert.org/marchon/zappier-account-balance
**Catalog:** https://zapier.georgelambert.org/packages/zappier-account-balance/README.pdf

View file

@ -78,6 +78,26 @@ export class AccountBooks {
payments: match(this.payments),
};
}
dump() {
return {
prepaid: this.prepaid,
veraeUserIds: this.veraeUserIds,
credits: this.credits,
usage: this.usage,
payments: this.payments,
};
}
load(raw) {
if (!raw || typeof raw !== 'object') return this;
this.prepaid = raw.prepaid || {};
this.veraeUserIds = raw.veraeUserIds || {};
this.credits = Array.isArray(raw.credits) ? raw.credits : [];
this.usage = Array.isArray(raw.usage) ? raw.usage : [];
this.payments = Array.isArray(raw.payments) ? raw.payments : [];
return this;
}
}
export function handle(subject, payload, books) {

View file

@ -0,0 +1,28 @@
import fs from 'node:fs';
import path from 'node:path';
export function booksPath() {
return (
process.env.BOOKS_PATH ||
path.join(process.env.FLEET_STATE_DIR || process.cwd(), 'data', 'books.json')
);
}
export function loadInto(books) {
const p = booksPath();
if (!fs.existsSync(p)) return books;
try {
books.load(JSON.parse(fs.readFileSync(p, 'utf8')));
} catch {
/* keep empty */
}
return books;
}
export function saveFrom(books) {
const p = booksPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(books.dump(), null, 2));
fs.renameSync(tmp, p);
}

View file

@ -2,10 +2,17 @@
import http from 'node:http';
import { AccountBooks, handle } from './books.js';
import { SUBJECTS } from './subjects.js';
import { loadInto, saveFrom } from './persist.js';
const PORT = Number(process.env.PORT || process.env.FLEET_HEALTH_PORT || 3010);
const BIND = process.env.FLEET_HEALTH_BIND || '0.0.0.0';
const books = new AccountBooks();
const books = loadInto(new AccountBooks());
function apply(subject, payload) {
const out = handle(subject, payload, books);
saveFrom(books);
return out;
}
let natsOk = false;
async function startNats() {
@ -23,21 +30,22 @@ async function startNats() {
payload = {};
}
const out = fn(payload);
saveFrom(books);
if (m.reply) m.respond(sc.encode(JSON.stringify(out)));
}
};
reply(nc.subscribe(SUBJECTS.STATEMENT_GET, { queue: SUBJECTS.QUEUE }), (p) =>
handle(SUBJECTS.STATEMENT_GET, p, books),
apply(SUBJECTS.STATEMENT_GET, p),
);
reply(nc.subscribe(SUBJECTS.BALANCE_GET, { queue: SUBJECTS.QUEUE }), (p) =>
handle(SUBJECTS.BALANCE_GET, p, books),
apply(SUBJECTS.BALANCE_GET, p),
);
reply(nc.subscribe(SUBJECTS.BALANCE_ADJUST, { queue: SUBJECTS.QUEUE }), (p) =>
handle(SUBJECTS.BALANCE_ADJUST, p, books),
apply(SUBJECTS.BALANCE_ADJUST, p),
);
(async () => {
for await (const m of nc.subscribe(SUBJECTS.USAGE_RECORDED)) {
handle(SUBJECTS.USAGE_RECORDED, JSON.parse(sc.decode(m.data) || '{}'), books);
apply(SUBJECTS.USAGE_RECORDED, JSON.parse(sc.decode(m.data) || '{}'));
}
})();
// payment.recorded / credit.applied are fan-out events. Prepaid mutations
@ -76,7 +84,7 @@ const server = http.createServer(async (req, res) => {
}
if (req.method === 'POST' && url.pathname === '/adjust') {
const body = await readBody(req);
return json(200, books.adjust(body));
return json(200, apply(SUBJECTS.BALANCE_ADJUST, body));
}
json(404, { error: 'not found' });
} catch (err) {

View file

@ -1,7 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { AccountBooks, handle } from '../src/books.js';
import { SUBJECTS } from '../src/subjects.js';
import { loadInto, saveFrom, booksPath } from '../src/persist.js';
test('adjust credits prepaid and statement lists credits usage payments', () => {
const books = new AccountBooks();
@ -19,3 +23,15 @@ test('adjust credits prepaid and statement lists credits usage payments', () =>
assert.equal(st.usage[0].endpointId, 'timestamp');
assert.equal(st.payments[0].kind, 'payment');
});
test('persist round-trip keeps prepaid', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'books-'));
process.env.BOOKS_PATH = path.join(dir, 'books.json');
const a = new AccountBooks();
handle(SUBJECTS.BALANCE_ADJUST, { customerId: 'c9', cents: 120, kind: 'reload' }, a);
saveFrom(a);
assert.ok(fs.existsSync(booksPath()));
const b = loadInto(new AccountBooks());
assert.equal(b.prepaidCents('c9'), 120);
delete process.env.BOOKS_PATH;
});

View file

@ -0,0 +1,3 @@
# NATS — zappier-identity
Queue `identity`. Subjects `verae.identity.bind`, `verae.identity.lookup`.

View file

@ -0,0 +1,12 @@
# zappier-identity
Mailbox for **Verae user id bind/lookup**. Public credentials stay zappier API keys. JWTs never live here.
**Forgejo:** https://git.georgelambert.org/marchon/zappier-identity
| Address | Kind |
|---------|------|
| `verae.identity.bind` | request-reply `{ email, customerId }` |
| `verae.identity.lookup` | request-reply `{ customerId }` or `{ veraeUserId }` |
HTTP `:3026` `POST /bind` `GET /lookup/:customerId`.

View file

@ -0,0 +1,14 @@
{
"name": "zappier-identity",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Mailbox: bind and lookup veraeUserId (not JWTs)",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"nats": "^2.28.2"
}
}

View file

@ -0,0 +1,15 @@
import { createHash } from 'node:crypto';
export function stableVeraeUserId(username) {
const n = String(username || '')
.trim()
.toLowerCase();
return `vu_${createHash('sha256').update(n).digest('hex').slice(0, 16)}`;
}
export function bindEmail(email) {
const veraeUsername = String(email || '')
.trim()
.toLowerCase();
return { veraeUserId: stableVeraeUserId(veraeUsername), veraeUsername, bound: true };
}

View file

@ -0,0 +1,89 @@
#!/usr/bin/env node
/** Mailbox: bind/lookup veraeUserId. JWT never stored here. */
import http from 'node:http';
import { bindEmail } from './ids.js';
import { load, put } from './store.js';
import { SUBJECTS } from './subjects.js';
const PORT = Number(process.env.PORT || 3026);
const BIND = process.env.FLEET_HEALTH_BIND || '0.0.0.0';
const db = load();
function handleBind(p) {
const email = p.email || p.veraeUsername;
const bound = bindEmail(email);
const customerId = p.customerId || bound.veraeUsername;
put(db, { customerId, ...bound });
return { ...bound, customerId };
}
function handleLookup(p) {
if (p.customerId && db.byCustomer[p.customerId]) return db.byCustomer[p.customerId];
if (p.veraeUserId && db.byVerae[p.veraeUserId]) return db.byVerae[p.veraeUserId];
if (p.email) return bindEmail(p.email);
return { error: 'not found' };
}
async function startNats() {
const url = process.env.NATS_URL;
if (!url) return;
const { connect, StringCodec } = await import('nats');
const nc = await connect({ servers: url.split(','), name: 'zappier-identity' });
const sc = StringCodec();
for (const subj of [SUBJECTS.BIND, SUBJECTS.LOOKUP]) {
(async () => {
for await (const m of nc.subscribe(subj, { queue: SUBJECTS.QUEUE })) {
let p = {};
try {
p = JSON.parse(sc.decode(m.data) || '{}');
} catch {
p = {};
}
const out = subj === SUBJECTS.BIND ? handleBind(p) : handleLookup(p);
if (m.reply) m.respond(sc.encode(JSON.stringify(out)));
}
})();
}
}
function readBody(req) {
return new Promise((resolve) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
} catch {
resolve({});
}
});
});
}
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: 'zappier-identity', subjects: SUBJECTS });
}
if (req.method === 'POST' && url.pathname === '/bind') {
return json(200, handleBind(await readBody(req)));
}
const who = url.pathname.match(/^\/lookup\/([^/]+)$/);
if (req.method === 'GET' && who) {
return json(200, handleLookup({ customerId: who[1] }));
}
json(404, { error: 'not found' });
} catch (err) {
json(500, { error: err.message });
}
});
server.listen(PORT, BIND, () => {
process.stdout.write(`zappier-identity http://${BIND}:${PORT}/\n`);
});
startNats().catch((err) => process.stderr.write(`nats optional: ${err.message}\n`));

View file

@ -0,0 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
const file = () =>
process.env.IDENTITY_PATH ||
path.join(process.env.FLEET_STATE_DIR || process.cwd(), 'data', 'identity.json');
export function load() {
const p = file();
if (!fs.existsSync(p)) return { byCustomer: {}, byVerae: {} };
try {
return { byCustomer: {}, byVerae: {}, ...JSON.parse(fs.readFileSync(p, 'utf8')) };
} catch {
return { byCustomer: {}, byVerae: {} };
}
}
export function save(db) {
const p = file();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(db, null, 2));
}
export function put(db, { customerId, veraeUserId, veraeUsername }) {
if (!customerId || !veraeUserId) return db;
db.byCustomer[customerId] = { veraeUserId, veraeUsername };
db.byVerae[veraeUserId] = { customerId, veraeUsername };
save(db);
return db;
}

View file

@ -0,0 +1,5 @@
export const SUBJECTS = {
BIND: 'verae.identity.bind',
LOOKUP: 'verae.identity.lookup',
QUEUE: 'identity',
};

View file

@ -0,0 +1,36 @@
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';
import { stableVeraeUserId } from '../src/ids.js';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
test('identity bind is stable and not a JWT', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'id-'));
const port = 18026;
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: { ...process.env, PORT: String(port), IDENTITY_PATH: path.join(dir, 'id.json') },
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 400));
try {
const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json();
assert.equal(h.role, 'zappier-identity');
const b = await (
await fetch(`http://127.0.0.1:${port}/bind`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'Ada@Example.com', customerId: 'cust_x' }),
})
).json();
assert.equal(b.veraeUserId, stableVeraeUserId('ada@example.com'));
assert.doesNotMatch(JSON.stringify(b), /eyJ|mock-jwt/);
} finally {
child.kill('SIGTERM');
}
});

View file

@ -13,7 +13,8 @@ import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './billing-nats';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger';
// Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, number>();
@ -229,7 +230,7 @@ export function adminRouter(
/* ---------------- customer service: credits ---------------- */
router.post('/credits', (req, res) => {
router.post('/credits', async (req, res) => {
const { customerId, cents, reason, agent } = req.body ?? {};
if (typeof customerId !== 'string' || !Number.isFinite(Number(cents))) {
res.status(400).json({ error: 'customerId and cents required' });
@ -247,9 +248,7 @@ export function adminRouter(
reason: typeof reason === 'string' ? reason : 'credit adjustment',
agent: typeof agent === 'string' ? agent : 'admin',
});
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, { ...rec, veraeUserId: customer.veraeUserId }, 'staff');
void natsAdjust(
const row = await ledgerAdjust(
{
customerId,
veraeUserId: customer.veraeUserId,
@ -260,7 +259,12 @@ export function adminRouter(
},
'staff',
);
res.status(201).json(rec);
let prepaid = customer.balanceCents ?? 0;
if (row && typeof row.prepaidCents === 'number') prepaid = row.prepaidCents;
else if (!booksConfigured()) prepaid += delta;
customers.save({ ...customer, balanceCents: prepaid });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, { ...rec, veraeUserId: customer.veraeUserId }, 'staff');
res.status(201).json({ ...rec, prepaidCents: prepaid });
});
router.get('/credits', (req, res) => {
@ -274,9 +278,9 @@ export function adminRouter(
res.status(404).json({ error: 'customer not found' });
return;
}
const fromNats = await natsStatement(customer.id, 'staff', customer.veraeUserId);
if (fromNats) {
res.json({ ...fromNats, name: customer.name, tierId: customer.tierId, source: 'nats' });
const fromBooks = await ledgerStatement(customer.id, 'staff', customer.veraeUserId);
if (fromBooks) {
res.json({ ...fromBooks, name: customer.name, tierId: customer.tierId, source: fromBooks.source || 'account-balance' });
return;
}
res.json({

View file

@ -33,8 +33,9 @@ const payments: PaymentClient = hasRealStripeKey(process.env.STRIPE_SECRET_KEY)
const { app } = buildApp({ usage, customers, pricingStore, invoices, adminUsers, sessions, payments });
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Zappier API listening on http://localhost:${port}`);
const bind = process.env.BIND ?? '127.0.0.1';
app.listen(port, bind, () => {
console.log(`Zappier API listening on http://${bind}:${port}`);
console.log(`OpenAPI docs at http://localhost:${port}/docs`);
console.log(`Customer portal at http://localhost:${port}/portal`);
console.log(`Admin console at http://localhost:${port}/admin`);

View file

@ -0,0 +1,50 @@
/**
* 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 type { AccessPlane } from './billing-nats';
export type PrepaidRow = {
customerId: string;
veraeUserId?: string;
cents: number;
reason: string;
agent: string;
kind?: string;
prepaidCents?: number;
};
function booksUrl(): string {
return (process.env.ACCOUNT_BALANCE_URL || '').replace(/\/$/, '');
}
export function booksConfigured(): boolean {
return Boolean(process.env.NATS_URL || booksUrl());
}
export async function ledgerAdjust(row: PrepaidRow, plane: AccessPlane): Promise<PrepaidRow | null> {
const viaNats = await natsAdjust(row, plane);
if (viaNats && typeof viaNats === 'object' && viaNats !== null && 'prepaidCents' in viaNats) {
return viaNats as PrepaidRow;
}
const base = booksUrl();
if (!base) return null;
const r = await fetch(`${base}/adjust`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(row),
});
if (!r.ok) return null;
return (await r.json()) as PrepaidRow;
}
export async function ledgerStatement(customerId: string, plane: AccessPlane, veraeUserId?: string) {
const nats = await natsStatement(customerId, plane, veraeUserId);
if (nats) return nats;
const base = booksUrl();
if (!base) return null;
const r = await fetch(`${base}/statement/${encodeURIComponent(customerId)}`);
if (!r.ok) return null;
return r.json();
}

View file

@ -16,8 +16,9 @@ import { RateCard, TierConfig } from './pricing';
import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish, natsStatement, natsAdjust } from './billing-nats';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { bindVeraeUser } from './verae-bind';
import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger';
/**
* Customer portal API (/portal/api): signup, login with optional TOTP 2FA,
@ -209,9 +210,9 @@ export function portalRouter(deps: PortalDeps): Router {
});
router.get('/statement', async (req, res) => {
const fromNats = await natsStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
if (fromNats) {
res.json({ ...fromNats, source: 'nats' });
const fromBooks = await ledgerStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
if (fromBooks) {
res.json({ ...fromBooks, source: fromBooks.source || 'account-balance' });
return;
}
res.json({
@ -287,26 +288,12 @@ export function portalRouter(deps: PortalDeps): Router {
return;
}
const result = await deps.payments.reload(req.customer!, amountCents);
const customer = {
...req.customer!,
balanceCents: (req.customer!.balanceCents ?? 0) + result.creditedCents,
};
if (result.creditedCents > 0) save(deps, customer);
let prepaid = req.customer!.balanceCents ?? 0;
if (result.creditedCents > 0) {
natsPublish(
BILLING_SUBJECTS.PAYMENT_RECORDED,
const row = await ledgerAdjust(
{
customerId: customer.id,
veraeUserId: customer.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
},
'web',
);
void natsAdjust(
{
customerId: customer.id,
veraeUserId: customer.veraeUserId,
customerId: req.customer!.id,
veraeUserId: req.customer!.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
agent: 'portal',
@ -314,9 +301,22 @@ export function portalRouter(deps: PortalDeps): Router {
},
'web',
);
if (row && typeof row.prepaidCents === 'number') prepaid = row.prepaidCents;
else if (!booksConfigured()) prepaid += result.creditedCents;
save(deps, { ...req.customer!, balanceCents: prepaid });
natsPublish(
BILLING_SUBJECTS.PAYMENT_RECORDED,
{
customerId: req.customer!.id,
veraeUserId: req.customer!.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
},
'web',
);
}
res.json({
balanceCents: customer.balanceCents,
balanceCents: prepaid,
mode: result.mode,
...(result.clientSecret ? { clientSecret: result.clientSecret } : {}),
});

View file

@ -28,7 +28,20 @@ function mockBind(email: string): VeraeBind {
* Register or look up the customer on api.veraetime.net.
* MOCK_VERAE (default) or missing VERAE_API_BASE_URL stable id, no network.
*/
export async function bindVeraeUser(email: string): Promise<VeraeBind> {
export async function bindVeraeUser(email: string, customerId?: string): Promise<VeraeBind> {
const identity = (process.env.IDENTITY_URL || '').replace(/\/$/, '');
if (identity) {
try {
const r = await fetch(`${identity}/bind`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, customerId }),
});
if (r.ok) return (await r.json()) as VeraeBind;
} catch {
/* fall through */
}
}
const mock = process.env.MOCK_VERAE !== 'false';
const base = (process.env.VERAE_API_BASE_URL || '').replace(/\/$/, '');
if (mock || !base) return mockBind(email);