diff --git a/packages/overview/02-modules-and-repos.md b/packages/overview/02-modules-and-repos.md
index 5becb76..184a80f 100644
--- a/packages/overview/02-modules-and-repos.md
+++ b/packages/overview/02-modules-and-repos.md
@@ -35,6 +35,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
| **UI-Docs** | `packages/ui-docs` | Operator/staff/portal walkthrough, screenshots, review PDF |
| **verae-staff-session** | `packages/verae-staff-session` | Shared staff cookie login for department HTML |
| **verae-staff-ui** | `packages/verae-staff-ui` | Shared staff review HTML (CS + access-staff) |
+| **verae-staff-iam** | `packages/verae-staff-iam` | Internal staff users, roles, permissions, audit |
| **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` |
| **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services |
diff --git a/packages/verae-access-staff/src/iam-gate.js b/packages/verae-access-staff/src/iam-gate.js
new file mode 100644
index 0000000..c5e6144
--- /dev/null
+++ b/packages/verae-access-staff/src/iam-gate.js
@@ -0,0 +1,35 @@
+export function iamBase() {
+ return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+}
+
+export async function iamCheck(req, permission) {
+ const base = iamBase();
+ if (!base) {
+ if (process.env.STAFF_AUTH === '1') {
+ const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
+ const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
+ return { ok: Boolean(r && r.ok) };
+ }
+ return { ok: true, skipped: true };
+ }
+ const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
+ const r = await fetch(`${base}/check${q}`, {
+ headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
+ }).catch(() => null);
+ if (!r) return { ok: false, status: 502 };
+ const body = await r.json().catch(() => ({}));
+ return { ok: r.ok, status: r.status, ...body };
+}
+
+export async function denyOrRedirect(req, res, json, { permission, html }) {
+ const out = await iamCheck(req, permission);
+ if (out.ok) return true;
+ const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
+ if (html) {
+ res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
+ res.end();
+ return false;
+ }
+ json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
+ return false;
+}
diff --git a/packages/verae-access-staff/src/server.js b/packages/verae-access-staff/src/server.js
index a517f1e..61967ce 100644
--- a/packages/verae-access-staff/src/server.js
+++ b/packages/verae-access-staff/src/server.js
@@ -6,6 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { listCustomers, withCustomerName } from './names.js';
import { staffPageHtml } from './staff-page.js';
+import { denyOrRedirect } from './iam-gate.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const PORT = Number(process.env.PORT || 3025);
@@ -32,15 +33,7 @@ const server = http.createServer(async (req, res) => {
};
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
- if (process.env.STAFF_AUTH === '1') {
- const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
- const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
- if (!chk || !chk.ok) {
- res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
- res.end();
- return;
- }
- }
+ if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane', html: true }))) return;
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(
await staffPageHtml(
@@ -62,6 +55,7 @@ const server = http.createServer(async (req, res) => {
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane' }))) return;
const id = decodeURIComponent(review[1]);
const gate = await check('verae.billing.statement.get', { principal: id });
if (!gate.allow) return json(403, gate);
@@ -70,6 +64,7 @@ const server = http.createServer(async (req, res) => {
return json(r.status, body);
}
if (req.method === 'POST' && url.pathname === '/credits') {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return;
const chunks = [];
for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
diff --git a/packages/verae-fleet/services/access-staff.json b/packages/verae-fleet/services/access-staff.json
index 988b429..bef385e 100644
--- a/packages/verae-fleet/services/access-staff.json
+++ b/packages/verae-fleet/services/access-staff.json
@@ -16,7 +16,8 @@
"env": {
"PORT": "3025",
"AUTHZ_URL": "http://127.0.0.1:3020",
- "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010"
+ "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
+ "STAFF_IAM_URL": "http://127.0.0.1:3028"
},
"nats": { "in": [], "out": ["verae.access.authz.check"] }
}
diff --git a/packages/verae-fleet/services/accounting-export.json b/packages/verae-fleet/services/accounting-export.json
index e24a5a5..a0b27ce 100644
--- a/packages/verae-fleet/services/accounting-export.json
+++ b/packages/verae-fleet/services/accounting-export.json
@@ -17,7 +17,8 @@
"PORT": "3013",
"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",
+ "STAFF_IAM_URL": "http://127.0.0.1:3028"
},
"nats": { "in": [], "out": ["verae.billing.statement.get"] }
}
diff --git a/packages/verae-fleet/services/customer-service.json b/packages/verae-fleet/services/customer-service.json
index 6d39b76..8470c02 100644
--- a/packages/verae-fleet/services/customer-service.json
+++ b/packages/verae-fleet/services/customer-service.json
@@ -18,7 +18,8 @@
"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",
- "AUTHZ_URL": "http://127.0.0.1:3020"
+ "AUTHZ_URL": "http://127.0.0.1:3020",
+ "STAFF_IAM_URL": "http://127.0.0.1:3028"
},
"nats": {
"in": [],
diff --git a/packages/verae-fleet/services/sales-pricing.json b/packages/verae-fleet/services/sales-pricing.json
index fbda599..d95744e 100644
--- a/packages/verae-fleet/services/sales-pricing.json
+++ b/packages/verae-fleet/services/sales-pricing.json
@@ -18,7 +18,8 @@
"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",
- "AUTHZ_URL": "http://127.0.0.1:3020"
+ "AUTHZ_URL": "http://127.0.0.1:3020",
+ "STAFF_IAM_URL": "http://127.0.0.1:3028"
},
"nats": { "in": [], "out": ["verae.billing.statement.get"] }
}
diff --git a/packages/verae-fleet/services/staff-iam.json b/packages/verae-fleet/services/staff-iam.json
new file mode 100644
index 0000000..437b98a
--- /dev/null
+++ b/packages/verae-fleet/services/staff-iam.json
@@ -0,0 +1,20 @@
+{
+ "id": "staff-iam",
+ "title": "Internal staff IAM (users, roles, permissions)",
+ "kind": "http",
+ "package": "verae-staff-iam",
+ "role": "staff-iam",
+ "managed": true,
+ "runtime": "HTTP :3028",
+ "health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
+ "ports": { "healthBase": 3028 },
+ "spawn": {
+ "cwd": "../verae-staff-iam",
+ "command": "node",
+ "args": ["src/server.js"]
+ },
+ "env": {
+ "PORT": "3028"
+ },
+ "nats": { "in": [], "out": [] }
+}
diff --git a/packages/verae-fleet/services/zappier-edge.json b/packages/verae-fleet/services/zappier-edge.json
index 52ec636..3f9ed25 100644
--- a/packages/verae-fleet/services/zappier-edge.json
+++ b/packages/verae-fleet/services/zappier-edge.json
@@ -15,6 +15,7 @@
},
"env": {
"PORT": "3000",
+ "STAFF_IAM_URL": "http://127.0.0.1:3028",
"BIND": "127.0.0.1",
"ZAPPIER_UPSTREAM": "http://127.0.0.1:3100",
"CS_SERVICE_URL": "http://127.0.0.1:3011",
diff --git a/packages/verae-fleet/src/iam-gate.js b/packages/verae-fleet/src/iam-gate.js
new file mode 100644
index 0000000..c5e6144
--- /dev/null
+++ b/packages/verae-fleet/src/iam-gate.js
@@ -0,0 +1,35 @@
+export function iamBase() {
+ return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+}
+
+export async function iamCheck(req, permission) {
+ const base = iamBase();
+ if (!base) {
+ if (process.env.STAFF_AUTH === '1') {
+ const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
+ const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
+ return { ok: Boolean(r && r.ok) };
+ }
+ return { ok: true, skipped: true };
+ }
+ const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
+ const r = await fetch(`${base}/check${q}`, {
+ headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
+ }).catch(() => null);
+ if (!r) return { ok: false, status: 502 };
+ const body = await r.json().catch(() => ({}));
+ return { ok: r.ok, status: r.status, ...body };
+}
+
+export async function denyOrRedirect(req, res, json, { permission, html }) {
+ const out = await iamCheck(req, permission);
+ if (out.ok) return true;
+ const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
+ if (html) {
+ res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
+ res.end();
+ return false;
+ }
+ json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
+ return false;
+}
diff --git a/packages/verae-fleet/src/server.js b/packages/verae-fleet/src/server.js
index 67843b1..da2289b 100644
--- a/packages/verae-fleet/src/server.js
+++ b/packages/verae-fleet/src/server.js
@@ -10,6 +10,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { listServices } from './load.js';
import { ACTIONS, Simulator } from '../../verae-zapier-simulator/src/pipeline.js';
+import { denyOrRedirect } from './iam-gate.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@@ -33,6 +34,16 @@ export function startControlServer(sup, mon) {
if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, { ok: true, role: 'operator-console' });
}
+ const gated =
+ url.pathname === '/' ||
+ url.pathname === '/index.html' ||
+ (url.pathname.startsWith('/api/') && req.method !== 'GET');
+ if (gated) {
+ const j = (code, obj) => json(res, code, obj);
+ if (!(await denyOrRedirect(req, res, j, { permission: 'fleet.operate', html: url.pathname === '/' || url.pathname === '/index.html' }))) {
+ return;
+ }
+ }
if (req.method === 'GET' && url.pathname === '/api/sim/actions') {
return json(res, 200, { actions: ACTIONS, state: sim.snapshot() });
}
diff --git a/packages/verae-staff-iam/NATS.md b/packages/verae-staff-iam/NATS.md
new file mode 100644
index 0000000..199aa61
--- /dev/null
+++ b/packages/verae-staff-iam/NATS.md
@@ -0,0 +1,3 @@
+# NATS
+
+HTTP only. Department doors call `GET /check?permission=`. Authz stays plane-based; IAM is the people hop in front of the staff plane.
diff --git a/packages/verae-staff-iam/README.md b/packages/verae-staff-iam/README.md
new file mode 100644
index 0000000..dd404cb
--- /dev/null
+++ b/packages/verae-staff-iam/README.md
@@ -0,0 +1,32 @@
+# verae-staff-iam
+
+Named **internal staff** accounts with **roles and permissions**. Source of truth for who may use CS, sales, accounting, the staff plane, the operator console, and the billing admin console.
+
+**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-iam
+
+Port **`:3028`**. UI: sign-in, people, roles, audit.
+
+## Seed lab users
+
+| Username | Password | Roles |
+|----------|----------|--------|
+| `admin` | `admin-dev-key` | owner (all) |
+| `cs` | `cs-dev-key` | cs |
+| `sales` | `sales-dev-key` | sales |
+| `accounting` | `acct-dev-key` | accounting |
+| `operator` | `fleet-dev-key` | operator |
+
+Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc. Persist: `STAFF_IAM_PATH`.
+
+## Wire other doors
+
+```bash
+STAFF_IAM_URL=http://127.0.0.1:3028
+STAFF_AUTH=1 # department HTML still redirects if check fails
+```
+
+`GET /check?permission=cs.credit` — cookie or `Authorization: Bearer`. Cookie name remains `staff_session`. Multi-host: `STAFF_COOKIE_DOMAIN`.
+
+## Roles
+
+`owner`, `iam-admin`, `billing-admin`, `cs`, `sales`, `accounting`, `operator`, `viewer`. Permissions are listed on `/roles`.
diff --git a/packages/verae-staff-iam/SUMMARY.md b/packages/verae-staff-iam/SUMMARY.md
new file mode 100644
index 0000000..b07e966
--- /dev/null
+++ b/packages/verae-staff-iam/SUMMARY.md
@@ -0,0 +1,3 @@
+# verae-staff-iam
+
+Internal staff directory: users, roles, permissions, sessions, audit. Not customer portal accounts.
diff --git a/packages/verae-staff-iam/package.json b/packages/verae-staff-iam/package.json
new file mode 100644
index 0000000..8283d8b
--- /dev/null
+++ b/packages/verae-staff-iam/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "verae-staff-iam",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Internal staff user management: named accounts, roles, permissions, audit",
+ "scripts": {
+ "start": "node src/server.js",
+ "test": "node --test test/*.test.js"
+ }
+}
diff --git a/packages/verae-staff-iam/public/app.html b/packages/verae-staff-iam/public/app.html
new file mode 100644
index 0000000..46573e6
--- /dev/null
+++ b/packages/verae-staff-iam/public/app.html
@@ -0,0 +1,170 @@
+
+
+
+
+
+ Staff IAM
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/verae-staff-iam/public/login.html b/packages/verae-staff-iam/public/login.html
new file mode 100644
index 0000000..2616bee
--- /dev/null
+++ b/packages/verae-staff-iam/public/login.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ Staff sign-in
+
+
+
+
+
+
+
+
diff --git a/packages/verae-staff-iam/src/client.js b/packages/verae-staff-iam/src/client.js
new file mode 100644
index 0000000..13d3b6c
--- /dev/null
+++ b/packages/verae-staff-iam/src/client.js
@@ -0,0 +1,47 @@
+/** HTTP client used by department doors and the admin console. */
+
+export function iamBase() {
+ return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+}
+
+export async function iamCheck(req, permission) {
+ const base = iamBase();
+ if (!base) {
+ if (process.env.STAFF_AUTH === '1') {
+ const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
+ const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
+ return { ok: Boolean(r && r.ok), skipped: false, legacy: true };
+ }
+ return { ok: true, skipped: true };
+ }
+ const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
+ const r = await fetch(`${base}/check${q}`, {
+ headers: {
+ cookie: req.headers.cookie || '',
+ authorization: req.headers.authorization || '',
+ },
+ }).catch(() => null);
+ if (!r) return { ok: false, status: 502 };
+ const body = await r.json().catch(() => ({}));
+ return { ok: r.ok, status: r.status, ...body };
+}
+
+export function iamLoginUrl(next) {
+ const base = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
+ return `${base}/login?next=${encodeURIComponent(next)}`;
+}
+
+export async function denyOrRedirect(req, res, { permission, html, json }) {
+ const out = await iamCheck(req, permission);
+ if (out.ok) return out;
+ if (html) {
+ res.writeHead(302, { location: iamLoginUrl(`http://${req.headers.host || '127.0.0.1'}/`) });
+ res.end();
+ return out;
+ }
+ json(out.status === 403 ? 403 : 401, {
+ error: out.reason || 'unauthorized',
+ permission: permission || undefined,
+ });
+ return out;
+}
diff --git a/packages/verae-staff-iam/src/passwords.js b/packages/verae-staff-iam/src/passwords.js
new file mode 100644
index 0000000..47a3543
--- /dev/null
+++ b/packages/verae-staff-iam/src/passwords.js
@@ -0,0 +1,26 @@
+import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
+
+const N = 16384;
+const R = 8;
+const P = 1;
+const KEY_LEN = 32;
+
+export function hashPassword(password) {
+ const salt = randomBytes(16);
+ const hash = scryptSync(String(password), salt, KEY_LEN, { N, r: R, p: P });
+ return `scrypt:${N}:${R}:${P}:${salt.toString('base64')}:${hash.toString('base64')}`;
+}
+
+export function verifyPassword(password, stored) {
+ const parts = String(stored || '').split(':');
+ if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
+ const [, n, r, p, saltB64, hashB64] = parts;
+ const expected = Buffer.from(hashB64, 'base64');
+ if (!expected.length) return false;
+ const actual = scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, {
+ N: Number(n),
+ r: Number(r),
+ p: Number(p),
+ });
+ return timingSafeEqual(actual, expected);
+}
diff --git a/packages/verae-staff-iam/src/roles.js b/packages/verae-staff-iam/src/roles.js
new file mode 100644
index 0000000..fe821c6
--- /dev/null
+++ b/packages/verae-staff-iam/src/roles.js
@@ -0,0 +1,98 @@
+/** Internal staff roles → permissions. Default deny on unknown. */
+
+export const PERMISSIONS = Object.freeze([
+ 'iam.users.read',
+ 'iam.users.write',
+ 'admin.pricing',
+ 'admin.tiers',
+ 'admin.customers',
+ 'admin.statement',
+ 'admin.invoices',
+ 'admin.reports',
+ 'admin.system',
+ 'cs.review',
+ 'cs.credit',
+ 'sales.review',
+ 'sales.quote',
+ 'accounting.review',
+ 'accounting.export',
+ 'staff.plane',
+ 'fleet.operate',
+]);
+
+export const ROLES = Object.freeze({
+ owner: {
+ title: 'Owner',
+ permissions: ['*'],
+ },
+ 'iam-admin': {
+ title: 'IAM admin',
+ permissions: ['iam.users.read', 'iam.users.write'],
+ },
+ 'billing-admin': {
+ title: 'Billing admin',
+ permissions: [
+ 'admin.pricing',
+ 'admin.tiers',
+ 'admin.customers',
+ 'admin.statement',
+ 'admin.invoices',
+ 'admin.reports',
+ 'admin.system',
+ ],
+ },
+ cs: {
+ title: 'Customer service',
+ permissions: ['cs.review', 'cs.credit', 'admin.statement', 'staff.plane'],
+ },
+ sales: {
+ title: 'Sales',
+ permissions: ['sales.review', 'sales.quote', 'admin.statement', 'staff.plane'],
+ },
+ accounting: {
+ title: 'Accounting',
+ permissions: ['accounting.review', 'accounting.export', 'admin.statement', 'admin.invoices', 'staff.plane'],
+ },
+ operator: {
+ title: 'Fleet operator',
+ permissions: ['fleet.operate'],
+ },
+ viewer: {
+ title: 'Read-only staff',
+ permissions: ['cs.review', 'sales.review', 'accounting.review', 'admin.statement', 'admin.reports', 'admin.system'],
+ },
+});
+
+export function expandRoles(roles) {
+ const set = new Set();
+ for (const id of roles || []) {
+ const def = ROLES[id];
+ if (!def) continue;
+ for (const p of def.permissions) {
+ if (p === '*') return ['*'];
+ set.add(p);
+ }
+ }
+ return [...set].sort();
+}
+
+export function allows(permissions, need) {
+ if (!need) return true;
+ const list = permissions || [];
+ if (list.includes('*')) return true;
+ return list.includes(need);
+}
+
+export function publicUser(u) {
+ if (!u) return null;
+ const roles = [...(u.roles || [])];
+ return {
+ id: u.id,
+ username: u.username,
+ name: u.name || u.username,
+ active: u.active !== false,
+ roles,
+ permissions: expandRoles(roles),
+ createdMs: u.createdMs,
+ };
+}
diff --git a/packages/verae-staff-iam/src/server.js b/packages/verae-staff-iam/src/server.js
new file mode 100644
index 0000000..4b1f622
--- /dev/null
+++ b/packages/verae-staff-iam/src/server.js
@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import http from 'node:http';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { allows, publicUser, ROLES, PERMISSIONS, expandRoles } from './roles.js';
+import { verifyPassword } from './passwords.js';
+import { loadIam, saveIam } from './store.js';
+import {
+ issueSession,
+ getSession,
+ revokeSession,
+ cookieHeader,
+ clearCookieHeader,
+ tokenFromReq,
+} from './sessions.js';
+
+const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
+const PORT = Number(process.env.PORT || 3028);
+const iam = loadIam();
+saveIam(iam);
+
+function readBody(req) {
+ return new Promise((resolve) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ if ((req.headers['content-type'] || '').includes('json')) {
+ try {
+ resolve(JSON.parse(text || '{}'));
+ } catch {
+ resolve({});
+ }
+ return;
+ }
+ resolve(Object.fromEntries(new URLSearchParams(text)));
+ });
+ });
+}
+
+function actorOf(req) {
+ const tok = tokenFromReq(req);
+ const s = getSession(tok);
+ if (!s) return null;
+ const u = iam.findById(s.userId);
+ if (!u || !u.active) return null;
+ return publicUser(u);
+}
+
+function requirePerm(req, res, json, perm) {
+ const me = actorOf(req);
+ if (!me) {
+ json(401, { ok: false, reason: 'not signed in' });
+ return null;
+ }
+ if (!allows(me.permissions, perm)) {
+ iam.log(me.username, 'deny', perm, req.url);
+ saveIam(iam);
+ json(403, { ok: false, reason: 'missing permission', permission: perm, username: me.username });
+ return null;
+ }
+ return me;
+}
+
+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', 'cache-control': 'no-store' });
+ res.end(JSON.stringify(obj));
+ };
+ try {
+ if (req.method === 'GET' && url.pathname === '/health') {
+ return json(200, { ok: true, role: 'verae-staff-iam', users: iam.users.length });
+ }
+ if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) {
+ const me = actorOf(req);
+ const file = me ? 'app.html' : 'login.html';
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ return res.end(fs.readFileSync(path.join(PUBLIC, file)));
+ }
+ if (req.method === 'GET' && url.pathname === '/roles') {
+ return json(200, { roles: ROLES, permissions: PERMISSIONS });
+ }
+ if (req.method === 'POST' && url.pathname === '/login') {
+ const body = await readBody(req);
+ const username = String(body.username || '').trim();
+ const password = String(body.password || '');
+ const next = body.next || '/';
+ const user = iam.findByUsername(username);
+ if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
+ iam.log(username || 'unknown', 'login.fail', username);
+ saveIam(iam);
+ if ((req.headers['content-type'] || '').includes('json')) return json(401, { error: 'invalid username or password' });
+ res.writeHead(302, { location: '/login?error=1' });
+ return res.end();
+ }
+ const token = issueSession(user.id);
+ iam.log(username, 'login.ok', username);
+ saveIam(iam);
+ const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/';
+ if ((req.headers['content-type'] || '').includes('json')) {
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'set-cookie': cookieHeader(token),
+ });
+ return res.end(JSON.stringify({ token, user: publicUser(user) }));
+ }
+ res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc });
+ return res.end();
+ }
+ if (req.method === 'POST' && url.pathname === '/logout') {
+ revokeSession(tokenFromReq(req));
+ if ((req.headers['content-type'] || '').includes('json')) {
+ res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearCookieHeader() });
+ return res.end(JSON.stringify({ ok: true }));
+ }
+ res.writeHead(302, { 'set-cookie': clearCookieHeader(), location: '/login' });
+ return res.end();
+ }
+ if (req.method === 'GET' && url.pathname === '/check') {
+ const me = actorOf(req);
+ if (!me) return json(401, { ok: false, reason: 'not signed in' });
+ const need = url.searchParams.get('permission');
+ if (need && !allows(me.permissions, need)) {
+ iam.log(me.username, 'deny', need, 'check');
+ saveIam(iam);
+ return json(403, { ok: false, reason: 'missing permission', permission: need, username: me.username });
+ }
+ return json(200, { ok: true, user: me, permission: need || null });
+ }
+ if (req.method === 'GET' && url.pathname === '/me') {
+ const me = actorOf(req);
+ if (!me) return json(401, { ok: false });
+ return json(200, { user: me });
+ }
+ if (req.method === 'GET' && url.pathname === '/users') {
+ const me = requirePerm(req, res, json, 'iam.users.read');
+ if (!me) return;
+ return json(200, { users: iam.users.map(publicUser) });
+ }
+ if (req.method === 'GET' && url.pathname === '/audit') {
+ const me = requirePerm(req, res, json, 'iam.users.read');
+ if (!me) return;
+ return json(200, { audit: iam.audit.slice(0, 200) });
+ }
+ if (req.method === 'POST' && url.pathname === '/users') {
+ const me = requirePerm(req, res, json, 'iam.users.write');
+ if (!me) return;
+ const body = await readBody(req);
+ try {
+ const user = iam.create({ ...body, actor: me.username });
+ saveIam(iam);
+ return json(201, publicUser(user));
+ } catch (err) {
+ return json(err.status || 400, { error: err.message });
+ }
+ }
+ const upd = url.pathname.match(/^\/users\/([^/]+)$/);
+ if (req.method === 'PUT' && upd) {
+ const me = requirePerm(req, res, json, 'iam.users.write');
+ if (!me) return;
+ const body = await readBody(req);
+ try {
+ const user = iam.update(decodeURIComponent(upd[1]), body, me.username);
+ saveIam(iam);
+ return json(200, publicUser(user));
+ } catch (err) {
+ return json(err.status || 400, { error: err.message });
+ }
+ }
+ json(404, { error: 'not found' });
+ } catch (err) {
+ json(500, { error: err.message });
+ }
+});
+
+server.listen(PORT, '0.0.0.0', () => {
+ process.stdout.write(`verae-staff-iam http://0.0.0.0:${PORT}/ users=${iam.users.length}\n`);
+});
diff --git a/packages/verae-staff-iam/src/sessions.js b/packages/verae-staff-iam/src/sessions.js
new file mode 100644
index 0000000..688cdd3
--- /dev/null
+++ b/packages/verae-staff-iam/src/sessions.js
@@ -0,0 +1,48 @@
+import { randomBytes } from 'node:crypto';
+
+const TTL_MS = 12 * 60 * 60 * 1000;
+const sessions = new Map();
+
+export function issueSession(userId) {
+ const token = randomBytes(24).toString('hex');
+ sessions.set(token, { userId, exp: Date.now() + TTL_MS });
+ return token;
+}
+
+export function getSession(token) {
+ if (!token) return null;
+ const s = sessions.get(token);
+ if (!s) return null;
+ if (s.exp < Date.now()) {
+ sessions.delete(token);
+ return null;
+ }
+ return s;
+}
+
+export function revokeSession(token) {
+ if (token) sessions.delete(token);
+}
+
+export function cookieHeader(token) {
+ let s = `staff_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(TTL_MS / 1000)}`;
+ const domain = process.env.STAFF_COOKIE_DOMAIN;
+ if (domain) s += `; Domain=${domain}`;
+ if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure';
+ return s;
+}
+
+export function clearCookieHeader() {
+ let s = 'staff_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0';
+ const domain = process.env.STAFF_COOKIE_DOMAIN;
+ if (domain) s += `; Domain=${domain}`;
+ return s;
+}
+
+export function tokenFromReq(req) {
+ const bearer = req.headers?.authorization;
+ if (bearer?.startsWith('Bearer ')) return bearer.slice(7);
+ const raw = req.headers?.cookie || '';
+ const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
+ return m ? m[1] : '';
+}
diff --git a/packages/verae-staff-iam/src/store.js b/packages/verae-staff-iam/src/store.js
new file mode 100644
index 0000000..3b606c2
--- /dev/null
+++ b/packages/verae-staff-iam/src/store.js
@@ -0,0 +1,166 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { randomBytes } from 'node:crypto';
+import { hashPassword } from './passwords.js';
+import { ROLES } from './roles.js';
+
+export function storePath() {
+ return process.env.STAFF_IAM_PATH || path.join(process.env.FLEET_STATE_DIR || process.cwd(), 'data', 'staff-iam.json');
+}
+
+export function newId() {
+ return `stu_${randomBytes(6).toString('hex')}`;
+}
+
+export class StaffIam {
+ constructor() {
+ /** @type {Array<{id:string,username:string,name:string,passwordHash:string,roles:string[],active:boolean,createdMs:number}>} */
+ this.users = [];
+ /** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
+ this.audit = [];
+ }
+
+ seed() {
+ if (this.users.length) return this;
+ const seeds = [
+ {
+ username: process.env.IAM_OWNER_USER || process.env.ADMIN_USER || 'admin',
+ password: process.env.IAM_OWNER_PASSWORD || process.env.ADMIN_KEY || 'admin-dev-key',
+ name: 'Owner',
+ roles: ['owner'],
+ },
+ {
+ username: process.env.IAM_CS_USER || 'cs',
+ password: process.env.IAM_CS_PASSWORD || 'cs-dev-key',
+ name: 'Customer service',
+ roles: ['cs'],
+ },
+ {
+ username: process.env.IAM_SALES_USER || 'sales',
+ password: process.env.IAM_SALES_PASSWORD || 'sales-dev-key',
+ name: 'Sales',
+ roles: ['sales'],
+ },
+ {
+ username: process.env.IAM_ACCT_USER || 'accounting',
+ password: process.env.IAM_ACCT_PASSWORD || 'acct-dev-key',
+ name: 'Accounting',
+ roles: ['accounting'],
+ },
+ {
+ username: process.env.IAM_OPS_USER || 'operator',
+ password: process.env.IAM_OPS_PASSWORD || 'fleet-dev-key',
+ name: 'Fleet operator',
+ roles: ['operator'],
+ },
+ ];
+ for (const s of seeds) {
+ this.users.push({
+ id: newId(),
+ username: s.username,
+ name: s.name,
+ passwordHash: hashPassword(s.password),
+ roles: s.roles,
+ active: true,
+ createdMs: Date.now(),
+ });
+ }
+ this.log('system', 'seed', null, `${this.users.length} users`);
+ return this;
+ }
+
+ log(actor, action, target, detail) {
+ this.audit.unshift({
+ t: new Date().toISOString(),
+ actor: actor || 'system',
+ action,
+ target: target || undefined,
+ detail: detail || undefined,
+ });
+ this.audit = this.audit.slice(0, 400);
+ }
+
+ findByUsername(username) {
+ return this.users.find((u) => u.username === username);
+ }
+
+ findById(id) {
+ return this.users.find((u) => u.id === id);
+ }
+
+ create({ username, password, name, roles, actor }) {
+ if (!/^[a-zA-Z0-9_.-]+$/.test(username || '')) throw Object.assign(new Error('bad username'), { status: 400 });
+ if (!password || String(password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 });
+ if (this.findByUsername(username)) throw Object.assign(new Error('username already exists'), { status: 409 });
+ const cleanRoles = (roles || []).filter((r) => ROLES[r]);
+ const user = {
+ id: newId(),
+ username,
+ name: name || username,
+ passwordHash: hashPassword(password),
+ roles: cleanRoles.length ? cleanRoles : ['viewer'],
+ active: true,
+ createdMs: Date.now(),
+ };
+ this.users.push(user);
+ this.log(actor, 'user.create', username, user.roles.join(','));
+ return user;
+ }
+
+ update(username, patch, actor) {
+ const user = this.findByUsername(username);
+ if (!user) throw Object.assign(new Error('not found'), { status: 404 });
+ if (patch.name !== undefined) user.name = String(patch.name);
+ if (Array.isArray(patch.roles)) {
+ user.roles = patch.roles.filter((r) => ROLES[r]);
+ }
+ if (typeof patch.active === 'boolean') {
+ if (patch.active === false) {
+ const owners = this.users.filter((u) => u.active && u.roles.includes('owner'));
+ if (user.roles.includes('owner') && owners.length <= 1) {
+ throw Object.assign(new Error('cannot deactivate the last owner'), { status: 400 });
+ }
+ }
+ user.active = patch.active;
+ }
+ if (patch.password) {
+ if (String(patch.password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 });
+ user.passwordHash = hashPassword(patch.password);
+ }
+ this.log(actor, 'user.update', username, JSON.stringify({ roles: user.roles, active: user.active }));
+ return user;
+ }
+
+ dump() {
+ return { users: this.users, audit: this.audit };
+ }
+
+ load(raw) {
+ if (!raw || typeof raw !== 'object') return this;
+ this.users = Array.isArray(raw.users) ? raw.users : [];
+ this.audit = Array.isArray(raw.audit) ? raw.audit : [];
+ return this;
+ }
+}
+
+export function loadIam() {
+ const iam = new StaffIam();
+ const p = storePath();
+ if (fs.existsSync(p)) {
+ try {
+ iam.load(JSON.parse(fs.readFileSync(p, 'utf8')));
+ } catch {
+ /* empty */
+ }
+ }
+ if (!iam.users.length) iam.seed();
+ return iam;
+}
+
+export function saveIam(iam) {
+ const p = storePath();
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ const tmp = `${p}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(iam.dump(), null, 2));
+ fs.renameSync(tmp, p);
+}
diff --git a/packages/verae-staff-iam/test/health.test.js b/packages/verae-staff-iam/test/health.test.js
new file mode 100644
index 0000000..b9ffb95
--- /dev/null
+++ b/packages/verae-staff-iam/test/health.test.js
@@ -0,0 +1,57 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+test('iam login, permission check, user CRUD', async () => {
+ const port = 18028;
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-'));
+ const child = spawn(process.execPath, ['src/server.js'], {
+ cwd: root,
+ env: { ...process.env, PORT: String(port), STAFF_IAM_PATH: path.join(dir, 'iam.json') },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ await new Promise((r) => setTimeout(r, 500));
+ try {
+ const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json();
+ assert.equal(h.role, 'verae-staff-iam');
+ const login = await fetch(`http://127.0.0.1:${port}/login`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username: 'admin', password: 'admin-dev-key' }),
+ });
+ assert.equal(login.status, 200);
+ const { token, user } = await login.json();
+ assert.ok(user.permissions.includes('*'));
+ const hdr = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
+ const ok = await fetch(`http://127.0.0.1:${port}/check?permission=cs.credit`, { headers: hdr });
+ assert.equal(ok.status, 200);
+ const csLogin = await fetch(`http://127.0.0.1:${port}/login`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username: 'cs', password: 'cs-dev-key' }),
+ });
+ const cs = await csLogin.json();
+ const deny = await fetch(`http://127.0.0.1:${port}/check?permission=accounting.export`, {
+ headers: { authorization: `Bearer ${cs.token}` },
+ });
+ assert.equal(deny.status, 403);
+ const created = await fetch(`http://127.0.0.1:${port}/users`, {
+ method: 'POST',
+ headers: hdr,
+ body: JSON.stringify({ username: 'pat', password: 'pat-pass-99', name: 'Pat', roles: ['sales'] }),
+ });
+ assert.equal(created.status, 201);
+ const body = await created.json();
+ assert.deepEqual(body.roles, ['sales']);
+ const list = await (await fetch(`http://127.0.0.1:${port}/users`, { headers: hdr })).json();
+ assert.ok(list.users.some((u) => u.username === 'pat'));
+ } finally {
+ child.kill('SIGTERM');
+ }
+});
diff --git a/packages/verae-staff-iam/test/roles.test.js b/packages/verae-staff-iam/test/roles.test.js
new file mode 100644
index 0000000..e89bd41
--- /dev/null
+++ b/packages/verae-staff-iam/test/roles.test.js
@@ -0,0 +1,22 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { expandRoles, allows, ROLES } from '../src/roles.js';
+import { StaffIam } from '../src/store.js';
+import { verifyPassword } from '../src/passwords.js';
+
+test('owner expands to all permissions', () => {
+ assert.deepEqual(expandRoles(['owner']), ['*']);
+ assert.equal(allows(['*'], 'cs.credit'), true);
+ assert.equal(allows(expandRoles(['cs']), 'cs.credit'), true);
+ assert.equal(allows(expandRoles(['cs']), 'accounting.export'), false);
+ assert.ok(ROLES.sales);
+});
+
+test('cannot deactivate last owner; passwords hash', () => {
+ const iam = new StaffIam().seed();
+ const owner = iam.findByUsername('admin');
+ assert.ok(verifyPassword(process.env.ADMIN_KEY || 'admin-dev-key', owner.passwordHash));
+ assert.throws(() => iam.update('admin', { active: false }, 'admin'), /last owner/);
+ const cs = iam.create({ username: 'anna', password: 'anna-pass-1', name: 'Anna', roles: ['cs'], actor: 'admin' });
+ assert.deepEqual(cs.roles, ['cs']);
+});
diff --git a/packages/verae-staff-session/src/server.js b/packages/verae-staff-session/src/server.js
index 525d3cc..809c6ce 100644
--- a/packages/verae-staff-session/src/server.js
+++ b/packages/verae-staff-session/src/server.js
@@ -50,8 +50,35 @@ const server = http.createServer(async (req, res) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(obj));
};
+ const IAM = (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+ if (IAM && req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) {
+ const next = url.searchParams.get('next') || '';
+ res.writeHead(302, { location: `${IAM}/login?next=${encodeURIComponent(next)}` });
+ return res.end();
+ }
+ if (IAM && (url.pathname === '/check' || url.pathname === '/login' || url.pathname === '/logout')) {
+ const target = `${IAM}${url.pathname}${url.search}`;
+ const r = await fetch(target, {
+ method: req.method,
+ headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '', 'content-type': req.headers['content-type'] || '' },
+ body: req.method === 'GET' ? undefined : await new Promise((resolve) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => resolve(Buffer.concat(chunks)));
+ }),
+ redirect: 'manual',
+ });
+ const buf = Buffer.from(await r.arrayBuffer());
+ const headers = { 'content-type': r.headers.get('content-type') || 'application/json' };
+ const sc = r.headers.get('set-cookie');
+ if (sc) headers['set-cookie'] = sc;
+ const loc = r.headers.get('location');
+ if (loc) headers.location = loc;
+ res.writeHead(r.status, headers);
+ return res.end(buf);
+ }
if (req.method === 'GET' && url.pathname === '/health') {
- return json(200, { ok: true, role: 'verae-staff-session' });
+ return json(200, { ok: true, role: 'verae-staff-session', iam: Boolean(IAM) });
}
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
diff --git a/packages/zapier-decisions/LOG.md b/packages/zapier-decisions/LOG.md
index 49cd044..d714a0e 100644
--- a/packages/zapier-decisions/LOG.md
+++ b/packages/zapier-decisions/LOG.md
@@ -32,6 +32,13 @@
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
- SSH spawn timeout 8s; failed hosts skipped.
+## 2026-09-11 — staff IAM
+
+- New `verae-staff-iam` :3028 — users, roles, permissions, sessions, audit UI.
+- Admin login uses IAM when `STAFF_IAM_URL` is set; tabs hide without permission.
+- CS/sales/accounting/access-staff/fleet mutating APIs check `cs.credit`, `sales.quote`, `accounting.export`, `fleet.operate`, etc.
+- Seed: admin/cs/sales/accounting/operator. Shared staff key is fallback only.
+
## 2026-09-11 — last three UI leftovers
- Swagger `/docs` stays stock; banner names it OpenAPI explorer.
diff --git a/packages/zapier-decisions/TODO.md b/packages/zapier-decisions/TODO.md
index a928b72..153f9e5 100644
--- a/packages/zapier-decisions/TODO.md
+++ b/packages/zapier-decisions/TODO.md
@@ -4,5 +4,6 @@
- [ ] NATS nkeys/mTLS on a real three-node cluster (accounts file is the lab stand-in).
- [x] Exclusive JetStream consumer for `verae.zapier.jobs.events` on `verae-jobs-events` (`JOBS_EVENTS_EXCLUSIVE=1`; middleware skips the router).
- [x] Auth on CS/sales/accounting HTML via `verae-staff-session` (`STAFF_AUTH=1`).
+- [x] Staff IAM: named users, roles, permissions (`verae-staff-iam` :3028).
- [ ] Zapier Platform `push` of a private app.
- [ ] Move portal static files fully into `verae-access-web` (today it proxies `/portal` to loopback edge).
diff --git a/packages/zappier-accounting-export/src/iam-gate.js b/packages/zappier-accounting-export/src/iam-gate.js
new file mode 100644
index 0000000..c5e6144
--- /dev/null
+++ b/packages/zappier-accounting-export/src/iam-gate.js
@@ -0,0 +1,35 @@
+export function iamBase() {
+ return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+}
+
+export async function iamCheck(req, permission) {
+ const base = iamBase();
+ if (!base) {
+ if (process.env.STAFF_AUTH === '1') {
+ const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
+ const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
+ return { ok: Boolean(r && r.ok) };
+ }
+ return { ok: true, skipped: true };
+ }
+ const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
+ const r = await fetch(`${base}/check${q}`, {
+ headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
+ }).catch(() => null);
+ if (!r) return { ok: false, status: 502 };
+ const body = await r.json().catch(() => ({}));
+ return { ok: r.ok, status: r.status, ...body };
+}
+
+export async function denyOrRedirect(req, res, json, { permission, html }) {
+ const out = await iamCheck(req, permission);
+ if (out.ok) return true;
+ const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
+ if (html) {
+ res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
+ res.end();
+ return false;
+ }
+ json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
+ return false;
+}
diff --git a/packages/zappier-accounting-export/src/server.js b/packages/zappier-accounting-export/src/server.js
index fbe02ff..aef0758 100644
--- a/packages/zappier-accounting-export/src/server.js
+++ b/packages/zappier-accounting-export/src/server.js
@@ -6,6 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js';
import { listCustomers, withCustomerName } from './names.js';
+import { denyOrRedirect } from './iam-gate.js';
const PORT = Number(process.env.PORT || 3013);
const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
@@ -27,15 +28,7 @@ const server = http.createServer(async (req, res) => {
const json = (code, obj) => send(code, 'application/json', JSON.stringify(obj));
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
- if (process.env.STAFF_AUTH === '1') {
- const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
- const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
- if (!chk || !chk.ok) {
- res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
- res.end();
- return;
- }
- }
+ if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.review', html: true }))) return;
return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(PUBLIC, 'index.html')));
}
if (req.method === 'GET' && url.pathname === '/health') {
@@ -46,6 +39,7 @@ const server = http.createServer(async (req, res) => {
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.review' }))) return;
const id = decodeURIComponent(review[1]);
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id });
if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY));
@@ -58,10 +52,12 @@ const server = http.createServer(async (req, res) => {
const period = url.searchParams.get('period');
const q = period ? `?period=${encodeURIComponent(period)}` : '';
if (req.method === 'GET' && url.pathname === '/export/quickbooks.iif') {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.export' }))) return;
const r = await edge(`/admin/api/exports/quickbooks.iif${q}`);
return send(r.status, 'text/plain', await r.text());
}
if (req.method === 'GET' && url.pathname === '/export/accounting.csv') {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.export' }))) return;
const r = await edge(`/admin/api/exports/accounting.csv${q}`);
return send(r.status, 'text/csv', await r.text());
}
diff --git a/packages/zappier-customer-service/src/iam-gate.js b/packages/zappier-customer-service/src/iam-gate.js
new file mode 100644
index 0000000..c5e6144
--- /dev/null
+++ b/packages/zappier-customer-service/src/iam-gate.js
@@ -0,0 +1,35 @@
+export function iamBase() {
+ return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+}
+
+export async function iamCheck(req, permission) {
+ const base = iamBase();
+ if (!base) {
+ if (process.env.STAFF_AUTH === '1') {
+ const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
+ const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
+ return { ok: Boolean(r && r.ok) };
+ }
+ return { ok: true, skipped: true };
+ }
+ const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
+ const r = await fetch(`${base}/check${q}`, {
+ headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
+ }).catch(() => null);
+ if (!r) return { ok: false, status: 502 };
+ const body = await r.json().catch(() => ({}));
+ return { ok: r.ok, status: r.status, ...body };
+}
+
+export async function denyOrRedirect(req, res, json, { permission, html }) {
+ const out = await iamCheck(req, permission);
+ if (out.ok) return true;
+ const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
+ if (html) {
+ res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
+ res.end();
+ return false;
+ }
+ json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
+ return false;
+}
diff --git a/packages/zappier-customer-service/src/server.js b/packages/zappier-customer-service/src/server.js
index 8f069fa..fef39ee 100644
--- a/packages/zappier-customer-service/src/server.js
+++ b/packages/zappier-customer-service/src/server.js
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js';
import { listCustomers, withCustomerName } from './names.js';
import { staffPageHtml } from './staff-page.js';
+import { denyOrRedirect } from './iam-gate.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@@ -34,16 +35,7 @@ const server = http.createServer(async (req, res) => {
};
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
- if (process.env.STAFF_AUTH === '1') {
- const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
- const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
- if (!chk || !chk.ok) {
- const next = `http://${req.headers.host || '127.0.0.1'}/`;
- res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent(next)}` });
- res.end();
- return;
- }
- }
+ if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review', html: true }))) return;
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(
await staffPageHtml(
@@ -65,12 +57,14 @@ const server = http.createServer(async (req, res) => {
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return;
const id = decodeURIComponent(review[1]);
const out = await statement(id);
out.body = await withCustomerName(out.body, id, EDGE, KEY);
return json(out.status, out.body);
}
if (req.method === 'POST' && url.pathname === '/credits') {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return;
const chunks = [];
for await (const c of req) chunks.push(c);
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
diff --git a/packages/zappier-sales-pricing/src/iam-gate.js b/packages/zappier-sales-pricing/src/iam-gate.js
new file mode 100644
index 0000000..c5e6144
--- /dev/null
+++ b/packages/zappier-sales-pricing/src/iam-gate.js
@@ -0,0 +1,35 @@
+export function iamBase() {
+ return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+}
+
+export async function iamCheck(req, permission) {
+ const base = iamBase();
+ if (!base) {
+ if (process.env.STAFF_AUTH === '1') {
+ const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
+ const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
+ return { ok: Boolean(r && r.ok) };
+ }
+ return { ok: true, skipped: true };
+ }
+ const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
+ const r = await fetch(`${base}/check${q}`, {
+ headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
+ }).catch(() => null);
+ if (!r) return { ok: false, status: 502 };
+ const body = await r.json().catch(() => ({}));
+ return { ok: r.ok, status: r.status, ...body };
+}
+
+export async function denyOrRedirect(req, res, json, { permission, html }) {
+ const out = await iamCheck(req, permission);
+ if (out.ok) return true;
+ const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
+ if (html) {
+ res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
+ res.end();
+ return false;
+ }
+ json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
+ return false;
+}
diff --git a/packages/zappier-sales-pricing/src/server.js b/packages/zappier-sales-pricing/src/server.js
index e36fa6c..ff85574 100644
--- a/packages/zappier-sales-pricing/src/server.js
+++ b/packages/zappier-sales-pricing/src/server.js
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js';
import { listCustomers, withCustomerName } from './names.js';
+import { denyOrRedirect } from './iam-gate.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@@ -37,15 +38,7 @@ const server = http.createServer(async (req, res) => {
};
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
- if (process.env.STAFF_AUTH === '1') {
- const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
- const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
- if (!chk || !chk.ok) {
- res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
- res.end();
- return;
- }
- }
+ if (!(await denyOrRedirect(req, res, json, { permission: 'sales.review', html: true }))) return;
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
return;
@@ -58,6 +51,7 @@ const server = http.createServer(async (req, res) => {
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'sales.review' }))) return;
const id = decodeURIComponent(review[1]);
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id });
if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY));
@@ -68,6 +62,7 @@ const server = http.createServer(async (req, res) => {
}
const quote = url.pathname.match(/^\/quotes\/([^/]+)$/);
if (req.method === 'GET' && quote) {
+ if (!(await denyOrRedirect(req, res, json, { permission: 'sales.quote' }))) return;
const forwarded = await edge(`/admin/api/sales/quote/${quote[1]}`);
return json(forwarded.status, forwarded.body);
}
diff --git a/packages/zappier/admin/app.js b/packages/zappier/admin/app.js
index 000aff0..1e024ae 100644
--- a/packages/zappier/admin/app.js
+++ b/packages/zappier/admin/app.js
@@ -92,11 +92,41 @@ function currentPeriod() {
return new Date().toISOString().slice(0, 7);
}
+const TAB_PERM = {
+ endpoints: 'admin.pricing',
+ tiers: 'admin.tiers',
+ customers: 'admin.customers',
+ statement: 'admin.statement',
+ invoices: 'admin.invoices',
+ reports: 'admin.reports',
+ system: 'admin.system',
+ users: 'iam.users.read',
+};
+function can(perm) {
+ const p = state.me?.permissions || ['*'];
+ return p.includes('*') || p.includes(perm);
+}
+function applyNav() {
+ document.querySelectorAll('aside nav button').forEach((btn) => {
+ const need = TAB_PERM[btn.dataset.tab];
+ btn.style.display = !need || can(need) ? '' : 'none';
+ });
+}
+
async function load() {
+ state.me = await api('/me').catch(() => ({ permissions: ['*'] }));
+ applyNav();
+ if (!can('admin.pricing') && !can('admin.tiers') && !can('admin.customers')) {
+ if (state.me?.iamUrl) {
+ document.getElementById('endpoints').innerHTML =
+ `Staff IAM
This console is for billing admins. Manage people at ${state.me.iamUrl}.
`;
+ }
+ }
+ if (!can('admin.pricing') && !can('admin.tiers')) return;
state.pricing = await api('/pricing');
- state.customers = (await api('/customers')).customers;
- state.invoices = (await api('/invoices')).invoices;
- state.users = (await api('/users')).users;
+ state.customers = can('admin.customers') ? (await api('/customers')).customers : [];
+ state.invoices = can('admin.invoices') ? (await api('/invoices')).invoices : [];
+ state.users = can('iam.users.read') || !state.me?.iam ? (await api('/users')).users : [];
renderEndpoints();
renderTiers();
renderCustomers();
@@ -703,6 +733,16 @@ async function loadSystem() {
/* ---------------- admin users ---------------- */
function renderUsers() {
+ if (state.me?.iam) {
+ document.getElementById('users').innerHTML = `
+ Staff users
+ Named internal accounts and roles live in Staff IAM — not the billing-console seed table.
+ `;
+ return;
+ }
const rows = state.users
.slice()
.sort((a, b) => a.username.localeCompare(b.username))
diff --git a/packages/zappier/src/admin.ts b/packages/zappier/src/admin.ts
index f0db185..9f9ff7a 100644
--- a/packages/zappier/src/admin.ts
+++ b/packages/zappier/src/admin.ts
@@ -15,26 +15,43 @@ import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-e
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { booksConfigured, ledgerAdjust, ledgerPutCustomer, ledgerStatement } from './ledger';
+import { allows, iamLogin, iamUrl, permForAdminPath, StaffSession } from './staff-iam';
// Issued login tokens (in-memory; a restart simply requires logging in again).
-const sessions = new Map();
+const sessions = new Map();
+
+declare module 'express-serve-static-core' {
+ interface Request {
+ staff?: StaffSession;
+ }
+}
export function adminLoginRouter(users: AdminUserRepo): Router {
const router = Router();
- router.post('/login', (req, res) => {
+ router.post('/login', async (req, res) => {
const { username, password } = req.body ?? {};
if (typeof username !== 'string' || typeof password !== 'string') {
res.status(401).json({ error: 'invalid username or password' });
return;
}
+ if (iamUrl()) {
+ const via = await iamLogin(username, password);
+ if (!via) {
+ res.status(401).json({ error: 'invalid username or password' });
+ return;
+ }
+ sessions.set(via.token, via.user);
+ res.json({ token: via.token, user: via.user });
+ return;
+ }
const user = users.findByUsername(username);
if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
res.status(401).json({ error: 'invalid username or password' });
return;
}
const token = randomBytes(24).toString('hex');
- sessions.set(token, Date.now());
- res.json({ token });
+ sessions.set(token, { username, permissions: ['*'], t: Date.now() });
+ res.json({ token, user: { username, permissions: ['*'] } });
});
return router;
}
@@ -42,14 +59,33 @@ export function adminLoginRouter(users: AdminUserRepo): Router {
export function adminAuth(): RequestHandler {
return (req, res, next) => {
const expected = process.env.ADMIN_KEY ?? 'admin-dev-key';
- if (req.header('x-admin-key') === expected) return next();
+ if (req.header('x-admin-key') === expected) {
+ req.staff = { username: 'x-admin-key', permissions: ['*'], t: Date.now() };
+ return next();
+ }
const bearer = req.header('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined;
- if (token && sessions.has(token)) return next();
+ const sess = token ? sessions.get(token) : undefined;
+ if (sess) {
+ req.staff = sess;
+ return next();
+ }
res.status(403).json({ error: 'invalid or missing admin key' });
};
}
+export function adminPerms(): RequestHandler {
+ return (req, res, next) => {
+ if (!iamUrl()) return next();
+ const need = permForAdminPath(req.method, req.path);
+ if (!allows(req.staff?.permissions, need)) {
+ res.status(403).json({ error: 'forbidden', permission: need });
+ return;
+ }
+ next();
+ };
+}
+
function isValidRule(rule: unknown): rule is PriceRule {
if (!rule || typeof rule !== 'object') return false;
const r = rule as Record;
@@ -148,6 +184,17 @@ export function adminRouter(
): Router {
const router = Router();
+ router.get('/me', (req, res) => {
+ res.json({
+ username: req.staff?.username,
+ name: req.staff?.name,
+ roles: req.staff?.roles || [],
+ permissions: req.staff?.permissions || ['*'],
+ iam: Boolean(iamUrl()),
+ iamUrl: iamUrl() || undefined,
+ });
+ });
+
router.get('/pricing', (req, res) => {
res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() });
});
diff --git a/packages/zappier/src/app.ts b/packages/zappier/src/app.ts
index a8c2c8a..140a3e0 100644
--- a/packages/zappier/src/app.ts
+++ b/packages/zappier/src/app.ts
@@ -19,7 +19,7 @@ import {
PricingStore,
} from './pricing';
import { InMemoryUsageRepo, UsageRepo } from './usage';
-import { adminAuth, adminLoginRouter, adminRouter } from './admin';
+import { adminAuth, adminLoginRouter, adminPerms, adminRouter } from './admin';
import { AdminUserRepo, InMemoryAdminUserRepo, seedAdminUsersFromEnv } from './admin-users';
import { InMemorySessionRepo, SessionRepo } from './accounts';
import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
@@ -135,6 +135,7 @@ export function buildApp(deps: AppDeps = {}): {
'/admin/api',
adminLoginRouter(adminUsers),
adminAuth(),
+ adminPerms(),
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits),
);
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
diff --git a/packages/zappier/src/staff-iam.ts b/packages/zappier/src/staff-iam.ts
new file mode 100644
index 0000000..71cf921
--- /dev/null
+++ b/packages/zappier/src/staff-iam.ts
@@ -0,0 +1,61 @@
+export function iamUrl(): string {
+ return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
+}
+
+export type StaffSession = {
+ username: string;
+ name?: string;
+ roles?: string[];
+ permissions: string[];
+ t: number;
+};
+
+export function allows(permissions: string[] | undefined, need: string | undefined): boolean {
+ if (!need) return true;
+ const list = permissions || [];
+ return list.includes('*') || list.includes(need);
+}
+
+export function permForAdminPath(method: string, p: string): string | undefined {
+ if (p === '/me' || p === '/login') return undefined;
+ if (p === '/pricing' || p.startsWith('/pricing')) return 'admin.pricing';
+ if (p.startsWith('/endpoints')) return 'admin.pricing';
+ if (p.startsWith('/tiers')) return 'admin.tiers';
+ if (p.startsWith('/customers') || p.startsWith('/credits')) return 'admin.customers';
+ if (p.startsWith('/statement')) return 'admin.statement';
+ if (p.startsWith('/invoices') || p.startsWith('/exports')) return 'admin.invoices';
+ if (p.startsWith('/reports')) return 'admin.reports';
+ if (p.startsWith('/zapier')) return 'admin.system';
+ if (p.startsWith('/users') || p.startsWith('/staff')) {
+ return method === 'GET' ? 'iam.users.read' : 'iam.users.write';
+ }
+ return undefined;
+}
+
+export async function iamLogin(
+ username: string,
+ password: string,
+): Promise<{ token: string; user: StaffSession } | null> {
+ const base = iamUrl();
+ if (!base) return null;
+ const r = await fetch(`${base}/login`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!r.ok) return null;
+ const data = (await r.json()) as {
+ token: string;
+ user: { username: string; name?: string; roles?: string[]; permissions: string[] };
+ };
+ return {
+ token: data.token,
+ user: {
+ username: data.user.username,
+ name: data.user.name,
+ roles: data.user.roles,
+ permissions: data.user.permissions || [],
+ t: Date.now(),
+ },
+ };
+}
diff --git a/scripts/build-docs-site.py b/scripts/build-docs-site.py
index 0358140..95eff37 100755
--- a/scripts/build-docs-site.py
+++ b/scripts/build-docs-site.py
@@ -367,6 +367,7 @@ def main() -> None:
"ui-docs",
"verae-staff-session",
"verae-staff-ui",
+ "verae-staff-iam",
):
pkg_root = ROOT / "packages" / pkg
if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}:
diff --git a/scripts/gen-module-docs.py b/scripts/gen-module-docs.py
index 3992a8f..b522e06 100644
--- a/scripts/gen-module-docs.py
+++ b/scripts/gen-module-docs.py
@@ -70,6 +70,7 @@ REPO_READMES = [
"ui-docs",
"verae-staff-session",
"verae-staff-ui",
+ "verae-staff-iam",
]
SKIP_PARTS = {"test", "tests", "node_modules", "dist"}
diff --git a/scripts/push-module-repos.sh b/scripts/push-module-repos.sh
index 19d3ca8..2a49f9c 100755
--- a/scripts/push-module-repos.sh
+++ b/scripts/push-module-repos.sh
@@ -67,6 +67,7 @@ create zapier-decisions "Architecture decisions and action log"
create UI-Docs "UI walkthrough, screenshots, and review PDF"
create verae-staff-session "Shared staff cookie login for department HTML"
create verae-staff-ui "Shared staff review HTML template"
+create verae-staff-iam "Internal staff users, roles, and permissions"
push_dir "$ROOT/packages/zappier" zappier-edge
push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware
@@ -100,5 +101,6 @@ push_dir "$ROOT/packages/zapier-decisions" zapier-decisions
push_dir "$ROOT/packages/ui-docs" UI-Docs
push_dir "$ROOT/packages/verae-staff-session" verae-staff-session
push_dir "$ROOT/packages/verae-staff-ui" verae-staff-ui
+push_dir "$ROOT/packages/verae-staff-iam" verae-staff-iam
echo ALL_MODULE_REPOS_PUSHED