[object Object]

← back to Dandeana Hub

initial scaffold: private Dandeana/Poway news hub — Basic Auth gate, dossier, RSS/CEQA/city refresher, timeline, docs, map

004ecee4222754f7cd2d58f880f3de94520c2036 · 2026-07-22 11:55:11 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit 004ecee4222754f7cd2d58f880f3de94520c2036
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 11:55:11 2026 -0700

    initial scaffold: private Dandeana/Poway news hub — Basic Auth gate, dossier, RSS/CEQA/city refresher, timeline, docs, map
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 .deploy.conf                              |   7 +
 .gitignore                                |   9 +
 data/documents.json                       |   9 +
 data/news.jsonl                           |   6 +
 data/project.json                         |  42 ++
 data/refresh-meta.json                    |   6 +
 data/sources.json                         |  11 +
 data/timeline.json                        |   7 +
 docs/poway-notice-of-availability-mnd.pdf | Bin 0 -> 22037 bytes
 package-lock.json                         | 828 ++++++++++++++++++++++++++++++
 package.json                              |  14 +
 public/index.html                         | 216 ++++++++
 scripts/refresh.js                        | 133 +++++
 server.js                                 |  68 +++
 14 files changed, 1356 insertions(+)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..60b2c38
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,7 @@
+PROJECT_NAME=dandeana-hub
+DEPLOY_PATH=/root/public-projects/dandeana-hub
+HEALTH_URL=http://localhost:9914/healthz
+INSTALL_CMD="npm ci --omit=dev"
+BUILD_CMD=""
+REQUIRED_ENVS=""
+RSYNC_EXTRA_EXCLUDES="tmp"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4a0b3ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/data/documents.json b/data/documents.json
new file mode 100644
index 0000000..f163a9b
--- /dev/null
+++ b/data/documents.json
@@ -0,0 +1,9 @@
+[
+  {
+    "title": "Notice of Public Review Period — Mitigated Negative Declaration & City Council Hearing (TTM 16-005)",
+    "file": "/docs/poway-notice-of-availability-mnd.pdf",
+    "sourceUrl": "https://poway.org/DocumentCenter/View/5115/Notice-of-Availability-PDF",
+    "date": "2018-02-12",
+    "source": "City of Poway"
+  }
+]
diff --git a/data/news.jsonl b/data/news.jsonl
new file mode 100644
index 0000000..255d8f1
--- /dev/null
+++ b/data/news.jsonl
@@ -0,0 +1,6 @@
+{"id":"ffec53d2bff03acf","firstSeen":"2026-07-22T18:54:40.767Z","title":"Climb to one or both of Poway’s Twin Peaks for sunset or sunrise - San Diego Union-Tribune","url":"https://news.google.com/rss/articles/CBMiyAFBVV95cUxNeTBxMjFNLW1OLWo5dGo2bGN6MEJXbTNsZ05rYi1yOFlYVlJUNEtNOEsxU3RSZzVLTFpSV044ajdPbml1cFdxT25lSmpudGJnWThidTJJU19ETHAtemY0d09LV3pvNkY1WExwM09JVVVDZGQ0SGNjREtNemYxRGR1a2gyQlZ4aHY5Z191NXJIN25naVpKaDgxRmdFWEhQZHpCcEtBOTJRX2ZiMWFOWHY2bl8wekM3Tm9WRnRpRkgyZi1OVGFpTkwxRQ?oc=5","source":"San Diego Union-Tribune","category":"news","published":"2025-05-05T07:00:00.000Z","snippet":"Climb to one or both of Poway’s Twin Peaks for sunset or sunrise San Diego Union-Tribune"}
+{"id":"7e835926ce3c1c89","firstSeen":"2026-07-22T18:54:40.767Z","title":"Mountains, lakes, grasslands and desert: 44 East County hikes - San Diego Union-Tribune","url":"https://news.google.com/rss/articles/CBMiqwFBVV95cUxPclRMRWJTa0JaOFNqUERpZF81TzVERF9BSEx5d3BHOE1nQXdWQnBoLVk5V3E1TW4yMHFOdnBYTkliYVRRTlRGVUxHRUpXM0g0c1BINVVicmhBckpBOFhrdXgwTDNlNU04T2stR3FFd0s0blVYeVA5Z3RXRnNJd2sxS0RiYmZNU2diOEtBczVndVFVOE4xT1FLTy1vUUR1S3F1bUtCakROdlJpT0E?oc=5","source":"San Diego Union-Tribune","category":"news","published":"2025-12-19T08:00:00.000Z","snippet":"Mountains, lakes, grasslands and desert: 44 East County hikes San Diego Union-Tribune"}
+{"id":"6fec394fdd73c439","firstSeen":"2026-07-22T18:54:40.767Z","title":"The best things to do in Poway, Rancho Bernardo and 4S Ranch: July 16-30 - San Diego Union-Tribune","url":"https://news.google.com/rss/articles/CBMiuwFBVV95cUxQM3gxUzlkWmVxdGdoVE9DdnNQTGpuSmtTQW4xS01HMTNISTRrR0YyRjQ5cWdraDB1TGpQSU9ROFp5Z0FUaHFaVExBMklaRUVhTXdZS0JBN09sTjJPVDBrZlpkQ1F5djhWNjREdFBoTjVZWVc5c1FYdktGTnNjeUhKZVBRZzllNUtHZmdteWdJbDlIaGNNSWFKcnlITHJkR2VRX29DWFFfNEVNa3JoQlhhNGctTzlpeTRtWW9V?oc=5","source":"San Diego Union-Tribune","category":"news","published":"2026-07-15T19:16:26.000Z","snippet":"The best things to do in Poway, Rancho Bernardo and 4S Ranch: July 16-30 San Diego Union-Tribune"}
+{"id":"8005bcace444eb44","firstSeen":"2026-07-22T18:54:40.767Z","title":"Peaks, valleys, canyons and woods: 101 San Diego County hikes - San Diego Union-Tribune","url":"https://news.google.com/rss/articles/CBMiaEFVX3lxTE1PRU5uWjhWdXVid3JDZ2pyc29kNjg2ODZGbkZRc2tyT0k2VlpSeU9LTUJsUnI1Zi15b09URHdZZnhQN3hOZ1dyV0RCWGRYUTkzMGV4V3lMbHpkYkk4WUtGdnQwYXd3bFVT?oc=5","source":"San Diego Union-Tribune","category":"news","published":"2025-12-06T08:00:00.000Z","snippet":"Peaks, valleys, canyons and woods: 101 San Diego County hikes San Diego Union-Tribune"}
+{"id":"6b2915614892f7ef","firstSeen":"2026-07-22T18:54:40.767Z","title":"Fake flyer a decoy for surprise proposal on Iron Mountain - San Diego Union-Tribune","url":"https://news.google.com/rss/articles/CBMiywFBVV95cUxQLVdCdDFVbEtCT2MxUGpQMzlDUkFteUF1Wk1QMkVPR2ZpSFdfTVZlcWVhT241WmdxWEdvc1o2aFNxLXUydUtyQldfNnhvVmptbFJMM0ZzWjV0ZXN6b1RMMjV0emZwMmItNm9HSjlzV3dnUHVmV0t5OUgtSW16ZG1lZDQwa3haai1WY3RzcWxCdGRFN1RjVjhPT0ExbmJ5ekctSU55RV9lcnpIQnJ0cnl5MDRMaUNRVzRsRTNhczJoZDVpNUdtRDdWbjNocw?oc=5","source":"San Diego Union-Tribune","category":"news","published":"2024-01-05T08:00:00.000Z","snippet":"Fake flyer a decoy for surprise proposal on Iron Mountain San Diego Union-Tribune"}
+{"id":"8ee3ab21c760951e","firstSeen":"2026-07-22T18:54:40.767Z","title":"CEQAnet — Larchmont Street Subdivision (SCH 2018021026): 1 document(s) on record","url":"https://ceqanet.lci.ca.gov/2018021026","source":"CEQAnet (State Clearinghouse)","category":"ceqa","published":null,"snippet":"Download CSV"}
diff --git a/data/project.json b/data/project.json
new file mode 100644
index 0000000..280f54d
--- /dev/null
+++ b/data/project.json
@@ -0,0 +1,42 @@
+{
+  "name": "Larchmont Street Subdivision",
+  "entity": "Dandeana Larchmont, LLC (Marina del Rey, CA)",
+  "caseNumber": "Tentative Tract Map (TTM) 16-005",
+  "ceqa": {
+    "schNumber": "2018021026",
+    "documentType": "Mitigated Negative Declaration (MND)",
+    "stateReview": "Feb 9 – Mar 12, 2018",
+    "url": "https://ceqanet.lci.ca.gov/2018021026"
+  },
+  "location": {
+    "description": "Easterly terminus of Larchmont Street, east of Pomerado Road, north of Twin Peaks Road, Poway, CA 92064",
+    "apns": ["314-370-05", "314-032-01"],
+    "lat": 32.9782,
+    "lng": -117.0486,
+    "zoning": "Rural Residential A and C"
+  },
+  "scope": {
+    "siteAcreage": "~80 acres",
+    "lots": "10 residential lots, 1–7 acres each, clustered on the westerly portion",
+    "openSpace": "~59 acres permanently preserved (east/southeast)",
+    "features": ["Gated entry", "Public trails per the City's General Plan", "Clustering per Poway Municipal Code to preserve topography/open space"]
+  },
+  "opposition": {
+    "summary": "Residents (petition led by Nancy Dannhauser) have opposed the project since 2016 over preservation of the Twin Peaks Mountain hiking trails and surrounding open space.",
+    "firstCoverage": "NBC 7 San Diego, Nov 21, 2016"
+  },
+  "cityContact": {
+    "name": "Jason Martin",
+    "org": "City of Poway — Development Services / Planning",
+    "phone": "(858) 668-4658",
+    "address": "13325 Civic Center Drive, Poway, CA 92064"
+  },
+  "links": [
+    { "label": "CEQAnet record (SCH 2018021026)", "url": "https://ceqanet.lci.ca.gov/2018021026" },
+    { "label": "City of Poway — Notice of Availability (MND)", "url": "https://poway.org/DocumentCenter/View/5115/Notice-of-Availability-PDF" },
+    { "label": "City of Poway — Planning & Zoning", "url": "https://poway.org/303/Planning-Zoning" },
+    { "label": "City of Poway — Agendas & Minutes", "url": "https://poway.org/AgendaCenter" },
+    { "label": "Poway Trails GIS", "url": "https://powaygis.poway.org/websites/CityOfPowayTrails/" },
+    { "label": "NBC 7 — Residents Fighting to Preserve Trails (Nov 2016)", "url": "https://www.nbcsandiego.com/news/local/poway-residents-fighting-to-preserve-popular-hiking-trails/51896/" }
+  ]
+}
diff --git a/data/refresh-meta.json b/data/refresh-meta.json
new file mode 100644
index 0000000..7c902af
--- /dev/null
+++ b/data/refresh-meta.json
@@ -0,0 +1,6 @@
+{
+  "lastRefresh": "2026-07-22T18:55:11.332Z",
+  "added": 0,
+  "checked": 6,
+  "errors": []
+}
\ No newline at end of file
diff --git a/data/sources.json b/data/sources.json
new file mode 100644
index 0000000..f322094
--- /dev/null
+++ b/data/sources.json
@@ -0,0 +1,11 @@
+[
+  { "type": "gnews", "label": "Google News", "category": "news", "query": "\"Dandeana\" Poway" },
+  { "type": "gnews", "label": "Google News", "category": "news", "query": "\"Larchmont Street\" Poway subdivision" },
+  { "type": "gnews", "label": "Google News", "category": "news", "query": "Poway \"Twin Peaks\" trails development" },
+  { "type": "gnews", "label": "Google News", "category": "news", "query": "\"Dandeana Larchmont\"" },
+  { "type": "gnews", "label": "Google News", "category": "government", "query": "Poway city council \"tentative tract map\" Larchmont" },
+  { "type": "ceqa", "label": "Larchmont Street Subdivision (SCH 2018021026)", "url": "https://ceqanet.lci.ca.gov/2018021026" },
+  { "type": "scan", "label": "City of Poway — Agenda Center", "category": "government", "url": "https://poway.org/AgendaCenter", "keywords": ["larchmont", "dandeana", "ttm 16-005", "16-005"] },
+  { "type": "scan", "label": "City of Poway — City Projects", "category": "government", "url": "https://poway.org/1155/City-Projects-Initiatives", "keywords": ["larchmont", "dandeana", "twin peaks"] },
+  { "type": "scan", "label": "Change.org — Poway trails petitions", "category": "community", "url": "https://www.change.org/search?q=Poway%20trails", "keywords": ["poway", "twin peaks", "larchmont"] }
+]
diff --git a/data/timeline.json b/data/timeline.json
new file mode 100644
index 0000000..98abd23
--- /dev/null
+++ b/data/timeline.json
@@ -0,0 +1,7 @@
+[
+  { "date": "2016-11-21", "title": "First major press coverage", "detail": "NBC 7 San Diego reports Poway residents organizing to preserve the Twin Peaks hiking trails as Dandeana Larchmont, LLC files its early application for a 10-home development on the 80-acre site.", "url": "https://www.nbcsandiego.com/news/local/poway-residents-fighting-to-preserve-popular-hiking-trails/51896/" },
+  { "date": "2016-12-01", "title": "TTM 16-005 application in process", "detail": "Tentative Tract Map 16-005 filed with the City of Poway — 10 residential lots, gated entry, ~59 acres open space with public trails. (Application year per case number; exact filing date per city record.)", "url": "https://poway.org/303/Planning-Zoning" },
+  { "date": "2018-02-09", "title": "Mitigated Negative Declaration circulated", "detail": "City of Poway, as lead agency, submits the MND for the Larchmont Street Subdivision to the State Clearinghouse (SCH 2018021026). Public/state review period opens.", "url": "https://ceqanet.lci.ca.gov/2018021026" },
+  { "date": "2018-02-12", "title": "Notice of City Council public hearing", "detail": "City of Poway posts the Notice of Availability / public hearing notice for the MND and TTM 16-005.", "url": "https://poway.org/DocumentCenter/View/5115/Notice-of-Availability-PDF" },
+  { "date": "2018-03-12", "title": "State review period closes", "detail": "CEQA state agency review period for the MND concludes.", "url": "https://ceqanet.lci.ca.gov/2018021026" }
+]
diff --git a/docs/poway-notice-of-availability-mnd.pdf b/docs/poway-notice-of-availability-mnd.pdf
new file mode 100644
index 0000000..565a045
Binary files /dev/null and b/docs/poway-notice-of-availability-mnd.pdf differ
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b9d63f9
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,828 @@
+{
+  "name": "dandeana-hub",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dandeana-hub",
+      "version": "1.0.0",
+      "dependencies": {
+        "express": "^4.19.2"
+      }
+    },
+    "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.6",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/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/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/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/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-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.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/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/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/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/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/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.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/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/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/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"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e0ac120
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "dandeana-hub",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Private client news hub for the Dandeana Larchmont / Poway Larchmont Street Subdivision project",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "refresh": "node scripts/refresh.js"
+  },
+  "dependencies": {
+    "express": "^4.19.2"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..143bc0c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,216 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<title>Dandeana — Poway Project Hub</title>
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
+<style>
+:root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --ink:#e8eaf0; --dim:#9aa3b2; --accent:#c9a35c; --teal:#4fd1c5; }
+* { box-sizing:border-box; }
+body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
+header { padding:26px 28px 18px; border-bottom:1px solid var(--line); display:flex; flex-wrap:wrap; align-items:baseline; gap:12px; }
+header h1 { margin:0; font-size:22px; letter-spacing:.06em; font-weight:600; }
+header h1 span { color:var(--accent); }
+.badge { font-size:11px; letter-spacing:.12em; text-transform:uppercase; border:1px solid var(--accent); color:var(--accent); padding:3px 9px; border-radius:99px; }
+.meta { margin-left:auto; color:var(--dim); font-size:13px; display:flex; gap:14px; align-items:center; }
+button.refresh { background:none; border:1px solid var(--line); color:var(--ink); padding:6px 14px; border-radius:8px; cursor:pointer; font-size:13px; }
+button.refresh:hover { border-color:var(--accent); color:var(--accent); }
+main { max-width:1200px; margin:0 auto; padding:22px 28px 60px; display:grid; grid-template-columns:340px 1fr; gap:22px; }
+@media (max-width:900px){ main { grid-template-columns:1fr; } }
+.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
+.panel h2 { margin:0; padding:13px 16px; font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:var(--dim); border-bottom:1px solid var(--line); cursor:pointer; user-select:none; }
+.panel h2::before { content:"▾ "; color:var(--accent); }
+.panel.closed h2::before { content:"▸ "; }
+.panel.closed .body { display:none; }
+.body { padding:14px 16px; }
+.kv { display:grid; grid-template-columns:auto 1fr; gap:4px 12px; font-size:13.5px; }
+.kv dt { color:var(--dim); white-space:nowrap; }
+.kv dd { margin:0; }
+.links a, .card a, .tl a, .doc a { color:var(--teal); text-decoration:none; }
+.links a:hover, .card a:hover { text-decoration:underline; }
+.links li { margin:4px 0; font-size:13.5px; }
+.controls { display:flex; flex-wrap:wrap; gap:8px; padding:12px 16px; border-bottom:1px solid var(--line); align-items:center; }
+.controls input[type=search] { flex:1; min-width:140px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); padding:7px 10px; font-size:13.5px; }
+.controls select { background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); padding:7px 8px; font-size:13px; }
+.chip { border:1px solid var(--line); border-radius:99px; padding:4px 11px; font-size:12px; cursor:pointer; color:var(--dim); background:none; }
+.chip.on { border-color:var(--accent); color:var(--accent); }
+.card { border-bottom:1px solid var(--line); padding:13px 16px; }
+.card:last-child { border-bottom:none; }
+.card .t { font-size:14.5px; font-weight:600; }
+.card .s { color:var(--dim); font-size:12.5px; margin-top:3px; display:flex; gap:10px; flex-wrap:wrap; }
+.cat { text-transform:uppercase; letter-spacing:.08em; font-size:10.5px; padding:1px 7px; border-radius:4px; border:1px solid var(--line); }
+.cat.news{color:#8ab4f8;border-color:#8ab4f8}.cat.government{color:var(--accent);border-color:var(--accent)}.cat.ceqa{color:#b58ef8;border-color:#b58ef8}.cat.community{color:#f88a8a;border-color:#f88a8a}
+.snippet { color:var(--dim); font-size:13px; margin-top:5px; }
+.when { color:var(--dim); }
+.tl-item { position:relative; padding:0 0 18px 22px; border-left:1px solid var(--line); margin-left:6px; }
+.tl-item::before { content:""; position:absolute; left:-5px; top:4px; width:9px; height:9px; border-radius:50%; background:var(--accent); }
+.tl-item .d { color:var(--accent); font-size:12px; letter-spacing:.08em; }
+.tl-item .t { font-weight:600; font-size:14px; margin:2px 0; }
+.tl-item .x { color:var(--dim); font-size:13px; }
+.doc { padding:8px 0; font-size:13.5px; border-bottom:1px solid var(--line); }
+.doc:last-child { border-bottom:none; }
+.doc .d { color:var(--dim); font-size:12px; }
+#map { height:260px; border-radius:8px; }
+.empty { color:var(--dim); padding:20px 16px; font-size:13.5px; }
+.errbar { color:#f88a8a; font-size:12px; padding:6px 16px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>DAN<span>DEANA</span> · Poway Project Hub</h1>
+  <span class="badge">Private Client</span>
+  <div class="meta">
+    <span id="lastRefresh">—</span>
+    <button class="refresh" id="btnRefresh">↻ Refresh now</button>
+  </div>
+</header>
+<main>
+  <div class="col">
+    <section class="panel" id="p-dossier">
+      <h2>Project Dossier</h2>
+      <div class="body" id="dossier"></div>
+    </section>
+    <section class="panel" style="margin-top:22px" id="p-map">
+      <h2>Site Map</h2>
+      <div class="body"><div id="map"></div><div id="mapCaption" style="color:var(--dim);font-size:12px;margin-top:8px"></div></div>
+    </section>
+    <section class="panel" style="margin-top:22px" id="p-docs">
+      <h2>Documents</h2>
+      <div class="body" id="docs"></div>
+    </section>
+  </div>
+  <div class="col">
+    <section class="panel" id="p-news">
+      <h2>News &amp; Activity Feed</h2>
+      <div class="controls">
+        <input type="search" id="q" placeholder="Search items…">
+        <select id="sort">
+          <option value="newest">Newest</option>
+          <option value="oldest">Oldest</option>
+          <option value="source">Source A→Z</option>
+        </select>
+        <button class="chip on" data-cat="all">All</button>
+        <button class="chip" data-cat="news">News</button>
+        <button class="chip" data-cat="government">Government</button>
+        <button class="chip" data-cat="ceqa">CEQA</button>
+        <button class="chip" data-cat="community">Community</button>
+      </div>
+      <div id="errors"></div>
+      <div id="feed"></div>
+    </section>
+    <section class="panel" style="margin-top:22px" id="p-timeline">
+      <h2>Project Timeline</h2>
+      <div class="body" id="timeline"></div>
+    </section>
+  </div>
+</main>
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+<script>
+const $ = s => document.querySelector(s);
+const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const fmt = iso => iso ? new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
+let items = [], cat = 'all';
+
+// collapsible panels, state persisted
+document.querySelectorAll('.panel h2').forEach(h => {
+  const p = h.parentElement, key = 'dh-' + p.id;
+  if (localStorage.getItem(key) === '1') p.classList.add('closed');
+  h.onclick = () => { p.classList.toggle('closed'); localStorage.setItem(key, p.classList.contains('closed') ? '1' : '0'); };
+});
+
+async function j(u, opt) { const r = await fetch(u, opt); if (!r.ok) throw new Error(u + ' → ' + r.status); return r.json(); }
+
+function renderDossier(p) {
+  $('#dossier').innerHTML = `
+    <dl class="kv">
+      <dt>Project</dt><dd><b>${esc(p.name)}</b></dd>
+      <dt>Applicant</dt><dd>${esc(p.entity)}</dd>
+      <dt>Case</dt><dd>${esc(p.caseNumber)}</dd>
+      <dt>CEQA</dt><dd>${esc(p.ceqa.documentType)} — SCH <a href="${esc(p.ceqa.url)}" target="_blank" rel="noopener noreferrer">${esc(p.ceqa.schNumber)}</a><br><span class="when">State review ${esc(p.ceqa.stateReview)}</span></dd>
+      <dt>Location</dt><dd>${esc(p.location.description)}</dd>
+      <dt>APNs</dt><dd>${p.location.apns.map(esc).join(', ')}</dd>
+      <dt>Zoning</dt><dd>${esc(p.location.zoning)}</dd>
+      <dt>Site</dt><dd>${esc(p.scope.siteAcreage)} — ${esc(p.scope.lots)}</dd>
+      <dt>Open space</dt><dd>${esc(p.scope.openSpace)}</dd>
+      <dt>City contact</dt><dd>${esc(p.cityContact.name)}, ${esc(p.cityContact.org)}<br>${esc(p.cityContact.phone)}</dd>
+    </dl>
+    <div style="margin-top:10px;color:var(--dim);font-size:13px">${p.scope.features.map(esc).join(' · ')}</div>
+    <ul class="links" style="margin:12px 0 0;padding-left:18px">${p.links.map(l => `<li><a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a></li>`).join('')}</ul>`;
+  $('#mapCaption').textContent = p.location.description;
+  const map = L.map('map').setView([p.location.lat, p.location.lng], 14);
+  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; OpenStreetMap' }).addTo(map);
+  L.marker([p.location.lat, p.location.lng]).addTo(map).bindPopup('Larchmont Street Subdivision site (approx.)');
+}
+
+function renderTimeline(t) {
+  $('#timeline').innerHTML = t.slice().sort((a,b) => b.date.localeCompare(a.date)).map(e => `
+    <div class="tl-item">
+      <div class="d">${esc(new Date(e.date + 'T12:00:00').toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric'}))}</div>
+      <div class="t">${esc(e.title)}</div>
+      <div class="x">${esc(e.detail)} ${e.url ? `<a href="${esc(e.url)}" target="_blank" rel="noopener noreferrer">source</a>` : ''}</div>
+    </div>`).join('') || '<div class="empty">No timeline entries.</div>';
+}
+
+function renderDocs(d) {
+  $('#docs').innerHTML = d.map(x => `
+    <div class="doc">
+      <a href="${esc(x.file)}" target="_blank" rel="noopener noreferrer">${esc(x.title)}</a>
+      <div class="d">${esc(x.source)} · ${esc(x.date)} · <a href="${esc(x.sourceUrl)}" target="_blank" rel="noopener noreferrer">original</a></div>
+    </div>`).join('') || '<div class="empty">No documents cached yet.</div>';
+}
+
+function renderFeed() {
+  const q = $('#q').value.trim().toLowerCase();
+  const sort = $('#sort').value;
+  let list = items.filter(it => (cat === 'all' || it.category === cat) &&
+    (!q || (it.title + ' ' + (it.snippet||'') + ' ' + it.source).toLowerCase().includes(q)));
+  const key = it => it.published || it.firstSeen || '';
+  if (sort === 'newest') list.sort((a,b) => key(b).localeCompare(key(a)));
+  if (sort === 'oldest') list.sort((a,b) => key(a).localeCompare(key(b)));
+  if (sort === 'source') list.sort((a,b) => a.source.localeCompare(b.source));
+  $('#feed').innerHTML = list.map(it => `
+    <div class="card">
+      <div class="t"><a href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">${esc(it.title)}</a></div>
+      <div class="s">
+        <span class="cat ${esc(it.category)}">${esc(it.category)}</span>
+        <span>${esc(it.source)}</span>
+        <span class="when" title="${esc(it.published || it.firstSeen || '')}">🕓 ${esc(fmt(it.published || it.firstSeen))}</span>
+      </div>
+      ${it.snippet ? `<div class="snippet">${esc(it.snippet)}</div>` : ''}
+    </div>`).join('') || '<div class="empty">No items match. Try Refresh, or widen the filters.</div>';
+}
+
+async function loadItems() {
+  const data = await j('/api/items');
+  items = data.items;
+  $('#lastRefresh').textContent = data.lastRefresh ? 'Updated ' + fmt(data.lastRefresh) : 'Never refreshed';
+  renderFeed();
+}
+
+document.querySelectorAll('.chip').forEach(c => c.onclick = () => {
+  document.querySelectorAll('.chip').forEach(x => x.classList.remove('on'));
+  c.classList.add('on'); cat = c.dataset.cat; renderFeed();
+});
+$('#q').oninput = renderFeed;
+$('#sort').onchange = () => { localStorage.setItem('dh-sort', $('#sort').value); renderFeed(); };
+$('#sort').value = localStorage.getItem('dh-sort') || 'newest';
+$('#btnRefresh').onclick = async () => {
+  $('#btnRefresh').textContent = '… refreshing'; $('#btnRefresh').disabled = true;
+  try {
+    const r = await j('/api/refresh', { method: 'POST' });
+    $('#errors').innerHTML = (r.errors||[]).length ? `<div class="errbar">Some sources failed: ${r.errors.map(esc).join(' · ')}</div>` : '';
+    await loadItems();
+  } catch (e) { $('#errors').innerHTML = `<div class="errbar">${esc(e.message)}</div>`; }
+  $('#btnRefresh').textContent = '↻ Refresh now'; $('#btnRefresh').disabled = false;
+};
+
+(async () => {
+  const [proj, tl, docs] = await Promise.all([j('/api/project'), j('/api/timeline'), j('/api/documents')]);
+  renderDossier(proj); renderTimeline(tl); renderDocs(docs);
+  await loadItems();
+})();
+</script>
+</body>
+</html>
diff --git a/scripts/refresh.js b/scripts/refresh.js
new file mode 100644
index 0000000..63cd2a6
--- /dev/null
+++ b/scripts/refresh.js
@@ -0,0 +1,133 @@
+// Source refresher — pulls all configured free sources, dedupes by URL hash,
+// appends new items to data/news.jsonl. $0: public RSS/HTML only, no paid APIs.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) DandeanaHub/1.0';
+
+function hash(url) { return crypto.createHash('sha256').update(url.trim()).digest('hex').slice(0, 16); }
+
+function decodeEntities(s) {
+  return (s || '')
+    .replace(/<!\[CDATA\[(.*?)\]\]>/gs, '$1')
+    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&nbsp;/g, ' ')
+    .replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
+}
+
+async function get(url, timeoutMs = 20000) {
+  const ctl = new AbortController();
+  const t = setTimeout(() => ctl.abort(), timeoutMs);
+  try {
+    const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: ctl.signal, redirect: 'follow' });
+    if (!r.ok) throw new Error(`HTTP ${r.status}`);
+    return await r.text();
+  } finally { clearTimeout(t); }
+}
+
+// Google News RSS <item> blocks → items
+function parseRss(xml, sourceLabel, category) {
+  const items = [];
+  for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
+    const block = m[1];
+    const pick = tag => { const mm = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`)); return mm ? mm[1] : ''; };
+    const title = decodeEntities(pick('title'));
+    const link = decodeEntities(pick('link'));
+    if (!title || !link) continue;
+    const pub = pick('pubDate');
+    const srcTag = decodeEntities(pick('source'));
+    items.push({
+      title, url: link,
+      source: srcTag || sourceLabel,
+      category,
+      published: pub ? new Date(pub).toISOString() : null,
+      snippet: decodeEntities(pick('description')).slice(0, 300),
+    });
+  }
+  return items;
+}
+
+// CEQAnet project page → one status item when content changes
+async function checkCeqa(src) {
+  const html = await get(src.url);
+  const docs = [...html.matchAll(/<a[^>]+href="(\/[^"]*(?:Document|Attachment)[^"]*)"[^>]*>([^<]+)<\/a>/gi)]
+    .map(m => decodeEntities(m[2])).slice(0, 20);
+  const fingerprint = hash(src.url + '|' + docs.join('|'));
+  return [{
+    title: `CEQAnet — ${src.label}: ${docs.length ? docs.length + ' document(s) on record' : 'record checked'}`,
+    url: src.url, source: 'CEQAnet (State Clearinghouse)', category: 'ceqa',
+    published: null, snippet: docs.slice(0, 5).join(' · '),
+    _fp: fingerprint, // dedupe on content fingerprint, not URL, so a changed record re-surfaces
+  }];
+}
+
+// Poway page scan → items for links whose text matches the keywords
+async function scanPage(src) {
+  const html = await get(src.url);
+  const kw = src.keywords.map(k => k.toLowerCase());
+  const out = [];
+  for (const m of html.matchAll(/<a[^>]+href="([^"#]+)"[^>]*>([\s\S]{3,200}?)<\/a>/gi)) {
+    const text = decodeEntities(m[2]);
+    if (!text) continue;
+    const low = text.toLowerCase();
+    if (!kw.some(k => low.includes(k))) continue;
+    let href = m[1];
+    if (href.startsWith('/')) href = new URL(src.url).origin + href;
+    if (!href.startsWith('http')) continue;
+    out.push({ title: text.slice(0, 180), url: href, source: src.label, category: src.category || 'government', published: null, snippet: `Matched on ${src.url}` });
+  }
+  return out;
+}
+
+async function runRefresh(DATA) {
+  const sources = JSON.parse(fs.readFileSync(path.join(DATA, 'sources.json'), 'utf8'));
+  const newsPath = path.join(DATA, 'news.jsonl');
+  const existing = new Set();
+  try {
+    for (const line of fs.readFileSync(newsPath, 'utf8').split('\n')) {
+      if (!line) continue;
+      const it = JSON.parse(line);
+      existing.add(it.id);
+    }
+  } catch { /* first run */ }
+
+  const collected = [];
+  const errors = [];
+  for (const src of sources) {
+    try {
+      if (src.type === 'gnews') {
+        const url = `https://news.google.com/rss/search?q=${encodeURIComponent(src.query)}&hl=en-US&gl=US&ceid=US:en`;
+        collected.push(...parseRss(await get(url), src.label, src.category || 'news'));
+      } else if (src.type === 'rss') {
+        collected.push(...parseRss(await get(src.url), src.label, src.category || 'news'));
+      } else if (src.type === 'ceqa') {
+        collected.push(...await checkCeqa(src));
+      } else if (src.type === 'scan') {
+        collected.push(...await scanPage(src));
+      }
+    } catch (e) { errors.push(`${src.label}: ${e.message}`); }
+  }
+
+  let added = 0;
+  const lines = [];
+  for (const it of collected) {
+    const id = it._fp || hash(it.url);
+    if (existing.has(id)) continue;
+    existing.add(id);
+    delete it._fp;
+    lines.push(JSON.stringify({ id, firstSeen: new Date().toISOString(), ...it }));
+    added++;
+  }
+  if (lines.length) fs.appendFileSync(newsPath, lines.join('\n') + '\n');
+  const meta = { lastRefresh: new Date().toISOString(), added, checked: collected.length, errors };
+  fs.writeFileSync(path.join(DATA, 'refresh-meta.json'), JSON.stringify(meta, null, 2));
+  console.log(`refresh: ${added} new / ${collected.length} checked${errors.length ? ' — errors: ' + errors.join('; ') : ''}`);
+  return meta;
+}
+
+module.exports = { runRefresh };
+
+if (require.main === module) {
+  runRefresh(path.join(__dirname, '..', 'data')).catch(e => { console.error(e); process.exit(1); });
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..85c5538
--- /dev/null
+++ b/server.js
@@ -0,0 +1,68 @@
+// Dandeana Hub — private client news hub for the Larchmont Street Subdivision (Poway)
+// Whole-site HTTP Basic Auth: every route 401s without creds except /healthz (pm2/deploy smoke).
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const { runRefresh } = require('./scripts/refresh');
+
+const PORT = process.env.PORT || 9914;
+const DATA = path.join(__dirname, 'data');
+// BASIC_AUTH="user:pass" overrides; default is the client credential pair.
+const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'dandeana:w3tEgaYeOPDQ').split(':');
+
+const app = express();
+app.disable('x-powered-by');
+
+app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'dandeana-hub' }));
+
+app.use((req, res, next) => {
+  const hdr = req.headers.authorization || '';
+  const [scheme, b64] = hdr.split(' ');
+  if (scheme === 'Basic' && b64) {
+    const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
+    if (u === AUTH_USER && p === AUTH_PASS) return next();
+  }
+  res.set('WWW-Authenticate', 'Basic realm="Dandeana Private Hub"');
+  return res.status(401).send('Authentication required');
+});
+
+app.use(express.static(path.join(__dirname, 'public')));
+app.use('/docs', express.static(path.join(__dirname, 'docs')));
+
+function readJson(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(path.join(DATA, file), 'utf8')); }
+  catch { return fallback; }
+}
+function readItems() {
+  try {
+    return fs.readFileSync(path.join(DATA, 'news.jsonl'), 'utf8')
+      .split('\n').filter(Boolean).map(l => JSON.parse(l));
+  } catch { return []; }
+}
+
+app.get('/api/project', (_req, res) => res.json(readJson('project.json', {})));
+app.get('/api/timeline', (_req, res) => res.json(readJson('timeline.json', [])));
+app.get('/api/documents', (_req, res) => res.json(readJson('documents.json', [])));
+app.get('/api/items', (_req, res) => {
+  const meta = readJson('refresh-meta.json', {});
+  res.json({ lastRefresh: meta.lastRefresh || null, items: readItems() });
+});
+
+let refreshing = false;
+app.post('/api/refresh', async (_req, res) => {
+  if (refreshing) return res.status(409).json({ ok: false, error: 'refresh already running' });
+  refreshing = true;
+  try {
+    const result = await runRefresh(DATA);
+    res.json({ ok: true, ...result });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: String(e.message || e) });
+  } finally { refreshing = false; }
+});
+
+app.listen(PORT, '127.0.0.1', () => console.log(`dandeana-hub listening on 127.0.0.1:${PORT}`));
+
+// Daily self-refresh so the hub stays current with zero external schedulers.
+const DAY = 24 * 60 * 60 * 1000;
+setInterval(() => { runRefresh(DATA).catch(e => console.error('scheduled refresh failed:', e.message)); }, DAY);
+setTimeout(() => { runRefresh(DATA).catch(e => console.error('startup refresh failed:', e.message)); }, 15 * 1000);

(oldest)  ·  back to Dandeana Hub  ·  move to port 9915 (9914 taken by dw-pairs-well on Kamatera) 7cdac0c →