commit 87c9d78f2dcbf20ac2e14083296b42432dfd2d8f Author: George Lambert Date: Fri Sep 11 18:20:25 2026 -0400 Initial import of verae-access-zapier from zapier monorepo diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..a3ee860 --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 0000000..e25cf46 --- /dev/null +++ b/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/package.json b/package.json new file mode 100644 index 0000000..8c71481 --- /dev/null +++ b/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/src/server.js b/src/server.js new file mode 100644 index 0000000..a3804ec --- /dev/null +++ b/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/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..6509f9f --- /dev/null +++ b/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'); + } +});