Turn lab IAM on, move portal to access-web, harden sessions and receipts.
Some checks are pending
offline / test (push) Waiting to run
Some checks are pending
offline / test (push) Waiting to run
Fleet and department doors now check STAFF_IAM_URL. Walkthrough: cs can credit (agent is the IAM user) and cannot export; operator can fleet POST; admin /me is owner. Portal is the public web door at :3021/portal/. IAM sessions persist; login is rate-limited per user; receipt PDF is branded. lan-134 stays disabled.
This commit is contained in:
parent
d299d245e8
commit
c32b65038a
20 changed files with 277 additions and 73 deletions
|
|
@ -9,7 +9,9 @@ Every NATS hop is `verae.access.web.*` → `verae.access.authz.check` → intern
|
|||
| Route | Job |
|
||||
|-------|-----|
|
||||
| `GET /health` | `{ plane: "web" }` |
|
||||
| `GET /portal/` | Customer portal (static) |
|
||||
| `*` `/portal/api/*` | Proxy to loopback zappier-edge |
|
||||
| `GET /statement/:id` | Authz then statement |
|
||||
| `POST /reload` | Authz then `balance.adjust` kind=reload |
|
||||
|
||||
Port `:3021`.
|
||||
Port `:3021`. Customer portal (public door): **http://0.0.0.0:3021/portal/** — static from `packages/zappier/portal`, `/portal/api` proxied to loopback zappier-edge. Edge itself stays on `127.0.0.1:3000`.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,69 @@
|
|||
#!/usr/bin/env node
|
||||
/** Direct customer web access. Not Zapier. NATS only after authz. */
|
||||
/** Direct customer web access. Not Zapier. Serves the portal; APIs after authz. */
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PLANE, statement, reload } from './gate.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3021);
|
||||
const EDGE = (process.env.ZAPPIER_EDGE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORTAL = process.env.PORTAL_STATIC || path.join(HERE, '..', '..', 'zappier', 'portal');
|
||||
|
||||
function mime(p) {
|
||||
if (p.endsWith('.js')) return 'application/javascript; charset=utf-8';
|
||||
if (p.endsWith('.css')) return 'text/css; charset=utf-8';
|
||||
if (p.endsWith('.html')) return 'text/html; charset=utf-8';
|
||||
if (p.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (p.endsWith('.json')) return 'application/json';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function proxyPortalApi(req, res, url) {
|
||||
const dest = `${EDGE}${url.pathname}${url.search}`;
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const r = await fetch(dest, {
|
||||
method: req.method,
|
||||
headers: {
|
||||
'content-type': req.headers['content-type'] || 'application/json',
|
||||
authorization: req.headers.authorization || '',
|
||||
cookie: req.headers.cookie || '',
|
||||
},
|
||||
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks),
|
||||
});
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
const headers = { 'content-type': r.headers.get('content-type') || 'application/json' };
|
||||
const setc = r.headers.get('set-cookie');
|
||||
if (setc) headers['set-cookie'] = setc;
|
||||
res.writeHead(r.status, headers);
|
||||
res.end(buf);
|
||||
}
|
||||
|
||||
function servePortal(req, res, url) {
|
||||
let rel = url.pathname.replace(/^\/portal\/?/, '') || 'index.html';
|
||||
if (rel.endsWith('/')) rel += 'index.html';
|
||||
const file = path.normalize(path.join(PORTAL, rel));
|
||||
if (!file.startsWith(path.normalize(PORTAL))) {
|
||||
res.writeHead(403);
|
||||
res.end('forbidden');
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
const index = path.join(PORTAL, 'index.html');
|
||||
if (fs.existsSync(index) && !path.extname(rel)) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(index));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': mime(file) });
|
||||
res.end(fs.readFileSync(file));
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
|
||||
|
|
@ -13,7 +73,17 @@ const server = http.createServer(async (req, res) => {
|
|||
};
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return json(200, { ok: true, role: 'verae-access-web', plane: PLANE });
|
||||
return json(200, { ok: true, role: 'verae-access-web', plane: PLANE, portal: fs.existsSync(PORTAL) });
|
||||
}
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/portal')) {
|
||||
res.writeHead(302, { location: '/portal/' });
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname.startsWith('/portal/api')) {
|
||||
return proxyPortalApi(req, res, url);
|
||||
}
|
||||
if (url.pathname.startsWith('/portal')) {
|
||||
return servePortal(req, res, url);
|
||||
}
|
||||
const st = url.pathname.match(/^\/statement\/([^/]+)$/);
|
||||
if (req.method === 'GET' && st) {
|
||||
|
|
@ -34,5 +104,5 @@ const server = http.createServer(async (req, res) => {
|
|||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
process.stdout.write(`verae-access-web http://0.0.0.0:${PORT}/ plane=${PLANE}\n`);
|
||||
process.stdout.write(`verae-access-web http://0.0.0.0:${PORT}/ plane=${PLANE} portal=${PORTAL}\n`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ test('web plane can read statement after authz, cannot skip authz', async () =>
|
|||
});
|
||||
const h = await (await fetch(`http://127.0.0.1:${webPort}/health`)).json();
|
||||
assert.equal(h.plane, 'web');
|
||||
const portal = await fetch(`http://127.0.0.1:${webPort}/portal/`);
|
||||
assert.equal(portal.status, 200);
|
||||
assert.match(await portal.text(), /Zappier Portal|portal/i);
|
||||
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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue