[object Object]

← back to Sublease Agentabrams

sublease.agentabrams.com: CRUnifiedDB + gated marketplace + per-firm crawler framework + Premier Workspaces agent (12 live listings) + business plan PDF

28bae2767daacf5fe703fce807e733b623b92168 · 2026-07-20 10:41:05 -0700 · Steve

Files touched

Diff

commit 28bae2767daacf5fe703fce807e733b623b92168
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 20 10:41:05 2026 -0700

    sublease.agentabrams.com: CRUnifiedDB + gated marketplace + per-firm crawler framework + Premier Workspaces agent (12 live listings) + business plan PDF
---
 .deploy.conf                     |   10 +
 .gitignore                       |    9 +
 crawl/base-crawler.js            |   92 ++
 crawl/firms/premierworkspaces.js |   72 ++
 crawl/run.js                     |   54 ++
 package-lock.json                | 1796 ++++++++++++++++++++++++++++++++++++++
 package.json                     |   18 +
 public/admin.html                |   50 ++
 public/broker.html               |   82 ++
 public/index.html                |  197 +++++
 scripts/build-business-plan.js   |  159 ++++
 scripts/geocode-backfill.js      |   27 +
 scripts/schema.sql               |   76 ++
 scripts/seed-sponsors.sql        |   42 +
 server.js                        |  100 +++
 15 files changed, 2784 insertions(+)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..a0de9b7
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,10 @@
+# sublease.agentabrams.com — deploy config. NOTE: Kamatera deploy + DNS are Steve-gated.
+# New *.agentabrams.com vhost MUST `listen 45.61.58.125:443` (agentabrams IP-bound pattern).
+PROJECT_NAME=sublease-agentabrams
+DEPLOY_HOST=45.61.58.125
+DEPLOY_PATH=/root/public-projects/sublease-agentabrams
+HEALTH_URL=http://localhost:9714/api/health
+INSTALL_CMD="npm ci --omit=dev"
+BUILD_CMD=""
+REQUIRED_ENVS="PGHOST PGDATABASE"
+RSYNC_EXTRA_EXCLUDES="tmp-server.log data/*.db"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..657cc5d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+data/*.db
diff --git a/crawl/base-crawler.js b/crawl/base-crawler.js
new file mode 100644
index 0000000..6f6fb05
--- /dev/null
+++ b/crawl/base-crawler.js
@@ -0,0 +1,92 @@
+'use strict';
+// Shared crawler toolkit for every firm agent. Public-pages-only, polite, $0 by default.
+const { Pool } = require('pg');
+const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'crunified' });
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+const UA = 'Mozilla/5.0 (compatible; SubleaseMarketplaceBot/0.1; +https://sublease.agentabrams.com)';
+
+// Polite GET with timeout + one retry. Returns { ok, status, text }.
+async function httpGet(url, { timeout = 20000, headers = {} } = {}) {
+  for (let attempt = 0; attempt < 2; attempt++) {
+    try {
+      const r = await fetch(url, { headers: { 'User-Agent': UA, 'Accept-Language': 'en-US', ...headers }, signal: AbortSignal.timeout(timeout) });
+      const text = await r.text();
+      return { ok: r.ok, status: r.status, text };
+    } catch (e) { if (attempt) return { ok: false, status: 0, text: '', error: String(e) }; await sleep(1500); }
+  }
+}
+
+// robots.txt gate — returns true if the path is allowed for our UA (fail-open only on fetch error).
+const robotsCache = new Map();
+async function robotsAllowed(url) {
+  try {
+    const u = new URL(url);
+    const key = u.origin;
+    if (!robotsCache.has(key)) {
+      const r = await httpGet(u.origin + '/robots.txt', { timeout: 8000 });
+      robotsCache.set(key, r.ok ? r.text : '');
+    }
+    const body = robotsCache.get(key);
+    if (!body) return true;
+    // minimal parse: global (User-agent: *) Disallow rules
+    const lines = body.split(/\r?\n/); let active = false; const dis = [];
+    for (const ln of lines) {
+      const m = ln.match(/^\s*User-agent:\s*(.+)/i);
+      if (m) { active = m[1].trim() === '*'; continue; }
+      const d = ln.match(/^\s*Disallow:\s*(.*)/i);
+      if (active && d && d[1].trim()) dis.push(d[1].trim());
+    }
+    return !dis.some(p => u.pathname.startsWith(p));
+  } catch { return true; }
+}
+
+// Free US address geocoder (Census Bureau — no key, no cost). Returns {lat,lng} or null.
+async function geocodeCensus(address) {
+  if (!address) return null;
+  try {
+    const url = 'https://geocoding.geo.census.gov/geocoder/locations/onelineaddress'
+      + '?benchmark=Public_AR_Current&format=json&address=' + encodeURIComponent(address);
+    const r = await httpGet(url, { timeout: 12000 });
+    if (!r.ok) return null;
+    const j = JSON.parse(r.text);
+    const m = j?.result?.addressMatches?.[0]?.coordinates;
+    return m ? { lat: m.y, lng: m.x } : null;
+  } catch { return null; }
+}
+
+// Upsert one listing keyed on (sponsor_id, external_id). Bumps last_seen on repeat.
+async function upsertListing(sponsorId, L) {
+  const sql = `
+    INSERT INTO listings
+      (sponsor_id, external_id, title, address, city, state, zip, lat, lng, space_type,
+       is_sublease, size_sf, price_amount, price_unit, term, available_on, source_url, image_url, description, raw, last_seen)
+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, now())
+    ON CONFLICT (sponsor_id, external_id) DO UPDATE SET
+      title=EXCLUDED.title, address=EXCLUDED.address, size_sf=EXCLUDED.size_sf,
+      price_amount=EXCLUDED.price_amount, price_unit=EXCLUDED.price_unit, term=EXCLUDED.term,
+      source_url=EXCLUDED.source_url, image_url=EXCLUDED.image_url, description=EXCLUDED.description,
+      raw=EXCLUDED.raw, last_seen=now(), status='active'
+    RETURNING (xmax = 0) AS inserted`;
+  const vals = [sponsorId, L.external_id, L.title, L.address, L.city, L.state || 'CA', L.zip,
+    L.lat, L.lng, L.space_type, L.is_sublease !== false, L.size_sf, L.price_amount, L.price_unit,
+    L.term, L.available_on, L.source_url, L.image_url, L.description, L.raw ? JSON.stringify(L.raw) : null];
+  const { rows } = await pool.query(sql, vals);
+  return rows[0]?.inserted === true;
+}
+
+async function getSponsor(slug) {
+  const { rows } = await pool.query('SELECT * FROM sponsors WHERE slug=$1', [slug]);
+  return rows[0];
+}
+async function startRun(sponsorId) {
+  const { rows } = await pool.query('INSERT INTO crawl_runs (sponsor_id) VALUES ($1) RETURNING id', [sponsorId]);
+  return rows[0].id;
+}
+async function finishRun(runId, { status, found = 0, added = 0, cost = 0, notes = '' }) {
+  await pool.query(
+    `UPDATE crawl_runs SET finished_at=now(), status=$2, listings_found=$3, listings_new=$4, cost_usd=$5, notes=$6 WHERE id=$1`,
+    [runId, status, found, added, cost, notes]);
+}
+
+module.exports = { pool, sleep, httpGet, robotsAllowed, geocodeCensus, upsertListing, getSponsor, startRun, finishRun, UA };
diff --git a/crawl/firms/premierworkspaces.js b/crawl/firms/premierworkspaces.js
new file mode 100644
index 0000000..1ec3a87
--- /dev/null
+++ b/crawl/firms/premierworkspaces.js
@@ -0,0 +1,72 @@
+'use strict';
+// Premier Workspaces — flexible office / executive-suite operator (WordPress + JetEngine).
+// Public location pages carry a LocalBusiness JSON-LD with a full PostalAddress; we parse
+// that, geocode via the free Census API, and store each center as a flexible-space listing.
+const LOCATIONS = 'https://premierworkspaces.com/locations/';
+
+const titleCase = (s) => (s || '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+
+function parseJsonLd(html) {
+  const out = [];
+  for (const m of html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/g)) {
+    try { const j = JSON.parse(m[1]); (j['@graph'] || [j]).forEach(n => out.push(n)); } catch { /* skip */ }
+  }
+  return out;
+}
+
+module.exports = {
+  meta: { firm: 'Premier Workspaces', space_type: 'flex' },
+  async crawl({ base, sponsor, upsert }) {
+    const home = await base.httpGet(LOCATIONS, { timeout: 20000 });
+    if (!home.ok) throw new Error('locations page ' + home.status);
+
+    // Individual center pages: /locations/<state>/<city>/<center>/
+    const links = [...new Set(
+      [...home.text.matchAll(/href="(https:\/\/premierworkspaces\.com\/locations\/[a-z0-9\-]+\/[a-z0-9\-]+\/[a-z0-9\-]+\/)"/g)]
+        .map(m => m[1]))].filter(u => !/\/view-all\//.test(u));
+
+    // Focus on California first (the LA market); env override to widen.
+    const onlyState = process.env.PW_STATE || 'california';
+    const scoped = links.filter(u => u.includes(`/locations/${onlyState}/`));
+    const cap = Number(process.env.PW_CAP || 30);
+    const targets = (scoped.length ? scoped : links).slice(0, cap);
+
+    let geocoded = 0;
+    for (const url of targets) {
+      const parts = url.split('/').filter(Boolean);        // .. locations, state, city, center
+      const state = parts[parts.length - 3], city = titleCase(parts[parts.length - 2]);
+      const centerName = titleCase(parts[parts.length - 1]);
+      const page = await base.httpGet(url, { timeout: 18000 });
+      let address = null, zip = null, image = null, lat = null, lng = null;
+      if (page.ok) {
+        for (const node of parseJsonLd(page.text)) {
+          const addr = node.address;
+          if (addr && (addr.streetAddress || addr['@type'] === 'PostalAddress')) {
+            address = [addr.streetAddress, addr.addressLocality].filter(Boolean).join(', ');
+            zip = addr.postalCode || null;
+          }
+          if (node.geo) { lat = Number(node.geo.latitude) || null; lng = Number(node.geo.longitude) || null; }
+          if (!image && node.image) image = typeof node.image === 'string' ? node.image : node.image.url;
+        }
+      }
+      const full = [address, `${titleCase(state)}`, zip].filter(Boolean).join(', ');
+      if ((lat == null || lng == null) && (address || city)) {
+        const g = await base.geocodeCensus(full || `${city}, ${titleCase(state)}`);
+        if (g) { lat = g.lat; lng = g.lng; geocoded++; await base.sleep(300); }
+      }
+      await upsert({
+        external_id: url.replace('https://premierworkspaces.com', '').replace(/\/$/, ''),
+        title: `${centerName} — flexible office space`,
+        address: address || null, city, state: state === 'california' ? 'CA' : titleCase(state), zip,
+        lat, lng, space_type: 'flex', is_sublease: true,
+        size_sf: null, price_amount: null, price_unit: 'call for terms',
+        term: 'flexible (month-to-month & short-term)',
+        source_url: url, image_url: image,
+        description: `Premier Workspaces flexible workspace: private offices, coworking & meeting rooms at ${centerName}, ${city}.`,
+        raw: { center: centerName, city, state },
+      });
+      await base.sleep(700); // polite
+    }
+    return { cost: 0, notes: `${targets.length} centers scanned, ${geocoded} geocoded (Census, $0)` };
+  },
+};
diff --git a/crawl/run.js b/crawl/run.js
new file mode 100644
index 0000000..01ccfdf
--- /dev/null
+++ b/crawl/run.js
@@ -0,0 +1,54 @@
+'use strict';
+// CRUnifiedDB crawl dispatcher.
+//   node crawl/run.js <slug>        run one firm agent
+//   node crawl/run.js all           run every crawl-enabled, low/medium-ToS firm
+//   node crawl/run.js <slug> --force run even a high-ToS firm (LoopNet/CoStar) — you accept the risk
+const fs = require('fs');
+const path = require('path');
+const base = require('./base-crawler');
+
+const FIRMS_DIR = path.join(__dirname, 'firms');
+const force = process.argv.includes('--force');
+const arg = process.argv[2];
+
+function firmModule(slug) {
+  const f = path.join(FIRMS_DIR, `${slug}.js`);
+  return fs.existsSync(f) ? require(f) : null;
+}
+
+async function runOne(slug) {
+  const sponsor = await base.getSponsor(slug);
+  if (!sponsor) { console.log(`✗ ${slug}: not a sponsor in CRUnifiedDB`); return; }
+  if (sponsor.role === 'financing_partner') { console.log(`— ${slug}: financing partner (no listings to crawl)`); return; }
+  const mod = firmModule(slug);
+  if (!mod || typeof mod.crawl !== 'function') { console.log(`— ${slug}: no crawler module yet (crawl/firms/${slug}.js)`); return; }
+  if (sponsor.tos_risk === 'high' && !force) {
+    console.log(`⚠ ${slug}: ToS risk = HIGH (${sponsor.notes || ''}). Skipped. Re-run with --force to accept the risk.`);
+    return;
+  }
+  const runId = await base.startRun(sponsor.id);
+  console.log(`▶ ${slug}: crawling ${sponsor.listings_url || sponsor.website} …`);
+  let found = 0, added = 0, cost = 0;
+  try {
+    const res = await mod.crawl({ base, sponsor,
+      upsert: async (L) => { found++; if (await base.upsertListing(sponsor.id, L)) added++; } });
+    cost = res?.cost || 0;
+    await base.finishRun(runId, { status: 'ok', found, added, cost, notes: res?.notes || '' });
+    console.log(`✓ ${slug}: ${found} found, ${added} new  ($${cost.toFixed(2)})`);
+  } catch (e) {
+    await base.finishRun(runId, { status: 'error', found, added, cost, notes: String(e).slice(0, 400) });
+    console.log(`✗ ${slug}: ${e}`);
+  }
+}
+
+(async () => {
+  if (!arg) { console.log('usage: node crawl/run.js <slug|all> [--force]'); process.exit(1); }
+  if (arg === 'all') {
+    const { rows } = await base.pool.query(
+      `SELECT slug FROM sponsors WHERE crawl_enabled AND role='broker' AND tos_risk <> 'high' ORDER BY slug`);
+    for (const r of rows) await runOne(r.slug);
+  } else {
+    await runOne(arg);
+  }
+  await base.pool.end();
+})();
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..819cab6
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1796 @@
+{
+  "name": "sublease-agentabrams",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "sublease-agentabrams",
+      "version": "0.1.0",
+      "dependencies": {
+        "express": "^4.19.2",
+        "pdfkit": "^0.15.0",
+        "pg": "^8.11.5"
+      }
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.3.17",
+      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz",
+      "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-buffer-byte-length": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+      "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "is-array-buffer": "^3.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/available-typed-arrays": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+      "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "possible-typed-array-names": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.6",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/brotli": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+      "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.1.2"
+      }
+    },
+    "node_modules/browserify-zlib": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+      "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+      "license": "MIT",
+      "dependencies": {
+        "pako": "~1.0.5"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+      "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "get-intrinsic": "^1.3.0",
+        "set-function-length": "^1.2.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/clone": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+      "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/crypto-js": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/deep-equal": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
+      "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
+      "license": "MIT",
+      "dependencies": {
+        "array-buffer-byte-length": "^1.0.0",
+        "call-bind": "^1.0.5",
+        "es-get-iterator": "^1.1.3",
+        "get-intrinsic": "^1.2.2",
+        "is-arguments": "^1.1.1",
+        "is-array-buffer": "^3.0.2",
+        "is-date-object": "^1.0.5",
+        "is-regex": "^1.1.4",
+        "is-shared-array-buffer": "^1.0.2",
+        "isarray": "^2.0.5",
+        "object-is": "^1.1.5",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.4",
+        "regexp.prototype.flags": "^1.5.1",
+        "side-channel": "^1.0.4",
+        "which-boxed-primitive": "^1.0.2",
+        "which-collection": "^1.0.1",
+        "which-typed-array": "^1.1.13"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/define-properties": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+      "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.0.1",
+        "has-property-descriptors": "^1.0.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dfa": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+      "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+      "license": "MIT"
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-get-iterator": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
+      "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "get-intrinsic": "^1.1.3",
+        "has-symbols": "^1.0.3",
+        "is-arguments": "^1.1.1",
+        "is-map": "^2.0.2",
+        "is-set": "^2.0.2",
+        "is-string": "^1.0.7",
+        "isarray": "^2.0.5",
+        "stop-iteration-iterator": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/fontkit": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz",
+      "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==",
+      "license": "MIT",
+      "dependencies": {
+        "@swc/helpers": "^0.3.13",
+        "brotli": "^1.3.2",
+        "clone": "^2.1.2",
+        "deep-equal": "^2.0.5",
+        "dfa": "^1.2.0",
+        "restructure": "^2.0.1",
+        "tiny-inflate": "^1.0.3",
+        "unicode-properties": "^1.3.1",
+        "unicode-trie": "^2.0.0"
+      }
+    },
+    "node_modules/for-each": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+      "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/functions-have-names": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+      "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-bigints": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+      "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/internal-slot": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+      "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "hasown": "^2.0.2",
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-arguments": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+      "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-array-buffer": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+      "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-bigint": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+      "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+      "license": "MIT",
+      "dependencies": {
+        "has-bigints": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-boolean-object": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+      "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-callable": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+      "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+      "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-map": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+      "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-number-object": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+      "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-regex": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-set": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+      "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-shared-array-buffer": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+      "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-string": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+      "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-symbol": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+      "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-symbols": "^1.1.0",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakmap": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+      "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakset": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+      "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+      "license": "MIT"
+    },
+    "node_modules/jpeg-exif": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
+      "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
+      "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+      "license": "MIT"
+    },
+    "node_modules/linebreak": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+      "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "0.0.8",
+        "unicode-trie": "^2.0.0"
+      }
+    },
+    "node_modules/linebreak/node_modules/base64-js": {
+      "version": "0.0.8",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+      "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-is": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+      "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.assign": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+      "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0",
+        "has-symbols": "^1.1.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/pdfkit": {
+      "version": "0.15.2",
+      "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz",
+      "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==",
+      "license": "MIT",
+      "dependencies": {
+        "crypto-js": "^4.2.0",
+        "fontkit": "^1.8.1",
+        "jpeg-exif": "^1.1.4",
+        "linebreak": "^1.0.2",
+        "png-js": "^1.0.0"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+      "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.15.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+      "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/png-js": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
+      "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
+      "dependencies": {
+        "browserify-zlib": "^0.2.0"
+      }
+    },
+    "node_modules/possible-typed-array-names": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+      "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/regexp.prototype.flags": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+      "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-errors": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "set-function-name": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/restructure": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz",
+      "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==",
+      "license": "MIT"
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safe-regex-test": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+      "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-regex": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-function-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+      "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "functions-have-names": "^1.2.3",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/stop-iteration-iterator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+      "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "internal-slot": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/tiny-inflate": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+      "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+      "license": "MIT"
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unicode-properties": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+      "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.0",
+        "unicode-trie": "^2.0.0"
+      }
+    },
+    "node_modules/unicode-trie": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+      "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "pako": "^0.2.5",
+        "tiny-inflate": "^1.0.0"
+      }
+    },
+    "node_modules/unicode-trie/node_modules/pako": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+      "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/which-boxed-primitive": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+      "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+      "license": "MIT",
+      "dependencies": {
+        "is-bigint": "^1.1.0",
+        "is-boolean-object": "^1.2.1",
+        "is-number-object": "^1.1.1",
+        "is-string": "^1.1.1",
+        "is-symbol": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-collection": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+      "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+      "license": "MIT",
+      "dependencies": {
+        "is-map": "^2.0.3",
+        "is-set": "^2.0.3",
+        "is-weakmap": "^2.0.2",
+        "is-weakset": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-typed-array": {
+      "version": "1.1.22",
+      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
+      "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
+      "license": "MIT",
+      "dependencies": {
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.9",
+        "call-bound": "^1.0.4",
+        "for-each": "^0.3.5",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1917b65
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+  "name": "sublease-agentabrams",
+  "version": "0.1.0",
+  "private": true,
+  "description": "sublease.agentabrams.com — gated commercial sublease marketplace backed by CRUnifiedDB",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "seed": "psql -h /tmp -d crunified -f scripts/schema.sql -f scripts/seed-sponsors.sql",
+    "crawl": "node crawl/run.js",
+    "plan": "node scripts/build-business-plan.js"
+  },
+  "dependencies": {
+    "express": "^4.19.2",
+    "pg": "^8.11.5",
+    "pdfkit": "^0.15.0"
+  }
+}
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..4f06f37
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Admin · CRUnifiedDB</title>
+<style>
+  :root{--ink:#14181d;--mut:#6b7480;--line:#e7e9ee;--bg:#fbfbfc;--accent:#1a5f3c}
+  body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:var(--bg)}
+  .wrap{max-width:1100px;margin:0 auto;padding:24px 22px}
+  a{color:inherit}h1{font-size:20px;margin:0 0 4px}.sub{color:var(--mut);margin:0 0 22px}
+  h2{font-size:13px;text-transform:uppercase;letter-spacing:.09em;color:var(--mut);margin:26px 0 12px}
+  .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}
+  .card{border:1px solid var(--line);border-radius:12px;background:#fff;padding:14px}
+  .card .n{font-weight:700}.card .m{color:var(--mut);font-size:12.5px}
+  .when{display:inline-block;margin-top:8px;font-size:11.5px;color:var(--mut);background:#f4f6f8;border-radius:6px;padding:3px 8px}
+  .st{font-size:11px;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.04em}
+  .st.ok{background:#e7f5ec;color:#1a7a44}.st.error{background:#fbe9e7;color:#b3261e}.st.running{background:#eef2f7;color:#4a5b6d}
+  .st.high{background:#fbe9e7;color:#b3261e}.st.low{background:#eef2f7;color:#4a5b6d}
+  code{background:#f0f2f5;padding:1px 5px;border-radius:4px;font-size:12px}
+  table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden}
+  th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);font-size:13px}
+  th{color:var(--mut);text-transform:uppercase;font-size:11px;letter-spacing:.06em}
+</style></head><body><div class="wrap">
+<h1>CRUnifiedDB — Admin</h1>
+<p class="sub"><a href="/">← marketplace</a> · per-firm crawler agents write here. Run one with <code>node crawl/run.js &lt;slug&gt;</code>.</p>
+
+<h2>Sponsor firms</h2>
+<div class="cards" id="sponsors"></div>
+
+<h2>Recent crawl runs</h2>
+<table id="runs"><thead><tr><th>Firm</th><th>Status</th><th>Found</th><th>Finished (local)</th></tr></thead><tbody></tbody></table>
+</div>
+<script>
+const fmt=t=>t?new Date(t).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
+async function boot(){
+  const [sp,st]=await Promise.all([fetch('/api/sponsors').then(r=>r.json()),fetch('/api/stats').then(r=>r.json())]);
+  document.getElementById('sponsors').innerHTML=sp.sponsors.map(s=>`
+    <div class="card">
+      <div class="n">${s.name}</div>
+      <div class="m">${s.kind} · ${s.role.replace('_',' ')}</div>
+      <div style="margin:8px 0"><span class="st ${s.tos_risk}">ToS: ${s.tos_risk}</span>
+        ${s.role==='broker'?`<span class="st low">${s.listing_count} listings</span>`:'<span class="st low">financing</span>'}</div>
+      <div class="when" title="${s.created_at}">🕓 added ${fmt(s.created_at)}</div>
+    </div>`).join('');
+  document.querySelector('#runs tbody').innerHTML=(st.recent_runs||[]).length?st.recent_runs.map(r=>`
+    <tr><td>${r.slug}</td><td><span class="st ${r.status}">${r.status}</span></td>
+      <td>${r.listings_found??'—'}</td><td>${fmt(r.finished_at)}</td></tr>`).join(''):
+    `<tr><td colspan="4" style="color:var(--mut)">No crawl runs yet.</td></tr>`;
+}
+boot();
+</script></body></html>
diff --git a/public/broker.html b/public/broker.html
new file mode 100644
index 0000000..dd3b895
--- /dev/null
+++ b/public/broker.html
@@ -0,0 +1,82 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Broker · Sublease Marketplace</title>
+<style>
+  :root{--ink:#14181d;--mut:#6b7480;--line:#e7e9ee;--bg:#fbfbfc;--accent:#1a5f3c;--gold:#b08a3e;--cols:4}
+  *{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:var(--bg)}
+  a{color:inherit;text-decoration:none}.wrap{max-width:1240px;margin:0 auto;padding:0 22px}
+  header.top{border-bottom:1px solid var(--line);background:#fff}.top .wrap{display:flex;align-items:center;height:60px;gap:16px}
+  .top a.back{color:var(--mut);font-size:14px}
+  .head{background:linear-gradient(135deg,#14181d,#233041);color:#fff;padding:40px 0}
+  .head img{height:52px;max-width:230px;object-fit:contain;object-position:left;background:#fff;padding:10px 14px;border-radius:10px}
+  .head h1{margin:16px 0 4px;font-size:26px}.head p{color:#c6cdd6;margin:0}
+  .head .tags{margin-top:14px;display:flex;gap:8px;flex-wrap:wrap}
+  .badge{font-size:11px;padding:3px 9px;border-radius:20px;background:rgba(255,255,255,.14);color:#fff;text-transform:uppercase;letter-spacing:.05em}
+  .badge.high{background:#b3261e}
+  section{padding:30px 0}h2{font-size:14px;text-transform:uppercase;letter-spacing:.1em;color:var(--mut);margin:0 0 16px}
+  .controls{display:flex;gap:14px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
+  select,input[type=search]{padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff}
+  .dens{margin-left:auto;color:var(--mut);font-size:13px;display:flex;gap:8px;align-items:center}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
+  .card{border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
+  .card .b{padding:12px 13px}.card .t{font-weight:600;font-size:14px}.card .a{color:var(--mut);font-size:12.5px;margin-top:3px}
+  .meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}.chip{background:#f4f6f8;border-radius:6px;padding:2px 7px;font-size:12px;color:#41505f}
+  .empty{grid-column:1/-1;border:1px dashed var(--line);border-radius:12px;padding:30px;text-align:center;color:var(--mut)}
+  .fin{background:#fff;border:1px solid var(--line);border-radius:14px;padding:26px;color:var(--ink)}
+  .fin h3{margin:0 0 8px}
+</style></head><body>
+<header class="top"><div class="wrap"><a class="back" href="/">← All brokers</a></div></header>
+<div class="head"><div class="wrap" id="head"></div></div>
+<section><div class="wrap">
+  <div id="finBlock"></div>
+  <div id="listBlock" style="display:none">
+    <h2>Current listings — <span id="cnt"></span></h2>
+    <div class="controls">
+      <input type="search" id="q" placeholder="Filter…">
+      <select id="sort"><option value="newest">Newest</option><option value="size_desc">Size ↓</option><option value="city">City A→Z</option></select>
+      <label class="dens">Density <input type="range" id="dens" min="2" max="6" value="4"></label>
+    </div>
+    <div class="grid" id="grid"></div>
+  </div>
+</div></section>
+<script>
+const $=s=>document.querySelector(s),slug=location.pathname.split('/').pop();
+const fmtSF=n=>n?Number(n).toLocaleString()+' SF':'—';
+let L=[];
+async function boot(){
+  const d=await fetch('/api/sponsors/'+slug).then(r=>r.json());
+  if(d.error){$('#head').innerHTML='<h1>Not found</h1>';return;}
+  const s=d.sponsor;
+  $('#head').innerHTML=`<img src="${s.logo_path}" onerror="this.style.display='none'">
+    <h1>${s.name}</h1><p>${s.tagline||''}</p>
+    <div class="tags"><span class="badge">${s.kind}</span>
+      <span class="badge">${s.role.replace('_',' ')}</span>
+      ${s.tos_risk==='high'?'<span class="badge high">ToS risk: high</span>':''}
+      ${s.website?`<a class="badge" href="${s.website}" target="_blank" rel="noopener">website ↗</a>`:''}</div>`;
+  if(s.role==='financing_partner'){
+    $('#finBlock').innerHTML=`<div class="fin"><h3>Financing partner</h3>
+      <p>${s.name} sponsors sublease deals through commercial real estate lending — it does not list space directly.
+      ${s.notes||''}</p><p style="color:var(--mut)">Use this partner to finance a sublease or buildout sourced from any broker on the marketplace.</p></div>`;
+    return;
+  }
+  L=d.listings; $('#listBlock').style.display='';
+  $('#cnt').textContent=L.length+' space'+(L.length==1?'':'s');
+  ['q','sort'].forEach(id=>$('#'+id).addEventListener('input',render));
+  $('#dens').addEventListener('input',e=>document.documentElement.style.setProperty('--cols',e.target.value));
+  render();
+}
+function render(){
+  const q=$('#q').value.toLowerCase(),so=$('#sort').value;
+  let rows=L.filter(l=>`${l.title} ${l.address} ${l.city}`.toLowerCase().includes(q));
+  rows.sort({size_desc:(a,b)=>(b.size_sf||0)-(a.size_sf||0),city:(a,b)=>(a.city||'').localeCompare(b.city||''),newest:(a,b)=>b.id-a.id}[so]);
+  $('#grid').innerHTML=rows.length?rows.map(l=>`<div class="card"><div class="b">
+    <div class="t">${l.title||'Sublease space'}</div>
+    <div class="a">${[l.address,l.city].filter(Boolean).join(', ')||l.city||''}</div>
+    <div class="meta"><span class="chip">${l.space_type||'—'}</span><span class="chip">${fmtSF(l.size_sf)}</span>
+      <span class="chip">${l.price_amount?'$'+Number(l.price_amount).toLocaleString()+' '+(l.price_unit||''):(l.price_unit||'Call')}</span></div>
+    ${l.source_url?`<div class="meta"><a class="chip" href="${l.source_url}" target="_blank" rel="noopener noreferrer">View ↗</a></div>`:''}
+    </div></div>`).join(''):`<div class="empty">No listings crawled yet for ${slug}. Run its broker agent to populate.</div>`;
+}
+boot();
+</script></body></html>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..692e326
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,197 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sublease Marketplace · agentabrams</title>
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
+<style>
+  :root{--ink:#14181d;--mut:#6b7480;--line:#e7e9ee;--bg:#fbfbfc;--accent:#1a5f3c;--gold:#b08a3e;--cols:4}
+  *{box-sizing:border-box}
+  body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
+  a{color:inherit;text-decoration:none}
+  .wrap{max-width:1320px;margin:0 auto;padding:0 22px}
+  header.top{border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:500}
+  .top .wrap{display:flex;align-items:center;justify-content:space-between;height:62px}
+  .brand{font-weight:700;letter-spacing:.02em;font-size:19px}
+  .brand small{color:var(--gold);font-weight:600}
+  nav a{color:var(--mut);margin-left:20px;font-size:14px}
+  .hero{background:linear-gradient(135deg,#14181d,#233041);color:#fff;padding:52px 0 44px}
+  .hero h1{font-size:34px;margin:0 0 8px;font-weight:700;letter-spacing:-.01em}
+  .hero p{color:#c6cdd6;margin:0;max-width:640px}
+  .stats{display:flex;gap:34px;margin-top:26px;flex-wrap:wrap}
+  .stats b{display:block;font-size:26px;color:#fff}
+  .stats span{color:#9aa6b2;font-size:12px;text-transform:uppercase;letter-spacing:.08em}
+  section{padding:34px 0}
+  h2{font-size:15px;text-transform:uppercase;letter-spacing:.1em;color:var(--mut);margin:0 0 18px;font-weight:700}
+  /* sponsor wall */
+  .sponsors{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px}
+  .sp{border:1px solid var(--line);border-radius:12px;background:#fff;padding:18px;display:flex;flex-direction:column;gap:10px;transition:.15s;min-height:132px}
+  .sp:hover{border-color:var(--accent);box-shadow:0 6px 22px rgba(20,24,29,.07);transform:translateY(-2px)}
+  .sp img{height:38px;max-width:150px;object-fit:contain;object-position:left}
+  .sp .role{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
+  .sp .cnt{font-weight:700;color:var(--accent)}
+  .sp .fin{color:var(--gold)}
+  .badge{display:inline-block;font-size:10px;padding:2px 7px;border-radius:20px;background:#f0f2f5;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
+  .badge.high{background:#fbe9e7;color:#b3261e}
+  #map{height:420px;border-radius:14px;border:1px solid var(--line);z-index:1}
+  /* controls */
+  .controls{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
+  .controls select,.controls input[type=search]{padding:8px 10px;border:1px solid var(--line);border-radius:8px;font-size:14px;background:#fff}
+  .controls .dens{display:flex;align-items:center;gap:8px;color:var(--mut);font-size:13px;margin-left:auto}
+  /* grid */
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
+  .card{border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden;display:flex;flex-direction:column}
+  .card .ph{height:120px;background:linear-gradient(135deg,#eef1f4,#dfe4ea);display:flex;align-items:center;justify-content:center;color:#aab4bf;font-size:12px}
+  .card .ph img{width:100%;height:100%;object-fit:cover}
+  .card .b{padding:12px 13px;display:flex;flex-direction:column;gap:5px}
+  .card .t{font-weight:600;font-size:14px;line-height:1.35}
+  .card .a{color:var(--mut);font-size:12.5px}
+  .card .meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px;font-size:12px}
+  .chip{background:#f4f6f8;border-radius:6px;padding:2px 7px;color:#41505f}
+  .card .foot{margin-top:auto;padding:9px 13px;border-top:1px solid var(--line);display:flex;align-items:center;gap:8px;font-size:12px}
+  .card .foot img{height:16px;max-width:70px;object-fit:contain}
+  .empty{color:var(--mut);border:1px dashed var(--line);border-radius:12px;padding:26px;text-align:center;grid-column:1/-1}
+  footer{border-top:1px solid var(--line);color:var(--mut);font-size:12px;padding:26px 0;background:#fff}
+</style>
+</head>
+<body>
+<header class="top"><div class="wrap">
+  <div class="brand">Sublease<small>Marketplace</small></div>
+  <nav><a href="#map-sec">Map</a><a href="#listings">Listings</a><a href="#sponsors">Brokers</a><a href="/admin.html">Admin</a></nav>
+</div></header>
+
+<div class="hero"><div class="wrap">
+  <h1>Commercial subleases, every broker in one place.</h1>
+  <p>A live marketplace of sublease & flexible space aggregated from the industry's leading brokerages and marketplaces — powered by CRUnifiedDB.</p>
+  <div class="stats" id="stats"></div>
+</div></div>
+
+<section id="sponsors"><div class="wrap">
+  <h2>Sponsor brokers & financing partners</h2>
+  <div class="sponsors" id="sponsorWall"></div>
+</div></section>
+
+<section id="map-sec"><div class="wrap">
+  <h2>Listings map</h2>
+  <div id="map"></div>
+</div></section>
+
+<section id="listings"><div class="wrap">
+  <h2>Available subleases</h2>
+  <div class="controls">
+    <input type="search" id="q" placeholder="Search address, city, title…">
+    <select id="fType"><option value="">All space types</option></select>
+    <select id="fSponsor"><option value="">All brokers</option></select>
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="size_desc">Size ↓</option>
+      <option value="size_asc">Size ↑</option>
+      <option value="city">City A→Z</option>
+      <option value="broker">Broker A→Z</option>
+    </select>
+    <label class="dens">Density
+      <input type="range" id="dens" min="2" max="6" value="4">
+    </label>
+  </div>
+  <div class="grid" id="grid"></div>
+</div></section>
+
+<footer><div class="wrap">CRUnifiedDB · gated access · listings sourced from public broker pages. Cap rates & terms are broker-stated — verify in diligence.</div></footer>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+<script>
+const $=s=>document.querySelector(s), fmtSF=n=>n?Number(n).toLocaleString()+' SF':'—';
+let ALL=[], SPON=[];
+
+async function boot(){
+  const [st,sp,ls,mp]=await Promise.all([
+    fetch('/api/stats').then(r=>r.json()),
+    fetch('/api/sponsors').then(r=>r.json()),
+    fetch('/api/listings').then(r=>r.json()),
+    fetch('/api/map-points').then(r=>r.json())
+  ]);
+  renderStats(st); SPON=sp.sponsors; renderSponsors(SPON); ALL=ls.listings;
+  fillFilters(); renderGrid(); renderMap(mp.points);
+}
+function renderStats(s){
+  $('#stats').innerHTML=`
+    <div><b>${s.listings}</b><span>Live listings</span></div>
+    <div><b>${s.sponsors}</b><span>Sponsor firms</span></div>
+    <div><b>${(s.by_type[0]||{}).space_type||'—'}</b><span>Top space type</span></div>`;
+}
+function renderSponsors(list){
+  $('#sponsorWall').innerHTML=list.map(s=>{
+    const fin=s.role==='financing_partner';
+    const risk=s.tos_risk==='high'?`<span class="badge high">ToS: high</span>`:'';
+    const line=fin?`<span class="role fin">Financing partner</span>`
+      :`<span class="cnt">${s.listing_count} listing${s.listing_count==1?'':'s'}</span>`;
+    return `<a class="sp" href="/broker/${s.slug}">
+      <img src="${s.logo_path}" alt="${s.name}" onerror="this.style.display='none'">
+      <div>${line}</div>
+      <div class="a" style="color:var(--mut);font-size:12px">${s.tagline||''}</div>
+      ${risk}</a>`;
+  }).join('');
+}
+function fillFilters(){
+  const types=[...new Set(ALL.map(l=>l.space_type).filter(Boolean))].sort();
+  $('#fType').innerHTML='<option value="">All space types</option>'+types.map(t=>`<option>${t}</option>`).join('');
+  const brk=SPON.filter(s=>s.role==='broker');
+  $('#fSponsor').innerHTML='<option value="">All brokers</option>'+brk.map(s=>`<option value="${s.slug}">${s.name}</option>`).join('');
+  ['q','fType','fSponsor','sort'].forEach(id=>$('#'+id).addEventListener('input',renderGrid));
+  $('#dens').addEventListener('input',e=>document.documentElement.style.setProperty('--cols',e.target.value));
+  const savedD=localStorage.getItem('sub_dens'); if(savedD){$('#dens').value=savedD;document.documentElement.style.setProperty('--cols',savedD);}
+  $('#dens').addEventListener('change',e=>localStorage.setItem('sub_dens',e.target.value));
+  const savedS=localStorage.getItem('sub_sort'); if(savedS){$('#sort').value=savedS;}
+  $('#sort').addEventListener('change',e=>localStorage.setItem('sub_sort',e.target.value));
+}
+function renderGrid(){
+  const q=$('#q').value.toLowerCase(),ty=$('#fType').value,sp=$('#fSponsor').value,so=$('#sort').value;
+  let rows=ALL.filter(l=>{
+    if(ty&&l.space_type!==ty)return false;
+    if(sp&&l.sponsor_slug!==sp)return false;
+    if(q&&!(`${l.title} ${l.address} ${l.city}`.toLowerCase().includes(q)))return false;
+    return true;
+  });
+  const s={size_desc:(a,b)=>(b.size_sf||0)-(a.size_sf||0),size_asc:(a,b)=>(a.size_sf||1e9)-(b.size_sf||1e9),
+    city:(a,b)=>(a.city||'').localeCompare(b.city||''),broker:(a,b)=>(a.sponsor_name||'').localeCompare(b.sponsor_name||''),
+    newest:(a,b)=>b.id-a.id}[so];
+  rows.sort(s);
+  $('#grid').innerHTML=rows.length?rows.map(cardHTML).join(''):
+    `<div class="empty">No listings match yet. Broker crawls populate this grid as they run.</div>`;
+}
+function cardHTML(l){
+  const price=l.price_amount?`$${Number(l.price_amount).toLocaleString()} ${l.price_unit||''}`:(l.price_unit||'Call for terms');
+  return `<div class="card">
+    <div class="ph">${l.image_url?`<img src="${l.image_url}" onerror="this.parentNode.textContent='${l.space_type||'space'}'">`:(l.space_type||'space')}</div>
+    <div class="b">
+      <div class="t">${l.title||'Sublease space'}</div>
+      <div class="a">${[l.address,l.city].filter(Boolean).join(', ')||l.city||''}</div>
+      <div class="meta">
+        <span class="chip">${l.space_type||'—'}</span>
+        <span class="chip">${fmtSF(l.size_sf)}</span>
+        <span class="chip">${price}</span>
+      </div>
+    </div>
+    <a class="foot" href="${l.source_url||'#'}" target="_blank" rel="noopener noreferrer">
+      <img src="${l.logo_path}" alt="${l.sponsor_name}" onerror="this.style.display='none'">
+      <span style="color:var(--mut)">${l.sponsor_name}</span></a>
+  </div>`;
+}
+function renderMap(points){
+  const map=L.map('map').setView([34.05,-118.32],10);
+  L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
+    {attribution:'© OpenStreetMap © CARTO',maxZoom:19}).addTo(map);
+  const pts=points.filter(p=>p.lat&&p.lng);
+  const g=L.featureGroup();
+  pts.forEach(p=>{
+    L.circleMarker([p.lat,p.lng],{radius:7,color:'#1a5f3c',fillColor:'#1a5f3c',fillOpacity:.7,weight:1})
+      .bindPopup(`<b>${p.title||'Sublease'}</b><br>${[p.address,p.city].filter(Boolean).join(', ')}<br>${p.sponsor_name} · ${p.space_type||''}<br><a href="${p.source_url}" target="_blank" rel="noopener">View listing →</a>`)
+      .addTo(g);
+  });
+  g.addTo(map); if(pts.length) map.fitBounds(g.getBounds().pad(.2));
+}
+boot();
+</script>
+</body>
+</html>
diff --git a/scripts/build-business-plan.js b/scripts/build-business-plan.js
new file mode 100644
index 0000000..42c9c4d
--- /dev/null
+++ b/scripts/build-business-plan.js
@@ -0,0 +1,159 @@
+'use strict';
+// Generates the Sublease Marketplace business plan PDF, grounded in live CRUnifiedDB counts.
+const fs = require('fs');
+const path = require('path');
+const PDFDocument = require('pdfkit');
+const { Pool } = require('pg');
+const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'crunified' });
+
+const OUT = process.argv[2] || path.join(process.env.HOME, 'Downloads', 'sublease-business-plan.pdf');
+const INK = '#14181d', MUT = '#5b6673', ACC = '#1a5f3c', GOLD = '#b08a3e', LINE = '#e0e3e8';
+
+(async () => {
+  const sponsors = (await pool.query('SELECT * FROM sponsors ORDER BY role, name')).rows;
+  const [{ n: listings }] = (await pool.query(`SELECT COUNT(*)::int n FROM listings WHERE status='active'`)).rows;
+  const brokers = sponsors.filter(s => s.role === 'broker');
+  const lenders = sponsors.filter(s => s.role === 'financing_partner');
+
+  const doc = new PDFDocument({ size: 'LETTER', margins: { top: 64, bottom: 64, left: 64, right: 64 } });
+  doc.pipe(fs.createWriteStream(OUT));
+  const W = doc.page.width - 128;
+
+  const H1 = (t) => { doc.moveDown(0.6); doc.fillColor(ACC).font('Helvetica-Bold').fontSize(15).text(t); doc.moveTo(64, doc.y + 2).lineTo(64 + W, doc.y + 2).strokeColor(LINE).stroke(); doc.moveDown(0.5); };
+  const H2 = (t) => { doc.moveDown(0.3); doc.fillColor(INK).font('Helvetica-Bold').fontSize(11.5).text(t); doc.moveDown(0.15); };
+  const P = (t) => { doc.fillColor(INK).font('Helvetica').fontSize(10).text(t, { align: 'left', lineGap: 2 }); doc.moveDown(0.35); };
+  const BUL = (arr) => { arr.forEach(t => doc.fillColor(INK).font('Helvetica').fontSize(10).text('•  ' + t, { indent: 6, lineGap: 2 })); doc.moveDown(0.35); };
+  const NEWPAGE_IF = (need) => { if (doc.y > doc.page.height - 64 - need) doc.addPage(); };
+
+  // ── Cover ──
+  doc.fillColor(INK).font('Helvetica-Bold').fontSize(30).text('Sublease Marketplace', { align: 'left' });
+  doc.fillColor(GOLD).fontSize(13).text('Business Plan', { align: 'left' });
+  doc.moveDown(1.2);
+  doc.fillColor(MUT).font('Helvetica').fontSize(11).text('A gated, broker-sponsored marketplace for commercial subleases & flexible space, backed by CRUnifiedDB — a unified CRE database populated by one automated crawler agent per partner firm.', { lineGap: 3 });
+  doc.moveDown(1);
+  doc.fillColor(INK).fontSize(10)
+    .text('sublease.agentabrams.com', { continued: true }).fillColor(MUT).text('   ·   access-gated (admin + Boomer)');
+  doc.fillColor(MUT).fontSize(9).text(`Prepared ${new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}  ·  ${sponsors.length} sponsor firms, ${listings} live listings at draft time`);
+  doc.moveDown(0.6);
+  doc.roundedRect(64, doc.y, W, 46, 6).fillAndStroke('#f7f9f8', LINE);
+  doc.fillColor(MUT).fontSize(8.5).text('Confidential. Listing data is aggregated from public broker pages; cap rates and lease terms are broker-stated and must be independently verified in diligence. CoStar/LoopNet content is gated behind ToS review.', 74, doc.y - 38, { width: W - 20, lineGap: 2 });
+  doc.moveDown(2);
+
+  // ── 1 Executive Summary ──
+  H1('1 · Executive Summary');
+  P('The commercial office market is carrying record sublease inventory. Tenants who downsized after 2020 hold long-dated leases they cannot fill; landlords and brokers need a faster channel to move that space. Sublease supply is fragmented across dozens of brokerage sites, national marketplaces, and operator portals — with no single, curated place to see it, and no clean tie to the financing needed to execute a deal.');
+  P('Sublease Marketplace aggregates sublease and flexible-space listings from the industry\'s leading brokerages and marketplaces into one gated marketplace. Each partner firm is a sponsor with its own dedicated crawler agent that pulls every current listing into a shared database (CRUnifiedDB) on a schedule. Four commercial lenders sit alongside the brokers as financing partners, so a tenant or investor can source the space and the capital in one place.');
+  BUL([
+    `${brokers.length} sponsor brokers/marketplaces: ${brokers.map(b => b.name).join(', ')}.`,
+    `${lenders.length} financing partners: ${lenders.map(l => l.name).join(', ')}.`,
+    'One automated crawler agent per firm → CRUnifiedDB (Postgres), the single source of truth.',
+    'Gated access (admin + client "Boomer" login) — a curated, invite-style product, not an open portal.',
+  ]);
+
+  // ── 2 The Problem ──
+  H1('2 · The Problem');
+  BUL([
+    'Sublease supply is fragmented: a tenant searching for 5,000 SF in West LA must check LoopNet, CoStar, Crexi, and a dozen individual brokerage sites, each with its own search and none complete.',
+    'Sublease space is under-marketed: it is a secondary priority for landlords and gets thin listing treatment vs. direct space.',
+    'No financing tie-in: finding the space and financing the buildout/acquisition are entirely separate journeys.',
+    'Data goes stale fast: subleases lease up quickly, and static aggregators do not re-crawl often enough to stay current.',
+  ]);
+
+  // ── 3 The Solution ──
+  H1('3 · The Solution');
+  P('A single gated marketplace with three surfaces: an interactive map, a filterable/sortable listings grid, and a sponsor-broker directory where each firm has its own page and its own live inventory. Behind it, CRUnifiedDB is continuously refreshed by per-firm crawler agents.');
+  H2('Why "one agent per firm"');
+  P('Every brokerage publishes listings differently — WordPress + REST for some, Buildout/Catylist embeds for others, JavaScript marketplaces for the largest. A single generic scraper breaks constantly. Instead, each firm gets a dedicated, independently-maintained crawler (the same pattern that runs a large multi-vendor catalog elsewhere in the portfolio): when one firm changes its site, only that one agent needs a fix, and the marketplace stays live.');
+  H2('Financing partners');
+  P('The four lenders (Fidelity Mortgage Lenders, Provident Bank, Mizrahi Tefahot / UMTB, Walker Realty Capital) do not list space — they sponsor deals. Each gets a partner page and a "finance this deal" role attached to any listing, turning the marketplace into a two-sided origination channel for them.');
+
+  NEWPAGE_IF(200);
+  // ── 4 Sponsor Roster ──
+  H1('4 · Sponsor Roster');
+  doc.font('Helvetica-Bold').fontSize(9).fillColor(MUT);
+  const cols = [64, 210, 300, 400]; const head = ['Firm', 'Type', 'Role', 'Data / ToS'];
+  head.forEach((h, i) => doc.text(h, cols[i], doc.y, { continued: i < head.length - 1, width: (cols[i + 1] || 64 + W) - cols[i] }));
+  doc.moveDown(0.2); doc.moveTo(64, doc.y).lineTo(64 + W, doc.y).strokeColor(LINE).stroke(); doc.moveDown(0.3);
+  sponsors.forEach(s => {
+    const y = doc.y; NEWPAGE_IF(20);
+    doc.font('Helvetica-Bold').fontSize(9).fillColor(INK).text(s.name, cols[0], doc.y, { width: cols[1] - cols[0] - 6 });
+    const yy = doc.y; doc.font('Helvetica').fontSize(9).fillColor(MUT);
+    doc.text(s.kind, cols[1], y, { width: cols[2] - cols[1] - 6 });
+    doc.text(s.role.replace('_', ' '), cols[2], y, { width: cols[3] - cols[2] - 6 });
+    doc.fillColor(s.tos_risk === 'high' ? '#b3261e' : MUT).text(`ToS: ${s.tos_risk}`, cols[3], y, { width: 64 + W - cols[3] });
+    doc.y = Math.max(yy, doc.y); doc.moveDown(0.15);
+  });
+  doc.moveDown(0.3);
+
+  // ── 5 Architecture ──
+  H1('5 · Technology & Data (CRUnifiedDB)');
+  BUL([
+    'CRUnifiedDB — a Postgres database with sponsors, listings, agents, and crawl_runs (an audit + cost trail per run).',
+    'Per-firm crawler agents write to CRUnifiedDB; a shared toolkit handles polite fetching, robots.txt compliance, free Census/OSM geocoding, and de-duplicated upserts keyed on each firm\'s own listing id.',
+    'A gated Express app reads CRUnifiedDB and serves the map, grid, and broker pages. No credentials or wholesale data are exposed client-side.',
+    'Refresh cadence per firm is scheduled; each run records listings found/new and cost (public-page crawling is ~$0).',
+  ]);
+  H2('Legal & compliance posture');
+  P('CoStar and LoopNet actively defend their data and run aggressive anti-bot measures. Their agents are ToS-gated: they do not run at volume without an explicit decision, and the plan favors official feeds, partnership/API access, or directory-only treatment for those two rather than adversarial scraping. The lower-risk brokerage and operator sites (NAI Capital, Premier Workspaces, CBRE public search) are crawled from public pages, rate-limited and robots-aware.');
+
+  NEWPAGE_IF(240);
+  // ── 6 Business Model ──
+  H1('6 · Business Model');
+  H2('Revenue streams');
+  BUL([
+    'Sponsor placement — brokers/marketplaces pay a monthly sponsorship to be featured and to have their inventory aggregated and cross-linked to qualified, gated demand.',
+    'Financing referral — the four lenders pay per qualified financing lead ("finance this deal") originated off a listing; CRE loan referral fees are high-value.',
+    'Premium listing — featured placement, top-of-map pins, and enhanced broker pages.',
+    'Lead-gen / data — anonymized demand signal (what space is being searched, where) sold back to sponsors as market intelligence.',
+  ]);
+  H2('Why gated (the "Boomer" access model)');
+  P('Access is invite-style behind a login. That keeps the audience qualified (real principals, brokers, and capital — not tire-kickers), protects sponsor relationships, and lets the product be sold as a curated deal-flow channel rather than a free directory competing head-on with LoopNet.');
+
+  // ── 7 Market & Competition ──
+  H1('7 · Market & Competitive Landscape');
+  P('National marketplaces (LoopNet, CoStar, Crexi) are broad but not sublease-focused, not curated, and not financing-linked. Individual brokerage sites are authoritative but siloed. Sublease Marketplace is deliberately narrow — subleases + flexible space, curated, gated, and wired to capital partners — which is defensible precisely because the incumbents are structurally horizontal and open.');
+  BUL([
+    'Focus: sublease & flexible space only — the segment incumbents under-serve.',
+    'Curation + gating: a qualified two-sided network, not an open listings dump.',
+    'Financing tie-in: origination channel the pure-listing incumbents do not offer.',
+    'Fresh data: per-firm agents re-crawl on cadence, so inventory stays current.',
+  ]);
+
+  NEWPAGE_IF(200);
+  // ── 8 Go-to-market & Roadmap ──
+  H1('8 · Go-to-Market & Roadmap');
+  H2('Phase 1 — Foundation (complete / in progress)');
+  BUL([
+    'CRUnifiedDB live; 9 sponsors seeded; gated marketplace with map, grid, and broker pages running.',
+    'First crawler agent live (Premier Workspaces) with real geocoded listings in the database.',
+  ]);
+  H2('Phase 2 — Coverage');
+  BUL([
+    'Stand up the remaining broker agents (NAI Capital via its Buildout search; CBRE public property search).',
+    'Resolve CoStar/LoopNet posture (partnership/feed vs. directory-only) before any volume crawl.',
+    'Scheduled refresh per firm + a crawl-health dashboard.',
+  ]);
+  H2('Phase 3 — Monetization');
+  BUL([
+    'Turn on sponsor placement and the "finance this deal" lead flow to the four lenders.',
+    'Premium listings + market-intelligence reports.',
+    'Public go-live at sublease.agentabrams.com behind the gated login.',
+  ]);
+
+  // ── 9 Risks ──
+  H1('9 · Key Risks & Mitigations');
+  BUL([
+    'Scraping / ToS (CoStar, LoopNet): mitigate via ToS-gating, official feeds/partnerships, robots compliance, and directory-only fallback.',
+    'Data freshness: per-firm scheduled re-crawls + last-seen tracking; stale listings auto-expire.',
+    'Sponsor concentration: diversify across brokers, operators, and lenders so no single firm is load-bearing.',
+    'Liability on listing accuracy: clear "broker-stated, verify in diligence" disclaimers throughout.',
+  ]);
+
+  doc.moveDown(1);
+  doc.fillColor(MUT).font('Helvetica-Oblique').fontSize(8.5).text('Generated from CRUnifiedDB. Counts reflect the database at generation time and grow as broker agents run.', { align: 'center' });
+
+  doc.end();
+  await new Promise(r => doc.on('end', r) || setTimeout(r, 400));
+  await pool.end();
+  console.log('PDF written →', OUT);
+})();
diff --git a/scripts/geocode-backfill.js b/scripts/geocode-backfill.js
new file mode 100644
index 0000000..068cbfa
--- /dev/null
+++ b/scripts/geocode-backfill.js
@@ -0,0 +1,27 @@
+'use strict';
+// Backfill lat/lng for listings missing coords, using free OSM Nominatim (city-level ok).
+// Polite: 1 req/sec, descriptive UA. $0.
+const { pool, httpGet, sleep } = require('../crawl/base-crawler');
+
+async function nominatim(qstr) {
+  const url = 'https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(qstr);
+  const r = await httpGet(url, { timeout: 15000, headers: { 'User-Agent': 'SubleaseMarketplace/0.1 (agentabrams.com)' } });
+  if (!r.ok) return null;
+  try { const j = JSON.parse(r.text); return j[0] ? { lat: +j[0].lat, lng: +j[0].lon } : null; } catch { return null; }
+}
+
+(async () => {
+  const { rows } = await pool.query(
+    `SELECT id, address, city, state FROM listings WHERE (lat IS NULL OR lng IS NULL) AND (city IS NOT NULL OR address IS NOT NULL) ORDER BY id`);
+  console.log(`${rows.length} listings need coords`);
+  let done = 0;
+  for (const r of rows) {
+    const qstr = [r.address, r.city, r.state || 'CA', 'USA'].filter(Boolean).join(', ');
+    const g = await nominatim(qstr);
+    if (g) { await pool.query('UPDATE listings SET lat=$2, lng=$3 WHERE id=$1', [r.id, g.lat, g.lng]); done++; process.stdout.write('.'); }
+    else process.stdout.write('x');
+    await sleep(1100); // Nominatim policy
+  }
+  console.log(`\ngeocoded ${done}/${rows.length}`);
+  await pool.end();
+})();
diff --git a/scripts/schema.sql b/scripts/schema.sql
new file mode 100644
index 0000000..a9221b3
--- /dev/null
+++ b/scripts/schema.sql
@@ -0,0 +1,76 @@
+-- CRUnifiedDB — unified commercial-real-estate database for sublease.agentabrams.com
+-- Mirrors the dw_unified pattern: per-firm crawler agents write here; the app reads here.
+
+CREATE TABLE IF NOT EXISTS sponsors (
+  id            SERIAL PRIMARY KEY,
+  slug          TEXT UNIQUE NOT NULL,          -- loopnet, cbre, naicapital ...
+  name          TEXT NOT NULL,
+  kind          TEXT NOT NULL,                 -- marketplace | brokerage | operator | lender | data
+  role          TEXT NOT NULL DEFAULT 'broker',-- broker | financing_partner
+  tagline       TEXT,
+  website       TEXT,
+  listings_url  TEXT,                          -- where the crawler starts
+  logo_path     TEXT,                          -- /assets/logos/<slug>.jpg
+  crawl_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+  tos_risk      TEXT NOT NULL DEFAULT 'low',   -- low | medium | high (CoStar/LoopNet = high)
+  notes         TEXT,
+  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS listings (
+  id            SERIAL PRIMARY KEY,
+  sponsor_id    INTEGER NOT NULL REFERENCES sponsors(id) ON DELETE CASCADE,
+  external_id   TEXT,                          -- the firm's own listing id
+  title         TEXT,
+  address       TEXT,
+  city          TEXT,
+  state         TEXT DEFAULT 'CA',
+  zip           TEXT,
+  lat           DOUBLE PRECISION,
+  lng           DOUBLE PRECISION,
+  space_type    TEXT,                          -- office | retail | industrial | flex | medical | land
+  is_sublease   BOOLEAN NOT NULL DEFAULT TRUE,
+  size_sf       INTEGER,                       -- rentable square feet
+  price_amount  NUMERIC,                       -- rate value
+  price_unit    TEXT,                          -- $/SF/yr, $/SF/mo, $/mo, negotiable
+  term          TEXT,                          -- remaining sublease term
+  available_on  TEXT,
+  source_url    TEXT,
+  image_url     TEXT,
+  description   TEXT,
+  status        TEXT NOT NULL DEFAULT 'active',-- active | pending | off_market
+  raw           JSONB,
+  first_seen    TIMESTAMPTZ NOT NULL DEFAULT now(),
+  last_seen     TIMESTAMPTZ NOT NULL DEFAULT now(),
+  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (sponsor_id, external_id)
+);
+CREATE INDEX IF NOT EXISTS idx_listings_sponsor ON listings(sponsor_id);
+CREATE INDEX IF NOT EXISTS idx_listings_geo ON listings(lat, lng);
+CREATE INDEX IF NOT EXISTS idx_listings_type ON listings(space_type);
+
+-- Individual agents/reps within a firm (populated by crawlers where available)
+CREATE TABLE IF NOT EXISTS agents (
+  id          SERIAL PRIMARY KEY,
+  sponsor_id  INTEGER NOT NULL REFERENCES sponsors(id) ON DELETE CASCADE,
+  name        TEXT NOT NULL,
+  title       TEXT,
+  email       TEXT,
+  phone       TEXT,
+  profile_url TEXT,
+  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (sponsor_id, name)
+);
+
+-- One row per crawler execution — the audit/cost trail
+CREATE TABLE IF NOT EXISTS crawl_runs (
+  id             SERIAL PRIMARY KEY,
+  sponsor_id     INTEGER REFERENCES sponsors(id) ON DELETE CASCADE,
+  started_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
+  finished_at    TIMESTAMPTZ,
+  status         TEXT NOT NULL DEFAULT 'running', -- running | ok | error | blocked
+  listings_found INTEGER DEFAULT 0,
+  listings_new   INTEGER DEFAULT 0,
+  cost_usd       NUMERIC DEFAULT 0,
+  notes          TEXT
+);
diff --git a/scripts/seed-sponsors.sql b/scripts/seed-sponsors.sql
new file mode 100644
index 0000000..facd771
--- /dev/null
+++ b/scripts/seed-sponsors.sql
@@ -0,0 +1,42 @@
+-- Seed the 9 sponsor firms. Idempotent (ON CONFLICT).
+INSERT INTO sponsors (slug, name, kind, role, tagline, website, listings_url, logo_path, tos_risk, notes) VALUES
+ ('loopnet','LoopNet','marketplace','broker',
+  'The most-trafficked commercial real estate marketplace',
+  'https://www.loopnet.com','https://www.loopnet.com/search/commercial-real-estate/los-angeles-ca/for-lease/?sk=sublease',
+  '/assets/logos/loopnet.jpg','high','CoStar-owned. Aggressive anti-bot + litigious on scraping — public pages only, rate-limited, run gated.'),
+ ('costar','CoStar','data','broker',
+  'The industry standard for commercial real estate information',
+  'https://www.costar.com','https://www.costar.com','/assets/logos/costar.jpg','high',
+  'Subscription/paywalled data. Historically litigious on data scraping — treat as directory sponsor + official-feed only.'),
+ ('cbre','CBRE','brokerage','broker',
+  'The world''s largest commercial real estate services firm',
+  'https://www.cbre.com','https://www.cbre.com/properties/properties-for-lease','/assets/logos/cbre.jpg','medium',
+  'Public property search on cbre.com; filter to sublease space.'),
+ ('naicapital','NAI Capital','brokerage','broker',
+  'Commercial Real Estate Services, Worldwide',
+  'https://www.naicapital.com','https://www.naicapital.com/properties/','/assets/logos/naicapital.jpg','low',
+  'Independent LA-based brokerage; public listing search.'),
+ ('premierworkspaces','Premier Workspaces','operator','broker',
+  'Flexible office, coworking & executive suites',
+  'https://www.premierworkspaces.com','https://premierworkspaces.com/locations/','/assets/logos/premierworkspaces.jpg','low',
+  'Office/coworking operator — locations map to sublease-style flexible space.'),
+ ('fidelity','Fidelity Mortgage Lenders','lender','financing_partner',
+  'Commercial & Residential mortgage lending',
+  'https://www.fidelityca.com',NULL,'/assets/logos/fidelity.jpg','low',
+  'Financing partner — no listings; sponsors deals via CRE lending.'),
+ ('provident','Provident Bank','lender','financing_partner',
+  'Commercial banking & real estate lending',
+  'https://www.provident.bank',NULL,'/assets/logos/provident.jpg','low',
+  'Financing partner — commercial RE lending.'),
+ ('mizrahi','Mizrahi Tefahot Bank (UMTB)','lender','financing_partner',
+  'International commercial banking · Member FDIC',
+  'https://www.umtb.com',NULL,'/assets/logos/mizrahi.jpg','low',
+  'Financing partner — UMTB, Member FDIC.'),
+ ('walker','Walker Realty Capital','lender','financing_partner',
+  'Commercial real estate capital & advisory',
+  NULL,NULL,'/assets/logos/walker.jpg','low',
+  'Financing partner — capital markets / advisory.')
+ON CONFLICT (slug) DO UPDATE SET
+  name=EXCLUDED.name, kind=EXCLUDED.kind, role=EXCLUDED.role, tagline=EXCLUDED.tagline,
+  website=EXCLUDED.website, listings_url=EXCLUDED.listings_url, logo_path=EXCLUDED.logo_path,
+  tos_risk=EXCLUDED.tos_risk, notes=EXCLUDED.notes;
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..0593419
--- /dev/null
+++ b/server.js
@@ -0,0 +1,100 @@
+'use strict';
+// sublease.agentabrams.com — gated commercial sublease marketplace over CRUnifiedDB.
+// Reads the Postgres `crunified` DB that the per-firm crawler agents populate.
+const path = require('path');
+const express = require('express');
+const { Pool } = require('pg');
+
+const app = express();
+const PORT = process.env.PORT || 9714;
+const PUB = path.join(__dirname, 'public');
+
+// CRUnifiedDB — local /tmp socket by default (matches the dw_unified pattern).
+const pool = new Pool({
+  host: process.env.PGHOST || '/tmp',
+  database: process.env.PGDATABASE || 'crunified',
+  user: process.env.PGUSER || undefined,
+  password: process.env.PGPASSWORD || undefined,
+  max: 8,
+});
+const q = (sql, params) => pool.query(sql, params).then(r => r.rows);
+
+// ── Basic Auth (unified admin + Boomer client login). Health stays open. ──
+const CREDS = ['admin:DW2024!', 'Boomer:Sublease2024!']
+  .concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map(s => s.trim()).filter(Boolean));
+const ACCEPTED = new Set(CREDS.map(c => 'Basic ' + Buffer.from(c).toString('base64')));
+app.get('/api/health', (_q, r) => r.json({ ok: true, at: new Date().toISOString() }));
+app.get('/healthz', (_q, r) => r.json({ ok: true }));
+app.use((req, res, next) => {
+  if (ACCEPTED.has(req.headers.authorization || '')) return next();
+  res.set('WWW-Authenticate', 'Basic realm="Sublease Marketplace"');
+  return res.status(401).send('Authentication required');
+});
+
+// ── APIs ──
+app.get('/api/sponsors', async (_q, res) => {
+  try {
+    const rows = await q(`
+      SELECT s.*, COUNT(l.id)::int AS listing_count
+      FROM sponsors s LEFT JOIN listings l ON l.sponsor_id = s.id AND l.status='active'
+      GROUP BY s.id ORDER BY s.role, s.name`);
+    res.json({ sponsors: rows });
+  } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+app.get('/api/sponsors/:slug', async (req, res) => {
+  try {
+    const [s] = await q(`SELECT * FROM sponsors WHERE slug=$1`, [req.params.slug]);
+    if (!s) return res.status(404).json({ error: 'not found' });
+    const listings = await q(
+      `SELECT * FROM listings WHERE sponsor_id=$1 AND status='active' ORDER BY size_sf DESC NULLS LAST, id`,
+      [s.id]);
+    const agents = await q(`SELECT * FROM agents WHERE sponsor_id=$1 ORDER BY name`, [s.id]);
+    res.json({ sponsor: s, listings, agents });
+  } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+app.get('/api/listings', async (req, res) => {
+  try {
+    const { type, sponsor, min_sf, max_sf, q: search } = req.query;
+    const where = [`l.status='active'`]; const p = [];
+    if (type)    { p.push(type);    where.push(`l.space_type = $${p.length}`); }
+    if (sponsor) { p.push(sponsor); where.push(`s.slug = $${p.length}`); }
+    if (min_sf)  { p.push(min_sf);  where.push(`l.size_sf >= $${p.length}`); }
+    if (max_sf)  { p.push(max_sf);  where.push(`l.size_sf <= $${p.length}`); }
+    if (search)  { p.push(`%${search}%`); where.push(`(l.title ILIKE $${p.length} OR l.address ILIKE $${p.length} OR l.city ILIKE $${p.length})`); }
+    const rows = await q(`
+      SELECT l.*, s.slug AS sponsor_slug, s.name AS sponsor_name, s.logo_path
+      FROM listings l JOIN sponsors s ON s.id=l.sponsor_id
+      WHERE ${where.join(' AND ')}
+      ORDER BY l.last_seen DESC, l.id LIMIT 2000`, p);
+    res.json({ listings: rows, count: rows.length });
+  } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+app.get('/api/map-points', async (_q, res) => {
+  try {
+    const rows = await q(`
+      SELECT l.id, l.title, l.address, l.city, l.lat, l.lng, l.space_type,
+             l.size_sf, l.price_amount, l.price_unit, l.source_url, s.slug AS sponsor_slug, s.name AS sponsor_name
+      FROM listings l JOIN sponsors s ON s.id=l.sponsor_id
+      WHERE l.status='active' AND l.lat IS NOT NULL AND l.lng IS NOT NULL`);
+    res.json({ built_at: new Date().toISOString(), points: rows });
+  } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+app.get('/api/stats', async (_q, res) => {
+  try {
+    const [tot]   = await q(`SELECT COUNT(*)::int n FROM listings WHERE status='active'`);
+    const [spn]   = await q(`SELECT COUNT(*)::int n FROM sponsors`);
+    const byType  = await q(`SELECT space_type, COUNT(*)::int n FROM listings WHERE status='active' GROUP BY space_type ORDER BY n DESC`);
+    const runs    = await q(`SELECT s.slug, cr.status, cr.finished_at, cr.listings_found FROM crawl_runs cr JOIN sponsors s ON s.id=cr.sponsor_id ORDER BY cr.started_at DESC LIMIT 20`);
+    res.json({ listings: tot.n, sponsors: spn.n, by_type: byType, recent_runs: runs });
+  } catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+// ── Clean routes ──
+app.get('/broker/:slug', (_q, r) => r.sendFile(path.join(PUB, 'broker.html')));
+app.use(express.static(PUB, { extensions: ['html'] }));
+
+app.listen(PORT, () => console.log(`sublease-agentabrams (CRUnifiedDB) on ${PORT}`));

← 4caa451 auto-save: 2026-07-20T10:31:23 (1 files) — public/  ·  back to Sublease Agentabrams  ·  Per-firm broker agents (naicapital/cbre discovery + loopnet/ 9504fac →