From 1b199ca4d4caec022ba867af9b062feaf745c510 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 16:05:05 -0400 Subject: [PATCH] Separate Zapier, web, API, and leaf access planes with NATS authz 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). --- docs/modules/README.md | 9 ++ docs/modules/verae-access-api/server.md | 41 ++++++++ docs/modules/verae-access-authz/client.md | 42 ++++++++ docs/modules/verae-access-authz/policy.md | 42 ++++++++ docs/modules/verae-access-authz/server.md | 44 +++++++++ docs/modules/verae-access-authz/subjects.md | 43 ++++++++ docs/modules/verae-access-leaf/server.md | 44 +++++++++ docs/modules/verae-access-web/gate.md | 42 ++++++++ docs/modules/verae-access-web/server.md | 41 ++++++++ docs/modules/verae-access-zapier/server.md | 41 ++++++++ .../zappier-customer-service/nats-billing.md | 5 +- .../zappier-sales-pricing/nats-billing.md | 5 +- docs/modules/zappier/admin.md | 2 +- docs/modules/zappier/billing-nats.md | 34 ++++--- docs/modules/zappier/portal.md | 2 +- docs/sphinx/index.rst | 14 +++ .../sphinx/modules/verae-access-api/server.md | 41 ++++++++ .../modules/verae-access-api/server.rst | 7 ++ .../modules/verae-access-authz/client.md | 42 ++++++++ .../modules/verae-access-authz/client.rst | 7 ++ .../modules/verae-access-authz/policy.md | 42 ++++++++ .../modules/verae-access-authz/policy.rst | 7 ++ .../modules/verae-access-authz/server.md | 44 +++++++++ .../modules/verae-access-authz/server.rst | 7 ++ .../modules/verae-access-authz/subjects.md | 43 ++++++++ .../modules/verae-access-authz/subjects.rst | 7 ++ .../modules/verae-access-leaf/server.md | 44 +++++++++ .../modules/verae-access-leaf/server.rst | 7 ++ docs/sphinx/modules/verae-access-web/gate.md | 42 ++++++++ docs/sphinx/modules/verae-access-web/gate.rst | 7 ++ .../sphinx/modules/verae-access-web/server.md | 41 ++++++++ .../modules/verae-access-web/server.rst | 7 ++ .../modules/verae-access-zapier/server.md | 41 ++++++++ .../modules/verae-access-zapier/server.rst | 7 ++ .../zappier-customer-service/nats-billing.md | 5 +- .../zappier-sales-pricing/nats-billing.md | 5 +- docs/sphinx/modules/zappier/admin.md | 2 +- docs/sphinx/modules/zappier/billing-nats.md | 34 ++++--- docs/sphinx/modules/zappier/portal.md | 2 +- docs/sphinx/repos/verae-access-api.md | 11 +++ docs/sphinx/repos/verae-access-authz.md | 34 +++++++ docs/sphinx/repos/verae-access-leaf.md | 13 +++ docs/sphinx/repos/verae-access-web.md | 15 +++ docs/sphinx/repos/verae-access-zapier.md | 9 ++ packages/docs-master/modules-and-nats.md | 12 ++- .../modules/verae-access-api/NATS.md | 3 + .../modules/verae-access-api/SUMMARY.md | 3 + .../modules/verae-access-authz/NATS.md | 3 + .../modules/verae-access-authz/SUMMARY.md | 3 + .../modules/verae-access-leaf/NATS.md | 3 + .../modules/verae-access-leaf/SUMMARY.md | 3 + .../modules/verae-access-web/NATS.md | 3 + .../modules/verae-access-web/SUMMARY.md | 3 + .../modules/verae-access-zapier/NATS.md | 3 + .../modules/verae-access-zapier/SUMMARY.md | 3 + packages/overview/01-system.md | 8 +- packages/overview/02-modules-and-repos.md | 5 + packages/overview/03-nats-cluster.md | 2 +- packages/overview/06-address-routing.md | 8 +- packages/overview/08-diagrams.md | 33 ++++--- packages/overview/INDEX.md | 2 +- packages/verae-access-api/NATS.md | 3 + packages/verae-access-api/README.md | 11 +++ packages/verae-access-api/package.json | 11 +++ packages/verae-access-api/src/server.js | 59 +++++++++++ packages/verae-access-api/test/health.test.js | 37 +++++++ packages/verae-access-authz/NATS.md | 11 +++ packages/verae-access-authz/README.md | 34 +++++++ packages/verae-access-authz/package-lock.json | 46 +++++++++ packages/verae-access-authz/package.json | 14 +++ packages/verae-access-authz/src/client.js | 38 +++++++ packages/verae-access-authz/src/policy.js | 98 +++++++++++++++++++ packages/verae-access-authz/src/server.js | 83 ++++++++++++++++ packages/verae-access-authz/src/subjects.js | 36 +++++++ .../verae-access-authz/test/health.test.js | 35 +++++++ .../verae-access-authz/test/policy.test.js | 66 +++++++++++++ packages/verae-access-leaf/NATS.md | 9 ++ packages/verae-access-leaf/README.md | 13 +++ packages/verae-access-leaf/package-lock.json | 46 +++++++++ packages/verae-access-leaf/package.json | 14 +++ packages/verae-access-leaf/src/server.js | 94 ++++++++++++++++++ .../verae-access-leaf/test/health.test.js | 63 ++++++++++++ packages/verae-access-web/NATS.md | 3 + packages/verae-access-web/README.md | 15 +++ packages/verae-access-web/package-lock.json | 46 +++++++++ packages/verae-access-web/package.json | 14 +++ packages/verae-access-web/src/gate.js | 50 ++++++++++ packages/verae-access-web/src/server.js | 38 +++++++ packages/verae-access-web/test/health.test.js | 52 ++++++++++ packages/verae-access-zapier/NATS.md | 3 + packages/verae-access-zapier/README.md | 9 ++ packages/verae-access-zapier/package.json | 11 +++ packages/verae-access-zapier/src/server.js | 55 +++++++++++ .../verae-access-zapier/test/health.test.js | 39 ++++++++ packages/verae-fleet/fleet.json | 5 + packages/verae-fleet/services/access-api.json | 27 +++++ .../verae-fleet/services/access-authz.json | 24 +++++ .../verae-fleet/services/access-leaf.json | 26 +++++ packages/verae-fleet/services/access-web.json | 26 +++++ .../verae-fleet/services/access-zapier.json | 26 +++++ .../services/customer-service.json | 3 +- .../verae-fleet/services/sales-pricing.json | 3 +- .../verae-fleet/services/zappier-edge.json | 3 +- packages/verae-fleet/test/fleet.test.js | 5 + packages/verae-ops/GETTING-STARTED.md | 2 + packages/verae-ops/docker-compose.yml | 83 +++++++++++++++- .../src/nats-billing.js | 28 +++++- .../zappier-customer-service/src/server.js | 2 +- .../zappier-sales-pricing/src/nats-billing.js | 23 ++++- packages/zappier-sales-pricing/src/server.js | 2 +- packages/zappier/src/admin.ts | 21 ++-- packages/zappier/src/app.ts | 2 +- packages/zappier/src/billing-nats.ts | 86 +++++++++++++--- packages/zappier/src/portal.ts | 33 ++++--- scripts/build-docs-site.py | 15 +++ scripts/gen-module-docs.py | 10 ++ scripts/push-module-repos.sh | 10 ++ 117 files changed, 2640 insertions(+), 105 deletions(-) create mode 100644 docs/modules/verae-access-api/server.md create mode 100644 docs/modules/verae-access-authz/client.md create mode 100644 docs/modules/verae-access-authz/policy.md create mode 100644 docs/modules/verae-access-authz/server.md create mode 100644 docs/modules/verae-access-authz/subjects.md create mode 100644 docs/modules/verae-access-leaf/server.md create mode 100644 docs/modules/verae-access-web/gate.md create mode 100644 docs/modules/verae-access-web/server.md create mode 100644 docs/modules/verae-access-zapier/server.md create mode 100644 docs/sphinx/modules/verae-access-api/server.md create mode 100644 docs/sphinx/modules/verae-access-api/server.rst create mode 100644 docs/sphinx/modules/verae-access-authz/client.md create mode 100644 docs/sphinx/modules/verae-access-authz/client.rst create mode 100644 docs/sphinx/modules/verae-access-authz/policy.md create mode 100644 docs/sphinx/modules/verae-access-authz/policy.rst create mode 100644 docs/sphinx/modules/verae-access-authz/server.md create mode 100644 docs/sphinx/modules/verae-access-authz/server.rst create mode 100644 docs/sphinx/modules/verae-access-authz/subjects.md create mode 100644 docs/sphinx/modules/verae-access-authz/subjects.rst create mode 100644 docs/sphinx/modules/verae-access-leaf/server.md create mode 100644 docs/sphinx/modules/verae-access-leaf/server.rst create mode 100644 docs/sphinx/modules/verae-access-web/gate.md create mode 100644 docs/sphinx/modules/verae-access-web/gate.rst create mode 100644 docs/sphinx/modules/verae-access-web/server.md create mode 100644 docs/sphinx/modules/verae-access-web/server.rst create mode 100644 docs/sphinx/modules/verae-access-zapier/server.md create mode 100644 docs/sphinx/modules/verae-access-zapier/server.rst create mode 100644 docs/sphinx/repos/verae-access-api.md create mode 100644 docs/sphinx/repos/verae-access-authz.md create mode 100644 docs/sphinx/repos/verae-access-leaf.md create mode 100644 docs/sphinx/repos/verae-access-web.md create mode 100644 docs/sphinx/repos/verae-access-zapier.md create mode 100644 packages/docs-master/modules/verae-access-api/NATS.md create mode 100644 packages/docs-master/modules/verae-access-api/SUMMARY.md create mode 100644 packages/docs-master/modules/verae-access-authz/NATS.md create mode 100644 packages/docs-master/modules/verae-access-authz/SUMMARY.md create mode 100644 packages/docs-master/modules/verae-access-leaf/NATS.md create mode 100644 packages/docs-master/modules/verae-access-leaf/SUMMARY.md create mode 100644 packages/docs-master/modules/verae-access-web/NATS.md create mode 100644 packages/docs-master/modules/verae-access-web/SUMMARY.md create mode 100644 packages/docs-master/modules/verae-access-zapier/NATS.md create mode 100644 packages/docs-master/modules/verae-access-zapier/SUMMARY.md create mode 100644 packages/verae-access-api/NATS.md create mode 100644 packages/verae-access-api/README.md create mode 100644 packages/verae-access-api/package.json create mode 100644 packages/verae-access-api/src/server.js create mode 100644 packages/verae-access-api/test/health.test.js create mode 100644 packages/verae-access-authz/NATS.md create mode 100644 packages/verae-access-authz/README.md create mode 100644 packages/verae-access-authz/package-lock.json create mode 100644 packages/verae-access-authz/package.json create mode 100644 packages/verae-access-authz/src/client.js create mode 100644 packages/verae-access-authz/src/policy.js create mode 100644 packages/verae-access-authz/src/server.js create mode 100644 packages/verae-access-authz/src/subjects.js create mode 100644 packages/verae-access-authz/test/health.test.js create mode 100644 packages/verae-access-authz/test/policy.test.js create mode 100644 packages/verae-access-leaf/NATS.md create mode 100644 packages/verae-access-leaf/README.md create mode 100644 packages/verae-access-leaf/package-lock.json create mode 100644 packages/verae-access-leaf/package.json create mode 100644 packages/verae-access-leaf/src/server.js create mode 100644 packages/verae-access-leaf/test/health.test.js create mode 100644 packages/verae-access-web/NATS.md create mode 100644 packages/verae-access-web/README.md create mode 100644 packages/verae-access-web/package-lock.json create mode 100644 packages/verae-access-web/package.json create mode 100644 packages/verae-access-web/src/gate.js create mode 100644 packages/verae-access-web/src/server.js create mode 100644 packages/verae-access-web/test/health.test.js create mode 100644 packages/verae-access-zapier/NATS.md create mode 100644 packages/verae-access-zapier/README.md create mode 100644 packages/verae-access-zapier/package.json create mode 100644 packages/verae-access-zapier/src/server.js create mode 100644 packages/verae-access-zapier/test/health.test.js create mode 100644 packages/verae-fleet/services/access-api.json create mode 100644 packages/verae-fleet/services/access-authz.json create mode 100644 packages/verae-fleet/services/access-leaf.json create mode 100644 packages/verae-fleet/services/access-web.json create mode 100644 packages/verae-fleet/services/access-zapier.json diff --git a/docs/modules/README.md b/docs/modules/README.md index 19309bf..75c84d7 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -126,3 +126,12 @@ One MD file per source module. - [zappier-sales-pricing/server](zappier-sales-pricing/server.md) - [zappier-accounting-export/nats-billing](zappier-accounting-export/nats-billing.md) - [zappier-accounting-export/server](zappier-accounting-export/server.md) +- [verae-access-authz/client](verae-access-authz/client.md) +- [verae-access-authz/policy](verae-access-authz/policy.md) +- [verae-access-authz/server](verae-access-authz/server.md) +- [verae-access-authz/subjects](verae-access-authz/subjects.md) +- [verae-access-web/gate](verae-access-web/gate.md) +- [verae-access-web/server](verae-access-web/server.md) +- [verae-access-api/server](verae-access-api/server.md) +- [verae-access-leaf/server](verae-access-leaf/server.md) +- [verae-access-zapier/server](verae-access-zapier/server.md) diff --git a/docs/modules/verae-access-api/server.md b/docs/modules/verae-access-api/server.md new file mode 100644 index 0000000..385849d --- /dev/null +++ b/docs/modules/verae-access-api/server.md @@ -0,0 +1,41 @@ +# `verae-access-api/server` + +**Package:** `verae-access-api` +**Source:** `packages/verae-access-api/src/server.js` +**Lines:** 60 + +## What this module is + +Implementation module in `verae-access-api`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, principal, extra = {}` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` + +## Call graph (identifiers invoked) + +`replace`, `check`, `fetch`, `stringify`, `json`, `createServer`, `async`, `writeHead`, `end`, `match`, `push`, `concat`, `listen`, `write` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-authz/client.md b/docs/modules/verae-access-authz/client.md new file mode 100644 index 0000000..ae42cbc --- /dev/null +++ b/docs/modules/verae-access-authz/client.md @@ -0,0 +1,42 @@ +# `verae-access-authz/client` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/client.js` +**Lines:** 39 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`authorizeThenRequest`, `authorizeThenPublish` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `authorizeThenRequest` | `nc, sc, { plane, subject, principal, payload, timeout = 2000 }` | — | `unknown` | see Call graph | +| | _Call verae.access.authz.check then the internal subject. Fail closed._ | | | | +| `authorizeThenPublish` | `nc, sc, { plane, subject, principal, payload, timeout = 2000 }` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `./subjects.js` + +## Call graph (identifiers invoked) + +`authorizeThenRequest`, `request`, `encode`, `stringify`, `parse`, `decode`, `authorizeThenPublish`, `publish` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-authz/policy.md b/docs/modules/verae-access-authz/policy.md new file mode 100644 index 0000000..ddeee18 --- /dev/null +++ b/docs/modules/verae-access-authz/policy.md @@ -0,0 +1,42 @@ +# `verae-access-authz/policy` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/policy.js` +**Lines:** 99 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`POLICY`, `authorize` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `prefixAllowed` | `allow, subject` | — | `unknown` | see Call graph | +| `authorize` | `req` | — | `unknown` | see Call graph | +| `deny` | `plane, subject, reason` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `./subjects.js` + +## Call graph (identifiers invoked) + +`workers`, `freeze`, `web`, `prefixAllowed`, `some`, `endsWith`, `startsWith`, `authorize`, `parseAddress`, `deny`, `includes`, `mismatch` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-authz/server.md b/docs/modules/verae-access-authz/server.md new file mode 100644 index 0000000..632d2de --- /dev/null +++ b/docs/modules/verae-access-authz/server.md @@ -0,0 +1,44 @@ +# `verae-access-authz/server` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/server.js` +**Lines:** 84 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `startNats` | `(none)` | — | `unknown` | see Call graph | +| `readBody` | `req` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` +- `./policy.js` +- `./subjects.js` + +## Call graph (identifiers invoked) + +`startNats`, `import`, `connect`, `split`, `subscribe`, `async`, `parse`, `decode`, `authorize`, `publish`, `encode`, `stringify`, `toISOString`, `respond`, `write`, `readBody`, `on`, `push`, `resolve`, `concat`, `toString`, `createServer`, `writeHead`, `end`, `json`, `listen` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-authz/subjects.md b/docs/modules/verae-access-authz/subjects.md new file mode 100644 index 0000000..fc6bdf2 --- /dev/null +++ b/docs/modules/verae-access-authz/subjects.md @@ -0,0 +1,43 @@ +# `verae-access-authz/subjects` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/subjects.js` +**Lines:** 37 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`PLANES`, `SUBJECTS`, `accessAddress`, `parseAddress` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `accessAddress` | `plane, internalSubject` | — | `unknown` | see Call graph | +| | _verae.billing.statement.get + plane web → verae.access.web.billing.statement.get_ | | | | +| `parseAddress` | `subject, planeHint` | — | `unknown` | see Call graph | +| | _Parse an access or internal subject into { plane, internal }._ | | | | + +## What it imports / requires + +_No imports detected._ + +## Call graph (identifiers invoked) + +`ingress`, `internal`, `freeze`, `accessAddress`, `includes`, `startsWith`, `slice`, `parseAddress`, `match` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-leaf/server.md b/docs/modules/verae-access-leaf/server.md new file mode 100644 index 0000000..d7b1fba --- /dev/null +++ b/docs/modules/verae-access-leaf/server.md @@ -0,0 +1,44 @@ +# `verae-access-leaf/server` + +**Package:** `verae-access-leaf` +**Source:** `packages/verae-access-leaf/src/server.js` +**Lines:** 95 + +## What this module is + +Implementation module in `verae-access-leaf`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, principal` | — | `unknown` | see Call graph | +| `authorizedToken` | `got` | — | `unknown` | see Call graph | +| `forward` | `body` | — | `unknown` | see Call graph | +| `startNats` | `(none)` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` + +## Call graph (identifiers invoked) + +`subjects`, `replace`, `check`, `fetch`, `stringify`, `json`, `authorizedToken`, `forward`, `import`, `connect`, `split`, `publish`, `encode`, `flush`, `close`, `startNats`, `async`, `subscribe`, `parse`, `decode`, `respond`, `write`, `createServer`, `writeHead`, `end`, `push`, `concat`, `toString`, `listen` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-web/gate.md b/docs/modules/verae-access-web/gate.md new file mode 100644 index 0000000..484e01e --- /dev/null +++ b/docs/modules/verae-access-web/gate.md @@ -0,0 +1,42 @@ +# `verae-access-web/gate` + +**Package:** `verae-access-web` +**Source:** `packages/verae-access-web/src/gate.js` +**Lines:** 51 + +## What this module is + +Implementation module in `verae-access-web`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`PLANE`, `AUTHZ_CHECK`, `check`, `statement`, `reload` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, { principal, kind, payload } = {}` | — | `unknown` | see Call graph | +| `statement` | `customerId, principal` | — | `unknown` | see Call graph | +| `reload` | `customerId, cents, principal` | — | `unknown` | see Call graph | + +## What it imports / requires + +_No imports detected._ + +## Call graph (identifiers invoked) + +`replace`, `check`, `fetch`, `stringify`, `json`, `statement`, `import`, `connect`, `split`, `request`, `encode`, `parse`, `decode`, `close`, `reload` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-web/server.md b/docs/modules/verae-access-web/server.md new file mode 100644 index 0000000..c659689 --- /dev/null +++ b/docs/modules/verae-access-web/server.md @@ -0,0 +1,41 @@ +# `verae-access-web/server` + +**Package:** `verae-access-web` +**Source:** `packages/verae-access-web/src/server.js` +**Lines:** 39 + +## What this module is + +Implementation module in `verae-access-web`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` +- `./gate.js` + +## Call graph (identifiers invoked) + +`createServer`, `async`, `writeHead`, `end`, `stringify`, `json`, `match`, `statement`, `push`, `parse`, `concat`, `toString`, `reload`, `listen`, `write` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/verae-access-zapier/server.md b/docs/modules/verae-access-zapier/server.md new file mode 100644 index 0000000..258cb8b --- /dev/null +++ b/docs/modules/verae-access-zapier/server.md @@ -0,0 +1,41 @@ +# `verae-access-zapier/server` + +**Package:** `verae-access-zapier` +**Source:** `packages/verae-access-zapier/src/server.js` +**Lines:** 56 + +## What this module is + +Implementation module in `verae-access-zapier`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, principal` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` + +## Call graph (identifiers invoked) + +`replace`, `check`, `fetch`, `stringify`, `json`, `createServer`, `async`, `writeHead`, `end`, `startsWith`, `push`, `concat`, `listen`, `write` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/modules/zappier-customer-service/nats-billing.md b/docs/modules/zappier-customer-service/nats-billing.md index aed55e1..178a917 100644 --- a/docs/modules/zappier-customer-service/nats-billing.md +++ b/docs/modules/zappier-customer-service/nats-billing.md @@ -2,7 +2,7 @@ **Package:** `zappier-customer-service` **Source:** `packages/zappier-customer-service/src/nats-billing.js` -**Lines:** 21 +**Lines:** 45 ## What this module is @@ -20,6 +20,7 @@ _None extracted._ | Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | |------|------------|---------------------|---------|-----------------------------| +| `authz` | `subject, payload` | — | `unknown` | see Call graph | | `billingRequest` | `subject, payload` | — | `unknown` | see Call graph | ## What it imports / requires @@ -28,7 +29,7 @@ _No imports detected._ ## Call graph (identifiers invoked) -`billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `stringify`, `parse`, `decode`, `close` +`authz`, `fetch`, `replace`, `stringify`, `json`, `billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `parse`, `decode`, `close` Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. diff --git a/docs/modules/zappier-sales-pricing/nats-billing.md b/docs/modules/zappier-sales-pricing/nats-billing.md index dcd029e..8c31d6b 100644 --- a/docs/modules/zappier-sales-pricing/nats-billing.md +++ b/docs/modules/zappier-sales-pricing/nats-billing.md @@ -2,7 +2,7 @@ **Package:** `zappier-sales-pricing` **Source:** `packages/zappier-sales-pricing/src/nats-billing.js` -**Lines:** 18 +**Lines:** 37 ## What this module is @@ -20,6 +20,7 @@ _None extracted._ | Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | |------|------------|---------------------|---------|-----------------------------| +| `authz` | `subject` | — | `unknown` | see Call graph | | `billingRequest` | `subject, payload` | — | `unknown` | see Call graph | ## What it imports / requires @@ -28,7 +29,7 @@ _No imports detected._ ## Call graph (identifiers invoked) -`billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `stringify`, `parse`, `decode`, `close` +`authz`, `fetch`, `replace`, `stringify`, `json`, `billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `parse`, `decode`, `close` Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. diff --git a/docs/modules/zappier/admin.md b/docs/modules/zappier/admin.md index 445ea51..f783eb0 100644 --- a/docs/modules/zappier/admin.md +++ b/docs/modules/zappier/admin.md @@ -2,7 +2,7 @@ **Package:** `zappier` **Source:** `packages/zappier/src/admin.ts` -**Lines:** 588 +**Lines:** 591 ## What this module is diff --git a/docs/modules/zappier/billing-nats.md b/docs/modules/zappier/billing-nats.md index caf750b..f4ec548 100644 --- a/docs/modules/zappier/billing-nats.md +++ b/docs/modules/zappier/billing-nats.md @@ -2,7 +2,7 @@ **Package:** `zappier` **Source:** `packages/zappier/src/billing-nats.ts` -**Lines:** 74 +**Lines:** 130 ## What this module is @@ -10,12 +10,13 @@ Implementation module in `zappier`. The tables below are extracted from the sour ## Exports -`BILLING_SUBJECTS`, `BillingStatement`, `natsStatement`, `natsAdjust`, `natsPublish` +`BILLING_SUBJECTS`, `AUTHZ_CHECK`, `AccessPlane`, `BillingStatement`, `natsStatement`, `natsAdjust`, `natsPublish` ## Types / interfaces / classes | Kind | Name | |------|------| +| type | `AccessPlane` | | type | `BillingStatement` | | type | `Nc` | @@ -26,15 +27,26 @@ Implementation module in `zappier`. The tables below are extracted from the sour | `nc` | `(none)` | — | `unknown` | see Call graph | | `encode` | `obj: unknown` | — | `unknown` | see Call graph | | `decode` | `buf: Uint8Array` | — | `unknown` | see Call graph | -| `natsStatement` | `customerId: string` | — | `unknown` | see Call graph | +| `authzAllow` | `plane: AccessPlane, + subject: string, + extra: { principal?: string; kind?: string } = {},` | — | `unknown` | see Call graph | +| `natsStatement` | `customerId: string, + plane: AccessPlane = 'web',` | — | `unknown` | see Call graph | | `natsAdjust` | `payload: { - customerId: string; - cents: number; - reason: string; - agent: string; - kind?: string; -}` | — | `unknown` | see Call graph | -| `natsPublish` | `subject: string, payload: unknown` | — | `unknown` | see Call graph | + customerId: string; + cents: number; + reason: string; + agent: string; + kind?: string; + }, + plane: AccessPlane = 'staff',` | — | `unknown` | see Call graph | +| `natsPublish` | `subject: string, payload: unknown, plane: AccessPlane = 'api'` | — | `unknown` | see Call graph | + +## Methods (class / object) + +| Name | Parameters | +|------|------------| +| `void` | `async (` | ## What it imports / requires @@ -42,7 +54,7 @@ _No imports detected._ ## Call graph (identifiers invoked) -`unset`, `nc`, `async`, `import`, `connect`, `split`, `encode`, `stringify`, `decode`, `parse`, `natsStatement`, `request`, `natsAdjust`, `natsPublish`, `then`, `publish` +`unset`, `nc`, `async`, `import`, `connect`, `split`, `encode`, `stringify`, `decode`, `parse`, `authzAllow`, `request`, `fetch`, `replace`, `json`, `natsStatement`, `natsAdjust`, `natsPublish`, `void`, `publish` Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. diff --git a/docs/modules/zappier/portal.md b/docs/modules/zappier/portal.md index 3a10365..c3123d1 100644 --- a/docs/modules/zappier/portal.md +++ b/docs/modules/zappier/portal.md @@ -2,7 +2,7 @@ **Package:** `zappier` **Source:** `packages/zappier/src/portal.ts` -**Lines:** 321 +**Lines:** 328 ## What this module is diff --git a/docs/sphinx/index.rst b/docs/sphinx/index.rst index 66d11b7..c8bf17b 100644 --- a/docs/sphinx/index.rst +++ b/docs/sphinx/index.rst @@ -28,6 +28,11 @@ Markdown indexes: https://zapier.georgelambert.org/index-md.html repos/zappier-customer-service repos/zappier-sales-pricing repos/zappier-accounting-export + repos/verae-access-authz + repos/verae-access-web + repos/verae-access-api + repos/verae-access-leaf + repos/verae-access-zapier .. toctree:: :maxdepth: 2 @@ -157,3 +162,12 @@ Markdown indexes: https://zapier.georgelambert.org/index-md.html modules/zappier-sales-pricing/server modules/zappier-accounting-export/nats-billing modules/zappier-accounting-export/server + modules/verae-access-authz/client + modules/verae-access-authz/policy + modules/verae-access-authz/server + modules/verae-access-authz/subjects + modules/verae-access-web/gate + modules/verae-access-web/server + modules/verae-access-api/server + modules/verae-access-leaf/server + modules/verae-access-zapier/server diff --git a/docs/sphinx/modules/verae-access-api/server.md b/docs/sphinx/modules/verae-access-api/server.md new file mode 100644 index 0000000..385849d --- /dev/null +++ b/docs/sphinx/modules/verae-access-api/server.md @@ -0,0 +1,41 @@ +# `verae-access-api/server` + +**Package:** `verae-access-api` +**Source:** `packages/verae-access-api/src/server.js` +**Lines:** 60 + +## What this module is + +Implementation module in `verae-access-api`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, principal, extra = {}` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` + +## Call graph (identifiers invoked) + +`replace`, `check`, `fetch`, `stringify`, `json`, `createServer`, `async`, `writeHead`, `end`, `match`, `push`, `concat`, `listen`, `write` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-api/server.rst b/docs/sphinx/modules/verae-access-api/server.rst new file mode 100644 index 0000000..515dc51 --- /dev/null +++ b/docs/sphinx/modules/verae-access-api/server.rst @@ -0,0 +1,7 @@ +verae-access-api.server +======================= + +Generated API sheet for ``verae-access-api/server``. + +.. include:: server.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-authz/client.md b/docs/sphinx/modules/verae-access-authz/client.md new file mode 100644 index 0000000..ae42cbc --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/client.md @@ -0,0 +1,42 @@ +# `verae-access-authz/client` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/client.js` +**Lines:** 39 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`authorizeThenRequest`, `authorizeThenPublish` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `authorizeThenRequest` | `nc, sc, { plane, subject, principal, payload, timeout = 2000 }` | — | `unknown` | see Call graph | +| | _Call verae.access.authz.check then the internal subject. Fail closed._ | | | | +| `authorizeThenPublish` | `nc, sc, { plane, subject, principal, payload, timeout = 2000 }` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `./subjects.js` + +## Call graph (identifiers invoked) + +`authorizeThenRequest`, `request`, `encode`, `stringify`, `parse`, `decode`, `authorizeThenPublish`, `publish` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-authz/client.rst b/docs/sphinx/modules/verae-access-authz/client.rst new file mode 100644 index 0000000..0b061f4 --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/client.rst @@ -0,0 +1,7 @@ +verae-access-authz.client +========================= + +Generated API sheet for ``verae-access-authz/client``. + +.. include:: client.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-authz/policy.md b/docs/sphinx/modules/verae-access-authz/policy.md new file mode 100644 index 0000000..ddeee18 --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/policy.md @@ -0,0 +1,42 @@ +# `verae-access-authz/policy` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/policy.js` +**Lines:** 99 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`POLICY`, `authorize` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `prefixAllowed` | `allow, subject` | — | `unknown` | see Call graph | +| `authorize` | `req` | — | `unknown` | see Call graph | +| `deny` | `plane, subject, reason` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `./subjects.js` + +## Call graph (identifiers invoked) + +`workers`, `freeze`, `web`, `prefixAllowed`, `some`, `endsWith`, `startsWith`, `authorize`, `parseAddress`, `deny`, `includes`, `mismatch` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-authz/policy.rst b/docs/sphinx/modules/verae-access-authz/policy.rst new file mode 100644 index 0000000..2ad3dc3 --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/policy.rst @@ -0,0 +1,7 @@ +verae-access-authz.policy +========================= + +Generated API sheet for ``verae-access-authz/policy``. + +.. include:: policy.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-authz/server.md b/docs/sphinx/modules/verae-access-authz/server.md new file mode 100644 index 0000000..632d2de --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/server.md @@ -0,0 +1,44 @@ +# `verae-access-authz/server` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/server.js` +**Lines:** 84 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `startNats` | `(none)` | — | `unknown` | see Call graph | +| `readBody` | `req` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` +- `./policy.js` +- `./subjects.js` + +## Call graph (identifiers invoked) + +`startNats`, `import`, `connect`, `split`, `subscribe`, `async`, `parse`, `decode`, `authorize`, `publish`, `encode`, `stringify`, `toISOString`, `respond`, `write`, `readBody`, `on`, `push`, `resolve`, `concat`, `toString`, `createServer`, `writeHead`, `end`, `json`, `listen` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-authz/server.rst b/docs/sphinx/modules/verae-access-authz/server.rst new file mode 100644 index 0000000..89d5708 --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/server.rst @@ -0,0 +1,7 @@ +verae-access-authz.server +========================= + +Generated API sheet for ``verae-access-authz/server``. + +.. include:: server.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-authz/subjects.md b/docs/sphinx/modules/verae-access-authz/subjects.md new file mode 100644 index 0000000..fc6bdf2 --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/subjects.md @@ -0,0 +1,43 @@ +# `verae-access-authz/subjects` + +**Package:** `verae-access-authz` +**Source:** `packages/verae-access-authz/src/subjects.js` +**Lines:** 37 + +## What this module is + +Implementation module in `verae-access-authz`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`PLANES`, `SUBJECTS`, `accessAddress`, `parseAddress` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `accessAddress` | `plane, internalSubject` | — | `unknown` | see Call graph | +| | _verae.billing.statement.get + plane web → verae.access.web.billing.statement.get_ | | | | +| `parseAddress` | `subject, planeHint` | — | `unknown` | see Call graph | +| | _Parse an access or internal subject into { plane, internal }._ | | | | + +## What it imports / requires + +_No imports detected._ + +## Call graph (identifiers invoked) + +`ingress`, `internal`, `freeze`, `accessAddress`, `includes`, `startsWith`, `slice`, `parseAddress`, `match` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-authz/subjects.rst b/docs/sphinx/modules/verae-access-authz/subjects.rst new file mode 100644 index 0000000..0a75665 --- /dev/null +++ b/docs/sphinx/modules/verae-access-authz/subjects.rst @@ -0,0 +1,7 @@ +verae-access-authz.subjects +=========================== + +Generated API sheet for ``verae-access-authz/subjects``. + +.. include:: subjects.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-leaf/server.md b/docs/sphinx/modules/verae-access-leaf/server.md new file mode 100644 index 0000000..d7b1fba --- /dev/null +++ b/docs/sphinx/modules/verae-access-leaf/server.md @@ -0,0 +1,44 @@ +# `verae-access-leaf/server` + +**Package:** `verae-access-leaf` +**Source:** `packages/verae-access-leaf/src/server.js` +**Lines:** 95 + +## What this module is + +Implementation module in `verae-access-leaf`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, principal` | — | `unknown` | see Call graph | +| `authorizedToken` | `got` | — | `unknown` | see Call graph | +| `forward` | `body` | — | `unknown` | see Call graph | +| `startNats` | `(none)` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` + +## Call graph (identifiers invoked) + +`subjects`, `replace`, `check`, `fetch`, `stringify`, `json`, `authorizedToken`, `forward`, `import`, `connect`, `split`, `publish`, `encode`, `flush`, `close`, `startNats`, `async`, `subscribe`, `parse`, `decode`, `respond`, `write`, `createServer`, `writeHead`, `end`, `push`, `concat`, `toString`, `listen` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-leaf/server.rst b/docs/sphinx/modules/verae-access-leaf/server.rst new file mode 100644 index 0000000..c32301f --- /dev/null +++ b/docs/sphinx/modules/verae-access-leaf/server.rst @@ -0,0 +1,7 @@ +verae-access-leaf.server +======================== + +Generated API sheet for ``verae-access-leaf/server``. + +.. include:: server.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-web/gate.md b/docs/sphinx/modules/verae-access-web/gate.md new file mode 100644 index 0000000..484e01e --- /dev/null +++ b/docs/sphinx/modules/verae-access-web/gate.md @@ -0,0 +1,42 @@ +# `verae-access-web/gate` + +**Package:** `verae-access-web` +**Source:** `packages/verae-access-web/src/gate.js` +**Lines:** 51 + +## What this module is + +Implementation module in `verae-access-web`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +`PLANE`, `AUTHZ_CHECK`, `check`, `statement`, `reload` + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, { principal, kind, payload } = {}` | — | `unknown` | see Call graph | +| `statement` | `customerId, principal` | — | `unknown` | see Call graph | +| `reload` | `customerId, cents, principal` | — | `unknown` | see Call graph | + +## What it imports / requires + +_No imports detected._ + +## Call graph (identifiers invoked) + +`replace`, `check`, `fetch`, `stringify`, `json`, `statement`, `import`, `connect`, `split`, `request`, `encode`, `parse`, `decode`, `close`, `reload` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-web/gate.rst b/docs/sphinx/modules/verae-access-web/gate.rst new file mode 100644 index 0000000..1bf2da1 --- /dev/null +++ b/docs/sphinx/modules/verae-access-web/gate.rst @@ -0,0 +1,7 @@ +verae-access-web.gate +===================== + +Generated API sheet for ``verae-access-web/gate``. + +.. include:: gate.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-web/server.md b/docs/sphinx/modules/verae-access-web/server.md new file mode 100644 index 0000000..c659689 --- /dev/null +++ b/docs/sphinx/modules/verae-access-web/server.md @@ -0,0 +1,41 @@ +# `verae-access-web/server` + +**Package:** `verae-access-web` +**Source:** `packages/verae-access-web/src/server.js` +**Lines:** 39 + +## What this module is + +Implementation module in `verae-access-web`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` +- `./gate.js` + +## Call graph (identifiers invoked) + +`createServer`, `async`, `writeHead`, `end`, `stringify`, `json`, `match`, `statement`, `push`, `parse`, `concat`, `toString`, `reload`, `listen`, `write` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-web/server.rst b/docs/sphinx/modules/verae-access-web/server.rst new file mode 100644 index 0000000..6b0a6d5 --- /dev/null +++ b/docs/sphinx/modules/verae-access-web/server.rst @@ -0,0 +1,7 @@ +verae-access-web.server +======================= + +Generated API sheet for ``verae-access-web/server``. + +.. include:: server.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/verae-access-zapier/server.md b/docs/sphinx/modules/verae-access-zapier/server.md new file mode 100644 index 0000000..258cb8b --- /dev/null +++ b/docs/sphinx/modules/verae-access-zapier/server.md @@ -0,0 +1,41 @@ +# `verae-access-zapier/server` + +**Package:** `verae-access-zapier` +**Source:** `packages/verae-access-zapier/src/server.js` +**Lines:** 56 + +## What this module is + +Implementation module in `verae-access-zapier`. The tables below are extracted from the source (signatures + JSDoc). + +## Exports + +_No named `export` / `module.exports` keys detected._ + +## Types / interfaces / classes + +_None extracted._ + +## Functions + +| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | +|------|------------|---------------------|---------|-----------------------------| +| `check` | `subject, principal` | — | `unknown` | see Call graph | +| `json` | `code, obj` | — | `unknown` | see Call graph | + +## What it imports / requires + +- `node:http` + +## Call graph (identifiers invoked) + +`replace`, `check`, `fetch`, `stringify`, `json`, `createServer`, `async`, `writeHead`, `end`, `startsWith`, `push`, `concat`, `listen`, `write` + +Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. + +## Return values (how to read this) + +- HTTP route handlers return Express `res.json(...)` bodies (see route docs). +- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches). +- Pricing functions return integer **cents** on `Quote.totalCents`. + diff --git a/docs/sphinx/modules/verae-access-zapier/server.rst b/docs/sphinx/modules/verae-access-zapier/server.rst new file mode 100644 index 0000000..9ad214d --- /dev/null +++ b/docs/sphinx/modules/verae-access-zapier/server.rst @@ -0,0 +1,7 @@ +verae-access-zapier.server +========================== + +Generated API sheet for ``verae-access-zapier/server``. + +.. include:: server.md + :parser: myst_parser.sphinx_ diff --git a/docs/sphinx/modules/zappier-customer-service/nats-billing.md b/docs/sphinx/modules/zappier-customer-service/nats-billing.md index aed55e1..178a917 100644 --- a/docs/sphinx/modules/zappier-customer-service/nats-billing.md +++ b/docs/sphinx/modules/zappier-customer-service/nats-billing.md @@ -2,7 +2,7 @@ **Package:** `zappier-customer-service` **Source:** `packages/zappier-customer-service/src/nats-billing.js` -**Lines:** 21 +**Lines:** 45 ## What this module is @@ -20,6 +20,7 @@ _None extracted._ | Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | |------|------------|---------------------|---------|-----------------------------| +| `authz` | `subject, payload` | — | `unknown` | see Call graph | | `billingRequest` | `subject, payload` | — | `unknown` | see Call graph | ## What it imports / requires @@ -28,7 +29,7 @@ _No imports detected._ ## Call graph (identifiers invoked) -`billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `stringify`, `parse`, `decode`, `close` +`authz`, `fetch`, `replace`, `stringify`, `json`, `billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `parse`, `decode`, `close` Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. diff --git a/docs/sphinx/modules/zappier-sales-pricing/nats-billing.md b/docs/sphinx/modules/zappier-sales-pricing/nats-billing.md index dcd029e..8c31d6b 100644 --- a/docs/sphinx/modules/zappier-sales-pricing/nats-billing.md +++ b/docs/sphinx/modules/zappier-sales-pricing/nats-billing.md @@ -2,7 +2,7 @@ **Package:** `zappier-sales-pricing` **Source:** `packages/zappier-sales-pricing/src/nats-billing.js` -**Lines:** 18 +**Lines:** 37 ## What this module is @@ -20,6 +20,7 @@ _None extracted._ | Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) | |------|------------|---------------------|---------|-----------------------------| +| `authz` | `subject` | — | `unknown` | see Call graph | | `billingRequest` | `subject, payload` | — | `unknown` | see Call graph | ## What it imports / requires @@ -28,7 +29,7 @@ _No imports detected._ ## Call graph (identifiers invoked) -`billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `stringify`, `parse`, `decode`, `close` +`authz`, `fetch`, `replace`, `stringify`, `json`, `billingRequest`, `import`, `connect`, `split`, `request`, `encode`, `parse`, `decode`, `close` Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. diff --git a/docs/sphinx/modules/zappier/admin.md b/docs/sphinx/modules/zappier/admin.md index 445ea51..f783eb0 100644 --- a/docs/sphinx/modules/zappier/admin.md +++ b/docs/sphinx/modules/zappier/admin.md @@ -2,7 +2,7 @@ **Package:** `zappier` **Source:** `packages/zappier/src/admin.ts` -**Lines:** 588 +**Lines:** 591 ## What this module is diff --git a/docs/sphinx/modules/zappier/billing-nats.md b/docs/sphinx/modules/zappier/billing-nats.md index caf750b..f4ec548 100644 --- a/docs/sphinx/modules/zappier/billing-nats.md +++ b/docs/sphinx/modules/zappier/billing-nats.md @@ -2,7 +2,7 @@ **Package:** `zappier` **Source:** `packages/zappier/src/billing-nats.ts` -**Lines:** 74 +**Lines:** 130 ## What this module is @@ -10,12 +10,13 @@ Implementation module in `zappier`. The tables below are extracted from the sour ## Exports -`BILLING_SUBJECTS`, `BillingStatement`, `natsStatement`, `natsAdjust`, `natsPublish` +`BILLING_SUBJECTS`, `AUTHZ_CHECK`, `AccessPlane`, `BillingStatement`, `natsStatement`, `natsAdjust`, `natsPublish` ## Types / interfaces / classes | Kind | Name | |------|------| +| type | `AccessPlane` | | type | `BillingStatement` | | type | `Nc` | @@ -26,15 +27,26 @@ Implementation module in `zappier`. The tables below are extracted from the sour | `nc` | `(none)` | — | `unknown` | see Call graph | | `encode` | `obj: unknown` | — | `unknown` | see Call graph | | `decode` | `buf: Uint8Array` | — | `unknown` | see Call graph | -| `natsStatement` | `customerId: string` | — | `unknown` | see Call graph | +| `authzAllow` | `plane: AccessPlane, + subject: string, + extra: { principal?: string; kind?: string } = {},` | — | `unknown` | see Call graph | +| `natsStatement` | `customerId: string, + plane: AccessPlane = 'web',` | — | `unknown` | see Call graph | | `natsAdjust` | `payload: { - customerId: string; - cents: number; - reason: string; - agent: string; - kind?: string; -}` | — | `unknown` | see Call graph | -| `natsPublish` | `subject: string, payload: unknown` | — | `unknown` | see Call graph | + customerId: string; + cents: number; + reason: string; + agent: string; + kind?: string; + }, + plane: AccessPlane = 'staff',` | — | `unknown` | see Call graph | +| `natsPublish` | `subject: string, payload: unknown, plane: AccessPlane = 'api'` | — | `unknown` | see Call graph | + +## Methods (class / object) + +| Name | Parameters | +|------|------------| +| `void` | `async (` | ## What it imports / requires @@ -42,7 +54,7 @@ _No imports detected._ ## Call graph (identifiers invoked) -`unset`, `nc`, `async`, `import`, `connect`, `split`, `encode`, `stringify`, `decode`, `parse`, `natsStatement`, `request`, `natsAdjust`, `natsPublish`, `then`, `publish` +`unset`, `nc`, `async`, `import`, `connect`, `split`, `encode`, `stringify`, `decode`, `parse`, `authzAllow`, `request`, `fetch`, `replace`, `json`, `natsStatement`, `natsAdjust`, `natsPublish`, `void`, `publish` Each identifier is a call site in this file. Follow the import list to see the defining module; open that module’s MD for parameter and return types. diff --git a/docs/sphinx/modules/zappier/portal.md b/docs/sphinx/modules/zappier/portal.md index 3a10365..c3123d1 100644 --- a/docs/sphinx/modules/zappier/portal.md +++ b/docs/sphinx/modules/zappier/portal.md @@ -2,7 +2,7 @@ **Package:** `zappier` **Source:** `packages/zappier/src/portal.ts` -**Lines:** 321 +**Lines:** 328 ## What this module is diff --git a/docs/sphinx/repos/verae-access-api.md b/docs/sphinx/repos/verae-access-api.md new file mode 100644 index 0000000..74102be --- /dev/null +++ b/docs/sphinx/repos/verae-access-api.md @@ -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`. diff --git a/docs/sphinx/repos/verae-access-authz.md b/docs/sphinx/repos/verae-access-authz.md new file mode 100644 index 0000000..c14cb93 --- /dev/null +++ b/docs/sphinx/repos/verae-access-authz.md @@ -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.... (ingress, that plane only) + → verae.access.authz.check (allow / deny) + → verae... (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"}' +``` diff --git a/docs/sphinx/repos/verae-access-leaf.md b/docs/sphinx/repos/verae-access-leaf.md new file mode 100644 index 0000000..ce2f273 --- /dev/null +++ b/docs/sphinx/repos/verae-access-leaf.md @@ -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. diff --git a/docs/sphinx/repos/verae-access-web.md b/docs/sphinx/repos/verae-access-web.md new file mode 100644 index 0000000..5d3414e --- /dev/null +++ b/docs/sphinx/repos/verae-access-web.md @@ -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`. diff --git a/docs/sphinx/repos/verae-access-zapier.md b/docs/sphinx/repos/verae-access-zapier.md new file mode 100644 index 0000000..e25cf46 --- /dev/null +++ b/docs/sphinx/repos/verae-access-zapier.md @@ -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`. diff --git a/packages/docs-master/modules-and-nats.md b/packages/docs-master/modules-and-nats.md index d0d14a9..6451653 100644 --- a/packages/docs-master/modules-and-nats.md +++ b/packages/docs-master/modules-and-nats.md @@ -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.` | replies | `archiveId`, `sha256`, `records[]`, `traceId` | diff --git a/packages/docs-master/modules/verae-access-api/NATS.md b/packages/docs-master/modules/verae-access-api/NATS.md new file mode 100644 index 0000000..764ac45 --- /dev/null +++ b/packages/docs-master/modules/verae-access-api/NATS.md @@ -0,0 +1,3 @@ +# NATS — verae-access-api + +Plane `api`. Statement and jobs.watch after authz. No credits, no archive. diff --git a/packages/docs-master/modules/verae-access-api/SUMMARY.md b/packages/docs-master/modules/verae-access-api/SUMMARY.md new file mode 100644 index 0000000..9a0401e --- /dev/null +++ b/packages/docs-master/modules/verae-access-api/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-access-api + +Direct customer API (`x-api-key`). Separate from Zapier Platform. diff --git a/packages/docs-master/modules/verae-access-authz/NATS.md b/packages/docs-master/modules/verae-access-authz/NATS.md new file mode 100644 index 0000000..5e41e52 --- /dev/null +++ b/packages/docs-master/modules/verae-access-authz/NATS.md @@ -0,0 +1,3 @@ +# NATS — verae-access-authz + +`verae.access.authz.check` request-reply. Queue `access-authz`. Fail closed. diff --git a/packages/docs-master/modules/verae-access-authz/SUMMARY.md b/packages/docs-master/modules/verae-access-authz/SUMMARY.md new file mode 100644 index 0000000..65215eb --- /dev/null +++ b/packages/docs-master/modules/verae-access-authz/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-access-authz + +Default-deny authorization step on the NATS address path. Planes: zapier, web, api, leaf, staff. diff --git a/packages/docs-master/modules/verae-access-leaf/NATS.md b/packages/docs-master/modules/verae-access-leaf/NATS.md new file mode 100644 index 0000000..546d4b4 --- /dev/null +++ b/packages/docs-master/modules/verae-access-leaf/NATS.md @@ -0,0 +1,3 @@ +# NATS — verae-access-leaf + +`verae.access.leaf.in` → authz.check → `verae.archive.*` / jobs.*. Denied: billing. diff --git a/packages/docs-master/modules/verae-access-leaf/SUMMARY.md b/packages/docs-master/modules/verae-access-leaf/SUMMARY.md new file mode 100644 index 0000000..389a693 --- /dev/null +++ b/packages/docs-master/modules/verae-access-leaf/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-access-leaf + +Server-to-server NATS leaf ingress. Archive and jobs only. Never billing. diff --git a/packages/docs-master/modules/verae-access-web/NATS.md b/packages/docs-master/modules/verae-access-web/NATS.md new file mode 100644 index 0000000..7f0c825 --- /dev/null +++ b/packages/docs-master/modules/verae-access-web/NATS.md @@ -0,0 +1,3 @@ +# NATS — verae-access-web + +Plane `web`. Authz then `verae.billing.statement.get` / `balance.adjust` kind=reload. diff --git a/packages/docs-master/modules/verae-access-web/SUMMARY.md b/packages/docs-master/modules/verae-access-web/SUMMARY.md new file mode 100644 index 0000000..56e767b --- /dev/null +++ b/packages/docs-master/modules/verae-access-web/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-access-web + +Direct customer web plane. Not Zapier. Statement + reload only after authz. diff --git a/packages/docs-master/modules/verae-access-zapier/NATS.md b/packages/docs-master/modules/verae-access-zapier/NATS.md new file mode 100644 index 0000000..3ebabee --- /dev/null +++ b/packages/docs-master/modules/verae-access-zapier/NATS.md @@ -0,0 +1,3 @@ +# NATS — verae-access-zapier + +Plane `zapier`. Authz then jobs.watch / usage.recorded. Zapier cloud never connects to NATS. diff --git a/packages/docs-master/modules/verae-access-zapier/SUMMARY.md b/packages/docs-master/modules/verae-access-zapier/SUMMARY.md new file mode 100644 index 0000000..de4e8c8 --- /dev/null +++ b/packages/docs-master/modules/verae-access-zapier/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-access-zapier + +Zapier Platform HTTPS plane only. No portal, no admin, no leaf. diff --git a/packages/overview/01-system.md b/packages/overview/01-system.md index 35089e8..f887712 100644 --- a/packages/overview/01-system.md +++ b/packages/overview/01-system.md @@ -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 | diff --git a/packages/overview/02-modules-and-repos.md b/packages/overview/02-modules-and-repos.md index 4be3f26..36c329c 100644 --- a/packages/overview/02-modules-and-repos.md +++ b/packages/overview/02-modules-and-repos.md @@ -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 | diff --git a/packages/overview/03-nats-cluster.md b/packages/overview/03-nats-cluster.md index fc3042b..4769ba6 100644 --- a/packages/overview/03-nats-cluster.md +++ b/packages/overview/03-nats-cluster.md @@ -20,7 +20,7 @@ Today’s 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 diff --git a/packages/overview/06-address-routing.md b/packages/overview/06-address-routing.md index 5f0d666..d9abdac 100644 --- a/packages/overview/06-address-routing.md +++ b/packages/overview/06-address-routing.md @@ -5,10 +5,14 @@ NATS **addresses** (subjects) are the extension point. A new search, store, or j ## Pattern ```text -verae... +verae.access.... # ingress (one plane) +verae.access.authz.check # authorization step +verae... # internal bus verae...reply. ``` +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...reply. 2. Rename `verae.example.process.in` / `.out` / `.reply.*` in `src/subjects.js`. 3. Add a row to that repo’s `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. diff --git a/packages/overview/08-diagrams.md b/packages/overview/08-diagrams.md index d78e79e..75d4299 100644 --- a/packages/overview/08-diagrams.md +++ b/packages/overview/08-diagrams.md @@ -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 diff --git a/packages/overview/INDEX.md b/packages/overview/INDEX.md index ce83659..d9140aa 100644 --- a/packages/overview/INDEX.md +++ b/packages/overview/INDEX.md @@ -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 diff --git a/packages/verae-access-api/NATS.md b/packages/verae-access-api/NATS.md new file mode 100644 index 0000000..46f8181 --- /dev/null +++ b/packages/verae-access-api/NATS.md @@ -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. diff --git a/packages/verae-access-api/README.md b/packages/verae-access-api/README.md new file mode 100644 index 0000000..74102be --- /dev/null +++ b/packages/verae-access-api/README.md @@ -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`. diff --git a/packages/verae-access-api/package.json b/packages/verae-access-api/package.json new file mode 100644 index 0000000..9a26c66 --- /dev/null +++ b/packages/verae-access-api/package.json @@ -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" + } +} diff --git a/packages/verae-access-api/src/server.js b/packages/verae-access-api/src/server.js new file mode 100644 index 0000000..5cb9f61 --- /dev/null +++ b/packages/verae-access-api/src/server.js @@ -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`); +}); diff --git a/packages/verae-access-api/test/health.test.js b/packages/verae-access-api/test/health.test.js new file mode 100644 index 0000000..2e9f92d --- /dev/null +++ b/packages/verae-access-api/test/health.test.js @@ -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'); + } +}); diff --git a/packages/verae-access-authz/NATS.md b/packages/verae-access-authz/NATS.md new file mode 100644 index 0000000..10afef7 --- /dev/null +++ b/packages/verae-access-authz/NATS.md @@ -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. diff --git a/packages/verae-access-authz/README.md b/packages/verae-access-authz/README.md new file mode 100644 index 0000000..c14cb93 --- /dev/null +++ b/packages/verae-access-authz/README.md @@ -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.... (ingress, that plane only) + → verae.access.authz.check (allow / deny) + → verae... (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"}' +``` diff --git a/packages/verae-access-authz/package-lock.json b/packages/verae-access-authz/package-lock.json new file mode 100644 index 0000000..5b944ab --- /dev/null +++ b/packages/verae-access-authz/package-lock.json @@ -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" + } + } +} diff --git a/packages/verae-access-authz/package.json b/packages/verae-access-authz/package.json new file mode 100644 index 0000000..850686f --- /dev/null +++ b/packages/verae-access-authz/package.json @@ -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" + } +} diff --git a/packages/verae-access-authz/src/client.js b/packages/verae-access-authz/src/client.js new file mode 100644 index 0000000..f443a9e --- /dev/null +++ b/packages/verae-access-authz/src/client.js @@ -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 }; +} diff --git a/packages/verae-access-authz/src/policy.js b/packages/verae-access-authz/src/policy.js new file mode 100644 index 0000000..d8f2149 --- /dev/null +++ b/packages/verae-access-authz/src/policy.js @@ -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 }; +} diff --git a/packages/verae-access-authz/src/server.js b/packages/verae-access-authz/src/server.js new file mode 100644 index 0000000..3e29401 --- /dev/null +++ b/packages/verae-access-authz/src/server.js @@ -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`)); diff --git a/packages/verae-access-authz/src/subjects.js b/packages/verae-access-authz/src/subjects.js new file mode 100644 index 0000000..029865d --- /dev/null +++ b/packages/verae-access-authz/src/subjects.js @@ -0,0 +1,36 @@ +/** + * Access planes sit in front of internal verae..* addresses. + * Pattern: + * verae.access.... ingress (that plane only) + * verae.access.authz.check authorization step + * verae... 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 }; +} diff --git a/packages/verae-access-authz/test/health.test.js b/packages/verae-access-authz/test/health.test.js new file mode 100644 index 0000000..6b2d967 --- /dev/null +++ b/packages/verae-access-authz/test/health.test.js @@ -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'); + } +}); diff --git a/packages/verae-access-authz/test/policy.test.js b/packages/verae-access-authz/test/policy.test.js new file mode 100644 index 0000000..30cee3b --- /dev/null +++ b/packages/verae-access-authz/test/policy.test.js @@ -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); +}); diff --git a/packages/verae-access-leaf/NATS.md b/packages/verae-access-leaf/NATS.md new file mode 100644 index 0000000..7f9d539 --- /dev/null +++ b/packages/verae-access-leaf/NATS.md @@ -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. diff --git a/packages/verae-access-leaf/README.md b/packages/verae-access-leaf/README.md new file mode 100644 index 0000000..ce2f273 --- /dev/null +++ b/packages/verae-access-leaf/README.md @@ -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. diff --git a/packages/verae-access-leaf/package-lock.json b/packages/verae-access-leaf/package-lock.json new file mode 100644 index 0000000..740e2af --- /dev/null +++ b/packages/verae-access-leaf/package-lock.json @@ -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" + } + } +} diff --git a/packages/verae-access-leaf/package.json b/packages/verae-access-leaf/package.json new file mode 100644 index 0000000..52dd272 --- /dev/null +++ b/packages/verae-access-leaf/package.json @@ -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" + } +} diff --git a/packages/verae-access-leaf/src/server.js b/packages/verae-access-leaf/src/server.js new file mode 100644 index 0000000..7324404 --- /dev/null +++ b/packages/verae-access-leaf/src/server.js @@ -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`)); diff --git a/packages/verae-access-leaf/test/health.test.js b/packages/verae-access-leaf/test/health.test.js new file mode 100644 index 0000000..1fb1d9a --- /dev/null +++ b/packages/verae-access-leaf/test/health.test.js @@ -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'); + } +}); diff --git a/packages/verae-access-web/NATS.md b/packages/verae-access-web/NATS.md new file mode 100644 index 0000000..657c03c --- /dev/null +++ b/packages/verae-access-web/NATS.md @@ -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). diff --git a/packages/verae-access-web/README.md b/packages/verae-access-web/README.md new file mode 100644 index 0000000..5d3414e --- /dev/null +++ b/packages/verae-access-web/README.md @@ -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`. diff --git a/packages/verae-access-web/package-lock.json b/packages/verae-access-web/package-lock.json new file mode 100644 index 0000000..34d05ac --- /dev/null +++ b/packages/verae-access-web/package-lock.json @@ -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" + } + } +} diff --git a/packages/verae-access-web/package.json b/packages/verae-access-web/package.json new file mode 100644 index 0000000..b38d1e1 --- /dev/null +++ b/packages/verae-access-web/package.json @@ -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" + } +} diff --git a/packages/verae-access-web/src/gate.js b/packages/verae-access-web/src/gate.js new file mode 100644 index 0000000..6162d32 --- /dev/null +++ b/packages/verae-access-web/src/gate.js @@ -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 } }; +} diff --git a/packages/verae-access-web/src/server.js b/packages/verae-access-web/src/server.js new file mode 100644 index 0000000..c5351df --- /dev/null +++ b/packages/verae-access-web/src/server.js @@ -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`); +}); diff --git a/packages/verae-access-web/test/health.test.js b/packages/verae-access-web/test/health.test.js new file mode 100644 index 0000000..fbbdf18 --- /dev/null +++ b/packages/verae-access-web/test/health.test.js @@ -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'); + } +}); diff --git a/packages/verae-access-zapier/NATS.md b/packages/verae-access-zapier/NATS.md new file mode 100644 index 0000000..a3ee860 --- /dev/null +++ b/packages/verae-access-zapier/NATS.md @@ -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.*. diff --git a/packages/verae-access-zapier/README.md b/packages/verae-access-zapier/README.md new file mode 100644 index 0000000..e25cf46 --- /dev/null +++ b/packages/verae-access-zapier/README.md @@ -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`. diff --git a/packages/verae-access-zapier/package.json b/packages/verae-access-zapier/package.json new file mode 100644 index 0000000..8c71481 --- /dev/null +++ b/packages/verae-access-zapier/package.json @@ -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" + } +} diff --git a/packages/verae-access-zapier/src/server.js b/packages/verae-access-zapier/src/server.js new file mode 100644 index 0000000..a3804ec --- /dev/null +++ b/packages/verae-access-zapier/src/server.js @@ -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`); +}); diff --git a/packages/verae-access-zapier/test/health.test.js b/packages/verae-access-zapier/test/health.test.js new file mode 100644 index 0000000..6509f9f --- /dev/null +++ b/packages/verae-access-zapier/test/health.test.js @@ -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'); + } +}); diff --git a/packages/verae-fleet/fleet.json b/packages/verae-fleet/fleet.json index 942284c..6177e0d 100644 --- a/packages/verae-fleet/fleet.json +++ b/packages/verae-fleet/fleet.json @@ -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 }, diff --git a/packages/verae-fleet/services/access-api.json b/packages/verae-fleet/services/access-api.json new file mode 100644 index 0000000..1d5a7ae --- /dev/null +++ b/packages/verae-fleet/services/access-api.json @@ -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"] + } +} diff --git a/packages/verae-fleet/services/access-authz.json b/packages/verae-fleet/services/access-authz.json new file mode 100644 index 0000000..f104616 --- /dev/null +++ b/packages/verae-fleet/services/access-authz.json @@ -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"] + } +} diff --git a/packages/verae-fleet/services/access-leaf.json b/packages/verae-fleet/services/access-leaf.json new file mode 100644 index 0000000..0ab8e7a --- /dev/null +++ b/packages/verae-fleet/services/access-leaf.json @@ -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"] + } +} diff --git a/packages/verae-fleet/services/access-web.json b/packages/verae-fleet/services/access-web.json new file mode 100644 index 0000000..65481a1 --- /dev/null +++ b/packages/verae-fleet/services/access-web.json @@ -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"] + } +} diff --git a/packages/verae-fleet/services/access-zapier.json b/packages/verae-fleet/services/access-zapier.json new file mode 100644 index 0000000..a3244b5 --- /dev/null +++ b/packages/verae-fleet/services/access-zapier.json @@ -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"] + } +} diff --git a/packages/verae-fleet/services/customer-service.json b/packages/verae-fleet/services/customer-service.json index 623f7ee..6d39b76 100644 --- a/packages/verae-fleet/services/customer-service.json +++ b/packages/verae-fleet/services/customer-service.json @@ -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": [], diff --git a/packages/verae-fleet/services/sales-pricing.json b/packages/verae-fleet/services/sales-pricing.json index 1865ec7..fbda599 100644 --- a/packages/verae-fleet/services/sales-pricing.json +++ b/packages/verae-fleet/services/sales-pricing.json @@ -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"] } } diff --git a/packages/verae-fleet/services/zappier-edge.json b/packages/verae-fleet/services/zappier-edge.json index b14b6da..21aeed1 100644 --- a/packages/verae-fleet/services/zappier-edge.json +++ b/packages/verae-fleet/services/zappier-edge.json @@ -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": [], diff --git a/packages/verae-fleet/test/fleet.test.js b/packages/verae-fleet/test/fleet.test.js index 79dbf00..c03b564 100644 --- a/packages/verae-fleet/test/fleet.test.js +++ b/packages/verae-fleet/test/fleet.test.js @@ -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', diff --git a/packages/verae-ops/GETTING-STARTED.md b/packages/verae-ops/GETTING-STARTED.md index cb4de0f..fb8253b 100644 --- a/packages/verae-ops/GETTING-STARTED.md +++ b/packages/verae-ops/GETTING-STARTED.md @@ -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` | diff --git a/packages/verae-ops/docker-compose.yml b/packages/verae-ops/docker-compose.yml index 38b50b4..6c4b2db 100644 --- a/packages/verae-ops/docker-compose.yml +++ b/packages/verae-ops/docker-compose.yml @@ -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] diff --git a/packages/zappier-customer-service/src/nats-billing.js b/packages/zappier-customer-service/src/nats-billing.js index 6798fb1..2a9d87d 100644 --- a/packages/zappier-customer-service/src/nats-billing.js +++ b/packages/zappier-customer-service/src/nats-billing.js @@ -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; } } diff --git a/packages/zappier-customer-service/src/server.js b/packages/zappier-customer-service/src/server.js index 39bd204..28b99ba 100644 --- a/packages/zappier-customer-service/src/server.js +++ b/packages/zappier-customer-service/src/server.js @@ -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 || {}) }); } }); diff --git a/packages/zappier-sales-pricing/src/nats-billing.js b/packages/zappier-sales-pricing/src/nats-billing.js index efab3b3..17e7a52 100644 --- a/packages/zappier-sales-pricing/src/nats-billing.js +++ b/packages/zappier-sales-pricing/src/nats-billing.js @@ -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; } } diff --git a/packages/zappier-sales-pricing/src/server.js b/packages/zappier-sales-pricing/src/server.js index bdb1995..0e5e2cb 100644 --- a/packages/zappier-sales-pricing/src/server.js +++ b/packages/zappier-sales-pricing/src/server.js @@ -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 }); } }); diff --git a/packages/zappier/src/admin.ts b/packages/zappier/src/admin.ts index bdfd9b8..6024e0d 100644 --- a/packages/zappier/src/admin.ts +++ b/packages/zappier/src/admin.ts @@ -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; diff --git a/packages/zappier/src/app.ts b/packages/zappier/src/app.ts index 0201c10..c244f86 100644 --- a/packages/zappier/src/app.ts +++ b/packages/zappier/src/app.ts @@ -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 } diff --git a/packages/zappier/src/billing-nats.ts b/packages/zappier/src/billing-nats.ts index b8654a0..b05782a 100644 --- a/packages/zappier/src/billing-nats.ts +++ b/packages/zappier/src/billing-nats.ts @@ -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 { +async function authzAllow( + plane: AccessPlane, + subject: string, + extra: { principal?: string; kind?: string } = {}, +): Promise { + 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 { 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 { +export async function natsAdjust( + payload: { + customerId: string; + cents: number; + reason: string; + agent: string; + kind?: string; + }, + plane: AccessPlane = 'staff', +): Promise { 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 })); + })(); } diff --git a/packages/zappier/src/portal.ts b/packages/zappier/src/portal.ts index 38c52af..f2ebb19 100644 --- a/packages/zappier/src/portal.ts +++ b/packages/zappier/src/portal.ts @@ -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, diff --git a/scripts/build-docs-site.py b/scripts/build-docs-site.py index d34a1d8..eb703cd 100755 --- a/scripts/build-docs-site.py +++ b/scripts/build-docs-site.py @@ -75,6 +75,11 @@ SECTIONS = [ ("packages/zappier-customer-service/README.md", "Customer-service credits API"), ("packages/zappier-sales-pricing/README.md", "Sales per-customer pricing API"), ("packages/zappier-accounting-export/README.md", "QuickBooks / accounting export"), + ("packages/verae-access-authz/README.md", "Access-plane authorization"), + ("packages/verae-access-web/README.md", "Direct web access plane"), + ("packages/verae-access-api/README.md", "Direct customer API plane"), + ("packages/verae-access-leaf/README.md", "S2S NATS leaf plane"), + ("packages/verae-access-zapier/README.md", "Zapier-only access plane"), ], ), ( @@ -331,6 +336,11 @@ def main() -> None: "zappier-customer-service", "zappier-sales-pricing", "zappier-accounting-export", + "verae-access-authz", + "verae-access-web", + "verae-access-api", + "verae-access-leaf", + "verae-access-zapier", ): pkg_root = ROOT / "packages" / pkg if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}: @@ -493,6 +503,11 @@ def main() -> None: ("zappier-customer-service", "CS credits"), ("zappier-sales-pricing", "Sales per-customer pricing"), ("zappier-accounting-export", "QuickBooks export"), + ("verae-access-authz", "Access-plane authorization"), + ("verae-access-web", "Direct web access"), + ("verae-access-api", "Direct customer API"), + ("verae-access-leaf", "S2S NATS leaf"), + ("verae-access-zapier", "Zapier access plane"), ] git_lis = "".join( f'
  • {name} ' diff --git a/scripts/gen-module-docs.py b/scripts/gen-module-docs.py index 8ab9030..914e744 100644 --- a/scripts/gen-module-docs.py +++ b/scripts/gen-module-docs.py @@ -26,6 +26,11 @@ PACKAGES = [ ("zappier-customer-service", ROOT / "packages" / "zappier-customer-service" / "src", (".js",)), ("zappier-sales-pricing", ROOT / "packages" / "zappier-sales-pricing" / "src", (".js",)), ("zappier-accounting-export", ROOT / "packages" / "zappier-accounting-export" / "src", (".js",)), + ("verae-access-authz", ROOT / "packages" / "verae-access-authz" / "src", (".js",)), + ("verae-access-web", ROOT / "packages" / "verae-access-web" / "src", (".js",)), + ("verae-access-api", ROOT / "packages" / "verae-access-api" / "src", (".js",)), + ("verae-access-leaf", ROOT / "packages" / "verae-access-leaf" / "src", (".js",)), + ("verae-access-zapier", ROOT / "packages" / "verae-access-zapier" / "src", (".js",)), ] REPO_READMES = [ @@ -48,6 +53,11 @@ REPO_READMES = [ "zappier-customer-service", "zappier-sales-pricing", "zappier-accounting-export", + "verae-access-authz", + "verae-access-web", + "verae-access-api", + "verae-access-leaf", + "verae-access-zapier", ] SKIP_PARTS = {"test", "tests", "node_modules", "dist"} diff --git a/scripts/push-module-repos.sh b/scripts/push-module-repos.sh index da92208..bae625b 100755 --- a/scripts/push-module-repos.sh +++ b/scripts/push-module-repos.sh @@ -54,6 +54,11 @@ create zappier-account-balance "NATS account balance source of truth" create zappier-customer-service "Customer-service prepaid credit adjustments" create zappier-sales-pricing "Sales per-customer pricing" create zappier-accounting-export "QuickBooks IIF and accounting CSV export" +create verae-access-authz "NATS authorization step for access planes" +create verae-access-web "Direct customer web access plane" +create verae-access-api "Direct customer API access plane" +create verae-access-leaf "Server-to-server NATS leaf access plane" +create verae-access-zapier "Zapier Platform access plane only" push_dir "$ROOT/packages/zappier" zappier-edge push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware @@ -74,5 +79,10 @@ push_dir "$ROOT/packages/zappier-account-balance" zappier-account-balance push_dir "$ROOT/packages/zappier-customer-service" zappier-customer-service push_dir "$ROOT/packages/zappier-sales-pricing" zappier-sales-pricing push_dir "$ROOT/packages/zappier-accounting-export" zappier-accounting-export +push_dir "$ROOT/packages/verae-access-authz" verae-access-authz +push_dir "$ROOT/packages/verae-access-web" verae-access-web +push_dir "$ROOT/packages/verae-access-api" verae-access-api +push_dir "$ROOT/packages/verae-access-leaf" verae-access-leaf +push_dir "$ROOT/packages/verae-access-zapier" verae-access-zapier echo ALL_MODULE_REPOS_PUSHED