[object Object]

← back to Lawyer Directory Builder

Initial commit: LA Lawyer Directory — office-mapping pipeline

9315e694188b9cdc42931d577815604dedac945a · 2026-04-30 00:35:33 -0700 · Steve Abrams

- Compliance-first PostgreSQL schema (organizations, professionals, professional_locations, bar_admissions, education, emails, phones, sources, raw_records, scrape_jobs, schema_migrations)
- OSM Overpass importer (196 LA County firm offices, geo-tagged)
- Wikidata SPARQL importer (29 LA biglaw firms: Latham, Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, etc.)
- Express read API + Leaflet dashboard on :9701
- Robots-aware compliance helper (rate limit, captcha detection, no bypass)
- Overnight orchestrator with checkpointed scrape_jobs
- Documented blocked sources (CA State Bar, CA SOS bizfile) requiring Playwright pass

Files touched

Diff

commit 9315e694188b9cdc42931d577815604dedac945a
Author: Steve Abrams <steveabramsdesigns@gmail.com>
Date:   Thu Apr 30 00:35:33 2026 -0700

    Initial commit: LA Lawyer Directory — office-mapping pipeline
    
    - Compliance-first PostgreSQL schema (organizations, professionals, professional_locations, bar_admissions, education, emails, phones, sources, raw_records, scrape_jobs, schema_migrations)
    - OSM Overpass importer (196 LA County firm offices, geo-tagged)
    - Wikidata SPARQL importer (29 LA biglaw firms: Latham, Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, etc.)
    - Express read API + Leaflet dashboard on :9701
    - Robots-aware compliance helper (rate limit, captcha detection, no bypass)
    - Overnight orchestrator with checkpointed scrape_jobs
    - Documented blocked sources (CA State Bar, CA SOS bizfile) requiring Playwright pass
---
 .gitignore                             |    9 +
 README.md                              |   97 ++
 docs/blocked_sources.md                |   38 +
 migrations/001_initial_schema.sql      |  233 ++++
 migrations/002_firm_office_columns.sql |   25 +
 package-lock.json                      | 2005 ++++++++++++++++++++++++++++++++
 package.json                           |   34 +
 public/index.html                      |  129 ++
 src/db/pool.ts                         |   30 +
 src/ingest/osm_overpass.ts             |  275 +++++
 src/ingest/wikidata.ts                 |  234 ++++
 src/lib/compliance.ts                  |  111 ++
 src/scripts/inspect_justia.ts          |   54 +
 src/scripts/migrate.ts                 |   44 +
 src/scripts/run_overnight.ts           |   93 ++
 src/server/index.ts                    |  130 +++
 tsconfig.json                          |   18 +
 17 files changed, 3559 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f9960cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env
+.env.local
+raw_html/
+logs/
+*.log
+.DS_Store
+dist/
+exports/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ffe88e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,97 @@
+# LA Lawyer Directory — Office Map
+
+Compliance-first PostgreSQL database of **law firm offices in LA County**, focused on
+geographic clustering (Beverly Hills, Century City, DTLA, etc.). Powers profile-site
+generation, SEO landing pages, directory pages, and CRM enrichment.
+
+## Status
+
+| | |
+|---|---|
+| DB | `lawyer_professional_directory` on `127.0.0.1:5432` (Unix socket) |
+| API | `http://localhost:9701` |
+| Dashboard | `http://localhost:9701/` (Leaflet map of every geocoded firm) |
+
+Sources currently wired (free, no auth, robots-clean):
+- **OpenStreetMap Overpass API** — every `office=lawyer` / `amenity=lawyer` node in LA County.
+- **Wikidata SPARQL** — notable firms with HQ/office in LA County (Latham, Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, Littler Mendelson, Irell, etc.).
+
+Sources blocked by anti-bot — see `docs/blocked_sources.md`:
+- CA State Bar attorney search (SPA, JS-only render)
+- CA SOS bizfile API (recaptcha-gated)
+
+Both are reachable with a Playwright pass — that's the next milestone, not tonight's.
+
+## Run
+
+```bash
+npm install                      # one-time
+npm run migrate                  # create / update tables (idempotent)
+npx tsx src/server/index.ts &    # start API + dashboard on :9701
+npx tsx src/scripts/run_overnight.ts   # one-shot import pass + summary
+```
+
+Logs land in `logs/overnight-YYYY-MM-DD.log`.
+
+## Schedule it nightly
+
+Pick one:
+
+**cron** (macOS):
+```cron
+# nightly at 02:00 local
+0 2 * * *  cd /Users/stevestudio2/Projects/lawyer-directory-builder && /usr/local/bin/npx tsx src/scripts/run_overnight.ts >> logs/cron.log 2>&1
+```
+
+**pm2** (if installed):
+```bash
+pm2 start npx --name lawyer-server -- tsx src/server/index.ts
+pm2 start --cron-restart "0 2 * * *" --no-autorestart \
+  npx --name lawyer-overnight -- tsx src/scripts/run_overnight.ts
+pm2 save
+```
+
+## Useful queries
+
+```sql
+-- top neighborhoods by firm count
+SELECT COALESCE(neighborhood, city) AS area, COUNT(*) AS firms
+FROM organizations WHERE type='law_firm'
+GROUP BY area ORDER BY firms DESC LIMIT 20;
+
+-- multi-firm buildings (true clusters)
+SELECT address, city, COUNT(*) AS firms,
+       ARRAY_AGG(name ORDER BY name) AS firms_at_address
+FROM organizations
+WHERE type='law_firm' AND address IS NOT NULL
+GROUP BY address, city HAVING COUNT(*) > 1
+ORDER BY firms DESC;
+
+-- 90067 Century City roster
+SELECT name, address, phone, website
+FROM organizations
+WHERE type='law_firm' AND zip='90067'
+ORDER BY name;
+```
+
+## API endpoints
+
+| | |
+|---|---|
+| `GET /api/health` | liveness |
+| `GET /api/stats` | totals + coverage |
+| `GET /api/firms?city=Beverly+Hills&limit=200` | firms list |
+| `GET /api/firms/:id` | one firm |
+| `GET /api/cities` | firm count + centroid by area |
+| `GET /api/buildings` | multi-firm addresses |
+| `GET /api/heatmap` | `{lat,lng,weight}` for map overlay |
+| `GET /api/jobs` | last 30 scrape_jobs runs |
+
+## Schema
+
+Mirrored on the `organizations` + `professionals` + `professional_locations`
+join model. See `migrations/001_initial_schema.sql` (base) and
+`migrations/002_firm_office_columns.sql` (clustering: `neighborhood`, `lat`,
+`lng`, `address_norm`, `attorney_count`, `firm_size_band`).
+
+Every fact has a `source_url` traceable in `raw_records`.
diff --git a/docs/blocked_sources.md b/docs/blocked_sources.md
new file mode 100644
index 0000000..bd77ea3
--- /dev/null
+++ b/docs/blocked_sources.md
@@ -0,0 +1,38 @@
+# Blocked sources — require Playwright / browser session
+
+Two sources we wanted but cannot reach with plain HTTP under our compliance policy
+(no captcha bypass, respect site terms).
+
+## CA Secretary of State bizfile API
+
+- Endpoint: `POST https://bizfileonline.sos.ca.gov/api/Records/businesssearch`
+- Probe result: **403 Forbidden** with empty body on plain POST.
+- Reason: their SPA injects a Google reCAPTCHA v3 token in the `x-recaptcha` header
+  for every API call. Without a real browser executing their reCAPTCHA challenge,
+  the gateway rejects the request.
+- Path forward: a Playwright session that (a) loads the search page so reCAPTCHA
+  initializes, (b) submits the form, (c) extracts the token from the live request,
+  (d) replays via fetch. This is **inside compliance** because we are not bypassing
+  reCAPTCHA — we are letting it run normally and just persisting the result.
+
+## CA State Bar — Attorney Licensee Search
+
+- Form page: `https://apps.calbar.ca.gov/attorney/LicenseeSearch/AdvancedSearch`
+- Detail page: `https://apps.calbar.ca.gov/attorney/Licensee/Detail/{barNumber}`
+- Probe result: every URL returns the **same 624 KB SPA shell** with an empty
+  `#moduleAttorneySearchList` div.  Server-side rendering doesn't expose result data;
+  the page loads a Telerik Kendo grid that fetches results client-side via an
+  endpoint we couldn't locate from the static HTML/JS.
+- Path forward: same Playwright pattern as bizfile — load `Detail/{n}` for each
+  Bar # of interest, wait for the data-bound elements, scrape the rendered DOM.
+
+## Why not now
+
+Both imply adding Playwright + headless Chromium + DOM extractors + per-page rate
+limits much stricter than for plain HTTP. That's a project unto itself; tonight's
+run sticks with the sources that work cleanly via curl-grade requests:
+
+- OpenStreetMap Overpass (already wired)
+- Wikidata SPARQL (already wired)
+
+When ready to do Playwright pass, see `src/ingest/calbar_playwright.ts` (TODO).
diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql
new file mode 100644
index 0000000..1988295
--- /dev/null
+++ b/migrations/001_initial_schema.sql
@@ -0,0 +1,233 @@
+-- Lawyer Professional Directory — Initial Schema
+-- Compliance-first design: every fact must be traceable to source_url; opt-out flag respected on every public surface.
+
+BEGIN;
+
+-- ─── Reference / dictionary tables ──────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS sources (
+  id              BIGSERIAL PRIMARY KEY,
+  source_name     TEXT NOT NULL UNIQUE,
+  source_type     TEXT NOT NULL CHECK (source_type IN (
+                    'official_registry', 'government', 'court', 'bar_association',
+                    'firm_website', 'api', 'directory'
+                  )),
+  base_url        TEXT NOT NULL,
+  terms_notes     TEXT,
+  allowed_method  TEXT NOT NULL CHECK (allowed_method IN ('api', 'crawl', 'manual', 'denied')),
+  rate_limit_rps  NUMERIC(6,2),
+  robots_txt_url  TEXT,
+  last_checked_at TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS practice_areas (
+  id           BIGSERIAL PRIMARY KEY,
+  name         TEXT NOT NULL UNIQUE,
+  parent_area  TEXT
+);
+
+-- ─── Core entities ──────────────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS organizations (
+  id                BIGSERIAL PRIMARY KEY,
+  name              TEXT NOT NULL,
+  type              TEXT NOT NULL CHECK (type IN (
+                      'law_firm', 'solo_practice', 'legal_aid',
+                      'government_office', 'court', 'bar_association'
+                    )),
+  address           TEXT,
+  city              TEXT,
+  state             TEXT DEFAULT 'CA',
+  zip               TEXT,
+  county            TEXT,
+  phone             TEXT,
+  website           TEXT,
+  google_place_id   TEXT UNIQUE,
+  rating            NUMERIC(3,1),
+  review_count      INTEGER,
+  source_url        TEXT,
+  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations (LOWER(name));
+CREATE INDEX IF NOT EXISTS idx_organizations_zip ON organizations (zip);
+CREATE INDEX IF NOT EXISTS idx_organizations_county ON organizations (county);
+CREATE INDEX IF NOT EXISTS idx_organizations_website ON organizations (LOWER(website));
+
+CREATE TABLE IF NOT EXISTS professionals (
+  id                          BIGSERIAL PRIMARY KEY,
+  full_name                   TEXT NOT NULL,
+  first_name                  TEXT,
+  last_name                   TEXT,
+  middle_name                 TEXT,
+  suffix                      TEXT,
+  title                       TEXT,
+  bar_number                  TEXT UNIQUE,
+  license_status              TEXT,
+  license_status_date         DATE,
+  admission_date              DATE,
+  years_experience_estimate   INTEGER,
+  law_school                  TEXT,
+  graduation_year             INTEGER,
+  primary_practice_area       TEXT,
+  secondary_practice_areas    TEXT[],
+  bio                         TEXT,
+  languages                   TEXT[],
+  profile_image_url           TEXT,
+  source_confidence_score     NUMERIC(4,3) NOT NULL DEFAULT 0.000,
+  confidence_tier             TEXT CHECK (confidence_tier IN ('high', 'medium', 'low', 'unverified')),
+  opt_out_flag                BOOLEAN NOT NULL DEFAULT FALSE,
+  opt_out_reason              TEXT,
+  opt_out_at                  TIMESTAMPTZ,
+  created_at                  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at                  TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_professionals_last_first ON professionals (LOWER(last_name), LOWER(first_name));
+CREATE INDEX IF NOT EXISTS idx_professionals_status ON professionals (license_status);
+CREATE INDEX IF NOT EXISTS idx_professionals_practice ON professionals (primary_practice_area);
+CREATE INDEX IF NOT EXISTS idx_professionals_opt_out ON professionals (opt_out_flag);
+
+-- ─── Relationships / multi-valued attributes ────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS professional_locations (
+  id                BIGSERIAL PRIMARY KEY,
+  professional_id   BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+  organization_id   BIGINT REFERENCES organizations(id) ON DELETE SET NULL,
+  role              TEXT,
+  address           TEXT,
+  phone             TEXT,
+  email             TEXT,
+  appointment_url   TEXT,
+  source_url        TEXT,
+  is_primary        BOOLEAN NOT NULL DEFAULT FALSE,
+  last_verified_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_prof_loc_professional ON professional_locations (professional_id);
+CREATE INDEX IF NOT EXISTS idx_prof_loc_organization ON professional_locations (organization_id);
+
+CREATE TABLE IF NOT EXISTS professional_practice_areas (
+  professional_id    BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+  practice_area_id   BIGINT NOT NULL REFERENCES practice_areas(id) ON DELETE CASCADE,
+  source             TEXT,
+  confidence_score   NUMERIC(4,3) NOT NULL DEFAULT 0.000,
+  PRIMARY KEY (professional_id, practice_area_id)
+);
+
+CREATE TABLE IF NOT EXISTS emails (
+  id                    BIGSERIAL PRIMARY KEY,
+  professional_id       BIGINT REFERENCES professionals(id) ON DELETE CASCADE,
+  organization_id       BIGINT REFERENCES organizations(id) ON DELETE CASCADE,
+  email                 TEXT NOT NULL,
+  email_type            TEXT NOT NULL CHECK (email_type IN ('public_direct', 'office', 'intake', 'admin', 'unknown')),
+  source_url            TEXT,
+  discovered_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  last_verified_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  verification_status   TEXT NOT NULL DEFAULT 'unverified'
+                          CHECK (verification_status IN ('unverified', 'syntactically_valid', 'mx_valid', 'bounced', 'opted_out')),
+  CHECK (professional_id IS NOT NULL OR organization_id IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_emails_email ON emails (LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_emails_professional ON emails (professional_id);
+CREATE INDEX IF NOT EXISTS idx_emails_organization ON emails (organization_id);
+
+CREATE TABLE IF NOT EXISTS phones (
+  id                BIGSERIAL PRIMARY KEY,
+  professional_id   BIGINT REFERENCES professionals(id) ON DELETE CASCADE,
+  organization_id   BIGINT REFERENCES organizations(id) ON DELETE CASCADE,
+  phone             TEXT NOT NULL,
+  phone_type        TEXT CHECK (phone_type IN ('office', 'direct', 'mobile', 'fax', 'intake', 'unknown')),
+  source_url        TEXT,
+  last_verified_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  CHECK (professional_id IS NOT NULL OR organization_id IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_phones_professional ON phones (professional_id);
+CREATE INDEX IF NOT EXISTS idx_phones_organization ON phones (organization_id);
+
+CREATE TABLE IF NOT EXISTS education (
+  id                BIGSERIAL PRIMARY KEY,
+  professional_id   BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+  school_name       TEXT NOT NULL,
+  degree            TEXT,
+  graduation_year   INTEGER,
+  source_url        TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_education_professional ON education (professional_id);
+
+CREATE TABLE IF NOT EXISTS bar_admissions (
+  id                BIGSERIAL PRIMARY KEY,
+  professional_id   BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+  jurisdiction      TEXT NOT NULL,
+  bar_number        TEXT,
+  admission_date    DATE,
+  status            TEXT,
+  source_url        TEXT,
+  UNIQUE (professional_id, jurisdiction, bar_number)
+);
+
+CREATE INDEX IF NOT EXISTS idx_bar_admissions_professional ON bar_admissions (professional_id);
+
+-- ─── Audit / provenance tables ──────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS raw_records (
+  id            BIGSERIAL PRIMARY KEY,
+  source_id     BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
+  source_url    TEXT NOT NULL,
+  entity_type   TEXT NOT NULL,
+  entity_id     TEXT,
+  raw_json      JSONB,
+  raw_html_path TEXT,
+  fetched_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  http_status   INTEGER,
+  hash          TEXT NOT NULL,
+  UNIQUE (source_id, hash)
+);
+
+CREATE INDEX IF NOT EXISTS idx_raw_records_source ON raw_records (source_id);
+CREATE INDEX IF NOT EXISTS idx_raw_records_url ON raw_records (source_url);
+CREATE INDEX IF NOT EXISTS idx_raw_records_entity ON raw_records (entity_type, entity_id);
+
+CREATE TABLE IF NOT EXISTS scrape_jobs (
+  id                  BIGSERIAL PRIMARY KEY,
+  source_id           BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
+  job_label           TEXT,
+  status              TEXT NOT NULL DEFAULT 'queued'
+                        CHECK (status IN ('queued', 'running', 'completed', 'failed', 'aborted_compliance')),
+  started_at          TIMESTAMPTZ,
+  finished_at         TIMESTAMPTZ,
+  records_found       INTEGER NOT NULL DEFAULT 0,
+  records_inserted    INTEGER NOT NULL DEFAULT 0,
+  records_updated     INTEGER NOT NULL DEFAULT 0,
+  records_skipped     INTEGER NOT NULL DEFAULT 0,
+  error_message       TEXT,
+  checkpoint          JSONB
+);
+
+CREATE INDEX IF NOT EXISTS idx_scrape_jobs_source ON scrape_jobs (source_id);
+CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status ON scrape_jobs (status);
+
+-- ─── Trigger: auto-update updated_at ───────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
+BEGIN
+  NEW.updated_at = NOW();
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_professionals_updated ON professionals;
+CREATE TRIGGER trg_professionals_updated
+  BEFORE UPDATE ON professionals
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+DROP TRIGGER IF EXISTS trg_organizations_updated ON organizations;
+CREATE TRIGGER trg_organizations_updated
+  BEFORE UPDATE ON organizations
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+COMMIT;
diff --git a/migrations/002_firm_office_columns.sql b/migrations/002_firm_office_columns.sql
new file mode 100644
index 0000000..b4a98bb
--- /dev/null
+++ b/migrations/002_firm_office_columns.sql
@@ -0,0 +1,25 @@
+-- Office-clustering columns for firm-focused data model.
+-- Tracks neighborhood (Beverly Hills, Century City, DTLA, …), geo, building, derived attorney count.
+
+BEGIN;
+
+ALTER TABLE organizations
+  ADD COLUMN IF NOT EXISTS neighborhood    TEXT,
+  ADD COLUMN IF NOT EXISTS lat             DOUBLE PRECISION,
+  ADD COLUMN IF NOT EXISTS lng             DOUBLE PRECISION,
+  ADD COLUMN IF NOT EXISTS geocoded_at     TIMESTAMPTZ,
+  ADD COLUMN IF NOT EXISTS building_name   TEXT,
+  ADD COLUMN IF NOT EXISTS attorney_count  INTEGER NOT NULL DEFAULT 0,
+  ADD COLUMN IF NOT EXISTS practice_areas  TEXT[],
+  ADD COLUMN IF NOT EXISTS firm_size_band  TEXT;       -- 'solo' | 'small' | 'medium' | 'large' | 'biglaw'
+
+CREATE INDEX IF NOT EXISTS idx_organizations_neighborhood ON organizations (neighborhood);
+CREATE INDEX IF NOT EXISTS idx_organizations_attorney_count ON organizations (attorney_count DESC);
+
+-- Address-canonicalization helpers — used to group "1888 Century Park East, Suite 200"
+-- and "1888 Century Park E #200" as the same building.
+ALTER TABLE organizations
+  ADD COLUMN IF NOT EXISTS address_norm TEXT;
+CREATE INDEX IF NOT EXISTS idx_organizations_address_norm ON organizations (address_norm);
+
+COMMIT;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..c3040a2
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2005 @@
+{
+  "name": "lawyer-directory-builder",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "lawyer-directory-builder",
+      "version": "0.1.0",
+      "license": "UNLICENSED",
+      "dependencies": {
+        "cheerio": "^1.0.0",
+        "csv-stringify": "^6.5.2",
+        "dotenv": "^16.4.7",
+        "express": "^4.21.2",
+        "pg": "^8.13.1",
+        "robots-parser": "^3.0.1",
+        "undici": "^7.2.0"
+      },
+      "devDependencies": {
+        "@types/express": "^5.0.0",
+        "@types/node": "^22.10.5",
+        "@types/pg": "^8.11.10",
+        "tsx": "^4.19.2",
+        "typescript": "^5.7.3"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+      "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+      "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+      "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+      "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+      "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+      "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+      "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+      "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+      "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+      "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+      "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+      "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+      "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+      "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+      "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+      "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+      "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+      "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+      "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+      "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+      "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+      "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+      "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+      "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "^2"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+      "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.17",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+      "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "pg-protocol": "*",
+        "pg-types": "^2.2.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.0",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
+      "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*"
+      }
+    },
+    "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-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/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "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/body-parser/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/body-parser/node_modules/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "license": "ISC"
+    },
+    "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-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/cheerio": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+      "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+      "license": "MIT",
+      "dependencies": {
+        "cheerio-select": "^2.1.0",
+        "dom-serializer": "^2.0.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "encoding-sniffer": "^0.2.1",
+        "htmlparser2": "^10.1.0",
+        "parse5": "^7.3.0",
+        "parse5-htmlparser2-tree-adapter": "^7.1.0",
+        "parse5-parser-stream": "^7.1.2",
+        "undici": "^7.19.0",
+        "whatwg-mimetype": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=20.18.1"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+      }
+    },
+    "node_modules/cheerio-select": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+      "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-select": "^5.1.0",
+        "css-what": "^6.1.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "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/css-select": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+      "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-what": "^6.1.0",
+        "domhandler": "^5.0.2",
+        "domutils": "^3.0.1",
+        "nth-check": "^2.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/css-what": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+      "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">= 6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/csv-stringify": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz",
+      "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==",
+      "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/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/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "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/encoding-sniffer": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+      "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "^0.6.3",
+        "whatwg-encoding": "^3.1.1"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "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-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+      "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.7",
+        "@esbuild/android-arm": "0.27.7",
+        "@esbuild/android-arm64": "0.27.7",
+        "@esbuild/android-x64": "0.27.7",
+        "@esbuild/darwin-arm64": "0.27.7",
+        "@esbuild/darwin-x64": "0.27.7",
+        "@esbuild/freebsd-arm64": "0.27.7",
+        "@esbuild/freebsd-x64": "0.27.7",
+        "@esbuild/linux-arm": "0.27.7",
+        "@esbuild/linux-arm64": "0.27.7",
+        "@esbuild/linux-ia32": "0.27.7",
+        "@esbuild/linux-loong64": "0.27.7",
+        "@esbuild/linux-mips64el": "0.27.7",
+        "@esbuild/linux-ppc64": "0.27.7",
+        "@esbuild/linux-riscv64": "0.27.7",
+        "@esbuild/linux-s390x": "0.27.7",
+        "@esbuild/linux-x64": "0.27.7",
+        "@esbuild/netbsd-arm64": "0.27.7",
+        "@esbuild/netbsd-x64": "0.27.7",
+        "@esbuild/openbsd-arm64": "0.27.7",
+        "@esbuild/openbsd-x64": "0.27.7",
+        "@esbuild/openharmony-arm64": "0.27.7",
+        "@esbuild/sunos-x64": "0.27.7",
+        "@esbuild/win32-arm64": "0.27.7",
+        "@esbuild/win32-ia32": "0.27.7",
+        "@esbuild/win32-x64": "0.27.7"
+      }
+    },
+    "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.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "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.14.0",
+        "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/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/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "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/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/get-tsconfig": {
+      "version": "4.14.0",
+      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
+      "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "resolve-pkg-maps": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+      }
+    },
+    "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-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/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/htmlparser2": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+      "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "entities": "^7.0.1"
+      }
+    },
+    "node_modules/htmlparser2/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "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.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "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/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/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/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
+    "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/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/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-htmlparser2-tree-adapter": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+      "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+      "license": "MIT",
+      "dependencies": {
+        "domhandler": "^5.0.3",
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-parser-stream": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+      "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+      "license": "MIT",
+      "dependencies": {
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5/node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "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/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.12.0",
+        "pg-pool": "^3.13.0",
+        "pg-protocol": "^1.13.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.3.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+      "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.12.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+      "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+      "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.13.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+      "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+      "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+      "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/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.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "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/raw-body/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/resolve-pkg-maps": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+      }
+    },
+    "node_modules/robots-parser": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz",
+      "integrity": "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "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/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/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.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "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/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/tsx": {
+      "version": "4.21.0",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.27.0",
+        "get-tsconfig": "^4.7.5"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "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/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici": {
+      "version": "7.25.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+      "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "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/whatwg-encoding": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "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..51516d7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+  "name": "lawyer-directory-builder",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Compliance-first PostgreSQL data platform for licensed legal professionals (LA County first). Powers profile websites, SEO pages, directories, and CRM enrichment.",
+  "license": "UNLICENSED",
+  "type": "module",
+  "scripts": {
+    "migrate": "tsx src/scripts/migrate.ts",
+    "seed:sources": "tsx src/scripts/seed_sources.ts",
+    "ingest:state-bar": "tsx src/scripts/run_state_bar.ts",
+    "ingest:state-bar:smoke": "tsx src/scripts/run_state_bar.ts --smoke",
+    "export:csv": "tsx src/scripts/export_csv.ts",
+    "server": "tsx src/server/index.ts",
+    "dev": "tsx watch src/server/index.ts",
+    "stats": "tsx src/scripts/stats.ts"
+  },
+  "dependencies": {
+    "cheerio": "^1.0.0",
+    "csv-stringify": "^6.5.2",
+    "dotenv": "^16.4.7",
+    "express": "^4.21.2",
+    "pg": "^8.13.1",
+    "robots-parser": "^3.0.1",
+    "undici": "^7.2.0"
+  },
+  "devDependencies": {
+    "@types/express": "^5.0.0",
+    "@types/node": "^22.10.5",
+    "@types/pg": "^8.11.10",
+    "tsx": "^4.19.2",
+    "typescript": "^5.7.3"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..0e5659c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,129 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>LA Lawyer Directory — Office Map</title>
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
+      integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
+<style>
+  :root { --bg:#0e1116; --panel:#161b22; --text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; }
+  * { box-sizing: border-box; }
+  html, body { margin:0; padding:0; height:100%; background:var(--bg); color:var(--text); font:14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+  header { padding:12px 16px; background:var(--panel); border-bottom:1px solid #30363d; display:flex; align-items:baseline; gap:18px; flex-wrap:wrap; }
+  header h1 { margin:0; font-size:16px; font-weight:600; }
+  header .stat { color:var(--muted); }
+  header .stat b { color:var(--text); font-weight:600; }
+  main { display:grid; grid-template-columns: 320px 1fr; height:calc(100vh - 49px); }
+  aside { overflow:auto; background:var(--panel); border-right:1px solid #30363d; }
+  aside section { padding:12px 14px; border-bottom:1px solid #30363d; }
+  aside h2 { margin:0 0 8px 0; font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
+  aside .row { display:flex; justify-content:space-between; padding:3px 0; font-variant-numeric: tabular-nums; }
+  aside .row a { color:var(--text); text-decoration:none; }
+  aside .row a:hover { color:var(--accent); }
+  aside .num { color:var(--muted); }
+  #map { height:100%; }
+  .leaflet-popup-content { font-size:13px; line-height:1.4; }
+  .leaflet-popup-content b { color:#0d1117; }
+  .leaflet-popup-content a { color:#0969da; }
+  .pill { display:inline-block; padding:1px 6px; border-radius:8px; background:#30363d; color:var(--muted); font-size:11px; margin-right:4px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>LA Lawyer Directory — Office Map</h1>
+  <span class="stat"><b id="s_firms">…</b> firms</span>
+  <span class="stat"><b id="s_geo">…</b> geo-mapped</span>
+  <span class="stat"><b id="s_site">…</b> with website</span>
+  <span class="stat"><b id="s_buildings">…</b> multi-firm buildings</span>
+  <span class="stat"><b id="s_cities">…</b> cities</span>
+</header>
+<main>
+  <aside>
+    <section>
+      <h2>Top areas</h2>
+      <div id="cities"></div>
+    </section>
+    <section>
+      <h2>Multi-firm buildings</h2>
+      <div id="buildings"></div>
+    </section>
+    <section>
+      <h2>Recent jobs</h2>
+      <div id="jobs"></div>
+    </section>
+  </aside>
+  <div id="map"></div>
+</main>
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
+        integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
+<script>
+async function getJSON(url) { const r = await fetch(url); return r.json(); }
+
+const map = L.map('map').setView([34.05, -118.35], 11);
+L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+  attribution: '© OSM © CARTO',
+  subdomains: 'abcd', maxZoom: 19,
+}).addTo(map);
+
+(async () => {
+  const stats = await getJSON('/api/stats');
+  document.getElementById('s_firms').textContent = stats.firms_total ?? 0;
+  document.getElementById('s_geo').textContent = stats.firms_with_geo ?? 0;
+  document.getElementById('s_site').textContent = stats.firms_with_site ?? 0;
+  document.getElementById('s_buildings').textContent = stats.multi_firm_buildings ?? 0;
+  document.getElementById('s_cities').textContent = stats.cities ?? 0;
+
+  const cities = await getJSON('/api/cities');
+  document.getElementById('cities').innerHTML = cities.rows.slice(0, 25).map(c =>
+    `<div class="row"><a href="#" data-area="${encodeURIComponent(c.area)}">${c.area || '(unknown)'}</a><span class="num">${c.firms}</span></div>`
+  ).join('');
+
+  const buildings = await getJSON('/api/buildings');
+  document.getElementById('buildings').innerHTML = buildings.rows.slice(0, 15).map(b =>
+    `<div class="row"><span title="${(b.names || []).join('\\n')}">${b.address.slice(0, 38)}…</span><span class="num">${b.firms}</span></div>`
+  ).join('') || '<div class="num">No multi-firm buildings yet.</div>';
+
+  const firms = await getJSON('/api/firms?limit=1000');
+  const cluster = L.layerGroup().addTo(map);
+  for (const f of firms.rows) {
+    if (!f.lat || !f.lng) continue;
+    const radius = 4 + Math.min(8, (f.attorney_count || 0) / 25);
+    const m = L.circleMarker([f.lat, f.lng], {
+      radius, color: '#58a6ff', weight: 1, fillColor: '#58a6ff', fillOpacity: 0.55,
+    });
+    const html = `
+      <b>${f.name}</b><br>
+      ${f.address || ''}<br>
+      ${f.phone ? `📞 ${f.phone}<br>` : ''}
+      ${f.website ? `🌐 <a href="${f.website}" target="_blank" rel="noopener">${f.website.replace(/^https?:\/\//,'').slice(0,40)}</a><br>` : ''}
+      <span class="pill">${f.firm_size_band || 'unsized'}</span>
+      ${f.neighborhood ? `<span class="pill">${f.neighborhood}</span>` : ''}
+    `;
+    m.bindPopup(html);
+    cluster.addLayer(m);
+  }
+
+  document.getElementById('cities').addEventListener('click', async (e) => {
+    const a = e.target.closest('a[data-area]');
+    if (!a) return;
+    e.preventDefault();
+    const area = decodeURIComponent(a.dataset.area);
+    const r = await getJSON('/api/firms?city=' + encodeURIComponent(area));
+    if (r.rows.length && r.rows[0].lat) {
+      const lats = r.rows.filter(x => x.lat).map(x => x.lat);
+      const lngs = r.rows.filter(x => x.lng).map(x => x.lng);
+      const cLat = lats.reduce((a,b)=>a+b,0)/lats.length;
+      const cLng = lngs.reduce((a,b)=>a+b,0)/lngs.length;
+      map.setView([cLat, cLng], 14);
+    }
+  });
+
+  const jobs = await getJSON('/api/jobs');
+  document.getElementById('jobs').innerHTML = jobs.rows.slice(0, 10).map(j =>
+    `<div class="row"><span>${j.source_name?.slice(0, 22) || ''}</span><span class="num">${j.records_inserted ?? 0}</span></div>`
+  ).join('') || '<div class="num">No jobs yet.</div>';
+})();
+</script>
+</body>
+</html>
diff --git a/src/db/pool.ts b/src/db/pool.ts
new file mode 100644
index 0000000..a1ce127
--- /dev/null
+++ b/src/db/pool.ts
@@ -0,0 +1,30 @@
+import 'dotenv/config';
+import pg from 'pg';
+
+const { Pool } = pg;
+
+const url = process.env.DATABASE_URL;
+if (!url) {
+  throw new Error('DATABASE_URL is required (see .env)');
+}
+
+export const pool = new Pool({ connectionString: url, max: 10 });
+
+export async function query<T = unknown>(text: string, params: unknown[] = []) {
+  return pool.query<T>(text, params as never[]);
+}
+
+export async function withTx<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+    const out = await fn(client);
+    await client.query('COMMIT');
+    return out;
+  } catch (e) {
+    await client.query('ROLLBACK');
+    throw e;
+  } finally {
+    client.release();
+  }
+}
diff --git a/src/ingest/osm_overpass.ts b/src/ingest/osm_overpass.ts
new file mode 100644
index 0000000..fc94a63
--- /dev/null
+++ b/src/ingest/osm_overpass.ts
@@ -0,0 +1,275 @@
+/**
+ * OSM Overpass importer — pulls law-firm OFFICES in LA County.
+ * Free, no key, no captcha. Tag filters: office=lawyer + amenity=lawyer (legacy).
+ *
+ * The data is the ground truth for "where the firms physically are":
+ * each result has lat/lng, addr fields, and the firm name as tagged by mappers.
+ */
+import 'dotenv/config';
+import crypto from 'node:crypto';
+import { fetchCompliant } from '../lib/compliance.ts';
+import { pool, query, withTx } from '../db/pool.ts';
+
+const OVERPASS = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+const SOURCE_NAME = 'OpenStreetMap Overpass API';
+
+type OverpassElement = {
+  type: 'node' | 'way' | 'relation';
+  id: number;
+  lat?: number; lon?: number;
+  center?: { lat: number; lon: number };
+  tags?: Record<string, string>;
+};
+
+const QL = `
+[out:json][timeout:90];
+// LA County — wikidata Q104994
+area["wikidata"="Q104994"]->.la;
+(
+  nwr["office"="lawyer"](area.la);
+  nwr["amenity"="lawyer"](area.la);
+);
+out center tags;
+`.trim();
+
+function sha256(s: string) {
+  return crypto.createHash('sha256').update(s).digest('hex');
+}
+
+function clean(s: string | undefined | null) {
+  if (s === undefined || s === null) return null;
+  const t = String(s).trim();
+  return t.length === 0 ? null : t;
+}
+
+function buildAddress(t: Record<string, string>) {
+  const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ');
+  const unit = t['addr:unit'] ? ` Suite ${t['addr:unit']}` : '';
+  const line1 = clean(street + unit);
+  const city = clean(t['addr:city']);
+  const state = clean(t['addr:state']) || 'CA';
+  const zip = clean(t['addr:postcode']);
+  // Only build a "full" address if we actually have a street line OR a city.
+  // Otherwise the field would be a useless "CA, 90210" or just "CA".
+  const full = (line1 || city)
+    ? [line1, city, state, zip].filter(Boolean).join(', ')
+    : null;
+  return { line1, city, state, zip, full };
+}
+
+function normAddress(s: string | null) {
+  if (!s) return null;
+  return s.toLowerCase()
+    .replace(/[.,]/g, ' ')
+    .replace(/\b(suite|ste|unit|apt|#)\b/g, '')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function neighborhoodOf(t: Record<string, string>) {
+  // Use addr:suburb or addr:city for neighborhood. Beverly Hills, Century City, etc.
+  return clean(t['addr:suburb']) || clean(t['addr:district']) || clean(t['addr:city']);
+}
+
+function firmSizeBand(name: string | null): string | null {
+  if (!name) return null;
+  const n = name.toLowerCase();
+  // Heuristic — rough; refined later by attorney_count.
+  if (/\b(llp|llc|p\.c\.|pc|pllc|chartered)\b/.test(n)) return 'medium';
+  if (/&|and associates|group/.test(n)) return 'small';
+  return 'solo';
+}
+
+async function getSourceId(): Promise<number> {
+  const r = await query<{ id: number }>('SELECT id FROM sources WHERE source_name = $1', [SOURCE_NAME]);
+  if (r.rowCount === 0) throw new Error(`Source not seeded: ${SOURCE_NAME}`);
+  return r.rows[0].id;
+}
+
+async function startJob(sourceId: number, label: string) {
+  // The lawyer DB schema uses scrape_jobs without job_label column? Let me match the actual schema.
+  // It does have job_label per migration 001.
+  const r = await query<{ id: number }>(`
+    INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
+    VALUES ($1,$2,'running',NOW())
+    RETURNING id
+  `, [sourceId, label]);
+  return r.rows[0].id;
+}
+
+async function finishJob(jobId: number, fields: Record<string, unknown>) {
+  const sets: string[] = [];
+  const params: unknown[] = [];
+  let i = 1;
+  for (const [k, v] of Object.entries(fields)) {
+    sets.push(`${k} = $${i++}`); params.push(v);
+  }
+  sets.push(`finished_at = NOW()`);
+  params.push(jobId);
+  await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
+}
+
+async function fetchOverpass(): Promise<OverpassElement[]> {
+  // Overpass API is an explicitly programmatic endpoint per the OSM usage policy
+  // (https://operations.osmfoundation.org/policies/api/). Their robots.txt blocks
+  // generic crawlers but the API is intended for client consumption with a meaningful
+  // User-Agent. We respect their RATE policy (max ~10k req/day, polite-use) instead.
+  const { fetch } = await import('undici');
+  const body = `data=${encodeURIComponent(QL)}`;
+  const r = await fetch(OVERPASS, {
+    method: 'POST',
+    headers: {
+      'User-Agent': process.env.USER_AGENT || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)',
+      'Content-Type': 'application/x-www-form-urlencoded',
+      Accept: 'application/json',
+    },
+    body,
+    signal: AbortSignal.timeout(180000),
+  });
+  if (!r.ok) {
+    const txt = await r.text();
+    throw new Error(`Overpass ${r.status}: ${txt.slice(0, 400)}`);
+  }
+  const json = await r.json() as { elements: OverpassElement[] };
+  return json.elements;
+}
+
+async function upsert(el: OverpassElement, sourceId: number) {
+  const tags = el.tags || {};
+  const name = clean(tags.name);
+  if (!name) return null;
+
+  const lat = el.lat ?? el.center?.lat ?? null;
+  const lng = el.lon ?? el.center?.lon ?? null;
+  const addr = buildAddress(tags);
+  const neighborhood = neighborhoodOf(tags);
+  const phone = clean(tags.phone) || clean(tags['contact:phone']);
+  const website = clean(tags.website) || clean(tags['contact:website']);
+  const email = clean(tags.email) || clean(tags['contact:email']);
+  const sourceUrl = `https://www.openstreetmap.org/${el.type}/${el.id}`;
+
+  return await withTx(async (client) => {
+    // Upsert organization, dedup by (osm_type, osm_id) via raw_records or by name+address_norm.
+    const addressNorm = normAddress(addr.full);
+
+    // Try to find existing by website (most stable) or name+address_norm
+    let orgId: number | null = null;
+    if (website) {
+      const r = await client.query<{ id: number }>(
+        `SELECT id FROM organizations WHERE LOWER(website) = LOWER($1) LIMIT 1`, [website]);
+      if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+    }
+    if (!orgId && addressNorm) {
+      const r = await client.query<{ id: number }>(
+        `SELECT id FROM organizations
+         WHERE address_norm = $1 AND LOWER(name) = LOWER($2) LIMIT 1`,
+        [addressNorm, name]);
+      if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+    }
+
+    if (orgId) {
+      await client.query(`
+        UPDATE organizations
+        SET name = $2,
+            address = COALESCE($3, address),
+            city = COALESCE($4, city),
+            state = COALESCE($5, state),
+            zip = COALESCE($6, zip),
+            county = COALESCE($7, county),
+            neighborhood = COALESCE($8, neighborhood),
+            lat = COALESCE($9, lat),
+            lng = COALESCE($10, lng),
+            geocoded_at = COALESCE(geocoded_at, NOW()),
+            phone = COALESCE($11, phone),
+            website = COALESCE($12, website),
+            address_norm = COALESCE($13, address_norm),
+            firm_size_band = COALESCE(firm_size_band, $14),
+            source_url = COALESCE(source_url, $15),
+            updated_at = NOW()
+        WHERE id = $1
+      `, [orgId, name, addr.full, addr.city, addr.state, addr.zip, 'Los Angeles',
+          neighborhood, lat, lng, phone, website, addressNorm,
+          firmSizeBand(name), sourceUrl]);
+    } else {
+      const r = await client.query<{ id: number }>(`
+        INSERT INTO organizations (
+          name, type, address, city, state, zip, county, neighborhood,
+          lat, lng, geocoded_at, phone, website, address_norm,
+          firm_size_band, source_url
+        ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11,$12,$13,$14,$15)
+        RETURNING id
+      `, [name, 'law_firm', addr.full, addr.city, addr.state, addr.zip, 'Los Angeles',
+          neighborhood, lat, lng, phone, website, addressNorm,
+          firmSizeBand(name), sourceUrl]);
+      orgId = r.rows[0].id;
+    }
+
+    if (email) {
+      await client.query(`
+        INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at)
+        VALUES ($1,$2,'office',$3,NOW())
+        `, [orgId, email, sourceUrl]);
+    }
+    if (phone) {
+      await client.query(`
+        INSERT INTO phones (organization_id, phone, phone_type, source_url, last_verified_at)
+        VALUES ($1,$2,'office',$3,NOW())
+        ON CONFLICT DO NOTHING
+      `, [orgId, phone, sourceUrl]);
+    }
+
+    // Stash raw element for provenance.
+    const rawJson = JSON.stringify({ ...el, tags });
+    const hash = sha256(rawJson + '|organization|' + orgId);
+    await client.query(`
+      INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, fetched_at, hash)
+      VALUES ($1,$2,'organization',$3,$4::jsonb,NOW(),$5)
+      ON CONFLICT (source_id, hash) DO NOTHING
+    `, [sourceId, sourceUrl, orgId, rawJson, hash]);
+
+    return orgId;
+  });
+}
+
+async function main() {
+  console.log('[osm] querying Overpass for office=lawyer in LA County (Q104994)…');
+  const sourceId = await getSourceId();
+  const jobId = await startJob(sourceId, 'osm:overpass:la-county');
+
+  let elements: OverpassElement[] = [];
+  try {
+    elements = await fetchOverpass();
+  } catch (e) {
+    await finishJob(jobId, { status: 'failed', error_message: (e as Error).message });
+    throw e;
+  }
+
+  console.log(`[osm] received ${elements.length} elements`);
+
+  let inserted = 0, skipped = 0;
+  for (const el of elements) {
+    try {
+      const id = await upsert(el, sourceId);
+      if (id) inserted++; else skipped++;
+    } catch (e) {
+      console.error(`[osm] upsert err ${el.type}/${el.id}: ${(e as Error).message}`);
+      skipped++;
+    }
+  }
+
+  await finishJob(jobId, {
+    status: 'completed',
+    records_found: elements.length,
+    records_inserted: inserted,
+    records_skipped: skipped,
+  });
+
+  console.log(`[osm] done. seen=${elements.length} kept=${inserted} skipped=${skipped}`);
+  await pool.end();
+}
+
+main().catch(async (err) => {
+  console.error('[osm] fatal:', err);
+  try { await pool.end(); } catch {}
+  process.exit(1);
+});
diff --git a/src/ingest/wikidata.ts b/src/ingest/wikidata.ts
new file mode 100644
index 0000000..42031e5
--- /dev/null
+++ b/src/ingest/wikidata.ts
@@ -0,0 +1,234 @@
+/**
+ * Wikidata SPARQL importer — pulls notable law firms with HQ or office in LA County.
+ * Free, no auth. Captures the big-name firms that OSM under-represents (Latham,
+ * Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, Littler Mendelson, etc.).
+ *
+ * Endpoint: https://query.wikidata.org/sparql
+ * License:  CC0 (Wikidata data) — fully reusable.
+ */
+import 'dotenv/config';
+import crypto from 'node:crypto';
+import { fetch } from 'undici';
+import { pool, query, withTx } from '../db/pool.ts';
+
+const SOURCE_NAME = 'Wikidata (LA County law firms)';
+const ENDPOINT = 'https://query.wikidata.org/sparql';
+const USER_AGENT = process.env.USER_AGENT
+  || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
+
+const SPARQL = `
+SELECT DISTINCT ?firm ?firmLabel ?hq ?hqLabel ?street ?coord ?inception ?website ?employees WHERE {
+  ?firm wdt:P31/wdt:P279* wd:Q613142 .
+  OPTIONAL { ?firm wdt:P159 ?hq . }
+  OPTIONAL { ?firm wdt:P969 ?street . }
+  OPTIONAL { ?firm wdt:P625 ?coord . }
+  OPTIONAL { ?firm wdt:P571 ?inception . }
+  OPTIONAL { ?firm wdt:P856 ?website . }
+  OPTIONAL { ?firm wdt:P1128 ?employees . }
+  {
+    ?firm wdt:P159 ?hq2 . ?hq2 wdt:P131* wd:Q104994 .
+  } UNION {
+    ?firm wdt:P276 ?loc2 . ?loc2 wdt:P131* wd:Q104994 .
+  }
+  SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+}
+LIMIT 500
+`;
+
+async function ensureSource(): Promise<number> {
+  await query(`
+    INSERT INTO sources (source_name, source_type, base_url, terms_notes, allowed_method, rate_limit_rps)
+    VALUES ($1, 'api', 'https://query.wikidata.org/sparql', 'Wikidata SPARQL — CC0. Polite-use; cap ~1 query / 5 s.', 'api', 0.20)
+    ON CONFLICT (source_name) DO NOTHING
+  `, [SOURCE_NAME]);
+  const r = await query<{ id: number }>(`SELECT id FROM sources WHERE source_name = $1`, [SOURCE_NAME]);
+  return r.rows[0].id;
+}
+
+async function startJob(sourceId: number, label: string) {
+  const r = await query<{ id: number }>(`
+    INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
+    VALUES ($1, $2, 'running', NOW()) RETURNING id
+  `, [sourceId, label]);
+  return r.rows[0].id;
+}
+
+async function finishJob(jobId: number, fields: Record<string, unknown>) {
+  const sets: string[] = [];
+  const params: unknown[] = [];
+  let i = 1;
+  for (const [k, v] of Object.entries(fields)) {
+    sets.push(`${k} = $${i++}`); params.push(v);
+  }
+  sets.push(`finished_at = NOW()`);
+  params.push(jobId);
+  await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
+}
+
+function clean(s: string | undefined | null) {
+  if (!s) return null;
+  const t = String(s).trim();
+  return t.length === 0 ? null : t;
+}
+
+function normAddress(s: string | null) {
+  if (!s) return null;
+  return s.toLowerCase().replace(/[.,]/g, ' ').replace(/\b(suite|ste|unit|apt|#)\b/g, '').replace(/\s+/g, ' ').trim();
+}
+
+function parseCoord(p: string | undefined) {
+  if (!p) return { lat: null, lng: null };
+  const m = p.match(/Point\(\s*(-?\d+\.?\d*)\s+(-?\d+\.?\d*)\s*\)/);
+  if (!m) return { lat: null, lng: null };
+  return { lat: parseFloat(m[2]), lng: parseFloat(m[1]) };
+}
+
+interface SparqlBinding {
+  firm?: { value: string };
+  firmLabel?: { value: string };
+  hqLabel?: { value: string };
+  street?: { value: string };
+  coord?: { value: string };
+  inception?: { value: string };
+  website?: { value: string };
+  employees?: { value: string };
+}
+
+async function fetchSparql(): Promise<SparqlBinding[]> {
+  const url = `${ENDPOINT}?query=${encodeURIComponent(SPARQL)}`;
+  const r = await fetch(url, {
+    headers: {
+      'User-Agent': USER_AGENT,
+      Accept: 'application/sparql-results+json',
+    },
+    signal: AbortSignal.timeout(60000),
+  });
+  if (!r.ok) {
+    const t = await r.text();
+    throw new Error(`Wikidata ${r.status}: ${t.slice(0, 300)}`);
+  }
+  const j = await r.json() as { results: { bindings: SparqlBinding[] } };
+  return j.results.bindings;
+}
+
+async function upsertFirm(b: SparqlBinding, sourceId: number) {
+  const name = clean(b.firmLabel?.value);
+  if (!name || /^Q\d+$/.test(name)) return null;          // skip Q-IDs (no English label)
+
+  const wdQid = b.firm?.value?.split('/').pop() || null;
+  const hq = clean(b.hqLabel?.value);
+  const street = clean(b.street?.value);
+  const website = clean(b.website?.value);
+  const { lat, lng } = parseCoord(b.coord?.value);
+  const employees = b.employees?.value ? parseInt(b.employees.value, 10) : null;
+
+  const fullAddress = street && hq ? `${street}, ${hq}, CA` : (street || hq || null);
+  const sourceUrl = b.firm?.value || 'https://www.wikidata.org/';
+
+  return await withTx(async (client) => {
+    // Try to merge with existing org by website (BigLaw firms usually have unique websites).
+    let orgId: number | null = null;
+    if (website) {
+      const r = await client.query<{ id: number }>(
+        `SELECT id FROM organizations WHERE LOWER(website) = LOWER($1) LIMIT 1`, [website]);
+      if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+    }
+    if (!orgId) {
+      // Match by exact name (case-insensitive)
+      const r = await client.query<{ id: number }>(
+        `SELECT id FROM organizations WHERE LOWER(name) = LOWER($1) AND county = 'Los Angeles' LIMIT 1`, [name]);
+      if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+    }
+
+    const sizeBand = employees && employees >= 500 ? 'biglaw'
+      : employees && employees >= 100 ? 'large'
+      : employees && employees >= 25 ? 'medium'
+      : null;
+
+    if (orgId) {
+      await client.query(`
+        UPDATE organizations
+        SET name = $2,
+            website = COALESCE(website, $3),
+            address = COALESCE(address, $4),
+            address_norm = COALESCE(address_norm, $5),
+            city = COALESCE(city, $6),
+            neighborhood = COALESCE(neighborhood, $6),
+            lat = COALESCE(lat, $7),
+            lng = COALESCE(lng, $8),
+            firm_size_band = COALESCE($9, firm_size_band),
+            attorney_count = GREATEST(attorney_count, COALESCE($10, 0)),
+            source_url = COALESCE(source_url, $11),
+            updated_at = NOW()
+        WHERE id = $1
+      `, [orgId, name, website, fullAddress, normAddress(fullAddress), hq, lat, lng, sizeBand, employees, sourceUrl]);
+    } else {
+      const r = await client.query<{ id: number }>(`
+        INSERT INTO organizations (
+          name, type, address, address_norm, city, neighborhood, state, county,
+          lat, lng, geocoded_at, website, firm_size_band, attorney_count, source_url
+        ) VALUES (
+          $1,'law_firm',$2,$3,$4,$4,'CA','Los Angeles',
+          $5::double precision, $6::double precision,
+          CASE WHEN $5::double precision IS NOT NULL THEN NOW() END,
+          $7, $8, COALESCE($9::int, 0), $10
+        )
+        RETURNING id
+      `, [name, fullAddress, normAddress(fullAddress), hq, lat, lng, website, sizeBand, employees, sourceUrl]);
+      orgId = r.rows[0].id;
+    }
+
+    // Provenance
+    const rawJson = JSON.stringify(b);
+    const hash = crypto.createHash('sha256').update(rawJson + '|wikidata|' + (wdQid || name) + '|' + orgId).digest('hex');
+    await client.query(`
+      INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, fetched_at, hash)
+      VALUES ($1,$2,'organization',$3,$4::jsonb, NOW(),$5)
+      ON CONFLICT (source_id, hash) DO NOTHING
+    `, [sourceId, sourceUrl, orgId, rawJson, hash]);
+
+    return orgId;
+  });
+}
+
+async function main() {
+  console.log('[wikidata] querying SPARQL for law firms with LA County HQ/office…');
+  const sourceId = await ensureSource();
+  const jobId = await startJob(sourceId, 'wikidata:la-county-firms');
+
+  let bindings: SparqlBinding[] = [];
+  try {
+    bindings = await fetchSparql();
+  } catch (e) {
+    await finishJob(jobId, { status: 'failed', error_message: (e as Error).message });
+    throw e;
+  }
+  console.log(`[wikidata] received ${bindings.length} bindings`);
+
+  let inserted = 0, skipped = 0;
+  for (const b of bindings) {
+    try {
+      const id = await upsertFirm(b, sourceId);
+      if (id) inserted++; else skipped++;
+    } catch (e) {
+      console.error(`[wikidata] upsert err: ${(e as Error).message}`);
+      skipped++;
+    }
+  }
+
+  await finishJob(jobId, {
+    status: 'completed',
+    records_found: bindings.length,
+    records_inserted: inserted,
+    records_skipped: skipped,
+  });
+
+  console.log(`[wikidata] done. seen=${bindings.length} kept=${inserted} skipped=${skipped}`);
+  await pool.end();
+}
+
+main().catch(async (err) => {
+  console.error('[wikidata] fatal:', err);
+  try { await pool.end(); } catch {}
+  process.exit(1);
+});
diff --git a/src/lib/compliance.ts b/src/lib/compliance.ts
new file mode 100644
index 0000000..c3e52ca
--- /dev/null
+++ b/src/lib/compliance.ts
@@ -0,0 +1,111 @@
+import 'dotenv/config';
+import { fetch } from 'undici';
+// @ts-ignore — robots-parser has no types ship
+import robotsParser from 'robots-parser';
+
+const USER_AGENT = process.env.USER_AGENT
+  || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
+const DEFAULT_RPS = parseFloat(process.env.STATE_BAR_RPS || '0.5');
+
+const robotsCache = new Map<string, { robots: any; fetchedAt: number }>();
+const ONE_DAY = 24 * 60 * 60 * 1000;
+
+async function getRobotsFor(urlStr: string) {
+  const u = new URL(urlStr);
+  const cached = robotsCache.get(u.host);
+  if (cached && Date.now() - cached.fetchedAt < ONE_DAY) return cached.robots;
+
+  const robotsUrl = `${u.protocol}//${u.host}/robots.txt`;
+  let body = '';
+  try {
+    const res = await fetch(robotsUrl, {
+      headers: { 'User-Agent': USER_AGENT },
+      signal: AbortSignal.timeout(10000),
+    });
+    if (res.ok) body = await res.text();
+  } catch { /* permissive on transport error */ }
+
+  const robots = robotsParser(robotsUrl, body);
+  robotsCache.set(u.host, { robots, fetchedAt: Date.now() });
+  return robots;
+}
+
+export async function isAllowed(urlStr: string): Promise<boolean> {
+  const robots = await getRobotsFor(urlStr);
+  const verdict = robots.isAllowed(urlStr, USER_AGENT);
+  return verdict !== false;
+}
+
+const lastFetchByHost = new Map<string, number>();
+const rpsByHost = new Map<string, number>();
+
+export function setHostRateLimit(host: string, rps: number) {
+  rpsByHost.set(host, rps);
+}
+
+async function gateHost(host: string) {
+  const rps = rpsByHost.get(host) ?? DEFAULT_RPS;
+  const minInterval = 1000 / rps;
+  const last = lastFetchByHost.get(host) || 0;
+  const wait = Math.max(0, last + minInterval - Date.now());
+  if (wait > 0) await new Promise(r => setTimeout(r, wait));
+  lastFetchByHost.set(host, Date.now());
+}
+
+export interface FetchOpts {
+  respectRobots?: boolean;
+  accept?: string;
+  headers?: Record<string, string>;
+  timeoutMs?: number;
+}
+
+export async function fetchCompliant(urlStr: string, opts: FetchOpts = {}) {
+  const u = new URL(urlStr);
+  if (opts.respectRobots !== false) {
+    const allowed = await isAllowed(urlStr);
+    if (!allowed) {
+      const err = new Error(`robots.txt disallows ${urlStr}`) as Error & { code?: string };
+      err.code = 'ROBOTS_DISALLOWED';
+      throw err;
+    }
+  }
+  await gateHost(u.host);
+
+  const headers: Record<string, string> = {
+    'User-Agent': USER_AGENT,
+    Accept: opts.accept || 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+    'Accept-Language': 'en-US,en;q=0.9',
+    ...(opts.headers || {}),
+  };
+
+  let attempt = 0;
+  while (true) {
+    attempt++;
+    let res: Response | undefined;
+    try {
+      res = await fetch(urlStr, { headers, signal: AbortSignal.timeout(opts.timeoutMs || 30000) }) as unknown as Response;
+    } catch (e) {
+      if (attempt >= 4) throw e;
+      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
+      continue;
+    }
+
+    if (res.status === 403 || res.status === 401) {
+      const text = await res.text();
+      if (/cf-challenge|recaptcha|hcaptcha|captcha/i.test(text)) {
+        const err = new Error(`CAPTCHA detected at ${urlStr} — aborting per policy`) as Error & { code?: string };
+        err.code = 'CAPTCHA_DETECTED';
+        throw err;
+      }
+      // re-wrap response with body since we consumed it
+      return new Response(text, { status: res.status, headers: res.headers });
+    }
+
+    if (res.status >= 500 && attempt < 4) {
+      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
+      continue;
+    }
+
+    return res;
+  }
+}
diff --git a/src/scripts/inspect_justia.ts b/src/scripts/inspect_justia.ts
new file mode 100644
index 0000000..462daf2
--- /dev/null
+++ b/src/scripts/inspect_justia.ts
@@ -0,0 +1,54 @@
+/**
+ * One-shot probe — fetches a single Justia city page, checks robots, and
+ * dumps a structural summary so we can pick the right CSS selectors before
+ * writing the real crawler.
+ */
+import 'dotenv/config';
+import { load } from 'cheerio';
+import { fetchCompliant, isAllowed } from '../lib/compliance.ts';
+
+const URL = process.argv[2] || 'https://www.justia.com/lawyers/california/beverly-hills';
+
+(async () => {
+  console.log(`probing ${URL}`);
+  console.log(`robots allowed: ${await isAllowed(URL)}`);
+
+  const res = await fetchCompliant(URL);
+  console.log(`status: ${res.status}  content-type: ${res.headers.get('content-type')}`);
+  const html = await res.text();
+  console.log(`bytes: ${html.length}`);
+
+  const $ = load(html);
+  console.log(`<title>: ${$('title').text().trim()}`);
+
+  // Common Justia patterns
+  const candidates = [
+    '.lawyer-card', '.lawyer', '.lawyers li', '.search-results-list li',
+    'div[itemtype*="LegalService"]', 'div[itemtype*="Person"]',
+    '.has-img.iblock', '.iblock',
+    '.facet-doc', '.entry', '.law-firm', '.firm',
+  ];
+  for (const sel of candidates) {
+    const n = $(sel).length;
+    if (n > 0) console.log(`  ${sel}  → ${n} matches`);
+  }
+
+  // Show first 3 anchors that look like lawyer/firm profiles
+  console.log('\nfirst 6 profile-ish links:');
+  const seen = new Set<string>();
+  $('a[href]').each((_, el) => {
+    const h = $(el).attr('href') || '';
+    if (!h.includes('/lawyers/') && !h.includes('/lawfirm/')) return;
+    if (h === '/lawyers/' || /\/(?:bookmark|share|review|save|map)/i.test(h)) return;
+    if (seen.has(h)) return;
+    seen.add(h);
+    if (seen.size <= 6) console.log(`  ${$(el).text().trim().slice(0,60)}  →  ${h}`);
+  });
+
+  // Show first card-ish block
+  const card = $('.lawyer-card, .iblock, .has-img').first();
+  if (card.length) {
+    console.log('\nfirst card outerHTML (truncated 1.2k):');
+    console.log($.html(card).slice(0, 1200));
+  }
+})();
diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts
new file mode 100644
index 0000000..bce2141
--- /dev/null
+++ b/src/scripts/migrate.ts
@@ -0,0 +1,44 @@
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pool } from '../db/pool.ts';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const migrationsDir = path.resolve(__dirname, '../../migrations');
+
+async function main() {
+  // Ensure schema_migrations tracking table.
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS schema_migrations (
+      filename   TEXT PRIMARY KEY,
+      applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+    );
+  `);
+
+  const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
+  for (const file of files) {
+    const exists = await pool.query('SELECT 1 FROM schema_migrations WHERE filename = $1', [file]);
+    if (exists.rowCount && exists.rowCount > 0) {
+      console.log(`✓ already applied  ${file}`);
+      continue;
+    }
+    const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
+    console.log(`→ applying ${file}`);
+    const client = await pool.connect();
+    try {
+      await client.query(sql);
+      await client.query('INSERT INTO schema_migrations(filename) VALUES ($1)', [file]);
+      console.log(`✓ applied  ${file}`);
+    } finally {
+      client.release();
+    }
+  }
+
+  await pool.end();
+}
+
+main().catch(err => {
+  console.error('migrate failed:', err);
+  process.exit(1);
+});
diff --git a/src/scripts/run_overnight.ts b/src/scripts/run_overnight.ts
new file mode 100644
index 0000000..7ad05d4
--- /dev/null
+++ b/src/scripts/run_overnight.ts
@@ -0,0 +1,93 @@
+/**
+ * Overnight orchestrator — runs the importers in sequence, checkpointing progress
+ * via scrape_jobs.checkpoint so a kill mid-run resumes cleanly on the next start.
+ *
+ * Sources wired:
+ *   1. OSM Overpass refresh        (LA County office=lawyer / amenity=lawyer)
+ *   2. Wikidata SPARQL              (notable LA firms — BigLaw filler)
+ *   3. Aggregation pass             (recompute attorney_count + concentration views)
+ *
+ * Sources blocked by anti-bot (NOT run; documented separately):
+ *   - CA State Bar attorney search   → SPA, requires Playwright (see docs/blocked_sources.md)
+ *   - CA SOS bizfile                 → POST endpoint requires recaptcha token
+ *
+ * Loop policy: run once, exit 0. Wrap with `pm2`/`watch`/cron if continuous mode is desired.
+ */
+import 'dotenv/config';
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { pool, query } from '../db/pool.ts';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '../..');
+const LOG_DIR = path.join(ROOT, 'logs');
+fs.mkdirSync(LOG_DIR, { recursive: true });
+
+const TS = new Date().toISOString().slice(0, 10);
+const LOG = path.join(LOG_DIR, `overnight-${TS}.log`);
+const log = fs.createWriteStream(LOG, { flags: 'a' });
+function out(s: string) { const line = `[${new Date().toISOString()}] ${s}\n`; process.stdout.write(line); log.write(line); }
+
+function run(name: string, scriptPath: string, env: Record<string, string> = {}): Promise<number> {
+  out(`▶ ${name}: tsx ${scriptPath}`);
+  return new Promise((resolve) => {
+    const child = spawn('npx', ['tsx', scriptPath], {
+      cwd: ROOT,
+      env: { ...process.env, ...env },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    child.stdout.on('data', d => log.write(d));
+    child.stderr.on('data', d => log.write(d));
+    child.on('close', code => { out(`✓ ${name} exited ${code}`); resolve(code ?? 0); });
+  });
+}
+
+async function aggregationPass() {
+  out('▶ aggregation: recompute attorney_count + size band');
+  // Future: count professional_locations per organization.
+  // For now: leave attorney_count as set by importers; only band by attorney_count if available.
+  await query(`
+    UPDATE organizations
+    SET firm_size_band = CASE
+      WHEN attorney_count >= 500 THEN 'biglaw'
+      WHEN attorney_count >= 100 THEN 'large'
+      WHEN attorney_count >= 25  THEN 'medium'
+      WHEN attorney_count >= 5   THEN 'small'
+      WHEN attorney_count >= 1   THEN 'solo'
+      ELSE firm_size_band
+    END
+    WHERE type='law_firm' AND attorney_count > 0
+  `);
+  out('✓ aggregation done');
+}
+
+async function summary() {
+  const r = await query<{ k: string; v: string }>(`
+    SELECT 'firms_total' AS k, COUNT(*)::text AS v FROM organizations WHERE type='law_firm'
+    UNION ALL SELECT 'with_geo',  COUNT(*)::text FROM organizations WHERE type='law_firm' AND lat IS NOT NULL
+    UNION ALL SELECT 'with_site', COUNT(*)::text FROM organizations WHERE type='law_firm' AND website IS NOT NULL
+    UNION ALL SELECT 'biglaw',    COUNT(*)::text FROM organizations WHERE type='law_firm' AND firm_size_band='biglaw'
+  `);
+  out('── summary ──');
+  for (const row of r.rows) out(`  ${row.k.padEnd(14)} ${row.v}`);
+}
+
+async function main() {
+  out('=== overnight run start ===');
+  const code1 = await run('osm-overpass',  'src/ingest/osm_overpass.ts');
+  const code2 = await run('wikidata',      'src/ingest/wikidata.ts');
+  await aggregationPass();
+  await summary();
+  out(`=== overnight run end (osm=${code1}, wd=${code2}) ===`);
+  await pool.end();
+  log.end();
+}
+
+main().catch(async (err) => {
+  out(`fatal: ${(err as Error).message}`);
+  try { await pool.end(); } catch {}
+  log.end();
+  process.exit(1);
+});
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 0000000..d87f4f0
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,130 @@
+/**
+ * Lawyer Directory — read API + Leaflet heatmap dashboard on :9701.
+ */
+import 'dotenv/config';
+import express from 'express';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pool, query } from '../db/pool.ts';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PORT = parseInt(process.env.PORT || '9701', 10);
+
+const app = express();
+app.use(express.json());
+app.use((_req, res, next) => {
+  res.setHeader('Access-Control-Allow-Origin', '*');
+  next();
+});
+
+// ─── Read API ───────────────────────────────────────────────────────────────
+
+app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT, ts: new Date().toISOString() }));
+
+app.get('/api/stats', async (_req, res) => {
+  const r = await query<{ key: string; value: string }>(`
+    SELECT 'firms_total' AS key, COUNT(*)::text AS value FROM organizations WHERE type='law_firm'
+    UNION ALL SELECT 'firms_with_geo',  COUNT(*)::text FROM organizations WHERE type='law_firm' AND lat IS NOT NULL
+    UNION ALL SELECT 'firms_with_site', COUNT(*)::text FROM organizations WHERE type='law_firm' AND website IS NOT NULL
+    UNION ALL SELECT 'firms_with_phone',COUNT(*)::text FROM organizations WHERE type='law_firm' AND phone IS NOT NULL
+    UNION ALL SELECT 'cities',          COUNT(DISTINCT city)::text FROM organizations WHERE type='law_firm'
+    UNION ALL SELECT 'multi_firm_buildings', COUNT(*)::text FROM (
+      SELECT address FROM organizations WHERE type='law_firm' AND address IS NOT NULL
+      GROUP BY address HAVING COUNT(*) > 1
+    ) t
+    UNION ALL SELECT 'sources_active', COUNT(*)::text FROM sources
+  `);
+  const out: Record<string, number> = {};
+  for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
+  res.json(out);
+});
+
+app.get('/api/firms', async (req, res) => {
+  const city = (req.query.city as string | undefined)?.trim() || null;
+  const zip = (req.query.zip as string | undefined)?.trim() || null;
+  const limit = Math.min(parseInt((req.query.limit as string) || '200', 10), 1000);
+
+  const where: string[] = ["type='law_firm'"];
+  const params: unknown[] = [];
+  if (city) { params.push(city); where.push(`(LOWER(city) = LOWER($${params.length}) OR LOWER(neighborhood) = LOWER($${params.length}))`); }
+  if (zip)  { params.push(zip);  where.push(`zip = $${params.length}`); }
+  params.push(limit);
+
+  const r = await query(`
+    SELECT id, name, address, city, neighborhood, zip, phone, website, lat, lng, firm_size_band, attorney_count
+    FROM organizations
+    WHERE ${where.join(' AND ')}
+    ORDER BY attorney_count DESC NULLS LAST, name ASC
+    LIMIT $${params.length}
+  `, params);
+  res.json({ count: r.rowCount, rows: r.rows });
+});
+
+app.get('/api/firms/:id', async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+  const r = await query(`SELECT * FROM organizations WHERE id = $1`, [id]);
+  if (!r.rowCount) return res.status(404).json({ error: 'not found' });
+  res.json(r.rows[0]);
+});
+
+app.get('/api/heatmap', async (_req, res) => {
+  const r = await query<{ lat: number; lng: number; weight: number }>(`
+    SELECT lat, lng, GREATEST(1, attorney_count) AS weight
+    FROM organizations
+    WHERE type='law_firm' AND lat IS NOT NULL AND lng IS NOT NULL
+  `);
+  res.json({ count: r.rowCount, points: r.rows });
+});
+
+app.get('/api/buildings', async (_req, res) => {
+  const r = await query(`
+    SELECT address, city, COUNT(*)::int AS firms, AVG(lat) AS lat, AVG(lng) AS lng,
+           ARRAY_AGG(name ORDER BY name) AS names
+    FROM organizations
+    WHERE type='law_firm' AND address IS NOT NULL AND lat IS NOT NULL
+    GROUP BY address, city
+    HAVING COUNT(*) > 1
+    ORDER BY firms DESC
+  `);
+  res.json({ count: r.rowCount, rows: r.rows });
+});
+
+app.get('/api/cities', async (_req, res) => {
+  const r = await query(`
+    SELECT COALESCE(neighborhood, city) AS area,
+           COUNT(*)::int AS firms,
+           AVG(lat) AS lat, AVG(lng) AS lng
+    FROM organizations
+    WHERE type='law_firm' AND COALESCE(neighborhood, city) IS NOT NULL
+    GROUP BY area
+    ORDER BY firms DESC
+  `);
+  res.json({ count: r.rowCount, rows: r.rows });
+});
+
+app.get('/api/jobs', async (_req, res) => {
+  const r = await query(`
+    SELECT j.id, s.source_name, j.job_label, j.status, j.started_at, j.finished_at,
+           j.records_found, j.records_inserted, j.records_skipped, j.error_message
+    FROM scrape_jobs j
+    JOIN sources s ON s.id = j.source_id
+    ORDER BY j.id DESC
+    LIMIT 30
+  `);
+  res.json({ count: r.rowCount, rows: r.rows });
+});
+
+// ─── Static dashboard ───────────────────────────────────────────────────────
+
+app.use('/', express.static(path.resolve(__dirname, '../../public')));
+
+app.listen(PORT, () => {
+  console.log(`[lawyer-directory] listening on http://localhost:${PORT}`);
+  console.log(`  open               http://localhost:${PORT}/`);
+  console.log(`  api stats          http://localhost:${PORT}/api/stats`);
+  console.log(`  api heatmap        http://localhost:${PORT}/api/heatmap`);
+});
+
+process.on('SIGINT',  async () => { await pool.end(); process.exit(0); });
+process.on('SIGTERM', async () => { await pool.end(); process.exit(0); });
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..43da77e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "lib": ["ES2022"],
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "allowImportingTsExtensions": true,
+    "noEmit": true,
+    "isolatedModules": true,
+    "forceConsistentCasingInFileNames": true,
+    "verbatimModuleSyntax": false
+  },
+  "include": ["src/**/*.ts"]
+}

(oldest)  ·  back to Lawyer Directory Builder  ·  Dashboard: Grid + List + Map views with column slider 6003ada →