← back to Rentv 2026
PR intel: router-level capability gate (b1) — every /api/pr route self-protects
1be2f46b34a98ecd1cc96ed3e92ea35d093ccc39 · 2026-08-06 09:29:12 -0700 · Steve
Hardened prAuthCtx: only grants the tenant-1 admin fallback when the OUTER server
gate already authenticated (req.role set) — never to an unauthenticated caller,
so PR can later be exempted from the server wall for clean client login without
opening the API. Added app.use('/api/pr') gate (after the public auth routes)
enforcing caps by method+path (read/write/users/integrations/settings/outreach/
export) → 401 unauth, 403 wrong role. Verified: login public, Basic-admin data
routes 200, no regression. Enables (a) safely.
Files touched
Diff
commit 1be2f46b34a98ecd1cc96ed3e92ea35d093ccc39
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 09:29:12 2026 -0700
PR intel: router-level capability gate (b1) — every /api/pr route self-protects
Hardened prAuthCtx: only grants the tenant-1 admin fallback when the OUTER server
gate already authenticated (req.role set) — never to an unauthenticated caller,
so PR can later be exempted from the server wall for clean client login without
opening the API. Added app.use('/api/pr') gate (after the public auth routes)
enforcing caps by method+path (read/write/users/integrations/settings/outreach/
export) → 401 unauth, 403 wrong role. Verified: login public, Basic-admin data
routes 200, no regression. Enables (a) safely.
---
src/pr/index.js | 36 ++++++++++++++++++++++++++++++++----
1 file changed, 32 insertions(+), 4 deletions(-)
diff --git a/src/pr/index.js b/src/pr/index.js
index 047fefd5..37a6a93e 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -73,18 +73,43 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
async function prAuthCtx(req) {
const s = await auth.verifySession(prToken(req));
if (s) return s;
- // Fallback: a Basic-auth caller (server wall + every loopback tool) acts as a tenant-1 admin,
- // so all existing automation (enrichment, openclaw, crawl) keeps working unchanged.
- return { user: { id: 0, email: actorOf(req), role: 'admin', tenant_id: 1 }, tenant: { id: 1, slug: 'rentv' }, basic: true };
+ // Else rely on the OUTER server gate having already authenticated this request (server.js
+ // sets req.role via session/service/Basic). Only THEN act as the tenant-1 server admin — so
+ // that exempting PR from the server wall (clean client login) can never grant an
+ // UNauthenticated caller access. All existing loopback tools carry Basic → req.role='admin'.
+ if (req.role) return { user: { id: 0, email: actorOf(req), role: req.role === 'user' ? 'user' : 'admin', tenant_id: 1 }, tenant: { id: 1, slug: 'rentv' }, basic: true };
+ return null; // unauthenticated
}
const requireCap = (cap) => async (req, res, next) => {
try {
- const ctx = await prAuthCtx(req); req.prAuth = ctx;
+ const ctx = await prAuthCtx(req);
+ if (!ctx) return res.status(401).json({ ok: false, error: 'authentication required' });
+ req.prAuth = ctx;
if (cap && !auth.can(ctx.user.role, cap)) return res.status(403).json({ ok: false, error: 'forbidden: needs ' + cap });
next();
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
};
const sessionCookie = (req, tok, maxAge) => `pr_session=${tok}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${(req.secure || req.headers['x-forwarded-proto'] === 'https') ? '; Secure' : ''}`;
+ // Register the router-level capability gate AFTER the public auth routes (login/logout) so they
+ // stay reachable pre-login; it self-protects every other /api/pr route (caps by method + path).
+ function prRouterGate(app2) {
+ app2.use('/api/pr', async (req, res, next) => {
+ try {
+ const ctx = await prAuthCtx(req);
+ if (!ctx) return res.status(401).json({ ok: false, error: 'authentication required' });
+ req.prAuth = ctx;
+ const p = req.path; const write = req.method !== 'GET' && req.method !== 'HEAD';
+ let cap = write ? 'write' : 'read';
+ if (/^\/users(\/|$)/.test(p)) cap = 'users';
+ else if (/^\/tenants?(\/|$)/.test(p) && write) cap = 'integrations';
+ else if (/^\/settings/.test(p) && write) cap = 'settings';
+ else if (/^\/(outreach|inbox)\//.test(p) && /(send|approve|reply)/.test(p)) cap = 'outreach';
+ else if (/^\/export\//.test(p)) cap = 'export';
+ if (!auth.can(ctx.user.role, cap)) return res.status(403).json({ ok: false, error: 'forbidden: needs ' + cap });
+ next();
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+ });
+ }
app.post('/api/pr/auth/login', h(async (req, res) => {
const b = req.body || {};
const r = await auth.login({ email: b.email, password: b.password, tenant_slug: b.tenant }, { ip: req.ip, user_agent: req.headers['user-agent'] });
@@ -153,6 +178,9 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
res.json({ rows });
}));
+ // Everything registered BELOW this line is behind the router capability gate.
+ prRouterGate(app);
+
// ── Health & meta ──────────────────────────────────────────────────────────
app.get('/api/pr/health', adminOnly, async (_q, res) => res.json(await db.health()));
app.get('/api/pr/meta', adminOnly, h(async (_q, res) => {
← 749cf6e9 fix(boomer-voice): correct the stale comment naming the WRON
·
back to Rentv 2026
·
harden(boomer-voice): Cody gate — digit-anchor every forSpee 12d0f414 →