commit 81ffb0d5209a797154266e7bf907bd394d6bb014 Author: George Lambert Date: Fri Sep 11 16:19:44 2026 -0400 Initial import of verae-access-api from zapier monorepo diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..46f8181 --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 0000000..74102be --- /dev/null +++ b/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/package.json b/package.json new file mode 100644 index 0000000..9a26c66 --- /dev/null +++ b/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/src/server.js b/src/server.js new file mode 100644 index 0000000..5cb9f61 --- /dev/null +++ b/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/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..2e9f92d --- /dev/null +++ b/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'); + } +});