← 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
A .deploy.confA .gitignoreA crawl/base-crawler.jsA crawl/firms/premierworkspaces.jsA crawl/run.jsA package-lock.jsonA package.jsonA public/admin.htmlA public/broker.htmlA public/index.htmlA scripts/build-business-plan.jsA scripts/geocode-backfill.jsA scripts/schema.sqlA scripts/seed-sponsors.sqlA server.js
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 <slug></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 →