[object Object]

← back to Rentv

PR intel (a): clean client login — server delegates PR surface to the PR auth layer

a3ed3e45ff2e9d35c45fac64adfb42901087a304 · 2026-08-06 09:39:22 -0700 · Steve

- server.js: /api/pr + /admin/pr-intelligence delegate to the PR layer (its own
  per-tenant login + router capability gate), while still resolving Basic/service
  creds so every loopback tool keeps working.
- index.js: shadow adminOnly on PR routes to accept any valid PR auth (pr_session
  or server-authed); per-route ROLE enforcement stays with the router gate +
  requireCap. login.html served PUBLIC; credentials.html behind auth.
- Verified full matrix: Basic 200, anon 401, login public, admin/pro/user each
  gated to their role (pro✗users, user✗write). No under-gating; no regression.

Files touched

Diff

commit a3ed3e45ff2e9d35c45fac64adfb42901087a304
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 09:39:22 2026 -0700

    PR intel (a): clean client login — server delegates PR surface to the PR auth layer
    
    - server.js: /api/pr + /admin/pr-intelligence delegate to the PR layer (its own
      per-tenant login + router capability gate), while still resolving Basic/service
      creds so every loopback tool keeps working.
    - index.js: shadow adminOnly on PR routes to accept any valid PR auth (pr_session
      or server-authed); per-route ROLE enforcement stays with the router gate +
      requireCap. login.html served PUBLIC; credentials.html behind auth.
    - Verified full matrix: Basic 200, anon 401, login public, admin/pro/user each
      gated to their role (pro✗users, user✗write). No under-gating; no regression.
---
 server.js       | 11 +++++++++++
 src/pr/index.js | 16 ++++++++++++++++
 2 files changed, 27 insertions(+)

diff --git a/server.js b/server.js
index 2be1bbb2..c5f5e40e 100644
--- a/server.js
+++ b/server.js
@@ -104,6 +104,17 @@ app.get('/api/whoami', (req, res) => { const s = sessionOf(req); res.json({ auth
 // Global authentication gate — session cookie → service token → Basic → clean-login redirect.
 app.use((req, res, next) => {
   if (process.env.OPEN === '1') { req.role = 'admin'; req.authVia = 'open'; return next(); } // local-preview bypass
+  // PR-Intelligence CRM self-authenticates (its own per-tenant login + router capability gate in
+  // src/pr/index.js — every /api/pr route 401/403s by role). Delegate its surface to that layer so
+  // CRM clients sign in with only their own credentials, but STILL resolve Basic/service creds here
+  // so every existing loopback tool (enrichment, openclaw, crawl) keeps working unchanged.
+  if (req.path.startsWith('/api/pr/') || req.path.startsWith('/admin/pr-intelligence/')) {
+    const az = req.headers.authorization || '';
+    const r = CRED_ROLE.get(az);
+    if (r) { req.role = r; req.authVia = 'basic'; }
+    else if (SERVICE_TOKEN && az === 'Bearer ' + SERVICE_TOKEN) { req.role = 'admin'; req.authVia = 'service'; }
+    return next(); // no server creds → the PR layer's own login/session (or its gate's 401) applies
+  }
   const s = sessionOf(req);
   if (s) { req.role = s.role; req.authVia = 'session'; req.session = s; return next(); }
   const authz = req.headers.authorization || '';
diff --git a/src/pr/index.js b/src/pr/index.js
index 37a6a93e..583cf655 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -89,6 +89,18 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
       next();
     } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
   };
+  // Shadow the server's admin-only `adminOnly` on PR routes: accept ANY valid PR auth (a pr_session
+  // OR a server-authed caller). Per-route ROLE enforcement is handled by the router capability gate
+  // + inline requireCap — so this only needs to confirm the request is authenticated. This is what
+  // lets a CRM client (pr_session, no server-admin role) reach the routes once server.js delegates.
+  adminOnly = 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;
+      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).
@@ -555,6 +567,10 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
   app.get('/api/pr/export/evidence', adminOnly, h(async (req, res) => sendCSV(res, 'pr-source-evidence.csv', await importexport.exportSourceEvidence(req.query.entity_type))));
 
   // ── Admin shells ───────────────────────────────────────────────────────────
+  // login.html is PUBLIC (the CRM front door — must be reachable before auth); the .html
+  // suffix variants are served explicitly since they aren't in the PAGES list.
+  app.get('/admin/pr-intelligence/login.html', (_q, r) => sendPage(r, path.join(SHELL_DIR, 'login.html')));
+  app.get('/admin/pr-intelligence/credentials.html', adminOnly, (_q, r) => sendPage(r, path.join(SHELL_DIR, 'credentials.html')));
   app.get('/admin/pr-intelligence', adminOnly, (_q, r) => sendPage(r, path.join(SHELL_DIR, 'index.html')));
   for (const page of PAGES.filter((p) => p !== 'index')) {
     app.get('/admin/pr-intelligence/' + page, adminOnly, (_q, r) => sendPage(r, path.join(SHELL_DIR, page + '.html')));

← 12d0f414 harden(boomer-voice): Cody gate — digit-anchor every forSpee  ·  back to Rentv  ·  Public polish: hide all admin nav links from public + SEO fo 88858d33 →