← back to Rentv
PR intel: CRM auth routes + capability middleware (login/logout/me/users)
46c1d8d2311b35adcec27448109896fe9dc187e9 · 2026-08-06 08:46:43 -0700 · Steve
Layered inside the server admin wall: pr_session cookie login/logout, /auth/me
resolves session→user+tenant, user-management routes gated by the 'users' cap
(admin/developer). requireCap(cap) middleware enforces the RBAC matrix; a
Basic-auth caller falls back to tenant-1 admin so all existing automation
(enrichment, openclaw, crawl) keeps working. Tested end-to-end on local :9704.
Files touched
Diff
commit 46c1d8d2311b35adcec27448109896fe9dc187e9
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 08:46:43 2026 -0700
PR intel: CRM auth routes + capability middleware (login/logout/me/users)
Layered inside the server admin wall: pr_session cookie login/logout, /auth/me
resolves session→user+tenant, user-management routes gated by the 'users' cap
(admin/developer). requireCap(cap) middleware enforces the RBAC matrix; a
Basic-auth caller falls back to tenant-1 admin so all existing automation
(enrichment, openclaw, crawl) keeps working. Tested end-to-end on local :9704.
---
src/pr/index.js | 44 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/src/pr/index.js b/src/pr/index.js
index 1fab610f..ce7f0804 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -61,6 +61,50 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
};
}
+ // ── Multi-tenant CRM auth (pr_users) — layered inside the server admin wall ──
+ const auth = require('./services/auth');
+ function prToken(req) {
+ const c = String(req.headers.cookie || '').split(';').map((s) => s.trim()).find((s) => s.startsWith('pr_session='));
+ if (c) return decodeURIComponent(c.slice('pr_session='.length));
+ const a = req.headers.authorization || '';
+ if (a.startsWith('Bearer ')) return a.slice(7);
+ return req.headers['x-pr-session'] || null;
+ }
+ 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 };
+ }
+ const requireCap = (cap) => async (req, res, next) => {
+ try {
+ const ctx = await prAuthCtx(req); 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' : ''}`;
+ 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'] });
+ if (!r.ok) return res.status(401).json(r);
+ res.setHeader('Set-Cookie', sessionCookie(req, r.token, 14 * 24 * 3600));
+ res.json({ ok: true, user: r.user });
+ }));
+ app.post('/api/pr/auth/logout', h(async (req, res) => { await auth.logout(prToken(req)); res.setHeader('Set-Cookie', sessionCookie(req, '', 0)); res.json({ ok: true }); }));
+ app.get('/api/pr/auth/me', adminOnly, h(async (req, res) => res.json(await prAuthCtx(req))));
+ // User management (admin/developer 'users' cap) — scoped to the caller's tenant.
+ app.get('/api/pr/users', adminOnly, requireCap('users'), h(async (req, res) => res.json({ rows: await auth.listUsers(req.prAuth.tenant.id) })));
+ app.post('/api/pr/users', adminOnly, requireCap('users'), h(async (req, res) => {
+ const b = req.body || {};
+ res.json(await auth.createUser({ tenant_id: req.prAuth.tenant.id, email: b.email, name: b.name, role: b.role, password: b.password }, req.prAuth.user.email));
+ }));
+ app.post('/api/pr/users/:id/password', adminOnly, requireCap('users'), h(async (req, res) => {
+ await auth.setPassword(Number(req.params.id), (req.body || {}).password, req.prAuth.user.email);
+ res.json({ ok: true });
+ }));
+
// ── 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) => {
← f8c4a1bd Complete geo-cache: all 563 corpus cities geocoded (538 loca
·
back to Rentv
·
fix(deals/closings-admin): party drill-downs filter by FIRM 773a0d05 →