Separate Zapier, web, API, and leaf access planes with NATS authz
Some checks are pending
offline / test (push) Waiting to run

Zapier is one ingress. Direct web, customer API, and S2S leaf nodes are their own services. Every hop to an internal subject must pass verae.access.authz.check (default deny by plane).
This commit is contained in:
George Lambert 2026-09-11 16:05:05 -04:00
parent ac38676645
commit 1b199ca4d4
117 changed files with 2640 additions and 105 deletions

View file

@ -30,7 +30,12 @@ Zapier cloud **never** connects to NATS. Internal services do: middleware worker
| Module | Repo (Forgejo) | Runtime | Listens | Sends |
|--------|----------------|---------|---------|-------|
| zapier-platform-app | `verae-zapier-app` | Zapier cloud | User Zap steps | HTTPS to zappier-edge |
| zappier-edge | `zappier-edge` | Public HTTPS :3000 | Zapier, portal, admin | HTTPS to middleware; NATS billing pub |
| zappier-edge | `zappier-edge` | Public HTTPS :3000 | Zapier, portal, admin | HTTPS to middleware; NATS billing after authz |
| access-authz | `verae-access-authz` | :3020 + NATS | `verae.access.authz.check` | allow/deny |
| access-web | `verae-access-web` | :3021 | customer browser | billing statement/reload |
| access-api | `verae-access-api` | :3022 | x-api-key | statement; HTTPS to middleware |
| access-leaf | `verae-access-leaf` | :3023 | S2S leaf | archive/jobs only |
| access-zapier | `verae-access-zapier` | :3024 | Zapier HTTPS | jobs.watch after authz |
| account-balance | `zappier-account-balance` | :3010 + NATS | `verae.billing.*` | statement/adjust replies |
| customer-service | `zappier-customer-service` | :3011 | CS staff HTTP | NATS balance.adjust / statement.get |
| sales-pricing | `zappier-sales-pricing` | :3012 | Sales HTTP | NATS statement.get; HTTPS to edge for multipliers |
@ -63,6 +68,11 @@ Zapier cloud **never** connects to NATS. Internal services do: middleware worker
| `verae.billing.usage.recorded` | pub | meter events from zappier-edge |
| `verae.billing.payment.recorded` | pub | portal reload / invoice paid |
| `verae.billing.credit.applied` | pub | CS goodwill |
| `verae.access.authz.check` | request-reply | `{ plane, subject, principal }``{ allow, reason }` |
| `verae.access.web.billing.statement.get` | ingress | web plane only; mapped after authz |
| `verae.access.api.*` | ingress | customer API plane |
| `verae.access.zapier.*` | ingress | Zapier plane |
| `verae.access.leaf.in` | ingress | S2S leaf; never billing |
| `verae.archive.put` | JetStream | `sha256`, `tenantId`, `kind` (`publicMeta\|privateMeta\|file\|tree`), `record`, `traceId` |
| `verae.archive.query` | pub to **all** archives | `correlationId`, `sha256`, `tenantId`, `kinds[]`, `traceId` |
| `verae.archive.reply.<correlationId>` | replies | `archiveId`, `sha256`, `records[]`, `traceId` |

View file

@ -0,0 +1,3 @@
# NATS — verae-access-api
Plane `api`. Statement and jobs.watch after authz. No credits, no archive.

View file

@ -0,0 +1,3 @@
# verae-access-api
Direct customer API (`x-api-key`). Separate from Zapier Platform.

View file

@ -0,0 +1,3 @@
# NATS — verae-access-authz
`verae.access.authz.check` request-reply. Queue `access-authz`. Fail closed.

View file

@ -0,0 +1,3 @@
# verae-access-authz
Default-deny authorization step on the NATS address path. Planes: zapier, web, api, leaf, staff.

View file

@ -0,0 +1,3 @@
# NATS — verae-access-leaf
`verae.access.leaf.in` → authz.check → `verae.archive.*` / jobs.*. Denied: billing.

View file

@ -0,0 +1,3 @@
# verae-access-leaf
Server-to-server NATS leaf ingress. Archive and jobs only. Never billing.

View file

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

View file

@ -0,0 +1,3 @@
# verae-access-web
Direct customer web plane. Not Zapier. Statement + reload only after authz.

View file

@ -0,0 +1,3 @@
# NATS — verae-access-zapier
Plane `zapier`. Authz then jobs.watch / usage.recorded. Zapier cloud never connects to NATS.

View file

@ -0,0 +1,3 @@
# verae-access-zapier
Zapier Platform HTTPS plane only. No portal, no admin, no leaf.

View file

@ -10,8 +10,12 @@ Verae Time proves a SHA-256 existed at a given time. Zapier lets customers regis
| Plane | What | Network |
|-------|------|---------|
| Zapier cloud | `verae-zapier-app`, `verae-activate` | HTTPS to zappier-edge |
| Commercial edge | `zappier-edge` portal, admin, `x-api-key`, Stripe meter | Public HTTPS; NATS billing pubs/requests |
| Zapier cloud | `verae-zapier-app` via **access-zapier** | HTTPS to `:3024` only (not NATS) |
| Direct web | `verae-access-web` customer browser | HTTPS `:3021` |
| 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 |
| 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 |

View file

@ -8,6 +8,11 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
| **verae-nats-process** | `packages/verae-nats-process` | **Template** for a new addressed NATS process |
| **master-zapier-plan-draft** | workspace root | Combined snapshot (`main` and `master`) |
| **zappier-edge** | `packages/zappier` | Metered HTTPS, portal, admin, Stripe; **proxies Verae calls to middleware** |
| **verae-access-authz** | `packages/verae-access-authz` | NATS authorization step (`authz.check`) |
| **verae-access-web** | `packages/verae-access-web` | Direct customer web plane |
| **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 |
| **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

@ -20,7 +20,7 @@ Todays NS1 box (`NS1.GEORGELAMBERT.ORG`, `70.88.205.138`) already runs JetStr
| Allowed | Forbidden |
|---------|-----------|
| verae-middleware, fleet workers, WORM, tree nodes, account-balance, zappier-edge billing, CS/sales/accounting, `verae-nats-process` clones | Zapier cloud, customer browsers (they use HTTPS portal/CS/sales UIs) |
| access-authz, access-leaf (after token), middleware, fleet workers, WORM, tree, account-balance, access planes, CS/sales | Zapier cloud, customer browsers (HTTPS to access-web / access-api / access-zapier only) |
## Address families already in use

View file

@ -5,10 +5,14 @@ NATS **addresses** (subjects) are the extension point. A new search, store, or j
## Pattern
```text
verae.<area>.<resource>.<action>
verae.access.<plane>.<area>.<resource>.<action> # ingress (one plane)
verae.access.authz.check # authorization step
verae.<area>.<resource>.<action> # internal bus
verae.<area>.<resource>.reply.<correlationId>
```
Planes: `zapier` | `web` | `api` | `leaf` | `staff`. Default deny. A leaf cannot `balance.adjust`; Zapier cannot `statement.get`; a browser cannot `archive.put`.
| Piece | Example | Meaning |
|-------|---------|---------|
| `verae` | — | Verae bus (not Zapier) |
@ -26,7 +30,7 @@ verae.<area>.<resource>.reply.<correlationId>
2. Rename `verae.example.process.in` / `.out` / `.reply.*` in `src/subjects.js`.
3. Add a row to that repos `ROUTING.md` and to [INDEX.md](INDEX.md).
4. Register the process in `verae-fleet` (`min`/`max`, machines, roles).
5. If Zapier must call it, add **one HTTPS route** on middleware — Zapier still never sees NATS.
5. If an access plane must call it, add the subject to **verae-access-authz** policy for that plane only. Zapier still never sees NATS.
Do **not** invent a public NATS URL for Zapier. Do **not** reuse `verae.archive.query` as a queue group.

View file

@ -8,23 +8,24 @@ SVG files in [`diagrams/`](diagrams/). The same shapes are repeated below in mer
```mermaid
flowchart LR
Z[Zapier cloud apps] -->|HTTPS x-api-key| E[zappier-edge]
Cust[customer portal] -->|HTTPS statement| E
CS[customer-service] -->|NATS statement/adjust| N[NATS 3-node cluster]
SA[sales-pricing] -->|NATS statement| N
AC[accounting-export] -->|NATS statement| N
E -->|NATS billing| N
BAL[account-balance] -->|reply verae.billing.*| N
E -->|HTTPS metered| M[verae-middleware]
M -->|HTTPS| C[Verae chain]
M -->|JetStream| N
Z[Zapier cloud] -->|HTTPS plane=zapier| AZ[access-zapier]
Web[customer browser] -->|HTTPS plane=web| AW[access-web]
API[customer API] -->|HTTPS plane=api| AA[access-api]
Leaf[S2S leaf] -->|verae.access.leaf.in| AL[access-leaf]
AZ --> Authz[access-authz]
AW --> Authz
AA --> Authz
AL --> Authz
CS[staff CS/sales] --> Authz
Authz -->|allow| N[NATS cluster]
BAL[account-balance] --> N
AZ -->|HTTPS| M[verae-middleware]
M --> N
N --> P[job-poller]
N --> W[webhook-deliver]
N --> A[archive-aggregator]
N --> R[WORM x N]
N --> T[tree-node x N]
P -->|HTTPS status| C
W -->|HTTPS REST Hook| Z
N --> R[WORM]
N --> T[tree-node]
P --> C[Verae chain]
M --> C
```
## 8.2 NATS.IO 3-server cluster

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-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-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,3 @@
# NATS — verae-access-api
Plane `api`. Ingress `verae.access.api.*`. Authz then statement.get / usage.recorded / jobs.watch. Never archive or credits.

View file

@ -0,0 +1,11 @@
# verae-access-api
**Direct customer API** plane (`x-api-key`). Separate from the Zapier Platform app.
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-api
Address path: `verae.access.api.*``verae.access.authz.check``verae.billing.statement.get` / `verae.zapier.jobs.watch` (HTTPS to middleware, not a Zapier NATS client).
Cannot `balance.adjust` or `archive.put`.
Port `:3022`.

View file

@ -0,0 +1,11 @@
{
"name": "verae-access-api",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Direct customer API access plane (x-api-key, not Zapier)",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
}
}

View file

@ -0,0 +1,59 @@
#!/usr/bin/env node
/** Direct customer API (x-api-key). Not Zapier Platform. */
import http from 'node:http';
const PORT = Number(process.env.PORT || 3022);
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 MW = (process.env.ZAPPIER_UPSTREAM || 'http://127.0.0.1:3100').replace(/\/$/, '');
const PLANE = 'api';
async function check(subject, principal, extra = {}) {
const r = await fetch(`${AUTHZ}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: PLANE, subject, principal, ...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-api', plane: PLANE });
}
const key = req.headers['x-api-key'];
const principal = typeof key === 'string' ? key : 'anonymous';
const st = url.pathname.match(/^\/v1\/statement\/([^/]+)$/);
if (req.method === 'GET' && st) {
const gate = await check('verae.billing.statement.get', principal);
if (!gate.allow) return json(403, gate);
const r = await fetch(`${BOOKS}/statement/${st[1]}`);
return json(r.status, { ...(await r.json()), plane: PLANE, source: 'account-balance' });
}
if (req.method === 'POST' && url.pathname === '/v1/timestamp') {
const gate = await check('verae.zapier.jobs.watch', principal);
if (!gate.allow) return json(403, gate);
const chunks = [];
for await (const c of req) chunks.push(c);
const r = await fetch(`${MW}/zapier/v1/timestamp`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': principal },
body: Buffer.concat(chunks),
});
return json(r.status, { ...(await r.json().catch(() => ({}))), 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-api http://0.0.0.0:${PORT}/ plane=${PLANE}\n`);
});

View file

@ -0,0 +1,37 @@
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');
test('api plane health and authz deny on credits', async () => {
const authzPort = 18024;
const apiPort = 18025;
const authz = spawn(process.execPath, ['src/server.js'], {
cwd: authzRoot,
env: { ...process.env, PORT: String(authzPort) },
stdio: ['ignore', 'pipe', 'pipe'],
});
const api = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: { ...process.env, PORT: String(apiPort), AUTHZ_URL: `http://127.0.0.1:${authzPort}` },
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const h = await (await fetch(`http://127.0.0.1:${apiPort}/health`)).json();
assert.equal(h.plane, 'api');
const deny = await fetch(`http://127.0.0.1:${authzPort}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: 'api', subject: 'verae.billing.balance.adjust' }),
});
assert.equal(deny.status, 403);
} finally {
api.kill('SIGTERM');
authz.kill('SIGTERM');
}
});

View file

@ -0,0 +1,11 @@
# NATS — verae-access-authz
| Direction | Address | Kind |
|-----------|---------|------|
| IN | `verae.access.authz.check` | request-reply, queue `access-authz` |
| OUT | `verae.access.authz.deny` | pub on deny (audit) |
Body in: `{ plane, subject, principal, kind, payload }`.
Body out: `{ allow, plane, subject, reason }`.
Fail closed: if this process is down, access gateways must not forward.

View file

@ -0,0 +1,34 @@
# verae-access-authz
Authorization step on the NATS **address path**. Zapier is only **one** access plane. Direct web, customer API, and server-to-server leaf nodes are separate, and each is default-deny against internal subjects.
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-authz
**Catalog:** https://zapier.georgelambert.org/packages/verae-access-authz/README.pdf
## Address path
```text
client
→ verae.access.<plane>.<area>.<resource>.<action> (ingress, that plane only)
→ verae.access.authz.check (allow / deny)
→ verae.<area>.<resource>.<action> (internal bus)
```
Internal subjects (`verae.billing.*`, `verae.archive.*`, `verae.zapier.jobs.*`) stay stable. Planes never publish them until `authz.check` returns `allow`.
| Plane | Who | May reach |
|-------|-----|-----------|
| `zapier` | Zapier Platform HTTPS | jobs.*, webhooks.deliver, billing.usage.recorded |
| `web` | Customer browser portal | statement.get, balance.adjust kind=reload\|payment |
| `api` | Customer `x-api-key` (not Zapier) | statement.get, usage.recorded, jobs.watch |
| `leaf` | S2S NATS leaf / mTLS | archive.*, jobs.*, webhooks — **not billing** |
| `staff` | CS / sales / admin | statement, balance.adjust (credits) |
A leaf node cannot credit an account. A Zapier hop cannot read a customer statement. A browser cannot `archive.put`.
```bash
NATS_URL=nats://127.0.0.1:4222 PORT=3020 npm start
curl -s http://127.0.0.1:3020/policy
curl -s -X POST http://127.0.0.1:3020/check -H 'content-type: application/json' \
-d '{"plane":"leaf","subject":"verae.billing.balance.adjust"}'
```

View file

@ -0,0 +1,46 @@
{
"name": "verae-access-authz",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "verae-access-authz",
"version": "0.1.0",
"dependencies": {
"nats": "^2.28.2"
}
},
"node_modules/nats": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
"license": "Apache-2.0",
"dependencies": {
"nkeys.js": "1.1.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/nkeys.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
"license": "Apache-2.0",
"dependencies": {
"tweetnacl": "1.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
}
}
}

View file

@ -0,0 +1,14 @@
{
"name": "verae-access-authz",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "NATS authorization step for access planes (web, api, zapier, leaf, staff)",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"nats": "^2.28.2"
}
}

View file

@ -0,0 +1,38 @@
/** Call verae.access.authz.check then the internal subject. Fail closed. */
import { SUBJECTS } from './subjects.js';
export async function authorizeThenRequest(nc, sc, { plane, subject, principal, payload, timeout = 2000 }) {
const kind = payload?.kind;
const checkMsg = await nc.request(
SUBJECTS.AUTHZ_CHECK,
sc.encode(JSON.stringify({ plane, subject, principal, kind, payload })),
{ timeout },
);
const decision = JSON.parse(sc.decode(checkMsg.data) || '{}');
if (!decision.allow) {
const err = new Error(decision.reason || 'denied');
err.decision = decision;
throw err;
}
const internal = decision.subject;
const body = { ...payload, plane, principal, traceId: payload?.traceId };
const m = await nc.request(internal, sc.encode(JSON.stringify(body)), { timeout });
return { decision, body: JSON.parse(sc.decode(m.data) || '{}') };
}
export async function authorizeThenPublish(nc, sc, { plane, subject, principal, payload, timeout = 2000 }) {
const kind = payload?.kind;
const checkMsg = await nc.request(
SUBJECTS.AUTHZ_CHECK,
sc.encode(JSON.stringify({ plane, subject, principal, kind, payload })),
{ timeout },
);
const decision = JSON.parse(sc.decode(checkMsg.data) || '{}');
if (!decision.allow) {
const err = new Error(decision.reason || 'denied');
err.decision = decision;
throw err;
}
nc.publish(decision.subject, sc.encode(JSON.stringify({ ...payload, plane, principal })));
return { decision };
}

View file

@ -0,0 +1,98 @@
import { PLANES, parseAddress } from './subjects.js';
/**
* Default-deny. Each access plane may only touch listed internal prefixes.
* Internal workers (poller, WORM, account-balance) are not planes; they
* already sit on the private bus. This policy gates *ingress*.
*/
export const POLICY = Object.freeze({
zapier: {
title: 'Zapier Platform (HTTPS only; never a NATS client)',
allow: [
'verae.zapier.jobs.watch',
'verae.zapier.jobs.events',
'verae.zapier.webhooks.deliver',
'verae.billing.usage.recorded',
],
},
web: {
title: 'Direct customer web (portal browser)',
allow: [
'verae.billing.statement.get',
'verae.billing.balance.adjust',
'verae.billing.payment.recorded',
],
adjustKinds: ['payment', 'reload'],
},
api: {
title: 'Direct customer API (x-api-key, not Zapier)',
allow: ['verae.billing.statement.get', 'verae.billing.usage.recorded', 'verae.zapier.jobs.watch'],
},
leaf: {
title: 'Server-to-server NATS leaf / mTLS',
allow: [
'verae.archive.put',
'verae.archive.query',
'verae.archive.reply.',
'verae.zapier.jobs.watch',
'verae.zapier.jobs.events',
'verae.zapier.webhooks.deliver',
],
},
staff: {
title: 'CS / sales / admin / accounting web',
allow: [
'verae.billing.statement.get',
'verae.billing.balance.get',
'verae.billing.balance.adjust',
'verae.billing.credit.applied',
'verae.billing.payment.recorded',
],
},
});
function prefixAllowed(allow, subject) {
return allow.some((p) => (p.endsWith('.') ? subject.startsWith(p) : subject === p || subject.startsWith(`${p}.`)));
}
/**
* @param {{ plane: string, subject: string, kind?: string, principal?: string }} req
*/
export function authorize(req) {
const plane = String(req?.plane || '');
const parsed = parseAddress(req?.subject, plane);
if (parsed.authz) {
return deny(plane, req?.subject, 'authz subjects are not forwardable');
}
if (!PLANES.includes(plane)) {
return deny(plane, parsed.internal, `unknown access plane`);
}
if (parsed.plane && parsed.plane !== plane) {
return deny(plane, parsed.internal, `plane mismatch (address is ${parsed.plane})`);
}
const internal = parsed.internal;
if (!internal.startsWith('verae.') || internal.startsWith('verae.access.')) {
return deny(plane, internal, 'not an internal verae.* subject');
}
const rule = POLICY[plane];
if (!prefixAllowed(rule.allow, internal)) {
return deny(plane, internal, `${plane} cannot reach ${internal}`);
}
if (internal === 'verae.billing.balance.adjust' && rule.adjustKinds) {
const kind = req?.kind || req?.payload?.kind;
if (kind && !rule.adjustKinds.includes(kind)) {
return deny(plane, internal, `${plane} cannot adjust kind=${kind}`);
}
}
return {
allow: true,
plane,
subject: internal,
principal: req?.principal || null,
reason: 'ok',
};
}
function deny(plane, subject, reason) {
return { allow: false, plane: plane || null, subject: subject || null, reason };
}

View file

@ -0,0 +1,83 @@
#!/usr/bin/env node
import http from 'node:http';
import { authorize, POLICY } from './policy.js';
import { PLANES, SUBJECTS } from './subjects.js';
const PORT = Number(process.env.PORT || process.env.FLEET_HEALTH_PORT || 3020);
const BIND = process.env.FLEET_HEALTH_BIND || '0.0.0.0';
let natsOk = false;
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-access-authz' });
const sc = StringCodec();
const sub = nc.subscribe(SUBJECTS.AUTHZ_CHECK, { queue: SUBJECTS.QUEUE });
(async () => {
for await (const m of sub) {
let payload = {};
try {
payload = JSON.parse(sc.decode(m.data) || '{}');
} catch {
payload = {};
}
const out = authorize(payload);
if (!out.allow && m.reply) {
nc.publish(SUBJECTS.AUTHZ_DENY, sc.encode(JSON.stringify({ ...out, at: new Date().toISOString() })));
}
if (m.reply) m.respond(sc.encode(JSON.stringify(out)));
}
})();
natsOk = true;
process.stdout.write(`access-authz nats ${url}\n`);
}
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: 'verae-access-authz',
nats: natsOk,
planes: PLANES,
subject: SUBJECTS.AUTHZ_CHECK,
});
}
if (req.method === 'GET' && url.pathname === '/policy') {
return json(200, { planes: PLANES, policy: POLICY, subjects: SUBJECTS });
}
if (req.method === 'POST' && url.pathname === '/check') {
const body = await readBody(req);
const out = authorize(body);
return json(out.allow ? 200 : 403, out);
}
json(404, { error: 'not found' });
} catch (err) {
json(500, { error: err.message });
}
});
server.listen(PORT, BIND, () => {
process.stdout.write(`verae-access-authz http://${BIND}:${PORT}/\n`);
});
startNats().catch((err) => process.stderr.write(`nats optional: ${err.message}\n`));

View file

@ -0,0 +1,36 @@
/**
* Access planes sit in front of internal verae.<area>.* addresses.
* Pattern:
* verae.access.<plane>.<area>.<resource>.<action> ingress (that plane only)
* verae.access.authz.check authorization step
* verae.<area>.<resource>.<action> internal (unchanged)
*/
export const PLANES = Object.freeze(['zapier', 'web', 'api', 'leaf', 'staff']);
export const SUBJECTS = Object.freeze({
AUTHZ_CHECK: 'verae.access.authz.check',
AUTHZ_DENY: 'verae.access.authz.deny',
QUEUE: 'access-authz',
ingress: (plane) => `verae.access.${plane}.in`,
});
/** verae.billing.statement.get + plane web → verae.access.web.billing.statement.get */
export function accessAddress(plane, internalSubject) {
if (!PLANES.includes(plane)) throw new Error(`unknown plane ${plane}`);
if (!internalSubject.startsWith('verae.') || internalSubject.startsWith('verae.access.')) {
throw new Error(`not an internal verae.* subject: ${internalSubject}`);
}
return `verae.access.${plane}.${internalSubject.slice('verae.'.length)}`;
}
/** Parse an access or internal subject into { plane, internal }. */
export function parseAddress(subject, planeHint) {
const s = String(subject || '');
const m = s.match(/^verae\.access\.([a-z]+)\.(.+)$/);
if (m) {
const plane = m[1];
if (plane === 'authz') return { plane: planeHint || null, internal: s, authz: true };
return { plane, internal: `verae.${m[2]}`, authz: false };
}
return { plane: planeHint || null, internal: s, authz: false };
}

View file

@ -0,0 +1,35 @@
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('authz health policy and check', async () => {
const port = 18020;
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-access-authz');
const ok = await fetch(`http://127.0.0.1:${port}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: 'web', subject: 'verae.billing.statement.get' }),
});
assert.equal(ok.status, 200);
const no = await fetch(`http://127.0.0.1:${port}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: 'leaf', subject: 'verae.billing.balance.adjust' }),
});
assert.equal(no.status, 403);
} finally {
child.kill('SIGTERM');
}
});

View file

@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { authorize } from '../src/policy.js';
import { accessAddress, parseAddress, PLANES } from '../src/subjects.js';
test('five access planes', () => {
assert.deepEqual([...PLANES], ['zapier', 'web', 'api', 'leaf', 'staff']);
});
test('web may read statement and reload, not archive or jobs', () => {
assert.equal(authorize({ plane: 'web', subject: 'verae.billing.statement.get' }).allow, true);
assert.equal(
authorize({ plane: 'web', subject: 'verae.billing.balance.adjust', kind: 'reload' }).allow,
true,
);
assert.equal(
authorize({ plane: 'web', subject: 'verae.billing.balance.adjust', kind: 'credit' }).allow,
false,
);
assert.equal(authorize({ plane: 'web', subject: 'verae.archive.put' }).allow, false);
assert.equal(authorize({ plane: 'web', subject: 'verae.zapier.jobs.watch' }).allow, false);
});
test('leaf may archive and jobs, not billing adjust', () => {
assert.equal(authorize({ plane: 'leaf', subject: 'verae.archive.put' }).allow, true);
assert.equal(authorize({ plane: 'leaf', subject: 'verae.archive.reply.abc' }).allow, true);
assert.equal(authorize({ plane: 'leaf', subject: 'verae.zapier.jobs.watch' }).allow, true);
assert.equal(authorize({ plane: 'leaf', subject: 'verae.billing.balance.adjust' }).allow, false);
assert.equal(authorize({ plane: 'leaf', subject: 'verae.billing.statement.get' }).allow, false);
});
test('zapier may meter and jobs, not customer statement or credits', () => {
assert.equal(authorize({ plane: 'zapier', subject: 'verae.zapier.jobs.watch' }).allow, true);
assert.equal(authorize({ plane: 'zapier', subject: 'verae.billing.usage.recorded' }).allow, true);
assert.equal(authorize({ plane: 'zapier', subject: 'verae.billing.statement.get' }).allow, false);
assert.equal(authorize({ plane: 'zapier', subject: 'verae.billing.balance.adjust' }).allow, false);
assert.equal(authorize({ plane: 'zapier', subject: 'verae.archive.put' }).allow, false);
});
test('api may statement and usage, not credits or archive', () => {
assert.equal(authorize({ plane: 'api', subject: 'verae.billing.statement.get' }).allow, true);
assert.equal(authorize({ plane: 'api', subject: 'verae.billing.usage.recorded' }).allow, true);
assert.equal(authorize({ plane: 'api', subject: 'verae.billing.balance.adjust' }).allow, false);
assert.equal(authorize({ plane: 'api', subject: 'verae.archive.query' }).allow, false);
});
test('staff may credit and statement, not archive', () => {
assert.equal(authorize({ plane: 'staff', subject: 'verae.billing.balance.adjust', kind: 'credit' }).allow, true);
assert.equal(authorize({ plane: 'staff', subject: 'verae.billing.statement.get' }).allow, true);
assert.equal(authorize({ plane: 'staff', subject: 'verae.archive.put' }).allow, false);
});
test('access-prefixed address is mapped to internal', () => {
const addr = accessAddress('leaf', 'verae.archive.put');
assert.equal(addr, 'verae.access.leaf.archive.put');
const parsed = parseAddress(addr);
assert.equal(parsed.plane, 'leaf');
assert.equal(parsed.internal, 'verae.archive.put');
assert.equal(authorize({ plane: 'leaf', subject: addr }).allow, true);
assert.equal(authorize({ plane: 'web', subject: addr }).allow, false);
});
test('unknown plane and authz subjects denied', () => {
assert.equal(authorize({ plane: 'partner', subject: 'verae.archive.put' }).allow, false);
assert.equal(authorize({ plane: 'web', subject: 'verae.access.authz.check' }).allow, false);
});

View file

@ -0,0 +1,9 @@
# NATS — verae-access-leaf
| Direction | Address | Kind |
|-----------|---------|------|
| IN | `verae.access.leaf.in` | queue `access-leaf` `{ token, target, payload, principal }` |
| OUT | `verae.access.authz.check` | request-reply |
| OUT | allowed `target` only | pub after allow |
Do not export `verae.billing.*` to the leaf account.

View file

@ -0,0 +1,13 @@
# verae-access-leaf
**Server-to-server leaf node** access plane. Remote machines connect as NATS leaf nodes (or POST `/forward` with a leaf token). They never get the core billing subjects.
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-leaf
```text
remote leaf → verae.access.leaf.in → authz.check → verae.archive.put | jobs.*
```
Denied: `verae.billing.balance.adjust`, `verae.billing.statement.get`. A compromised WORM/leaf cannot credit customers.
`LEAF_TOKEN` required. Port `:3023`. Do not publish NATS `4222` to the internet; leaf nodes use a private leafnode port / tunnel.

View file

@ -0,0 +1,46 @@
{
"name": "verae-access-leaf",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "verae-access-leaf",
"version": "0.1.0",
"dependencies": {
"nats": "^2.28.2"
}
},
"node_modules/nats": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
"license": "Apache-2.0",
"dependencies": {
"nkeys.js": "1.1.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/nkeys.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
"license": "Apache-2.0",
"dependencies": {
"tweetnacl": "1.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
}
}
}

View file

@ -0,0 +1,14 @@
{
"name": "verae-access-leaf",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Server-to-server NATS leaf-node access plane",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"nats": "^2.28.2"
}
}

View file

@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Server-to-server / NATS leaf-node ingress.
* Remote servers publish verae.access.leaf.in; this process authz-checks
* then forwards only allowed internal subjects (archive + jobs, never billing).
*/
import http from 'node:http';
const PORT = Number(process.env.PORT || 3023);
const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, '');
const TOKEN = process.env.LEAF_TOKEN || 'leaf-dev-token';
const PLANE = 'leaf';
const IN = 'verae.access.leaf.in';
async function check(subject, principal) {
const r = await fetch(`${AUTHZ}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: PLANE, subject, principal }),
});
return r.json();
}
function authorizedToken(got) {
return got && got === TOKEN;
}
async function forward(body) {
if (!authorizedToken(body.token)) {
return { status: 401, body: { allow: false, reason: 'bad leaf token' } };
}
const target = body.target || body.subject;
const gate = await check(target, body.principal || 'leaf');
if (!gate.allow) return { status: 403, body: gate };
const url = process.env.NATS_URL;
if (url) {
const { connect, StringCodec } = await import('nats');
const nc = await connect({ servers: url.split(','), name: 'verae-access-leaf' });
const sc = StringCodec();
nc.publish(gate.subject, sc.encode(JSON.stringify({ ...(body.payload || {}), plane: PLANE })));
await nc.flush();
await nc.close();
}
return { status: 200, body: { forwarded: gate.subject, plane: PLANE, nats: Boolean(url) } };
}
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-access-leaf' });
const sc = StringCodec();
(async () => {
for await (const m of nc.subscribe(IN, { queue: 'access-leaf' })) {
let payload = {};
try {
payload = JSON.parse(sc.decode(m.data) || '{}');
} catch {
payload = {};
}
const out = await forward(payload);
if (m.reply) m.respond(sc.encode(JSON.stringify(out.body)));
}
})();
process.stdout.write(`access-leaf nats ${IN}\n`);
}
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-leaf', plane: PLANE, ingress: IN });
}
if (req.method === 'POST' && url.pathname === '/forward') {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
const out = await forward(body);
return json(out.status, out.body);
}
json(404, { error: 'not found' });
} catch (err) {
json(502, { error: err.message });
}
});
server.listen(PORT, '0.0.0.0', () => {
process.stdout.write(`verae-access-leaf http://0.0.0.0:${PORT}/ plane=${PLANE}\n`);
});
startNats().catch((err) => process.stderr.write(`nats optional: ${err.message}\n`));

View file

@ -0,0 +1,63 @@
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');
test('leaf forwards archive.put and refuses billing adjust', async () => {
const authzPort = 18026;
const leafPort = 18027;
const authz = spawn(process.execPath, ['src/server.js'], {
cwd: authzRoot,
env: { ...process.env, PORT: String(authzPort) },
stdio: ['ignore', 'pipe', 'pipe'],
});
const leaf = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: {
...process.env,
PORT: String(leafPort),
AUTHZ_URL: `http://127.0.0.1:${authzPort}`,
LEAF_TOKEN: 'leaf-dev-token',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const h = await (await fetch(`http://127.0.0.1:${leafPort}/health`)).json();
assert.equal(h.plane, 'leaf');
const ok = await fetch(`http://127.0.0.1:${leafPort}/forward`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
token: 'leaf-dev-token',
target: 'verae.archive.put',
principal: 'ns2',
payload: { sha256: 'abc' },
}),
});
assert.equal(ok.status, 200);
const no = await fetch(`http://127.0.0.1:${leafPort}/forward`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
token: 'leaf-dev-token',
target: 'verae.billing.balance.adjust',
payload: { customerId: 'c1', cents: 9999 },
}),
});
assert.equal(no.status, 403);
const bad = await fetch(`http://127.0.0.1:${leafPort}/forward`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: 'wrong', target: 'verae.archive.put' }),
});
assert.equal(bad.status, 401);
} finally {
leaf.kill('SIGTERM');
authz.kill('SIGTERM');
}
});

View file

@ -0,0 +1,3 @@
# NATS — verae-access-web
Plane `web`. Ingress `verae.access.web.in` / `verae.access.web.billing.*`. Must pass `verae.access.authz.check`. Internal: `verae.billing.statement.get`, `verae.billing.balance.adjust` (reload/payment only).

View file

@ -0,0 +1,15 @@
# verae-access-web
**Direct web** access plane. Customer browsers hit this process, not Zapier and not the NATS port.
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-web
Every NATS hop is `verae.access.web.*``verae.access.authz.check` → internal `verae.billing.statement.get` (reload only, not CS credits).
| Route | Job |
|-------|-----|
| `GET /health` | `{ plane: "web" }` |
| `GET /statement/:id` | Authz then statement |
| `POST /reload` | Authz then `balance.adjust` kind=reload |
Port `:3021`.

View file

@ -0,0 +1,46 @@
{
"name": "verae-access-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "verae-access-web",
"version": "0.1.0",
"dependencies": {
"nats": "^2.28.2"
}
},
"node_modules/nats": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
"license": "Apache-2.0",
"dependencies": {
"nkeys.js": "1.1.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/nkeys.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
"license": "Apache-2.0",
"dependencies": {
"tweetnacl": "1.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
}
}
}

View file

@ -0,0 +1,14 @@
{
"name": "verae-access-web",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Direct customer web access plane (not Zapier)",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"nats": "^2.28.2"
}
}

View file

@ -0,0 +1,50 @@
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(/\/$/, '');
export const PLANE = 'web';
export const AUTHZ_CHECK = 'verae.access.authz.check';
export async function check(subject, { principal, kind, payload } = {}) {
const r = await fetch(`${AUTHZ}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: PLANE, subject, principal, kind, payload }),
});
return { status: r.status, body: await r.json().catch(() => ({ allow: false })) };
}
export async function statement(customerId, principal) {
const gate = await check('verae.billing.statement.get', { principal });
if (!gate.body.allow) return { status: 403, body: gate.body };
const url = process.env.NATS_URL;
if (url) {
try {
const { connect, StringCodec } = await import('nats');
const nc = await connect({ servers: url.split(','), name: 'verae-access-web' });
const sc = StringCodec();
const m = await nc.request(
'verae.billing.statement.get',
sc.encode(JSON.stringify({ customerId, plane: PLANE, principal })),
{ timeout: 2000 },
);
const out = JSON.parse(sc.decode(m.data) || '{}');
await nc.close();
return { status: 200, body: { ...out, source: 'nats', plane: PLANE } };
} catch {
/* HTTP fallback */
}
}
const r = await fetch(`${BOOKS}/statement/${customerId}`);
return { status: r.status, body: { ...(await r.json().catch(() => ({}))), source: 'account-balance', plane: PLANE } };
}
export async function reload(customerId, cents, principal) {
const gate = await check('verae.billing.balance.adjust', { principal, kind: 'reload' });
if (!gate.body.allow) return { status: 403, body: gate.body };
const r = await fetch(`${BOOKS}/adjust`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ customerId, cents, reason: 'reload', agent: principal || 'web', kind: 'reload' }),
});
return { status: r.status, body: { ...(await r.json().catch(() => ({}))), plane: PLANE } };
}

View file

@ -0,0 +1,38 @@
#!/usr/bin/env node
/** Direct customer web access. Not Zapier. NATS only after authz. */
import http from 'node:http';
import { PLANE, statement, reload } from './gate.js';
const PORT = Number(process.env.PORT || 3021);
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-web', plane: PLANE });
}
const st = url.pathname.match(/^\/statement\/([^/]+)$/);
if (req.method === 'GET' && st) {
const out = await statement(st[1], st[1]);
return json(out.status, out.body);
}
if (req.method === 'POST' && url.pathname === '/reload') {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
const out = await reload(body.customerId, body.cents, body.customerId);
return json(out.status, out.body);
}
json(404, { error: 'not found' });
} catch (err) {
json(502, { error: err.message });
}
});
server.listen(PORT, '0.0.0.0', () => {
process.stdout.write(`verae-access-web http://0.0.0.0:${PORT}/ plane=${PLANE}\n`);
});

View file

@ -0,0 +1,52 @@
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('web plane can read statement after authz, cannot skip authz', async () => {
const authzPort = 18021;
const booksPort = 18022;
const webPort = 18023;
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) },
stdio: ['ignore', 'pipe', 'pipe'],
});
const web = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: {
...process.env,
PORT: String(webPort),
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 {
await fetch(`http://127.0.0.1:${booksPort}/adjust`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ customerId: 'c-web', cents: 400, kind: 'reload' }),
});
const h = await (await fetch(`http://127.0.0.1:${webPort}/health`)).json();
assert.equal(h.plane, 'web');
const st = await (await fetch(`http://127.0.0.1:${webPort}/statement/c-web`)).json();
assert.equal(st.prepaidCents, 400);
assert.equal(st.plane, 'web');
} finally {
web.kill('SIGTERM');
books.kill('SIGTERM');
authz.kill('SIGTERM');
}
});

View file

@ -0,0 +1,3 @@
# NATS — verae-access-zapier
Plane `zapier`. Authz then `verae.zapier.jobs.watch` / `verae.billing.usage.recorded`. Denied: statement.get, balance.adjust, archive.*.

View file

@ -0,0 +1,9 @@
# verae-access-zapier
**Zapier is one access plane**, not the only one. This process is the Zapier HTTPS front door: timestamp/wait/hash into middleware. No customer portal, no admin, no leaf.
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-zapier
Zapier cloud never opens NATS. This process may request `verae.access.authz.check` for `verae.zapier.jobs.watch` and `verae.billing.usage.recorded` only.
Port `:3024`.

View file

@ -0,0 +1,11 @@
{
"name": "verae-access-zapier",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Zapier Platform access plane (HTTPS only, no portal)",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
}
}

View file

@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Zapier Platform access plane only. No portal, no customer API keys, no leaf.
* Zapier cloud still never connects to NATS; this process does after authz.
*/
import http from 'node:http';
const PORT = Number(process.env.PORT || 3024);
const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, '');
const MW = (process.env.ZAPPIER_UPSTREAM || 'http://127.0.0.1:3100').replace(/\/$/, '');
const PLANE = 'zapier';
async function check(subject, principal) {
const r = await fetch(`${AUTHZ}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: PLANE, subject, principal }),
});
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-zapier', plane: PLANE });
}
if (url.pathname.startsWith('/portal') || url.pathname.startsWith('/admin')) {
return json(404, { error: 'zapier plane has no portal/admin', plane: PLANE });
}
if (req.method === 'POST' && (url.pathname === '/v1/timestamp' || url.pathname === '/zapier/v1/timestamp')) {
const gate = await check('verae.zapier.jobs.watch', 'zapier-app');
if (!gate.allow) return json(403, gate);
const chunks = [];
for await (const c of req) chunks.push(c);
const r = await fetch(`${MW}/zapier/v1/timestamp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: Buffer.concat(chunks),
});
return json(r.status, { ...(await r.json().catch(() => ({}))), plane: PLANE });
}
json(404, { error: 'not found', plane: PLANE });
} catch (err) {
json(502, { error: err.message });
}
});
server.listen(PORT, '0.0.0.0', () => {
process.stdout.write(`verae-access-zapier http://0.0.0.0:${PORT}/ plane=${PLANE}\n`);
});

View file

@ -0,0 +1,39 @@
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');
test('zapier plane has no portal and cannot read statements', async () => {
const authzPort = 18028;
const zPort = 18029;
const authz = spawn(process.execPath, ['src/server.js'], {
cwd: authzRoot,
env: { ...process.env, PORT: String(authzPort) },
stdio: ['ignore', 'pipe', 'pipe'],
});
const z = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: { ...process.env, PORT: String(zPort), AUTHZ_URL: `http://127.0.0.1:${authzPort}` },
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const h = await (await fetch(`http://127.0.0.1:${zPort}/health`)).json();
assert.equal(h.plane, 'zapier');
const portal = await fetch(`http://127.0.0.1:${zPort}/portal`);
assert.equal(portal.status, 404);
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.statement.get' }),
});
assert.equal(deny.status, 403);
} finally {
z.kill('SIGTERM');
authz.kill('SIGTERM');
}
});

View file

@ -15,6 +15,11 @@
"services": {
"nats": { "min": 1, "max": 1, "keepFloor": false, "enabled": true, "managed": false },
"zappier-edge": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"access-authz": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"access-web": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
"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 },
"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

@ -0,0 +1,27 @@
{
"id": "access-api",
"title": "Direct customer API access",
"kind": "http",
"package": "verae-access-api",
"role": "access-api",
"managed": true,
"runtime": "HTTP :3022 plane=api",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3022 },
"spawn": {
"cwd": "../verae-access-api",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3022",
"AUTHZ_URL": "http://127.0.0.1:3020",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"ZAPPIER_UPSTREAM": "http://127.0.0.1:3100",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {
"in": [],
"out": ["verae.access.authz.check", "verae.billing.statement.get", "verae.zapier.jobs.watch"]
}
}

View file

@ -0,0 +1,24 @@
{
"id": "access-authz",
"title": "Access-plane authorization",
"kind": "http",
"package": "verae-access-authz",
"role": "access-authz",
"managed": true,
"runtime": "HTTP :3020 + NATS verae.access.authz.check",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3020 },
"spawn": {
"cwd": "../verae-access-authz",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3020",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {
"in": ["verae.access.authz.check"],
"out": ["verae.access.authz.deny"]
}
}

View file

@ -0,0 +1,26 @@
{
"id": "access-leaf",
"title": "Server-to-server NATS leaf access",
"kind": "http",
"package": "verae-access-leaf",
"role": "access-leaf",
"managed": true,
"runtime": "HTTP :3023 + NATS verae.access.leaf.in",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3023 },
"spawn": {
"cwd": "../verae-access-leaf",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3023",
"AUTHZ_URL": "http://127.0.0.1:3020",
"LEAF_TOKEN": "leaf-dev-token",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {
"in": ["verae.access.leaf.in"],
"out": ["verae.access.authz.check", "verae.archive.put", "verae.archive.query"]
}
}

View file

@ -0,0 +1,26 @@
{
"id": "access-web",
"title": "Direct customer web access",
"kind": "http",
"package": "verae-access-web",
"role": "access-web",
"managed": true,
"runtime": "HTTP :3021 plane=web",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3021 },
"spawn": {
"cwd": "../verae-access-web",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3021",
"AUTHZ_URL": "http://127.0.0.1:3020",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {
"in": [],
"out": ["verae.access.authz.check", "verae.billing.statement.get", "verae.billing.balance.adjust"]
}
}

View file

@ -0,0 +1,26 @@
{
"id": "access-zapier",
"title": "Zapier Platform access plane",
"kind": "http",
"package": "verae-access-zapier",
"role": "access-zapier",
"managed": true,
"runtime": "HTTP :3024 plane=zapier (no portal)",
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
"ports": { "healthBase": 3024 },
"spawn": {
"cwd": "../verae-access-zapier",
"command": "node",
"args": ["src/server.js"]
},
"env": {
"PORT": "3024",
"AUTHZ_URL": "http://127.0.0.1:3020",
"ZAPPIER_UPSTREAM": "http://127.0.0.1:3100",
"NATS_URL": "nats://127.0.0.1:4222"
},
"nats": {
"in": [],
"out": ["verae.access.authz.check", "verae.zapier.jobs.watch"]
}
}

View file

@ -17,7 +17,8 @@
"PORT": "3011",
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222"
"NATS_URL": "nats://127.0.0.1:4222",
"AUTHZ_URL": "http://127.0.0.1:3020"
},
"nats": {
"in": [],

View file

@ -17,7 +17,8 @@
"PORT": "3012",
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222"
"NATS_URL": "nats://127.0.0.1:4222",
"AUTHZ_URL": "http://127.0.0.1:3020"
},
"nats": { "in": [], "out": ["verae.billing.statement.get"] }
}

View file

@ -19,7 +19,8 @@
"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"
"NATS_URL": "nats://127.0.0.1:4222",
"AUTHZ_URL": "http://127.0.0.1:3020"
},
"nats": {
"in": [],

View file

@ -53,6 +53,11 @@ describe('verae-fleet', () => {
for (const need of [
'nats',
'zappier-edge',
'access-authz',
'access-web',
'access-api',
'access-leaf',
'access-zapier',
'account-balance',
'customer-service',
'sales-pricing',

View file

@ -10,6 +10,8 @@ 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 |
| **account-balance** | NATS prepaid balances (SoT) | `:3010` |
| **customer-service** | CS credit additions + review | `:3011` |

View file

@ -51,9 +51,89 @@ services:
PORT: "3000"
ZAPPIER_UPSTREAM: http://middleware:3100
NATS_URL: nats://nats1:4222
AUTHZ_URL: http://access-authz:3020
ports:
- "3000:3000"
depends_on: [nats1, account-balance]
depends_on: [nats1, account-balance, access-authz]
access-authz:
<<: *node
image: node:22-alpine
working_dir: /app
volumes:
- ${MONOREPO:-..}/packages/verae-access-authz:/app
command: ["node", "src/server.js"]
environment:
PORT: "3020"
NATS_URL: nats://nats1:4222
ports:
- "3020:3020"
depends_on: [nats1]
access-web:
<<: *node
image: node:22-alpine
working_dir: /app
volumes:
- ${MONOREPO:-..}/packages/verae-access-web:/app
command: ["node", "src/server.js"]
environment:
PORT: "3021"
AUTHZ_URL: http://access-authz:3020
ACCOUNT_BALANCE_URL: http://account-balance:3010
NATS_URL: nats://nats1:4222
ports:
- "3021:3021"
depends_on: [access-authz, account-balance]
access-api:
<<: *node
image: node:22-alpine
working_dir: /app
volumes:
- ${MONOREPO:-..}/packages/verae-access-api:/app
command: ["node", "src/server.js"]
environment:
PORT: "3022"
AUTHZ_URL: http://access-authz:3020
ACCOUNT_BALANCE_URL: http://account-balance:3010
ZAPPIER_UPSTREAM: http://middleware:3100
NATS_URL: nats://nats1:4222
ports:
- "3022:3022"
depends_on: [access-authz, middleware]
access-leaf:
<<: *node
image: node:22-alpine
working_dir: /app
volumes:
- ${MONOREPO:-..}/packages/verae-access-leaf:/app
command: ["node", "src/server.js"]
environment:
PORT: "3023"
AUTHZ_URL: http://access-authz:3020
LEAF_TOKEN: leaf-dev-token
NATS_URL: nats://nats1:4222
ports:
- "3023:3023"
depends_on: [access-authz, nats1]
access-zapier:
<<: *node
image: node:22-alpine
working_dir: /app
volumes:
- ${MONOREPO:-..}/packages/verae-access-zapier:/app
command: ["node", "src/server.js"]
environment:
PORT: "3024"
AUTHZ_URL: http://access-authz:3020
ZAPPIER_UPSTREAM: http://middleware:3100
NATS_URL: nats://nats1:4222
ports:
- "3024:3024"
depends_on: [access-authz, middleware]
account-balance:
<<: *node
@ -81,6 +161,7 @@ services:
ZAPPIER_ADMIN_URL: http://zappier-edge:3000
ACCOUNT_BALANCE_URL: http://account-balance:3010
NATS_URL: nats://nats1:4222
AUTHZ_URL: http://access-authz:3020
ports:
- "3011:3011"
depends_on: [zappier-edge, account-balance, nats1]

View file

@ -1,20 +1,44 @@
export const SUBJECTS = {
STATEMENT_GET: 'verae.billing.statement.get',
BALANCE_ADJUST: 'verae.billing.balance.adjust',
AUTHZ_CHECK: 'verae.access.authz.check',
};
const PLANE = 'staff';
async function authz(subject, payload) {
const http = process.env.AUTHZ_URL;
if (!http && !process.env.NATS_URL) return { allow: true, subject };
if (http) {
const r = await fetch(`${http.replace(/\/$/, '')}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: PLANE, subject, kind: payload?.kind, payload, principal: payload?.agent }),
});
return r.json();
}
return { allow: true, subject };
}
export async function billingRequest(subject, payload) {
const decision = await authz(subject, payload);
if (decision && decision.allow === false) {
const err = new Error(decision.reason || 'denied');
err.status = 403;
err.decision = decision;
throw err;
}
const url = process.env.NATS_URL;
if (!url) return null;
try {
const { connect, StringCodec } = await import('nats');
const nc = await connect({ servers: url.split(','), name: 'zappier-customer-service' });
const sc = StringCodec();
const m = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 2000 });
const m = await nc.request(subject, sc.encode(JSON.stringify({ ...payload, plane: PLANE })), { timeout: 2000 });
const out = JSON.parse(sc.decode(m.data) || '{}');
await nc.close();
return out;
} catch {
} catch (err) {
if (err.status === 403) throw err;
return null;
}
}

View file

@ -71,7 +71,7 @@ const server = http.createServer(async (req, res) => {
}
json(404, { error: 'not found' });
} catch (err) {
json(502, { error: err.message });
json(err.status === 403 ? 403 : 502, { error: err.message, ...(err.decision || {}) });
}
});

View file

@ -1,17 +1,36 @@
export const SUBJECTS = { STATEMENT_GET: 'verae.billing.statement.get' };
const PLANE = 'staff';
async function authz(subject) {
const http = process.env.AUTHZ_URL;
if (!http) return { allow: true, subject };
const r = await fetch(`${http.replace(/\/$/, '')}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane: PLANE, subject }),
});
return r.json();
}
export async function billingRequest(subject, payload) {
const decision = await authz(subject);
if (decision && decision.allow === false) {
const err = new Error(decision.reason || 'denied');
err.status = 403;
throw err;
}
const url = process.env.NATS_URL;
if (!url) return null;
try {
const { connect, StringCodec } = await import('nats');
const nc = await connect({ servers: url.split(','), name: 'zappier-sales-pricing' });
const sc = StringCodec();
const m = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 2000 });
const m = await nc.request(subject, sc.encode(JSON.stringify({ ...payload, plane: PLANE })), { timeout: 2000 });
const out = JSON.parse(sc.decode(m.data) || '{}');
await nc.close();
return out;
} catch {
} catch (err) {
if (err.status === 403) throw err;
return null;
}
}

View file

@ -67,7 +67,7 @@ const server = http.createServer(async (req, res) => {
}
json(404, { error: 'not found' });
} catch (err) {
json(502, { error: err.message });
json(err.status === 403 ? 403 : 502, { error: err.message });
}
});

View file

@ -248,14 +248,17 @@ export function adminRouter(
agent: typeof agent === 'string' ? agent : 'admin',
});
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, rec);
void natsAdjust({
customerId,
cents: delta,
reason: rec.reason,
agent: rec.agent,
kind: 'credit',
});
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, rec, 'staff');
void natsAdjust(
{
customerId,
cents: delta,
reason: rec.reason,
agent: rec.agent,
kind: 'credit',
},
'staff',
);
res.status(201).json(rec);
});
@ -270,7 +273,7 @@ export function adminRouter(
res.status(404).json({ error: 'customer not found' });
return;
}
const fromNats = await natsStatement(customer.id);
const fromNats = await natsStatement(customer.id, 'staff');
if (fromNats) {
res.json({ ...fromNats, name: customer.name, tierId: customer.tierId, source: 'nats' });
return;

View file

@ -103,7 +103,7 @@ export function buildApp(deps: AppDeps = {}): {
const items: StoredItem[] = [];
const credits = new CreditLedger();
const onUsage = (e: { customerId: string; endpointId: string; cents: number }) =>
natsPublish(BILLING_SUBJECTS.USAGE_RECORDED, { ...e, at: new Date().toISOString() });
natsPublish(BILLING_SUBJECTS.USAGE_RECORDED, { ...e, at: new Date().toISOString() }, 'api');
const hashIndex = new Map<
string,
{ jobId: string; sha256: string; data?: string; timestamp: string }

View file

@ -1,4 +1,6 @@
/** Internal NATS billing bus. No-op when NATS_URL is unset (tests). */
/** Internal NATS billing bus. No-op when NATS_URL is unset (tests).
* When NATS is on, every hop must pass verae.access.authz.check for a plane.
*/
export const BILLING_SUBJECTS = {
STATEMENT_GET: 'verae.billing.statement.get',
@ -8,6 +10,9 @@ export const BILLING_SUBJECTS = {
CREDIT_APPLIED: 'verae.billing.credit.applied',
};
export const AUTHZ_CHECK = 'verae.access.authz.check';
export type AccessPlane = 'zapier' | 'web' | 'api' | 'leaf' | 'staff';
export type BillingStatement = {
customerId: string;
prepaidCents: number;
@ -46,28 +51,79 @@ function decode(buf: Uint8Array): unknown {
return JSON.parse(new TextDecoder().decode(buf) || '{}');
}
export async function natsStatement(customerId: string): Promise<BillingStatement | null> {
async function authzAllow(
plane: AccessPlane,
subject: string,
extra: { principal?: string; kind?: string } = {},
): Promise<boolean> {
const http = process.env.AUTHZ_URL;
if (!http && !(await nc())) return true;
try {
const c = await nc();
if (c) {
const m = await c.request(AUTHZ_CHECK, encode({ plane, subject, ...extra }), { timeout: 1500 });
const d = decode(m.data) as { allow?: boolean };
return Boolean(d.allow);
}
if (http) {
const r = await fetch(`${http.replace(/\/$/, '')}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane, subject, ...extra }),
});
const d = (await r.json()) as { allow?: boolean };
return Boolean(d.allow);
}
} catch {
return false;
}
return true;
}
export async function natsStatement(
customerId: string,
plane: AccessPlane = 'web',
): Promise<BillingStatement | null> {
const c = await nc();
if (!c) return null;
const m = await c.request(BILLING_SUBJECTS.STATEMENT_GET, encode({ customerId }), { timeout: 2000 });
if (!(await authzAllow(plane, BILLING_SUBJECTS.STATEMENT_GET, { principal: customerId }))) return null;
const m = await c.request(
BILLING_SUBJECTS.STATEMENT_GET,
encode({ customerId, plane }),
{ timeout: 2000 },
);
return decode(m.data) as BillingStatement;
}
export async function natsAdjust(payload: {
customerId: string;
cents: number;
reason: string;
agent: string;
kind?: string;
}): Promise<unknown | null> {
export async function natsAdjust(
payload: {
customerId: string;
cents: number;
reason: string;
agent: string;
kind?: string;
},
plane: AccessPlane = 'staff',
): Promise<unknown | null> {
const c = await nc();
if (!c) return null;
const m = await c.request(BILLING_SUBJECTS.BALANCE_ADJUST, encode(payload), { timeout: 2000 });
if (
!(await authzAllow(plane, BILLING_SUBJECTS.BALANCE_ADJUST, {
principal: payload.agent,
kind: payload.kind,
}))
) {
return null;
}
const m = await c.request(BILLING_SUBJECTS.BALANCE_ADJUST, encode({ ...payload, plane }), { timeout: 2000 });
return decode(m.data);
}
export function natsPublish(subject: string, payload: unknown): void {
void nc().then((c) => {
if (c) c.publish(subject, encode(payload));
});
export function natsPublish(subject: string, payload: unknown, plane: AccessPlane = 'api'): void {
void (async () => {
const c = await nc();
if (!c) return;
if (!(await authzAllow(plane, subject))) return;
c.publish(subject, encode({ ...(payload as object), plane }));
})();
}

View file

@ -203,7 +203,7 @@ export function portalRouter(deps: PortalDeps): Router {
});
router.get('/statement', async (req, res) => {
const fromNats = await natsStatement(req.customer!.id);
const fromNats = await natsStatement(req.customer!.id, 'web');
if (fromNats) {
res.json({ ...fromNats, source: 'nats' });
return;
@ -286,18 +286,25 @@ export function portalRouter(deps: PortalDeps): Router {
};
if (result.creditedCents > 0) save(deps, customer);
if (result.creditedCents > 0) {
natsPublish(BILLING_SUBJECTS.PAYMENT_RECORDED, {
customerId: customer.id,
cents: result.creditedCents,
reason: 'reload',
});
void natsAdjust({
customerId: customer.id,
cents: result.creditedCents,
reason: 'reload',
agent: 'portal',
kind: 'payment',
});
natsPublish(
BILLING_SUBJECTS.PAYMENT_RECORDED,
{
customerId: customer.id,
cents: result.creditedCents,
reason: 'reload',
},
'web',
);
void natsAdjust(
{
customerId: customer.id,
cents: result.creditedCents,
reason: 'reload',
agent: 'portal',
kind: 'reload',
},
'web',
);
}
res.json({
balanceCents: customer.balanceCents,