Clean prepaid SoT, identity mailbox, public access planes, leaf policy, fleet spawn
Some checks are pending
offline / test (push) Waiting to run
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:
parent
345aeeead9
commit
ddf772454b
153 changed files with 2236 additions and 116 deletions
3
packages/docs-master/modules/verae-access-staff/NATS.md
Normal file
3
packages/docs-master/modules/verae-access-staff/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — verae-access-staff
|
||||
|
||||
Plane `staff`. Authz then billing statement/adjust.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# verae-access-staff
|
||||
|
||||
Staff plane for credits and statement review.
|
||||
3
packages/docs-master/modules/verae-jobs-events/NATS.md
Normal file
3
packages/docs-master/modules/verae-jobs-events/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — verae-jobs-events
|
||||
|
||||
IN `verae.zapier.jobs.events` queue `jobs-events`.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# verae-jobs-events
|
||||
|
||||
Mailbox for `verae.zapier.jobs.events` (passthrough + count).
|
||||
3
packages/docs-master/modules/verae-nats-accounts/NATS.md
Normal file
3
packages/docs-master/modules/verae-nats-accounts/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — verae-nats-accounts
|
||||
|
||||
Policy only. See `policy.json`.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# verae-nats-accounts
|
||||
|
||||
INTERNAL vs LEAF. Leaf deny `verae.billing.>`.
|
||||
3
packages/docs-master/modules/zapier-decisions/NATS.md
Normal file
3
packages/docs-master/modules/zapier-decisions/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — zapier-decisions
|
||||
|
||||
None.
|
||||
3
packages/docs-master/modules/zapier-decisions/SUMMARY.md
Normal file
3
packages/docs-master/modules/zapier-decisions/SUMMARY.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# zapier-decisions
|
||||
|
||||
Architecture decisions and action log for the lab cleanup (prepaid SoT, identity, access planes, leaf policy, fleet).
|
||||
3
packages/docs-master/modules/zappier-identity/NATS.md
Normal file
3
packages/docs-master/modules/zappier-identity/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — zappier-identity
|
||||
|
||||
`verae.identity.bind` / `verae.identity.lookup` queue `identity`.
|
||||
3
packages/docs-master/modules/zappier-identity/SUMMARY.md
Normal file
3
packages/docs-master/modules/zappier-identity/SUMMARY.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-identity
|
||||
|
||||
Bind and lookup `veraeUserId`. JWTs never stored.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
28
packages/verae-access-leaf/src/allow.js
Normal file
28
packages/verae-access-leaf/src/allow.js
Normal 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}.`),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
3
packages/verae-access-staff/NATS.md
Normal file
3
packages/verae-access-staff/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — verae-access-staff
|
||||
|
||||
Plane `staff`. Authz then `verae.billing.statement.get` / `balance.adjust`.
|
||||
7
packages/verae-access-staff/README.md
Normal file
7
packages/verae-access-staff/README.md
Normal 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`.
|
||||
11
packages/verae-access-staff/package.json
Normal file
11
packages/verae-access-staff/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
57
packages/verae-access-staff/src/server.js
Normal file
57
packages/verae-access-staff/src/server.js
Normal 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`);
|
||||
});
|
||||
56
packages/verae-access-staff/test/health.test.js
Normal file
56
packages/verae-access-staff/test/health.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
22
packages/verae-fleet/services/access-staff.json
Normal file
22
packages/verae-fleet/services/access-staff.json
Normal 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"] }
|
||||
}
|
||||
24
packages/verae-fleet/services/identity.json
Normal file
24
packages/verae-fleet/services/identity.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
24
packages/verae-fleet/services/jobs-events.json
Normal file
24
packages/verae-fleet/services/jobs-events.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
3
packages/verae-jobs-events/NATS.md
Normal file
3
packages/verae-jobs-events/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — verae-jobs-events
|
||||
|
||||
IN `verae.zapier.jobs.events` queue `jobs-events`.
|
||||
7
packages/verae-jobs-events/README.md
Normal file
7
packages/verae-jobs-events/README.md
Normal 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`.
|
||||
14
packages/verae-jobs-events/package.json
Normal file
14
packages/verae-jobs-events/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
42
packages/verae-jobs-events/src/server.js
Normal file
42
packages/verae-jobs-events/src/server.js
Normal 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`));
|
||||
4
packages/verae-jobs-events/src/subjects.js
Normal file
4
packages/verae-jobs-events/src/subjects.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const SUBJECTS = {
|
||||
IN: 'verae.zapier.jobs.events',
|
||||
QUEUE: 'jobs-events',
|
||||
};
|
||||
23
packages/verae-jobs-events/test/health.test.js
Normal file
23
packages/verae-jobs-events/test/health.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
3
packages/verae-nats-accounts/NATS.md
Normal file
3
packages/verae-nats-accounts/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — verae-nats-accounts
|
||||
|
||||
Not a subscriber. Defines INTERNAL vs LEAF. Leaf deny `verae.billing.>`.
|
||||
8
packages/verae-nats-accounts/README.md
Normal file
8
packages/verae-nats-accounts/README.md
Normal 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).
|
||||
21
packages/verae-nats-accounts/nats.conf
Normal file
21
packages/verae-nats-accounts/nats.conf
Normal 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.
|
||||
10
packages/verae-nats-accounts/package.json
Normal file
10
packages/verae-nats-accounts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
35
packages/verae-nats-accounts/policy.json
Normal file
35
packages/verae-nats-accounts/policy.json
Normal 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"
|
||||
]
|
||||
}
|
||||
14
packages/verae-nats-accounts/src/policy.js
Normal file
14
packages/verae-nats-accounts/src/policy.js
Normal 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}.`)));
|
||||
}
|
||||
12
packages/verae-nats-accounts/test/policy.test.js
Normal file
12
packages/verae-nats-accounts/test/policy.test.js
Normal 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.>'));
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
|
|
|
|||
40
packages/zapier-decisions/LOG.md
Normal file
40
packages/zapier-decisions/LOG.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Action log
|
||||
|
||||
## 2026-09-11 — bootstrap
|
||||
|
||||
- Created this repo to record decisions while executing cleanup items 1–5 (and a second pass on 2–5).
|
||||
- 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 (2–5)
|
||||
|
||||
- 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).
|
||||
3
packages/zapier-decisions/NATS.md
Normal file
3
packages/zapier-decisions/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — zapier-decisions
|
||||
|
||||
None. Documentation repo.
|
||||
13
packages/zapier-decisions/README.md
Normal file
13
packages/zapier-decisions/README.md
Normal 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.
|
||||
8
packages/zapier-decisions/TODO.md
Normal file
8
packages/zapier-decisions/TODO.md
Normal 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).
|
||||
6
packages/zapier-decisions/decisions/D001-prepaid-sot.md
Normal file
6
packages/zapier-decisions/decisions/D001-prepaid-sot.md
Normal 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.
|
||||
6
packages/zapier-decisions/decisions/D002-identity.md
Normal file
6
packages/zapier-decisions/decisions/D002-identity.md
Normal 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.
|
||||
6
packages/zapier-decisions/decisions/D003-public-doors.md
Normal file
6
packages/zapier-decisions/decisions/D003-public-doors.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
6
packages/zapier-decisions/package.json
Normal file
6
packages/zapier-decisions/package.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
28
packages/zappier-account-balance/src/persist.js
Normal file
28
packages/zappier-account-balance/src/persist.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
3
packages/zappier-identity/NATS.md
Normal file
3
packages/zappier-identity/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — zappier-identity
|
||||
|
||||
Queue `identity`. Subjects `verae.identity.bind`, `verae.identity.lookup`.
|
||||
12
packages/zappier-identity/README.md
Normal file
12
packages/zappier-identity/README.md
Normal 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`.
|
||||
14
packages/zappier-identity/package.json
Normal file
14
packages/zappier-identity/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
packages/zappier-identity/src/ids.js
Normal file
15
packages/zappier-identity/src/ids.js
Normal 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 };
|
||||
}
|
||||
89
packages/zappier-identity/src/server.js
Normal file
89
packages/zappier-identity/src/server.js
Normal 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`));
|
||||
30
packages/zappier-identity/src/store.js
Normal file
30
packages/zappier-identity/src/store.js
Normal 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;
|
||||
}
|
||||
5
packages/zappier-identity/src/subjects.js
Normal file
5
packages/zappier-identity/src/subjects.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const SUBJECTS = {
|
||||
BIND: 'verae.identity.bind',
|
||||
LOOKUP: 'verae.identity.lookup',
|
||||
QUEUE: 'identity',
|
||||
};
|
||||
36
packages/zappier-identity/test/health.test.js
Normal file
36
packages/zappier-identity/test/health.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
50
packages/zappier/src/ledger.ts
Normal file
50
packages/zappier/src/ledger.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue