[object Object]

← 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 →