diff --git a/.github/workflows/nearby-places.yml b/.github/workflows/nearby-places.yml new file mode 100644 index 00000000..bcfb148e --- /dev/null +++ b/.github/workflows/nearby-places.yml @@ -0,0 +1,35 @@ +name: Nearby Places checks + +on: + pull_request: + paths: + - 'applications/nearby-places/**' + - '.github/workflows/nearby-places.yml' + push: + branches: [main] + paths: + - 'applications/nearby-places/**' + - '.github/workflows/nearby-places.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/nearby-places + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2 branch + with: + deno-version: 2.9.7 + - run: deno install --frozen --entrypoint src/server.ts test/domain_test.ts test/cloud_test.ts + - run: deno task fmt:check + - run: deno task lint + - run: deno task check + - run: deno task test + +# Live PostGIS/browser checks require an explicitly provisioned dedicated fixture. diff --git a/applications/nearby-places/.deno-version b/applications/nearby-places/.deno-version new file mode 100644 index 00000000..10201185 --- /dev/null +++ b/applications/nearby-places/.deno-version @@ -0,0 +1 @@ +2.9.7 diff --git a/applications/nearby-places/.env.example b/applications/nearby-places/.env.example new file mode 100644 index 00000000..745241a5 --- /dev/null +++ b/applications/nearby-places/.env.example @@ -0,0 +1,8 @@ +PGHOST=your-service-hostname +PGPORT=5432 +PGDATABASE=postgres +PGUSER=places_reader +PGPASSWORD=replace-with-read-only-runtime-password +PGSSLROOTCERT=/absolute/path/cloud-ca.pem +PGSSLMODE=verify-full +PORT=4000 diff --git a/applications/nearby-places/.gitignore b/applications/nearby-places/.gitignore new file mode 100644 index 00000000..f9e87ddd --- /dev/null +++ b/applications/nearby-places/.gitignore @@ -0,0 +1,4 @@ +.env +*.pem +node_modules/ +.deno/ diff --git a/applications/nearby-places/README.md b/applications/nearby-places/README.md new file mode 100644 index 00000000..47f624c4 --- /dev/null +++ b/applications/nearby-places/README.md @@ -0,0 +1,246 @@ +# Nearby places + +A small Deno/Oak browser form and JSON API search a synthetic place directory in ClickHouse Managed +Postgres. PostGIS stores `geography(Point,4326)` and returns spheroidal geodesic +distances in meters. The results describe proximity over the Earth's surface, not routes or travel +time. + +The app has no accounts or mutation routes. Its database role can select the directory, while +separate administrator and schema-owner commands install PostGIS, migrate and seed. All labels are +fictional; coordinates are explicit test fixtures. It does not request geolocation, use map tiles, +geocode addresses or collect a real location directory. + +## Pinned stack + +- Deno 2.9.7, Oak 17.2.0 and npm Postgres.js 3.4.9. +- Committed `deno.lock` pins transitive JSR/npm dependencies. There is no npm install or + node_modules requirement for the application. +- Actual Cloud fixture on 2 October 2026: PostgreSQL 18.6, PostGIS 3.6.4. Setup prints + `pg_extension` and `PostGIS_Full_Version()`; inspect your service's actual version rather than + assuming this one. + +The query lives in [src/places.ts](src/places.ts), validation in [src/input.ts](src/input.ts), and +connection verification in [src/database.ts](src/database.ts). Oak's standard `handle()` API runs +its middleware on Deno's HTTP server; shutdown awaits `server.finished`, removes signal listeners +and closes the database pool. + +## Create a dedicated Cloud fixture + +Use an authenticated clickhousectl and a dedicated test service. The following modest shape was +verified in `us-east-1`; check current availability/pricing before allocating your own. This example +has no HA. + +```bash +export ORG_ID=your-organization-id +clickhousectl cloud postgres create --org-id "$ORG_ID" \ + --name nearby-places --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none --json \ + > /private/path/service.json +chmod 600 /private/path/service.json +export SERVICE_ID=your-returned-service-id +``` + +Save the returned hostname, port, username and password privately. Repeat this command until its +state is `running`: + +```bash +clickhousectl cloud postgres get "$SERVICE_ID" --org-id "$ORG_ID" --json +clickhousectl cloud postgres certs get "$SERVICE_ID" --org-id "$ORG_ID" \ + --output /private/path/cloud-ca.pem +``` + +See +[ClickHouse Managed Postgres extensions](https://clickhouse.com/docs/products/managed-postgres/extensions). +Running services incur charges. Keep receipts, credentials and CA files out of Git. + +## Install, migrate and seed + +Install the pinned Deno release and a PostgreSQL client. From this application directory, populate +the dependency cache from the committed lock: + +```bash +deno install --frozen --entrypoint src/server.ts test/domain_test.ts test/cloud_test.ts +deno task fmt:check +deno task lint +deno task check +deno task test +``` + +Create a private setup environment file. Use the administrator credentials from the receipt and two +distinct random role passwords of at least 24 characters: + +```dotenv +PGHOST=your-service-hostname +PGPORT=5432 +PGDATABASE=postgres +PGSSLROOTCERT=/absolute/path/cloud-ca.pem +PGSSLMODE=verify-full +ADMIN_USER=receipt-username +ADMIN_PASSWORD=receipt-password +MIGRATION_PASSWORD=distinct-random-schema-owner-password +APP_PASSWORD=distinct-random-read-only-password +``` + +Export fields to child processes and bootstrap as the administrator: + +```bash +set -a +source /private/path/setup.env +set +a +export PGUSER="$ADMIN_USER" PGPASSWORD="$ADMIN_PASSWORD" +psql -X -f sql/bootstrap.sql +export PGUSER=places_migration PGPASSWORD="$MIGRATION_PASSWORD" +psql -X -f sql/migrate.sql +psql -X -f sql/seed.sql +``` + +`bootstrap.sql` installs the available PostGIS extension in `public`, creates both roles and the +owned application schema, and prints the actual extension version. It runs once. The dedicated +database's public schema CREATE and database CREATE/TEMP privileges are revoked from PUBLIC. The +schema owner needs no database CREATE grant because the administrator creates its schema explicitly. + +`migrate.sql` records version 1, creates the places table and GiST geography/category indexes, and +grants SELECT on that table to the reader. `seed.sql` upserts the 12 explicit fixtures and analyzes +the table. Repeat migration and seed to verify reproduction. Administrator cleanup offers a +destructive fixture reset; no production downgrade path is included. Runtime startup never performs +DDL or seed writes. + +## Start with restricted permissions + +Create a private runtime file from [.env.example](.env.example), containing only the reader's +credentials, connection fields and optional `PORT=4000`. Do not include administrator or migration +passwords. + +```bash +set -a; source /private/path/runtime.env; set +a +unset ADMIN_USER ADMIN_PASSWORD MIGRATION_PASSWORD APP_PASSWORD +bash scripts/run.sh +``` + +Open `http://127.0.0.1:4000`. The browser offers London, equator and dateline fixtures, +category/limit controls and sorted result cards. A zero radius shows only coincident points. Display +distances are rounded; ordering uses the database's full returned distances. + +The [run script](scripts/run.sh) uses `--frozen --cached-only` and grants: + +- Network access only to `127.0.0.1:$PORT` and the configured Cloud hostname/port. +- File reading only for `public` assets and the configured CA file. +- Environment reading only for `PG*`, `PORT`, `NODE_DEBUG` and `NODE_EXTRA_CA_CERTS`. The PG prefix + is needed because Postgres.js reads its configuration defaults; the two Node variables support the + verified compatibility path. + +It grants no write, subprocess, FFI or blanket `-A` permission. Postgres.js supplies the downloaded +CA, `rejectUnauthorized:true` and the actual hostname to Node-compatible TLS. +`PGSSLMODE=verify-full` configures `psql`; the application sets verification explicitly instead of +relying on that variable. + +The reader's default read-only transaction setting is a client-changeable convenience. Table grants +enforce persistent read-only access even if that default is disabled; schema/database grants prevent +persistent and temporary DDL. The Cloud test disables the default before asserting SQLSTATE 42501 +for writes and DDL. The role can use public PostGIS functions needed by SELECT queries, and +PostgreSQL's ordinary system catalog access. + +This is a public read-only sample bound to loopback, with a three-connection pool and two-second +statement timeout. Public deployment would need HTTPS, traffic limits and appropriate operational +monitoring. Searches aren't written to an application table or logged by this example; +infrastructure/access logs are outside that claim. + +## JSON search + +```bash +curl -sS 'http://127.0.0.1:4000/api/nearby?longitude=0&latitude=0&radiusMeters=1200&limit=20' +curl -sS 'http://127.0.0.1:4000/api/nearby?longitude=179.999&latitude=0&radiusMeters=300&category=park' +``` + +`GET /api/nearby` returns `{ search, places, sampleData: true }`. Each place includes ID, label, +category, longitude, latitude and `distanceMeters`. The search echoes validated inputs. Results are +bounded by the supplied limit; there is no pagination or total-match count. Unsupported mutations +return `405`. + +| Parameter | Bounds | +| -------------- | -------------------------------------------------- | +| `longitude` | Required finite decimal, −180 through 180 | +| `latitude` | Required finite decimal, −90 through 90 | +| `radiusMeters` | Required finite decimal, 0 through 50,000 | +| `category` | Optional `cafe`, `library` or `park`; omit for all | +| `limit` | Optional integer 1–50; defaults to 20 | + +Unknown/repeated parameters, nonfinite numbers, malformed decimals and values outside bounds return +`400`. URLs longer than 2,048 characters return `414`. A database failure returns a generic `503` +without connection details. + +The query constructs its origin in **longitude, latitude** order. Parameterized +`ST_DWithin(geography, geography, meters, true)` restricts candidates and can use the GiST index. +`ST_Distance(..., true)` supplies spheroidal meters for ordering, followed by place ID as the +deterministic tie-breaker, then the bounded limit. It does not use a degree-based radius or +spherical KNN distance as a substitute for the requested ordering. + +## Native Cloud and browser acceptance + +Use a freshly bootstrapped dedicated fixture. After exporting setup.env, switch to the reader: + +```bash +export PGUSER=places_reader PGPASSWORD="$APP_PASSWORD" +export EVIDENCE_DIR=/private/path/evidence +mkdir -p "$EVIDENCE_DIR" +deno test --frozen --cached-only \ + --allow-env='PG*,EVIDENCE_DIR,NODE_DEBUG,NODE_EXTRA_CA_CERTS' \ + --allow-net="$PGHOST:$PGPORT" --allow-read="$PGSSLROOTCERT,/tmp" \ + --allow-write="/tmp,$EVIDENCE_DIR" --allow-run=openssl test/cloud_test.ts +``` + +The test needs OpenSSL only to generate an unrelated CA and writes evidence only to the configured +location. It checks known equatorial meters, identical points, radius/category filters, coordinate +order, stable ties/limits, antimeridian and polar behavior, grant denials after disabling the +read-only default, and negative TLS controls with a positive connection afterward. It saves an +actual `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` for the application query. No index is forced; a +tiny fixture's plan is evidence of that query shape, not a performance benchmark. + +The optional browser/HTTP harness uses Node 24.21.0 and Playwright 1.63.0 in an isolated development +directory. It starts the same restricted application command, checks desktop/mobile cards and +malformed HTTP requests, confirms the original process exits, then restarts it and compares durable +results: + +```bash +export BROWSER_DIR=/private/path/browser-dependencies +mkdir -p "$BROWSER_DIR" +npm --prefix "$BROWSER_DIR" install --save-exact playwright@1.63.0 +"$BROWSER_DIR/node_modules/.bin/playwright" install --with-deps chromium +cp test/browser.mjs "$BROWSER_DIR/browser.mjs" +export APP_DIR="$PWD" EVIDENCE_DIR=/private/path/evidence +# Use runtime.env, with only reader credentials, for this harness. +node "$BROWSER_DIR/browser.mjs" +``` + +CI runs frozen installation, formatting, lint, type checking and the three domain tests without +Cloud secrets. Cloud/browser evidence belongs to the dedicated development fixture. + +## Cleanup + +Stop the application before resetting schemas or deleting the service. To reset only this fixture, +explicitly restore administrator credentials: + +```bash +set -a; source /private/path/setup.env; set +a +export PGUSER="$ADMIN_USER" PGPASSWORD="$ADMIN_PASSWORD" +psql -X -f sql/cleanup.sql +``` + +The reset keeps the administrator-owned PostGIS extension so bootstrap can be repeated. +Database/public-schema privilege revocations also remain. To remove the billable fixture, use its +exact recorded ID and verify absence after asynchronous deletion: + +```bash +clickhousectl cloud postgres delete "$SERVICE_ID" --org-id "$ORG_ID" +clickhousectl cloud postgres list --org-id "$ORG_ID" --json +``` + +Deletion destroys that service's data. Preserve needed source and evidence first. + +## Primary references + +- [PostGIS ST_DWithin](https://postgis.net/docs/ST_DWithin.html) +- [PostGIS ST_Distance](https://postgis.net/docs/ST_Distance.html) +- [Deno's npm Postgres example](https://docs.deno.com/examples/postgres/) +- [Oak 17.2.0 source](https://jsr.io/@oak/oak@17.2.0) +- [Postgres.js 3.4.9 source](https://github.com/porsager/postgres/tree/v3.4.9) diff --git a/applications/nearby-places/deno.json b/applications/nearby-places/deno.json new file mode 100644 index 00000000..aede0720 --- /dev/null +++ b/applications/nearby-places/deno.json @@ -0,0 +1,16 @@ +{ + "imports": { + "@oak/oak": "jsr:@oak/oak@17.2.0", + "postgres": "npm:postgres@3.4.9" + }, + "tasks": { + "check": "deno check --frozen src/server.ts test/domain_test.ts test/cloud_test.ts", + "test": "deno test --frozen test/domain_test.ts", + "fmt:check": "deno fmt --check", + "lint": "deno lint src test" + }, + "fmt": { + "lineWidth": 100 + }, + "nodeModulesDir": "none" +} diff --git a/applications/nearby-places/deno.lock b/applications/nearby-places/deno.lock new file mode 100644 index 00000000..43acb452 --- /dev/null +++ b/applications/nearby-places/deno.lock @@ -0,0 +1,87 @@ +{ + "version": "5", + "specifiers": { + "jsr:@oak/commons@1": "1.0.1", + "jsr:@oak/oak@17.2.0": "17.2.0", + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/bytes@1": "1.0.6", + "jsr:@std/crypto@1": "1.1.0", + "jsr:@std/encoding@1": "1.0.11", + "jsr:@std/encoding@^1.0.11": "1.0.11", + "jsr:@std/http@1": "1.1.4", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/media-types@1": "1.1.0", + "jsr:@std/path@1": "1.1.6", + "npm:path-to-regexp@^6.3.0": "6.3.0", + "npm:postgres@3.4.9": "3.4.9" + }, + "jsr": { + "@oak/commons@1.0.1": { + "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/http", + "jsr:@std/media-types" + ] + }, + "@oak/oak@17.2.0": { + "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1", + "dependencies": [ + "jsr:@oak/commons", + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/http", + "jsr:@std/media-types", + "jsr:@std/path", + "npm:path-to-regexp" + ] + }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e" + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/crypto@1.1.0": { + "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" + }, + "@std/encoding@1.0.11": { + "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" + }, + "@std/http@1.1.4": { + "integrity": "4f1575226ca7c0df1df38b26d50265fed30869b2a63ab867edc5b92492150c8c", + "dependencies": [ + "jsr:@std/encoding@^1.0.11" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "path-to-regexp@6.3.0": { + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + "postgres@3.4.9": { + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@oak/oak@17.2.0", + "npm:postgres@3.4.9" + ] + } +} diff --git a/applications/nearby-places/public/app.js b/applications/nearby-places/public/app.js new file mode 100644 index 00000000..f1ad99af --- /dev/null +++ b/applications/nearby-places/public/app.js @@ -0,0 +1,86 @@ +const form = document.querySelector("#search-form"); +const results = document.querySelector("#results"); +const status = document.querySelector("#status"); +const submit = document.querySelector("#submit"); +let controller; +let sequence = 0; +const presets = { + london: { longitude: -0.12, latitude: 51.5, radiusMeters: 1000 }, + equator: { longitude: 0, latitude: 0, radiusMeters: 1200 }, + dateline: { longitude: 179.999, latitude: 0, radiusMeters: 300 }, +}; +for (const button of document.querySelectorAll("[data-preset]")) { + button.addEventListener("click", () => { + for (const [name, value] of Object.entries(presets[button.dataset.preset])) { + form.elements.namedItem(name).value = String(value); + } + form.elements.namedItem("category").value = ""; + form.requestSubmit(); + }); +} +form.addEventListener("submit", (event) => { + event.preventDefault(); + void search(); +}); +async function search() { + const current = ++sequence; + controller?.abort(); + controller = new AbortController(); + const params = new URLSearchParams(); + for (const [key, value] of new FormData(form)) { + if (value !== "") params.set(key, String(value)); + } + results.replaceChildren(); + status.textContent = "Looking around your point…"; + submit.disabled = true; + try { + const response = await fetch(`/api/nearby?${params}`, { signal: controller.signal }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error ?? "Search unavailable"); + if (current !== sequence) return; + status.textContent = data.places.length + ? `${data.places.length} sample ${ + data.places.length === 1 ? "place" : "places" + } within ${data.search.radiusMeters.toLocaleString()} meters` + : "No sample places within this radius. Try a larger radius or another sample."; + for (const place of data.places) { + const item = document.createElement("li"); + const card = document.createElement("article"); + card.className = "place-card"; + const icon = document.createElement("span"); + icon.className = `place-icon ${place.category}`; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = { cafe: "☕", library: "▤", park: "♧" }[place.category]; + const details = document.createElement("div"); + const category = document.createElement("p"); + category.className = "place-category"; + category.textContent = place.category; + const name = document.createElement("h3"); + name.textContent = place.name; + const coordinates = document.createElement("p"); + coordinates.className = "coordinates"; + coordinates.textContent = `${place.longitude.toFixed(3)}° longitude · ${ + place.latitude.toFixed(3) + }° latitude`; + details.append(category, name, coordinates); + const distance = document.createElement("div"); + distance.className = "distance"; + const value = document.createElement("strong"); + value.textContent = place.distanceMeters < 1000 + ? `${place.distanceMeters.toFixed(1)} m` + : `${(place.distanceMeters / 1000).toFixed(3)} km`; + const caption = document.createElement("span"); + caption.textContent = "from your point"; + distance.append(value, caption); + card.append(icon, details, distance); + item.append(card); + results.append(item); + } + } catch (error) { + if (current !== sequence || error.name === "AbortError") return; + status.textContent = error.message; + } finally { + if (current === sequence) submit.disabled = false; + } +} +void search(); diff --git a/applications/nearby-places/public/index.html b/applications/nearby-places/public/index.html new file mode 100644 index 00000000..9560252d --- /dev/null +++ b/applications/nearby-places/public/index.html @@ -0,0 +1,47 @@ + + + + + + Nearby places · A synthetic directory + + + + +
+
↗ Nearby placesSynthetic directory
+
+

A point. A radius. A few discoveries.

+

Find a little closer.

+

Explore cafes, libraries and parks around a point.
Every place in this directory is a made-up sample.

+
+
+
+

Choose your starting point

+

Enter coordinates or try a sample.

+
+
+
+ + + + +
+ +
+
+

Around your point

Nearest first
+

Loading the London sample…

+
    +
    +
    + +
    + + diff --git a/applications/nearby-places/public/style.css b/applications/nearby-places/public/style.css new file mode 100644 index 00000000..c3385f8e --- /dev/null +++ b/applications/nearby-places/public/style.css @@ -0,0 +1,350 @@ +:root { + font-family: Inter,ui-sans-serif,system-ui,-apple-system,sans-serif; + color: #263a32; + background: #f6f7f2; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + max-width: 1180px; + margin: 0 auto; + padding: 30px 40px; +} +a { + color: inherit; + text-decoration: none; +} +.masthead { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 28px; + border-bottom: 1px solid #dce2d7; +} +.brand { + display: flex; + gap: 11px; + align-items: center; + font-size: 18px; + font-weight: 700; + letter-spacing: -.4px; +} +.brand-icon { + display: grid; + place-items: center; + width: 35px; + height: 35px; + background: #244d3b; + border-radius: 10px; + color: #e4eebe; + font-size: 25px; +} +.sample-badge { + border: 1px solid #ccd6c6; + border-radius: 30px; + font-size: 12px; + padding: 8px 13px; + color: #53624e; +} +.intro { + padding: 45px 0 34px; +} +.eyebrow { + color: #648063; + text-transform: uppercase; + letter-spacing: 1.8px; + font-size: 11px; + font-weight: 700; +} +h1 { + font-family: Georgia,serif; + font-size: 52px; + letter-spacing: -1.8px; + font-weight: 400; + margin: 12px 0 15px; + line-height: 1.06; +} +.intro > p:last-child { + color: #657269; + font-size: 15px; + line-height: 1.7; +} +.workspace { + display: grid; + grid-template-columns: 345px 1fr; + gap: 34px; + align-items: start; +} +.search-panel { + background: white; + border: 1px solid #dce2d7; + border-radius: 15px; + padding: 25px; +} +.search-panel h2 { + font-size: 17px; + letter-spacing: -.4px; + margin: 0 0 7px; +} +.muted { + font-size: 12px; + color: #788177; + margin: 0 0 18px; +} +.presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 25px; +} +button, +input, +select { + font: inherit; +} +button { + cursor: pointer; +} +.presets button { + border: 1px solid #dde4d7; + border-radius: 6px; + background: #f7f9f2; + color: #486148; + padding: 7px 8px; + font-size: 10px; +} +.presets button:hover { + background: #e9efd9; +} +label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 17px; +} +input, +select { + display: block; + width: 100%; + padding: 11px 12px; + margin-top: 7px; + border: 1px solid #d4dbcf; + border-radius: 7px; + background: #fff; + color: #344538; + font-size: 13px; +} +.coordinate-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 13px; +} +input:focus, +select:focus { + outline: 2px solid #7e986c; + outline-offset: 2px; +} +.primary { + width: 100%; + display: flex; + justify-content: space-between; + padding: 13px 15px; + border: 0; + border-radius: 7px; + background: #2c533f; + color: white; + font-size: 13px; + font-weight: 600; +} +.primary:hover { + background: #1f432f; +} +.primary:disabled { + opacity: .6; + cursor: wait; +} +.distance-note { + display: flex; + gap: 11px; + border-top: 1px solid #e6e9df; + margin-top: 23px; + padding-top: 17px; + color: #7a8478; + font-size: 11px; + line-height: 1.6; +} +.distance-note > span { + font-size: 24px; + color: #6f856b; +} +.distance-note p { + margin: 0; +} +.results-top { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 0; +} +.results-top h2 { + font-size: 21px; + letter-spacing: -.6px; + font-weight: 600; + margin: 0; +} +.sort-note { + font-size: 11px; + color: #7b8677; +} +#status { + color: #7b8677; + font-size: 12px; + line-height: 1.5; + margin: 10px 0 22px; +} +#results { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 12px; +} +.place-card { + display: grid; + grid-template-columns: 48px 1fr auto; + gap: 16px; + align-items: center; + border: 1px solid #dce2d7; + background: #fff; + border-radius: 12px; + padding: 19px 20px; +} +.place-icon { + display: grid; + place-items: center; + width: 47px; + height: 47px; + border-radius: 12px; + font-size: 24px; + background: #f2eedf; + color: #967947; +} +.library { + background: #e9edf3; + color: #677a98; +} +.park { + background: #eaf0df; + color: #658150; +} +.place-category { + text-transform: uppercase; + font-size: 9px; + letter-spacing: 1.5px; + color: #8b9584; + margin: 0 0 5px; +} +h3 { + font-size: 17px; + font-weight: 600; + letter-spacing: -.4px; + margin: 0; +} +.coordinates { + font-size: 10px; + color: #8a9485; + margin: 7px 0 0; +} +.distance { + text-align: right; + padding-left: 8px; +} +.distance strong { + display: block; + font-size: 17px; + font-weight: 600; + color: #3a5942; +} +.distance span { + display: block; + font-size: 9px; + color: #8a9485; + margin-top: 5px; +} +footer { + border-top: 1px solid #dce2d7; + padding-top: 20px; + margin-top: 43px; + font-size: 11px; + color: #8a9485; + display: flex; + justify-content: space-between; +} +@media (max-width:800px) { + main { + padding: 20px; + } + .workspace { + grid-template-columns: 1fr; + gap: 26px; + } + .intro { + padding: 32px 0 20px; + } + h1 { + font-size: 42px; + } + .search-panel { + padding: 22px; + } + .place-card { + padding: 15px; + gap: 12px; + } + .coordinates { + font-size: 9px; + } + .distance strong { + font-size: 15px; + } + footer { + gap: 15px; + line-height: 1.6; + } +} +@media (max-width:430px) { + main { + padding: 15px; + } + .sample-badge { + font-size: 10px; + padding: 7px 9px; + } + .brand { + font-size: 16px; + } + .place-card { + grid-template-columns: 35px 1fr auto; + gap: 10px; + } + .place-icon { + width: 35px; + height: 35px; + font-size: 20px; + } + h3 { + font-size: 14px; + } + .coordinates { + max-width: 160px; + line-height: 1.5; + } + .distance span { + font-size: 8px; + } +} diff --git a/applications/nearby-places/scripts/run.sh b/applications/nearby-places/scripts/run.sh new file mode 100755 index 00000000..6d92df69 --- /dev/null +++ b/applications/nearby-places/scripts/run.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${PGHOST:?}" "${PGPORT:=5432}" "${PGSSLROOTCERT:?}" +: "${PORT:=4000}" +# PG* is required by Postgres.js's configuration defaults. No write/run/FFI grants. +exec deno run --frozen --cached-only \ + --allow-net="127.0.0.1:$PORT,$PGHOST:$PGPORT" \ + --allow-read="public,$PGSSLROOTCERT" \ + --allow-env='PG*,PORT,NODE_DEBUG,NODE_EXTRA_CA_CERTS' \ + src/server.ts diff --git a/applications/nearby-places/sql/bootstrap.sql b/applications/nearby-places/sql/bootstrap.sql new file mode 100644 index 00000000..a020e650 --- /dev/null +++ b/applications/nearby-places/sql/bootstrap.sql @@ -0,0 +1,24 @@ +\set ON_ERROR_STOP on +\getenv migration_password MIGRATION_PASSWORD +\getenv runtime_password APP_PASSWORD +SELECT length(:'migration_password') >= 24 AND length(:'runtime_password') >= 24 + AND :'migration_password' <> :'runtime_password' AS passwords_valid \gset +\if :passwords_valid +\else + DO $$ BEGIN RAISE EXCEPTION 'Use distinct random passwords of at least 24 characters'; END $$; +\endif +BEGIN; +CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public; +CREATE ROLE places_migration LOGIN PASSWORD :'migration_password'; +CREATE ROLE places_reader LOGIN PASSWORD :'runtime_password'; +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +SELECT format('REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC', current_database()) \gexec +CREATE SCHEMA nearby_places AUTHORIZATION places_migration; +REVOKE ALL ON SCHEMA nearby_places FROM PUBLIC; +GRANT USAGE ON SCHEMA nearby_places TO places_reader; +ALTER ROLE places_reader SET default_transaction_read_only = on; +ALTER ROLE places_reader SET statement_timeout = '2s'; +ALTER ROLE places_reader SET idle_in_transaction_session_timeout = '5s'; +COMMIT; +SELECT extname, extversion FROM pg_extension WHERE extname = 'postgis'; +SELECT public.PostGIS_Full_Version(); diff --git a/applications/nearby-places/sql/cleanup.sql b/applications/nearby-places/sql/cleanup.sql new file mode 100644 index 00000000..336693a0 --- /dev/null +++ b/applications/nearby-places/sql/cleanup.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP on +-- Administrator-only reset for this dedicated fixture, after the app stops. +DROP SCHEMA IF EXISTS nearby_places CASCADE; +DROP ROLE IF EXISTS places_reader; +DROP ROLE IF EXISTS places_migration; +-- Keep the administrator-owned PostGIS extension for bootstrap repetition. diff --git a/applications/nearby-places/sql/migrate.sql b/applications/nearby-places/sql/migrate.sql new file mode 100644 index 00000000..8c7bfa68 --- /dev/null +++ b/applications/nearby-places/sql/migrate.sql @@ -0,0 +1,23 @@ +\set ON_ERROR_STOP on +BEGIN; +CREATE TABLE IF NOT EXISTS nearby_places.schema_migrations ( + version integer PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() +); +SELECT NOT EXISTS (SELECT 1 FROM nearby_places.schema_migrations WHERE version = 1) + AS apply_migration \gset +\if :apply_migration +CREATE TABLE nearby_places.places ( + id integer PRIMARY KEY, + name varchar(80) NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 80), + category text NOT NULL CHECK (category IN ('cafe', 'library', 'park')), + location public.geography(Point, 4326) NOT NULL +); +CREATE INDEX places_location_gist ON nearby_places.places USING gist (location); +CREATE INDEX places_category ON nearby_places.places (category); +INSERT INTO nearby_places.schema_migrations (version) VALUES (1); +\else +\echo Migration already applied +\endif +GRANT SELECT ON nearby_places.places TO places_reader; +COMMIT; diff --git a/applications/nearby-places/sql/seed.sql b/applications/nearby-places/sql/seed.sql new file mode 100644 index 00000000..6ada0861 --- /dev/null +++ b/applications/nearby-places/sql/seed.sql @@ -0,0 +1,22 @@ +\set ON_ERROR_STOP on +-- Synthetic labels at explicit test coordinates, not a directory of real businesses. +INSERT INTO nearby_places.places (id, name, category, location) +SELECT id, name, category, + public.ST_SetSRID(public.ST_MakePoint(longitude, latitude), 4326)::public.geography +FROM (VALUES + (1, 'Anchor Cafe', 'cafe', -0.120::double precision, 51.500::double precision), + (2, 'Garden Library', 'library', -0.119, 51.501), + (3, 'Quay Park', 'park', -0.122, 51.499), + (4, 'Courtyard Cafe', 'cafe', -0.120, 51.500), + (5, 'Further Cafe', 'cafe', -0.110, 51.500), + (6, 'Equator Library', 'library', 0.000, 0.000), + (7, 'Equator East Cafe', 'cafe', 0.010, 0.000), + (8, 'Equator North Park', 'park', 0.000, 0.010), + (9, 'Dateline East Cafe', 'cafe', 179.999, 0.000), + (10, 'Dateline West Park', 'park', -179.999, 0.000), + (11, 'Off-axis Library', 'library', 12.000, 45.000), + (12, 'Polar Garden', 'park', 0.000, 89.999) +) AS fixture(id, name, category, longitude, latitude) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, category = EXCLUDED.category, + location = EXCLUDED.location; +ANALYZE nearby_places.places; diff --git a/applications/nearby-places/src/app.ts b/applications/nearby-places/src/app.ts new file mode 100644 index 00000000..62cf1837 --- /dev/null +++ b/applications/nearby-places/src/app.ts @@ -0,0 +1,54 @@ +import { Application, Router } from "@oak/oak"; +import { InputError, parseSearch } from "./input.ts"; +import { nearbyQuery } from "./places.ts"; +import type { Database } from "./database.ts"; +export function createApp(sql: Database, assets: Record) { + const app = new Application(); + app.use(async (ctx, next) => { + ctx.response.headers.set( + "Content-Security-Policy", + "default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + ); + ctx.response.headers.set("X-Content-Type-Options", "nosniff"); + ctx.response.headers.set("Referrer-Policy", "no-referrer"); + try { + if (ctx.request.url.href.length > 2048) { + ctx.response.status = 414; + ctx.response.body = { error: "url_too_long" }; + return; + } + await next(); + } catch (error) { + if (error instanceof InputError) { + ctx.response.status = 400; + ctx.response.body = { error: error.message }; + } else { + console.error("Nearby search failed"); + ctx.response.status = 503; + ctx.response.body = { error: "search_unavailable" }; + } + } + }); + const router = new Router(); + router.get("/health", (ctx) => { + ctx.response.body = { ok: true }; + }); + router.get("/api/nearby", async (ctx) => { + const search = parseSearch(ctx.request.url.searchParams); + ctx.response.body = { search, places: await nearbyQuery(sql, search), sampleData: true }; + ctx.response.headers.set("Cache-Control", "no-store"); + }); + for (const [path, asset] of Object.entries(assets)) { + router.get(path, (ctx) => { + ctx.response.type = asset.type; + ctx.response.body = asset.body; + }); + } + app.use(router.routes()); + app.use(router.allowedMethods()); + app.use((ctx) => { + ctx.response.status = 404; + ctx.response.body = { error: "not_found" }; + }); + return app; +} diff --git a/applications/nearby-places/src/database.ts b/applications/nearby-places/src/database.ts new file mode 100644 index 00000000..9393d56d --- /dev/null +++ b/applications/nearby-places/src/database.ts @@ -0,0 +1,32 @@ +import postgres from "postgres"; +export function databaseOptions() { + const required = (key: string): string => { + const value = Deno.env.get(key); + if (!value) throw new Error(`${key} is required`); + return value; + }; + const host = required("PGHOST"); + const port = Number(Deno.env.get("PGPORT") ?? "5432"); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid PGPORT"); + return { + host, + port, + database: required("PGDATABASE"), + username: required("PGUSER"), + password: required("PGPASSWORD"), + ssl: { + ca: Deno.readTextFileSync(required("PGSSLROOTCERT")), + rejectUnauthorized: true, + servername: host, + }, + max: 3, + connect_timeout: 15, + idle_timeout: 20, + fetch_types: false, + connection: { application_name: "nearby-places", statement_timeout: 2000 }, + }; +} +export function database() { + return postgres(databaseOptions()); +} +export type Database = ReturnType; diff --git a/applications/nearby-places/src/input.ts b/applications/nearby-places/src/input.ts new file mode 100644 index 00000000..27d1f9d6 --- /dev/null +++ b/applications/nearby-places/src/input.ts @@ -0,0 +1,47 @@ +export const CATEGORIES = ["cafe", "library", "park"] as const; +export type Category = typeof CATEGORIES[number]; +export type Search = { + longitude: number; + latitude: number; + radiusMeters: number; + limit: number; + category?: Category; +}; +export class InputError extends Error {} +const decimal = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i; +export function parseSearch(params: URLSearchParams): Search { + const allowed = new Set(["longitude", "latitude", "radiusMeters", "limit", "category"]); + const seen = new Set(); + for (const key of params.keys()) { + if (!allowed.has(key) || seen.has(key)) throw new InputError("Unknown or repeated parameter"); + seen.add(key); + } + const number = (key: string, minimum: number, maximum: number): number => { + const raw = params.get(key); + if (raw === null || !decimal.test(raw)) throw new InputError(`Invalid ${key}`); + const value = Number(raw); + if (!Number.isFinite(value) || value < minimum || value > maximum) { + throw new InputError(`${key} must be between ${minimum} and ${maximum}`); + } + return value; + }; + const longitude = number("longitude", -180, 180); + const latitude = number("latitude", -90, 90); + const radiusMeters = number("radiusMeters", 0, 50_000); + const rawLimit = params.get("limit") ?? "20"; + const limit = Number(rawLimit); + if (!/^\d+$/.test(rawLimit) || !Number.isInteger(limit) || limit < 1 || limit > 50) { + throw new InputError("limit must be an integer between 1 and 50"); + } + const category = params.get("category"); + if (category !== null && !CATEGORIES.includes(category as Category)) { + throw new InputError("category must be cafe, library or park"); + } + return { + longitude, + latitude, + radiusMeters, + limit, + ...(category ? { category: category as Category } : {}), + }; +} diff --git a/applications/nearby-places/src/places.ts b/applications/nearby-places/src/places.ts new file mode 100644 index 00000000..6e85dd5e --- /dev/null +++ b/applications/nearby-places/src/places.ts @@ -0,0 +1,29 @@ +import type { Database } from "./database.ts"; +import type { Search } from "./input.ts"; +export type Place = { + id: number; + name: string; + category: string; + longitude: number; + latitude: number; + distanceMeters: number; +}; +export function nearbyQuery(sql: Database, search: Search) { + return sql` + WITH origin AS ( + SELECT public.ST_SetSRID( + public.ST_MakePoint(${search.longitude}::double precision, ${search.latitude}::double precision), + 4326 + )::public.geography AS location + ) + SELECT p.id, p.name, p.category, + public.ST_X(p.location::public.geometry) AS longitude, + public.ST_Y(p.location::public.geometry) AS latitude, + public.ST_Distance(p.location, o.location, true) AS "distanceMeters" + FROM nearby_places.places p CROSS JOIN origin o + WHERE public.ST_DWithin(p.location, o.location, ${search.radiusMeters}::double precision, true) + ${search.category ? sql`AND p.category = ${search.category}` : sql``} + ORDER BY "distanceMeters", p.id + LIMIT ${search.limit} + `; +} diff --git a/applications/nearby-places/src/server.ts b/applications/nearby-places/src/server.ts new file mode 100644 index 00000000..3ba2ebb7 --- /dev/null +++ b/applications/nearby-places/src/server.ts @@ -0,0 +1,35 @@ +import { createApp } from "./app.ts"; +import { database } from "./database.ts"; +if (Deno.env.get("PGUSER") !== "places_reader") throw new Error("Server requires places_reader"); +const port = Number(Deno.env.get("PORT") ?? "4000"); +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid PORT"); +const sql = database(); +// Explicit files only: request paths are never translated to filesystem paths. +const assets = { + "/": { body: await Deno.readTextFile("public/index.html"), type: "text/html" }, + "/app.js": { body: await Deno.readTextFile("public/app.js"), type: "application/javascript" }, + "/style.css": { body: await Deno.readTextFile("public/style.css"), type: "text/css" }, +}; +await sql`SELECT 1`; +const controller = new AbortController(); +const stop = () => controller.abort(); +const signals = ["SIGINT", "SIGTERM"] as const; +for (const signal of signals) Deno.addSignalListener(signal, stop); +const app = createApp(sql, assets); +// Oak's standard handle() API keeps middleware native while Deno owns HTTP shutdown. +const server = Deno.serve( + { + hostname: "127.0.0.1", + port, + signal: controller.signal, + onListen: () => console.log("Nearby Places listening on loopback"), + }, + async (request, info) => + await app.handle(request, info.remoteAddr) ?? new Response(null, { status: 404 }), +); +try { + await server.finished; +} finally { + for (const signal of signals) Deno.removeSignalListener(signal, stop); + await sql.end({ timeout: 5 }); +} diff --git a/applications/nearby-places/test/browser.mjs b/applications/nearby-places/test/browser.mjs new file mode 100644 index 00000000..6aff7c25 --- /dev/null +++ b/applications/nearby-places/test/browser.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import process from "node:process"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdir } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import { chromium } from "playwright"; +const source = process.env.APP_DIR; +const evidence = process.env.EVIDENCE_DIR; +assert(source && evidence, "APP_DIR and EVIDENCE_DIR are required"); +await mkdir(evidence, { recursive: true }); +const env = {}; +for ( + const key of [ + "HOME", + "PATH", + "PGHOST", + "PGPORT", + "PGDATABASE", + "PGUSER", + "PGPASSWORD", + "PGSSLROOTCERT", + ] +) env[key] = process.env[key]; +env.PORT = "4082"; +let server; +let serverOutput = ""; +async function start() { + serverOutput = ""; + server = spawn("bash", ["scripts/run.sh"], { + cwd: source, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + server.stdout.on("data", (data) => { + serverOutput += data.toString(); + }); + server.stderr.on("data", (data) => { + serverOutput += data.toString(); + }); + for (let attempt = 0; attempt < 100; attempt++) { + if (server.exitCode !== null) throw new Error(`Server exited: ${serverOutput}`); + try { + if ((await fetch("http://127.0.0.1:4082/health")).status === 200) return; + } catch { /* Startup is not yet ready. */ } + await delay(100); + } + throw new Error("Server did not become ready"); +} +async function stop() { + if (!server || server.exitCode !== null || server.signalCode !== null) return; + const exited = once(server, "exit"); + server.kill("SIGTERM"); + const timeout = setTimeout(() => server.kill("SIGKILL"), 10_000); + const [code, signal] = await exited; + clearTimeout(timeout); + assert.notEqual(signal, "SIGKILL", "Graceful stop timed out"); + assert.equal(code, 0, serverOutput); +} +let browser; +let checks = 0; +async function check(name, fn) { + await fn(); + checks++; + console.log(`PASS ${checks}: ${name}`); +} +const url = "http://127.0.0.1:4082"; +try { + await start(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await check("initial synthetic directory is ordered by exact distance and ID", async () => { + await page.waitForFunction(() => + document.querySelector("#status").textContent.startsWith("5 sample places") + ); + assert.deepEqual(await page.locator("#results h3").allTextContents(), [ + "Anchor Cafe", + "Courtyard Cafe", + "Garden Library", + "Quay Park", + "Further Cafe", + ]); + assert((await page.locator("body").innerText()).includes("Synthetic directory")); + await page.screenshot({ path: `${evidence}/desktop.png`, fullPage: true }); + }); + await check("category and limit controls filter the real API results", async () => { + await page.getByLabel("Category", { exact: true }).selectOption("cafe"); + await page.getByRole("button", { name: "Find nearby places" }).click(); + await page.waitForFunction(() => + document.querySelector("#status").textContent.startsWith("3 sample places") + ); + assert.equal(await page.locator("#results h3").count(), 3); + await page.getByLabel("Maximum results").fill("1"); + await page.getByRole("button", { name: "Find nearby places" }).click(); + await page.waitForFunction(() => + document.querySelector("#status").textContent.startsWith("1 sample place") + ); + assert.equal(await page.locator("#results h3").first().innerText(), "Anchor Cafe"); + }); + await check("dateline preset returns the opposite side within 300 meters", async () => { + await page.getByLabel("Maximum results").fill("20"); + await page.getByRole("button", { name: "Across dateline" }).click(); + await page.waitForFunction(() => + document.querySelector("#status").textContent.startsWith("2 sample places") + ); + assert.deepEqual(await page.locator("#results h3").allTextContents(), [ + "Dateline East Cafe", + "Dateline West Park", + ]); + assert.equal(await page.locator(".distance strong").nth(1).innerText(), "222.6 m"); + }); + await check("empty result and mobile layout remain usable", async () => { + await page.getByLabel("Category", { exact: true }).selectOption("library"); + await page.getByRole("button", { name: "Find nearby places" }).click(); + await page.waitForFunction(() => + document.querySelector("#status").textContent.startsWith("No sample places") + ); + assert.equal(await page.locator("#results h3").count(), 0); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "London sample" }).click(); + await page.waitForFunction(() => + document.querySelector("#status").textContent.startsWith("5 sample places") + ); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({ path: `${evidence}/mobile.png`, fullPage: true }); + }); + const canonical = `${url}/api/nearby?longitude=0&latitude=0&radiusMeters=1200`; + let before; + await check( + "native HTTP rejects malformed and SQL-shaped values and exposes no mutation route", + async () => { + for ( + const suffix of [ + "&category=cafe%27%20OR%20true--", + "&longitude=1", + "&limit=51", + "&unexpected=1", + ] + ) assert.equal((await fetch(canonical + suffix)).status, 400); + for ( + const longitude of ["NaN", "Infinity", "181", "0%3BDROP%20TABLE%20nearby_places.places"] + ) { + assert.equal( + (await fetch(`${url}/api/nearby?longitude=${longitude}&latitude=0&radiusMeters=10`)) + .status, + 400, + ); + } + assert.equal((await fetch(`${url}/api/nearby`, { method: "POST" })).status, 405); + assert.equal((await fetch(`${url}/api/nearby?x=${"a".repeat(2100)}`)).status, 414); + const response = await fetch(canonical); + assert.equal(response.status, 200); + before = await response.json(); + assert.equal(before.places.length, 3); + }, + ); + await check( + "confirmed process exit and restart retain the same seeded Cloud results", + async () => { + const oldPid = server.pid; + await stop(); + await start(); + assert.notEqual(server.pid, oldPid); + assert.deepEqual(await (await fetch(canonical)).json(), before); + await page.reload(); + await page.waitForFunction(() => + document.querySelector("#status").textContent.startsWith("5 sample places") + ); + assert.deepEqual(errors, []); + }, + ); + console.log(`Browser/HTTP acceptance: ${checks} checks passed; no browser errors`); +} finally { + if (browser) await browser.close(); + await stop(); +} diff --git a/applications/nearby-places/test/cloud_test.ts b/applications/nearby-places/test/cloud_test.ts new file mode 100644 index 00000000..aae4e073 --- /dev/null +++ b/applications/nearby-places/test/cloud_test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import postgres from "postgres"; +import { database, databaseOptions } from "../src/database.ts"; +import { nearbyQuery } from "../src/places.ts"; +import type { Search } from "../src/input.ts"; +const search = ( + longitude = 0, + latitude = 0, + radiusMeters = 1200, + extra: Partial = {}, +): Search => ({ longitude, latitude, radiusMeters, limit: 20, ...extra }); +async function withDatabase(fn: (sql: ReturnType) => Promise) { + const sql = database(); + try { + await fn(sql); + } finally { + await sql.end({ timeout: 5 }); + } +} +Deno.test("geography meters, zero distance and independently known equatorial distances", async () => { + await withDatabase(async (sql) => { + const places = await nearbyQuery(sql, search()); + assert.deepEqual(places.map((p) => p.id), [6, 8, 7]); + assert.equal(places[0].distanceMeters, 0); + assert(Math.abs(places[2].distanceMeters - 1113.194908) < 0.1); + assert(Math.abs(places[1].distanceMeters - 1105.742758) < 0.1); + console.log( + "Equatorial meters:", + places.map(({ id, distanceMeters }) => ({ id, distanceMeters })), + ); + const zero = await nearbyQuery(sql, search(0, 0, 0)); + assert.deepEqual(zero.map((p) => p.id), [6]); + }); +}); +Deno.test("radius inclusion/exclusion and category filtering use exact geography predicates", async () => { + await withDatabase(async (sql) => { + assert.deepEqual((await nearbyQuery(sql, search(0, 0, 1110))).map((p) => p.id), [6, 8]); + assert.deepEqual((await nearbyQuery(sql, search(0, 0, 1114))).map((p) => p.id), [6, 8, 7]); + assert.deepEqual( + (await nearbyQuery(sql, search(0, 0, 1200, { category: "cafe" }))).map((p) => p.id), + [7], + ); + assert.equal((await nearbyQuery(sql, search(0, 0, 1, { category: "park" }))).length, 0); + }); +}); +Deno.test("longitude precedes latitude and equal distances use stable ID ordering/limit", async () => { + await withDatabase(async (sql) => { + const correct = await nearbyQuery(sql, search(12, 45, 0)); + assert.equal(correct[0].id, 11); + assert.equal(correct[0].longitude, 12); + assert.equal(correct[0].latitude, 45); + assert.equal((await nearbyQuery(sql, search(45, 12, 1000))).length, 0); + const ties = await nearbyQuery(sql, search(-0.12, 51.5, 0)); + assert.deepEqual(ties.map((p) => p.id), [1, 4]); + assert.deepEqual( + (await nearbyQuery(sql, search(-0.12, 51.5, 0, { limit: 1 }))).map((p) => p.id), + [1], + ); + const again = await nearbyQuery(sql, search(-0.12, 51.5, 0)); + assert.deepEqual(again, ties); + }); +}); +Deno.test("antimeridian proximity and polar coordinates remain geographic", async () => { + await withDatabase(async (sql) => { + const crossing = await nearbyQuery(sql, search(179.999, 0, 300)); + assert.deepEqual(crossing.map((p) => p.id), [9, 10]); + assert(Math.abs(crossing[1].distanceMeters - 222.638982) < 0.1); + assert.deepEqual((await nearbyQuery(sql, search(179.999, 0, 200))).map((p) => p.id), [9]); + assert.equal((await nearbyQuery(sql, search(0, 90, 200)))[0].id, 12); + console.log("Antimeridian distanceMeters:", crossing[1].distanceMeters); + }); +}); +Deno.test("runtime role stays read-only even if its session read-only default is disabled", async () => { + await withDatabase(async (sql) => { + assert.equal((await sql`SELECT current_user AS role`)[0].role, "places_reader"); + assert.equal( + (await sql`SHOW default_transaction_read_only`)[0].default_transaction_read_only, + "on", + ); + await sql`SET default_transaction_read_only = off`; + for ( + const statement of [ + "INSERT INTO nearby_places.places SELECT * FROM nearby_places.places WHERE false", + "UPDATE nearby_places.places SET name='changed' WHERE false", + "DELETE FROM nearby_places.places WHERE false", + "CREATE TABLE nearby_places.forbidden(id int)", + "CREATE SCHEMA runtime_forbidden", + "CREATE TEMP TABLE runtime_forbidden(id int)", + "UPDATE nearby_places.schema_migrations SET version=version WHERE false", + ] + ) { + await assert.rejects( + sql.unsafe(statement), + (e: unknown) => (e as { code?: string }).code === "42501", + ); + } + assert.equal( + (await sql`SELECT count(*)::integer AS count FROM nearby_places.places`)[0].count, + 12, + ); + }); +}); +Deno.test("Cloud CA and hostname controls reject wrong trust and wrong hostname", async () => { + const options = databaseOptions(); + const dir = await Deno.makeTempDir(); + try { + const output = await new Deno.Command("openssl", { + args: [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + `${dir}/key.pem`, + "-out", + `${dir}/ca.pem`, + "-days", + "1", + "-subj", + "/CN=unrelated-test-ca", + ], + stdout: "null", + stderr: "null", + }).output(); + assert(output.success); + for ( + const [ssl, expected] of [ + [ + { ...options.ssl, ca: await Deno.readTextFile(`${dir}/ca.pem`) }, + /CERT|issuer|certificate|UnknownIssuer|CaUsedAsEndEntity/i, + ], + [ + { ...options.ssl, servername: "wrong-hostname.example.test" }, + /ALTNAME|hostname|certificate|NotValidForName/i, + ], + ] as const + ) { + const bad = postgres({ ...options, ssl, max: 1 }); + try { + await assert.rejects(bad`SELECT 1`, (e: unknown) => { + const error = e as { code?: string; message: string }; + console.log("Negative TLS control", error.code ?? "no-code", error.message.split(":")[0]); + return expected.test(`${error.code} ${error.message}`); + }); + } finally { + await bad.end({ timeout: 1 }); + } + } + } finally { + await Deno.remove(dir, { recursive: true }); + } + await withDatabase(async (sql) => + assert.equal((await sql`SELECT 1 AS positive_control`)[0].positive_control, 1) + ); +}); +Deno.test("real EXPLAIN uses the exact application query without forcing an index", async () => { + await withDatabase(async (sql) => { + const query = nearbyQuery(sql, search(-0.12, 51.5, 1000)); + const rows = await sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`; + const plan = rows[0]["QUERY PLAN"]; + const text = JSON.stringify(plan, null, 2); + assert(/st_dwithin/i.test(text)); + const directory = Deno.env.get("EVIDENCE_DIR"); + if (directory) await Deno.writeTextFile(`${directory}/explain.json`, text + "\n"); + console.log("Actual plan:", text); + }); +}); diff --git a/applications/nearby-places/test/domain_test.ts b/applications/nearby-places/test/domain_test.ts new file mode 100644 index 00000000..a91c9e59 --- /dev/null +++ b/applications/nearby-places/test/domain_test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { InputError, parseSearch } from "../src/input.ts"; +const params = (extra = "") => + new URLSearchParams(`longitude=0&latitude=0&radiusMeters=1200${extra}`); +Deno.test("finite coordinate bounds, meters and bounded default limit", () => { + assert.deepEqual(parseSearch(params()), { + longitude: 0, + latitude: 0, + radiusMeters: 1200, + limit: 20, + }); + assert.equal( + parseSearch(new URLSearchParams("longitude=-180&latitude=90&radiusMeters=0&limit=50")) + .longitude, + -180, + ); +}); +Deno.test("nonfinite, out-of-range, malformed, duplicate and extra parameters fail", () => { + for ( + const query of [ + "longitude=NaN&latitude=0&radiusMeters=10", + "longitude=1e999&latitude=0&radiusMeters=10", + "longitude=181&latitude=0&radiusMeters=10", + "longitude=0&latitude=-91&radiusMeters=10", + "longitude=0&latitude=0&radiusMeters=-1", + "longitude=0&latitude=0&radiusMeters=50001", + "longitude=&latitude=0&radiusMeters=10", + "longitude=0x10&latitude=0&radiusMeters=10", + ] + ) assert.throws(() => parseSearch(new URLSearchParams(query)), InputError); + for ( + const extra of [ + "&limit=0", + "&limit=51", + "&limit=2.5", + "&category=unknown", + "&category=cafe%27%20OR%20true--", + "&longitude=1", + "&unexpected=1", + ] + ) assert.throws(() => parseSearch(params(extra)), InputError); +}); +Deno.test("valid optional category and explicit integer limit retain input order", () => { + assert.deepEqual( + parseSearch( + new URLSearchParams("longitude=12&latitude=45&radiusMeters=100&category=library&limit=2"), + ), + { longitude: 12, latitude: 45, radiusMeters: 100, category: "library", limit: 2 }, + ); +});