diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8f7ac25d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + timezone: "Asia/Seoul" + open-pull-requests-limit: 10 + groups: + runtime-security: + patterns: + - "*" + dependency-type: "production" + update-types: + - "minor" + - "patch" + development-tooling: + patterns: + - "*" + dependency-type: "development" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + timezone: "Asia/Seoul" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5244e469..8b8aed73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,27 +12,35 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: - version: 9.0.0 + version: 11.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.3.1" - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Audit production dependencies + run: pnpm security:audit + + - name: Materialize verified region data + env: + REGION_DATA_BASE_URL: ${{ vars.REGION_DATA_BASE_URL }} + run: node scripts/sync-region-data.mjs --groups region-dist,region-db --prune + - name: Check formatting run: pnpm format:check @@ -69,6 +77,9 @@ jobs: - name: Test web client run: pnpm --filter web test + - name: Enforce coverage policy + run: pnpm coverage + - name: Build run: pnpm build diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index c3184354..b96cc0e4 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -8,6 +8,7 @@ on: - "apps/api-ground-codes/**" - "packages/ground-codes/**" - "packages/geoint/region-dist/**" + - "packages/geoint/region-data-release.json" - "packages/codebook/codebook-dist/**" - ".github/workflows/deploy-api.yml" - "package.json" @@ -28,32 +29,38 @@ concurrency: jobs: deploy: runs-on: ubuntu-latest + environment: production steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: - version: 9.0.0 + version: 11.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.3.1" - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Materialize verified region data + env: + REGION_DATA_BASE_URL: ${{ vars.REGION_DATA_BASE_URL }} + run: node scripts/sync-region-data.mjs --groups region-dist --prune + - name: Type check API run: pnpm --filter api-ground-codes check-types @@ -63,9 +70,6 @@ jobs: - name: Build ground-codes package run: pnpm --filter ground-codes build - - name: Install Wrangler - run: pnpm install -g wrangler - - name: Apply PostGIS schema env: SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} @@ -75,6 +79,8 @@ jobs: - name: Detect changed region datasets id: regions + env: + REGION_DATA_BASE_URL: ${{ vars.REGION_DATA_BASE_URL }} run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.import_all_regions }}" = "true" ]; then echo "datasets=__all_missing__" >> "$GITHUB_OUTPUT" @@ -104,7 +110,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | - wrangler deploy \ + pnpm exec wrangler deploy \ --config apps/api-ground-codes/wrangler.toml \ --keep-vars \ --var API_RUNTIME_TAG:workspace \ @@ -113,4 +119,6 @@ jobs: --message "Deploy API Worker from ${GITHUB_SHA}" - name: Run production smoke + env: + GROUND_CODES_EXPECTED_RUNTIME_COMMIT: ${{ github.sha }} run: pnpm production:smoke diff --git a/.github/workflows/deploy-grok-spiral.yml b/.github/workflows/deploy-grok-spiral.yml index f94f3e2a..6dc1144a 100644 --- a/.github/workflows/deploy-grok-spiral.yml +++ b/.github/workflows/deploy-grok-spiral.yml @@ -13,24 +13,29 @@ on: - "pnpm-workspace.yaml" workflow_dispatch: +concurrency: + group: deploy-grok-spiral-production + cancel-in-progress: true + jobs: deploy: runs-on: ubuntu-latest + environment: production steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: - version: 9.0.0 + version: 11.4.0 - name: Get pnpm store directory id: pnpm-cache @@ -39,7 +44,7 @@ jobs: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - name: Setup pnpm cache - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -47,18 +52,13 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build Next.js app run: pnpm --filter grok-spiral build - - name: Install Wrangler - run: pnpm install -g wrangler - - name: Deploy to Cloudflare Pages env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: | - cd apps/grok-spiral - pnpm run pages:build && wrangler pages deploy + run: pnpm --filter grok-spiral deploy diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 762ccf04..e77be4a9 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -13,24 +13,29 @@ on: - "pnpm-workspace.yaml" workflow_dispatch: +concurrency: + group: deploy-web-production + cancel-in-progress: true + jobs: deploy: runs-on: ubuntu-latest + environment: production steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: - version: 9.0.0 + version: 11.4.0 - name: Get pnpm store directory id: pnpm-cache @@ -39,7 +44,7 @@ jobs: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - name: Setup pnpm cache - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -47,14 +52,11 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build Next.js app run: pnpm --filter web build - - name: Install Wrangler - run: pnpm install -g wrangler - - name: Deploy to Cloudflare Pages env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -67,6 +69,4 @@ jobs: NEXT_PUBLIC_CESIUM_MARS_ASSET_ID: ${{ secrets.NEXT_PUBLIC_CESIUM_MARS_ASSET_ID }} GOOGLE_MAPS_NODEJS_API_KEY: ${{ secrets.GOOGLE_MAPS_NODEJS_API_KEY }} OPENWEATHER_API_KEY: ${{ secrets.OPENWEATHER_API_KEY }} - run: | - cd apps/web - pnpm run pages:build && wrangler pages deploy + run: pnpm --filter web deploy diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml index fd4a1d24..8cf80493 100644 --- a/.github/workflows/production-smoke.yml +++ b/.github/workflows/production-smoke.yml @@ -3,6 +3,14 @@ name: Production Smoke on: workflow_dispatch: inputs: + profile: + description: "Smoke profile to run" + required: true + type: choice + options: + - full + - quick + default: full force_failure: description: "Force a smoke failure to test failure notifications" required: false @@ -10,6 +18,7 @@ on: default: false schedule: - cron: "*/30 * * * *" + - cron: "17 3 * * *" workflow_run: workflows: - Deploy Web to Cloudflare Pages @@ -20,6 +29,13 @@ permissions: contents: read issues: write +concurrency: + group: >- + ${{ github.event_name == 'schedule' + && 'production-smoke-scheduled' + || format('production-smoke-{0}-{1}', github.event_name, github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'schedule' }} + jobs: smoke: if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' @@ -27,40 +43,54 @@ jobs: env: MOSHI_WEBHOOK_TOKEN: ${{ secrets.MOSHI_WEBHOOK_TOKEN }} GROUND_CODES_SMOKE_FORCE_FAILURE: ${{ github.event.inputs.force_failure || 'false' }} + GROUND_CODES_SMOKE_PROFILE: >- + ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.profile + || github.event_name == 'schedule' && github.event.schedule == '*/30 * * * *' && 'quick' + || 'full' }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 1 + sparse-checkout-cone-mode: false + sparse-checkout: scripts/production-smoke*.mjs - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" package-manager-cache: false - name: Run production smoke + id: smoke + continue-on-error: true run: node scripts/production-smoke.mjs - name: Notify smoke failure - if: failure() && env.MOSHI_WEBHOOK_TOKEN != '' - run: | - curl -X POST https://api.getmoshi.app/api/webhook \ - -H "Content-Type: application/json" \ - -d "{\"token\":\"${MOSHI_WEBHOOK_TOKEN}\",\"title\":\"ground.codes smoke failed\",\"message\":\"Production smoke failed on ${GITHUB_REF_NAME}. Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}\"}" + id: moshi + if: steps.smoke.outcome == 'failure' && env.MOSHI_WEBHOOK_TOKEN != '' + continue-on-error: true + timeout-minutes: 1 + run: node scripts/production-smoke-notify.mjs - name: Open smoke failure issue - if: failure() && env.MOSHI_WEBHOOK_TOKEN == '' - uses: actions/github-script@v8 + if: always() && steps.smoke.outcome == 'failure' && (env.MOSHI_WEBHOOK_TOKEN == '' || steps.moshi.outcome == 'failure') + continue-on-error: true + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const title = "Production smoke failed"; const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; + const fallbackReason = process.env.MOSHI_WEBHOOK_TOKEN + ? "Moshi notification delivery failed, so this issue is the fallback notification." + : "MOSHI_WEBHOOK_TOKEN is not configured, so this issue is the fallback notification."; const message = [ `Production smoke failed on ${process.env.GITHUB_REF_NAME}.`, "", `Run: ${runUrl}`, "", - "MOSHI_WEBHOOK_TOKEN is not configured, so this issue is the fallback notification.", + fallbackReason, ].join("\n"); const { owner, repo } = context.repo; const { data: issues } = await github.rest.issues.listForRepo({ @@ -85,3 +115,7 @@ jobs: body: message, }); } + + - name: Fail failed smoke run + if: always() && steps.smoke.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/visual-qa.yml b/.github/workflows/visual-qa.yml index 33bbe11d..c875da77 100644 --- a/.github/workflows/visual-qa.yml +++ b/.github/workflows/visual-qa.yml @@ -9,27 +9,32 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: - version: 9.0.0 + version: 11.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: "1.3.1" - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Materialize verified region data + env: + REGION_DATA_BASE_URL: ${{ vars.REGION_DATA_BASE_URL }} + run: node scripts/sync-region-data.mjs --groups region-dist,region-db --prune + - name: Install Playwright browser run: pnpm --filter web exec playwright install --with-deps chromium @@ -41,7 +46,7 @@ jobs: - name: Upload screenshots if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: visual-qa-screenshots path: apps/web/test-results diff --git a/.gitignore b/.gitignore index b65e2e59..bb74f691 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ node_modules coverage apps/web/test-results/ dogfood-output/ +.region-data-staging/ # Turbo .turbo diff --git a/.nvmrc b/.nvmrc index 209e3ef4..2bd5a0a9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +22 diff --git a/Dockerfile b/Dockerfile index 2365bfab..e0fd9d6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl unzip \ && rm -rf /var/lib/apt/lists/* \ && corepack enable \ - && corepack prepare pnpm@9.0.0 --activate \ + && corepack prepare pnpm@11.4.0 --activate \ && curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.14 WORKDIR /repo diff --git a/README.md b/README.md index a215f75c..7ca4e058 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,31 @@ API usage is limited to 600 requests per minute per IP. For higher volume needs, Ground Codes uses a custom GIS algorithm called "Grok Spiral" 🌀 that determines coordinates by moving in a clockwise spiral from a central point. This implementation leverages the "Gauss Circle Problem" formula to achieve O(sqrt N) efficiency in coordinate generation. The spiral pattern maintains a circular shape regardless of distance from the center point, resulting in excellent coordinate indexing efficiency. +## 🛟 Operations + +- [Production service objectives](./docs/operations/service-objectives.md) +- [Production incident runbook](./docs/operations/incident-runbook.md) + +## 🧰 Local Development + +Region data is published as a verified immutable release. Materialize the +active release explicitly before running tests or builds that consume +`packages/geoint/region-dist` or `packages/geoint/region-db`: + +```bash +git clone https://github.com/hmmhmmhm/ground.codes.git +cd ground.codes +pnpm install --frozen-lockfile +REGION_DATA_BASE_URL=https://region-data.ground.codes pnpm region-data:sync +pnpm scripts:test +pnpm build +``` + +The synchronizer verifies the committed release pointer, manifest, size, and +SHA-256 of every downloaded file. Dataset generators never download inputs +implicitly; run them only in a working tree where both managed data groups +have already been materialized explicitly. + ## 📄 License MIT License diff --git a/apps/api-ground-codes/Dockerfile b/apps/api-ground-codes/Dockerfile index 94ff0e97..4c7bda12 100644 --- a/apps/api-ground-codes/Dockerfile +++ b/apps/api-ground-codes/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git unzip \ && rm -rf /var/lib/apt/lists/* \ && corepack enable \ - && corepack prepare pnpm@9.0.0 --activate \ + && corepack prepare pnpm@11.4.0 --activate \ && curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.14 WORKDIR /repo diff --git a/apps/api-ground-codes/package.json b/apps/api-ground-codes/package.json index 35df4a85..6c3ccd0f 100644 --- a/apps/api-ground-codes/package.json +++ b/apps/api-ground-codes/package.json @@ -1,7 +1,7 @@ { "name": "api-ground-codes", "version": "1.0.79", - "packageManager": "pnpm@9.0.0", + "packageManager": "pnpm@11.4.0", "private": true, "scripts": { "dev": "bun run --watch src/index.ts", @@ -10,22 +10,23 @@ "check-types": "tsc --noEmit", "data:apply-postgis-schema": "node scripts/apply-postgis-schema.mjs", "data:import-postgis": "node scripts/import-postgis-regions.mjs", - "test": "bun test src/*.test.ts" + "test": "bun test src/*.test.ts", + "test:coverage": "cd ../.. && bun test --coverage --coverage-reporter=lcov --coverage-reporter=text --coverage-dir=coverage/api apps/api-ground-codes/src/*.test.ts && pnpm exec c8 --all --exclude-after-remap --include 'apps/api-ground-codes/src/**/*.ts' --exclude 'apps/api-ground-codes/src/**/*.test.ts' --exclude 'apps/api-ground-codes/src/**/*.d.ts' --exclude '**/' --reporter=lcov --reporter=text --reports-dir coverage/api-branches pnpm --filter ground-codes exec tsx --test ../../scripts/coverage-branch-probes/api.test.ts && node scripts/merge-branch-coverage.mjs api" }, "dependencies": { - "@elysiajs/cors": "^1.2.0", - "@elysiajs/static": "^1.2.0", - "@elysiajs/swagger": "^1.2.2", + "@elysiajs/cors": "1.4.2", + "@elysiajs/static": "1.4.10", + "@elysiajs/swagger": "1.3.1", "@ground-codes/geoint": "workspace:*", "@repo/codebook": "workspace:*", "@sinclair/typebox": "^0.34.30", - "elysia": "latest", + "elysia": "1.4.29", "ground-codes": "workspace:*", "pg": "^8.21.0" }, "devDependencies": { "@types/pg": "^8.20.0", - "bun-types": "latest" + "bun-types": "1.3.1" }, "module": "src/index.js" } diff --git a/apps/api-ground-codes/scripts/list-changed-region-datasets.mjs b/apps/api-ground-codes/scripts/list-changed-region-datasets.mjs index bd17ae34..b50a7b50 100644 --- a/apps/api-ground-codes/scripts/list-changed-region-datasets.mjs +++ b/apps/api-ground-codes/scripts/list-changed-region-datasets.mjs @@ -1,41 +1,288 @@ +#!/usr/bin/env node + import { execFileSync } from "node:child_process"; -import { readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; -const [baseRef, headRef = "HEAD"] = process.argv.slice(2); -const regionDistDir = "packages/geoint/region-dist"; +import { + canonicalJson, + sha256Hex, + validateManifest, +} from "../../../scripts/region-data/manifest.mjs"; +import { + readResponseBytes, + requestWithRetry, +} from "../../../scripts/region-data/sync-http.mjs"; + +const pointerPath = "packages/geoint/region-data-release.json"; +const referencePattern = /^(?:HEAD|[a-f0-9]{40,64})$/i; +const hashPattern = /^[a-f0-9]{64}$/; +const versionPattern = /^sha256-[a-f0-9]{64}$/; + +const validateReference = (reference, label) => { + if (typeof reference !== "string" || !referencePattern.test(reference)) { + throw new TypeError(`${label} must be HEAD or a full Git object ID`); + } + return reference; +}; -const listAllDatasets = () => - readdirSync(resolve(process.cwd(), regionDistDir)) - .filter((fileName) => fileName.endsWith(".json")) - .map((fileName) => fileName.replace(/\.json$/, "")) +const parsePointer = (bytes, label) => { + let pointer; + try { + pointer = JSON.parse(bytes.toString("utf8")); + } catch { + throw new TypeError(`${label} release pointer is not valid JSON`); + } + if ( + pointer === null || + typeof pointer !== "object" || + Array.isArray(pointer) || + Object.getPrototypeOf(pointer) !== Object.prototype || + Object.keys(pointer).sort().join(",") !== + "manifestSha256,schemaVersion,version" || + pointer.schemaVersion !== 1 || + typeof pointer.manifestSha256 !== "string" || + !hashPattern.test(pointer.manifestSha256) || + typeof pointer.version !== "string" || + !versionPattern.test(pointer.version) || + !bytes.equals(Buffer.from(canonicalJson(pointer))) + ) { + throw new TypeError(`${label} release pointer is malformed`); + } + return pointer; +}; + +const referenceExists = (reference) => { + try { + execFileSync( + "git", + ["rev-parse", "--verify", "--quiet", `${reference}^{commit}`], + { + stdio: "ignore", + }, + ); + return true; + } catch { + return false; + } +}; + +const pointerAtReference = (reference, label) => { + validateReference(reference, label); + if (!referenceExists(reference)) { + throw new TypeError(`${label} Git reference is unavailable`); + } + let pointerRecord; + try { + pointerRecord = execFileSync( + "git", + ["ls-tree", "-z", reference, "--", pointerPath], + { + encoding: "utf8", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch (error) { + throw new TypeError(`${label} release pointer could not be inspected`, { + cause: error, + }); + } + if (pointerRecord.length === 0) return null; + if ( + !/^100(?:644|755) blob [a-f0-9]+\tpackages\/geoint\/region-data-release\.json\0$/.test( + pointerRecord, + ) + ) { + throw new TypeError(`${label} release pointer is not a regular Git blob`); + } + try { + return parsePointer( + execFileSync("git", ["show", `${reference}:${pointerPath}`], { + encoding: "buffer", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }), + label, + ); + } catch (error) { + if (error instanceof TypeError) throw error; + throw new TypeError(`${label} release pointer could not be read`, { + cause: error, + }); + } +}; + +const regionDataBaseUrl = (value) => { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError("REGION_DATA_BASE_URL is required"); + } + const url = new URL(value.endsWith("/") ? value : `${value}/`); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new TypeError("REGION_DATA_BASE_URL must be a plain HTTP(S) origin"); + } + return url; +}; + +const fetchReleaseManifest = async ({ pointer, baseUrl, label }) => { + try { + const bytes = await requestWithRetry( + new URL(`releases/${pointer.version}/manifest.json`, baseUrl), + (response) => + readResponseBytes(response, `${label} release manifest`, { + maximumBytes: 4 * 1024 * 1024, + }), + ); + if (sha256Hex(bytes) !== pointer.manifestSha256) { + throw new TypeError("hash does not match its release pointer"); + } + let manifest; + try { + manifest = JSON.parse(bytes.toString("utf8")); + } catch { + throw new TypeError("is not valid JSON"); + } + validateManifest(manifest); + if (manifest.version !== pointer.version) { + throw new TypeError("version does not match its release pointer"); + } + if (!bytes.equals(Buffer.from(canonicalJson(manifest)))) { + throw new TypeError("is not canonical JSON"); + } + return manifest; + } catch (error) { + throw new TypeError( + `${label} release manifest is unavailable or invalid: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } +}; + +const regionDistEntries = (manifest) => { + const datasets = new Map(); + for (const entry of manifest.entries) { + if (entry.group !== "region-dist") continue; + const match = entry.path.match( + /^packages\/geoint\/region-dist\/([^/]+)\.json$/, + ); + if (!match) { + throw new TypeError( + `unsupported region-dist manifest entry: ${entry.path}`, + ); + } + datasets.set(match[1], entry.sha256); + } + return datasets; +}; + +const changedManifestDatasets = (previous, current) => { + const oldEntries = regionDistEntries(previous); + const newEntries = regionDistEntries(current); + return [...new Set([...oldEntries.keys(), ...newEntries.keys()])] + .filter((name) => oldEntries.get(name) !== newEntries.get(name)) .sort(); +}; + +const changedGitDatasets = (baseRef, headRef) => { + const regionDistDir = "packages/geoint/region-dist"; + const output = execFileSync( + "git", + ["diff", "--name-only", baseRef, headRef, "--", `${regionDistDir}/`], + { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return [ + ...new Set( + output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.endsWith(".json")) + .map((line) => + line.slice(`${regionDistDir}/`.length).replace(/\.json$/, ""), + ), + ), + ].sort(); +}; + +const samePointer = (left, right) => + left.version === right.version && + left.manifestSha256 === right.manifestSha256; + +const detectChangedDatasets = async ({ baseRef, headRef, baseUrl }) => { + const head = validateReference(headRef ?? "HEAD", "head"); + const currentPointer = pointerAtReference(head, "current"); + if (!currentPointer) { + throw new TypeError("current release pointer is unavailable"); + } + + if (!baseRef || /^0+$/.test(baseRef)) { + const current = await fetchReleaseManifest({ + pointer: currentPointer, + baseUrl: regionDataBaseUrl(baseUrl), + label: "current", + }); + return [...regionDistEntries(current).keys()].sort(); + } + + const base = validateReference(baseRef, "base"); + const previousPointer = pointerAtReference(base, "previous"); + if (!previousPointer) { + return changedGitDatasets(base, head); + } + if (samePointer(previousPointer, currentPointer)) return []; + + const origin = regionDataBaseUrl(baseUrl); + const [previous, current] = await Promise.all([ + fetchReleaseManifest({ + pointer: previousPointer, + baseUrl: origin, + label: "previous", + }), + fetchReleaseManifest({ + pointer: currentPointer, + baseUrl: origin, + label: "current", + }), + ]); + return changedManifestDatasets(previous, current); +}; -const normalizeBaseRef = () => { - if (!baseRef || /^0+$/.test(baseRef)) return null; - return baseRef; +const main = async () => { + const [baseRef, headRef = "HEAD", ...extra] = process.argv.slice(2); + if (extra.length > 0) { + throw new TypeError( + "Usage: node list-changed-region-datasets.mjs [base-ref] [head-ref]", + ); + } + const datasets = await detectChangedDatasets({ + baseRef, + headRef, + baseUrl: process.env.REGION_DATA_BASE_URL, + }); + process.stdout.write(`${datasets.join(",")}\n`); }; -const base = normalizeBaseRef(); -if (!base) { - console.log(listAllDatasets().join(",")); - process.exit(0); +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]) +) { + main().catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); } -const changedFiles = execFileSync( - "git", - ["diff", "--name-only", base, headRef, "--", `${regionDistDir}/`], - { encoding: "utf8" }, -) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - -const datasets = changedFiles - .filter((fileName) => fileName.endsWith(".json")) - .map((fileName) => - fileName.slice(`${regionDistDir}/`.length).replace(/\.json$/, ""), - ) - .sort(); - -console.log([...new Set(datasets)].join(",")); +export { changedManifestDatasets, detectChangedDatasets }; diff --git a/apps/api-ground-codes/scripts/list-changed-region-datasets.test.mjs b/apps/api-ground-codes/scripts/list-changed-region-datasets.test.mjs new file mode 100644 index 00000000..f087b917 --- /dev/null +++ b/apps/api-ground-codes/scripts/list-changed-region-datasets.test.mjs @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, test } from "node:test"; + +import { + canonicalJson, + createManifest, + sha256Hex, +} from "../../../scripts/region-data/manifest.mjs"; + +const execFile = promisify(execFileCallback); +const detectorPath = resolve( + "apps/api-ground-codes/scripts/list-changed-region-datasets.mjs", +); +const temporaryRoots = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +const git = async (root, ...arguments_) => + execFile("git", arguments_, { cwd: root, encoding: "utf8" }); + +const releaseFixture = async (root, name, datasets) => { + const sourceRoot = join(root, `source-${name}`); + await mkdir(join(sourceRoot, "region-dist"), { recursive: true }); + await mkdir(join(sourceRoot, "region-db"), { recursive: true }); + await writeFile(join(sourceRoot, "region-db", "fixture.index"), "index"); + await Promise.all( + Object.entries(datasets).map(async ([datasetName, contents]) => { + const datasetPath = join( + sourceRoot, + "region-dist", + `${datasetName}.json`, + ); + await mkdir(dirname(datasetPath), { recursive: true }); + await writeFile(datasetPath, contents); + }), + ); + const manifest = await createManifest({ sourceRoot }); + const bytes = Buffer.from(canonicalJson(manifest)); + return { + bytes, + manifest, + pointer: { + manifestSha256: sha256Hex(bytes), + schemaVersion: 1, + version: manifest.version, + }, + }; +}; + +const commitPointer = async (root, pointer, message) => { + const pointerPath = join(root, "packages/geoint/region-data-release.json"); + await mkdir(join(root, "packages/geoint"), { recursive: true }); + await writeFile(pointerPath, canonicalJson(pointer)); + await git(root, "add", "packages/geoint/region-data-release.json"); + await git(root, "commit", "-q", "-m", message); + return (await git(root, "rev-parse", "HEAD")).stdout.trim(); +}; + +const repositoryFixture = async () => { + const root = await mkdtemp(join(tmpdir(), "region-dataset-detector-")); + temporaryRoots.push(root); + await git(root, "init", "-q"); + await git(root, "config", "user.name", "Region Data Test"); + await git(root, "config", "user.email", "region-data@example.test"); + return root; +}; + +const serveReleases = async (releases) => { + const server = createServer((request, response) => { + const match = request.url?.match( + /^\/releases\/(sha256-[a-f0-9]{64})\/manifest\.json$/, + ); + const bytes = match ? releases.get(match[1]) : undefined; + if (!bytes) { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { + "content-length": String(bytes.length), + "content-type": "application/json", + }); + response.end(bytes); + }); + await new Promise((resolveListen) => + server.listen(0, "127.0.0.1", resolveListen), + ); + const address = server.address(); + assert.ok(address && typeof address === "object"); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolveClose) => server.close(resolveClose)), + }; +}; + +const runDetector = (root, baseRef, headRef, baseUrl) => + execFile(process.execPath, [detectorPath, baseRef, headRef], { + cwd: root, + encoding: "utf8", + env: { ...process.env, REGION_DATA_BASE_URL: baseUrl }, + }); + +describe("release-manifest region dataset detection", () => { + test("reports added, removed, and hash-changed region-dist datasets", async () => { + const root = await repositoryFixture(); + const previous = await releaseFixture(root, "previous", { + changed: "old", + removed: "removed", + stable: "stable", + }); + const current = await releaseFixture(root, "current", { + added: "added", + changed: "new", + stable: "stable", + }); + const baseRef = await commitPointer(root, previous.pointer, "previous"); + const headRef = await commitPointer(root, current.pointer, "current"); + const server = await serveReleases( + new Map([ + [previous.manifest.version, previous.bytes], + [current.manifest.version, current.bytes], + ]), + ); + try { + const result = await runDetector(root, baseRef, headRef, server.baseUrl); + assert.equal(result.stdout.trim(), "added,changed,removed"); + } finally { + await server.close(); + } + }); + + test("returns empty without fetching when the release pointer is unchanged", async () => { + const root = await repositoryFixture(); + const release = await releaseFixture(root, "same", { stable: "stable" }); + const baseRef = await commitPointer(root, release.pointer, "base"); + await writeFile(join(root, "unrelated.txt"), "unrelated"); + await git(root, "add", "unrelated.txt"); + await git(root, "commit", "-q", "-m", "unrelated"); + const headRef = (await git(root, "rev-parse", "HEAD")).stdout.trim(); + + const result = await runDetector( + root, + baseRef, + headRef, + "http://127.0.0.1:1", + ); + assert.equal(result.stdout, "\n"); + }); + + test("fails closed when the previous release manifest is unavailable", async () => { + const root = await repositoryFixture(); + const previous = await releaseFixture(root, "missing", { old: "old" }); + const current = await releaseFixture(root, "available", { next: "next" }); + const baseRef = await commitPointer(root, previous.pointer, "previous"); + const headRef = await commitPointer(root, current.pointer, "current"); + const server = await serveReleases( + new Map([[current.manifest.version, current.bytes]]), + ); + try { + await assert.rejects( + runDetector(root, baseRef, headRef, server.baseUrl), + (error) => { + assert.notEqual(error.code, 0); + assert.match(error.stderr, /previous release manifest/i); + return true; + }, + ); + } finally { + await server.close(); + } + }); + + test("uses tracked Git data for the one-time pointer introduction", async () => { + const root = await repositoryFixture(); + const trackedDataset = join( + root, + "packages/geoint/region-dist/transition.json", + ); + await mkdir(dirname(trackedDataset), { recursive: true }); + await writeFile(trackedDataset, "old"); + await git(root, "add", "packages/geoint/region-dist/transition.json"); + await git(root, "commit", "-q", "-m", "git-backed data"); + const baseRef = (await git(root, "rev-parse", "HEAD")).stdout.trim(); + + const current = await releaseFixture(root, "transition", { + transition: "new", + }); + await writeFile(trackedDataset, "new"); + await git(root, "add", "packages/geoint/region-dist/transition.json"); + const headRef = await commitPointer(root, current.pointer, "add pointer"); + + const result = await runDetector( + root, + baseRef, + headRef, + "http://127.0.0.1:1", + ); + assert.equal(result.stdout.trim(), "transition"); + }); + + test("fails closed on a region-dist entry the importer cannot name", async () => { + const root = await repositoryFixture(); + const previous = await releaseFixture(root, "flat", { stable: "same" }); + const current = await releaseFixture(root, "nested", { + "nested/unsupported": "unsupported", + stable: "same", + }); + const baseRef = await commitPointer(root, previous.pointer, "previous"); + const headRef = await commitPointer(root, current.pointer, "current"); + const server = await serveReleases( + new Map([ + [previous.manifest.version, previous.bytes], + [current.manifest.version, current.bytes], + ]), + ); + try { + await assert.rejects( + runDetector(root, baseRef, headRef, server.baseUrl), + (error) => { + assert.notEqual(error.code, 0); + assert.match(error.stderr, /unsupported region-dist manifest entry/i); + return true; + }, + ); + } finally { + await server.close(); + } + }); +}); diff --git a/apps/api-ground-codes/src/app-part-2.test.ts b/apps/api-ground-codes/src/app-part-2.test.ts index 9a211c39..dfc7fe2d 100644 --- a/apps/api-ground-codes/src/app-part-2.test.ts +++ b/apps/api-ground-codes/src/app-part-2.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "./app.js"; -const app = createApp(); +const silentMetrics = { writeLog: () => undefined }; +const app = createApp({ metrics: silentMetrics }); const rateLimitedApp = createApp({ + metrics: silentMetrics, rateLimit: { max: 1, windowMs: 60_000, diff --git a/apps/api-ground-codes/src/app-part-3.test.ts b/apps/api-ground-codes/src/app-part-3.test.ts index 311ca6f9..02ad24ad 100644 --- a/apps/api-ground-codes/src/app-part-3.test.ts +++ b/apps/api-ground-codes/src/app-part-3.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "./app.js"; -const app = createApp(); +const silentMetrics = { writeLog: () => undefined }; +const app = createApp({ metrics: silentMetrics }); const rateLimitedApp = createApp({ + metrics: silentMetrics, rateLimit: { max: 1, windowMs: 60_000, diff --git a/apps/api-ground-codes/src/app-part-4.test.ts b/apps/api-ground-codes/src/app-part-4.test.ts index 9708dd0c..2780fe55 100644 --- a/apps/api-ground-codes/src/app-part-4.test.ts +++ b/apps/api-ground-codes/src/app-part-4.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "./app.js"; -const app = createApp(); +const silentMetrics = { writeLog: () => undefined }; +const app = createApp({ metrics: silentMetrics }); const rateLimitedApp = createApp({ + metrics: silentMetrics, rateLimit: { max: 1, windowMs: 60_000, diff --git a/apps/api-ground-codes/src/app.test.ts b/apps/api-ground-codes/src/app.test.ts index 7b68c186..064e7d29 100644 --- a/apps/api-ground-codes/src/app.test.ts +++ b/apps/api-ground-codes/src/app.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { createApp } from "./app.js"; -const app = createApp(); +const silentMetrics = { writeLog: () => undefined }; +const app = createApp({ metrics: silentMetrics }); const rateLimitedApp = createApp({ + metrics: silentMetrics, rateLimit: { max: 1, windowMs: 60_000, @@ -21,7 +24,35 @@ const postJson = (path: string, body: unknown) => const get = (path: string) => app.handle(new Request(`http://localhost${path}`)); +const readRepositoryDocument = (path: string) => + readFileSync(new URL(`../../../${path}`, import.meta.url), "utf8"); + describe("Ground Codes API contract", () => { + test("documents measurable production service objectives", () => { + const operationsDocuments = [ + readRepositoryDocument("docs/operations/service-objectives.md"), + readRepositoryDocument("docs/operations/incident-runbook.md"), + ].join("\n"); + + [ + /99\.9% monthly readiness availability[\s\S]*99\.9% monthly Web-root availability/i, + /UTC calendar month/i, + /00 and 30 minutes of every UTC hour/i, + /`api\.readiness`[\s\S]*`web\.root`[\s\S]*`earth\.english\.encode`[\s\S]*`earth\.english\.search`/, + /passed expected slots \/ all expected slots/i, + /1,440 expected\s+slots[\s\S]*at most 1 failed slot/i, + /missing, cancelled, not created, or non-passing/i, + /`durationMs` < 2,000 ms/i, + /missing, errored,\s+or slow check is a latency miss/i, + /full post-deploy production smoke.*before an incident is closed/i, + /console\.log\(record\)[\s\S]*application payload fields[\s\S]*Cloudflare platform metadata/i, + /logs must never include coordinates, search strings, ground codes, IP\s+addresses, headers, or credentials/i, + /Pages deployment ID and commit[\s\S]*Web response[\s\S]*full production smoke/i, + /Worker rollback does\s+not roll back PostGIS, R2, or other external state[\s\S]*older\s+code is compatible with the current schema and data[\s\S]*forward fix\s+or documented data recovery/i, + /startsWith\(status, "5"\)[\s\S]*startsWith\(status, "4"\)[\s\S]*startsWith\(status, "3"\)[\s\S]*startsWith\(status, "2"\)[\s\S]*status = "5xx"[^.]*invalid/i, + ].forEach((pattern) => expect(operationsDocuments).toMatch(pattern)); + }); + test("serves a readiness endpoint for deployment checks", async () => { const response = await get("/readyz"); @@ -64,19 +95,33 @@ describe("Ground Codes API contract", () => { }); test("serves lightweight operational metrics", async () => { - await get("/healthz"); + const firstApp = createApp({ metrics: silentMetrics, rateLimit: null }); + const secondApp = createApp({ metrics: silentMetrics, rateLimit: null }); - const response = await get("/metrics"); + await firstApp.handle(new Request("http://localhost/healthz")); + await new Promise((resolve) => setImmediate(resolve)); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.service).toBe("api-ground-codes"); - expect(body.scope).toBe("worker-isolate"); - expect(body.requests.total).toBeGreaterThan(0); + const firstResponse = await firstApp.handle( + new Request("http://localhost/metrics"), + ); + const secondResponse = await secondApp.handle( + new Request("http://localhost/metrics"), + ); + + expect(firstResponse.status).toBe(200); + const first = await firstResponse.json(); + expect(first.service).toBe("api-ground-codes"); + expect(first.scope).toBe("worker-isolate"); + expect(first.runtimeCommit).toMatch(/^[0-9a-f]{40}$/); + expect(first.requests.total).toBe(1); + + expect(secondResponse.status).toBe(200); + const second = await secondResponse.json(); + expect(second.requests.total).toBe(0); }); test("serves public API documentation without exposing legacy routes", async () => { - const docsResponse = await createApp().handle( + const docsResponse = await createApp({ metrics: silentMetrics }).handle( new Request("http://localhost/"), ); expect(docsResponse.status).toBe(200); @@ -151,9 +196,9 @@ describe("Ground Codes API contract", () => { expect(docsResponse.status).toBe(302); expect(docsResponse.headers.get("location")).toBe("/openapi/"); - const referenceResponse = await createApp().handle( - new Request("http://localhost/reference"), - ); + const referenceResponse = await createApp({ + metrics: silentMetrics, + }).handle(new Request("http://localhost/reference")); expect(referenceResponse.status).toBe(302); expect(referenceResponse.headers.get("location")).toBe("/openapi/"); diff --git a/apps/api-ground-codes/src/app.ts b/apps/api-ground-codes/src/app.ts index 28718a24..c1289358 100644 --- a/apps/api-ground-codes/src/app.ts +++ b/apps/api-ground-codes/src/app.ts @@ -1,8 +1,8 @@ import { Elysia } from "elysia"; import { healthz, readyz } from "./endpoints/healthz.js"; import { + createSwaggerEndpoint, openApiReferenceEndpoint, - swaggerEndpoint, swaggerRedirectEndpoint, } from "./endpoints/swagger.js"; import { createCorsEndpoint } from "./endpoints/cors.js"; @@ -10,7 +10,10 @@ import { codeEndpoint } from "./endpoints/code.js"; import { docsEndpoint } from "./endpoints/docs.js"; import { legacyEndpoints, v1Endpoints } from "./endpoints/v1/v1-endpoints.js"; import { formatApiError } from "./endpoints/v1/api-error.js"; -import { metricsEndpoint } from "./endpoints/metrics.js"; +import { + createMetricsEndpoint, + type MetricsOptions, +} from "./endpoints/metrics.js"; import { createRateLimitEndpoint, getDefaultRateLimit, @@ -21,6 +24,7 @@ interface AppOptions { port?: string | number; rateLimit?: RateLimitOptions | null; corsOrigins?: string[]; + metrics?: MetricsOptions; } /** @@ -30,25 +34,50 @@ interface AppOptions { * @returns An Elysia application instance. */ export const createApp = (portOrOptions?: string | number | AppOptions) => { - const options = + const options: AppOptions = typeof portOrOptions === "object" ? portOrOptions : { port: portOrOptions }; const rateLimit = "rateLimit" in options ? options.rateLimit : getDefaultRateLimit(); + const metricsEndpoint = createMetricsEndpoint(options.metrics); const app = new Elysia({ aot: false }) + .use(metricsEndpoint) .onError(({ error, code, set }) => formatApiError(error, code, set)) .use(createCorsEndpoint(options.corsOrigins)) .use(createRateLimitEndpoint(rateLimit)) - .use(metricsEndpoint) .use(swaggerRedirectEndpoint) .use(openApiReferenceEndpoint) .use(docsEndpoint) - .use(swaggerEndpoint) + .use(createSwaggerEndpoint()) .use(healthz) .use(readyz) .use(v1Endpoints) .use(legacyEndpoints) .use(codeEndpoint); + const installCompletionBoundary = () => { + const fetch = app.fetch; + const handle = async (request: Request) => { + const response = await fetch(request); + metricsEndpoint.completeResponse(request, response); + return response; + }; + Object.defineProperty(app, "fetch", { + value: handle, + configurable: true, + writable: true, + }); + app.handle = handle; + app.server?.reload({ fetch: handle }); + }; + + const compile = app.compile.bind(app); + app.compile = () => { + compile(); + installCompletionBoundary(); + return app; + }; + installCompletionBoundary(); + return options.port === undefined ? app : app.listen(options.port); }; diff --git a/apps/api-ground-codes/src/endpoints/docs.ts b/apps/api-ground-codes/src/endpoints/docs.ts index 7b28b342..3bb7f353 100644 --- a/apps/api-ground-codes/src/endpoints/docs.ts +++ b/apps/api-ground-codes/src/endpoints/docs.ts @@ -1,4 +1,4 @@ -import Elysia from "elysia"; +import Elysia, { type Context } from "elysia"; const docsHtml = ` @@ -161,7 +161,7 @@ const docsHtml = ` `; -const serveDocs = ({ set }: { set: { headers: Record } }) => { +const serveDocs = ({ set }: { set: Context["set"] }) => { set.headers["cache-control"] = "public, max-age=300"; set.headers["content-type"] = "text/html; charset=utf-8"; return docsHtml; diff --git a/apps/api-ground-codes/src/endpoints/metrics.ts b/apps/api-ground-codes/src/endpoints/metrics.ts index 566c5a10..0ba787f9 100644 --- a/apps/api-ground-codes/src/endpoints/metrics.ts +++ b/apps/api-ground-codes/src/endpoints/metrics.ts @@ -1,4 +1,5 @@ import Elysia from "elysia"; +import { getRuntimeMetadata } from "./healthz.js"; import { getRegionLoadMetrics } from "./v1/region/load-region.js"; interface PathMetrics { @@ -10,22 +11,40 @@ interface PathMetrics { } interface RequestMetricsSnapshot { - startedAt: string; total: number; totalMs: number; byPath: Record; routes: Record; } -const requestMetrics: RequestMetricsSnapshot = { - startedAt: new Date().toISOString(), - total: 0, - totalMs: 0, - byPath: {}, - routes: {}, +export interface MetricsClock { + nowMs(): number; + monotonicMs(): number; +} + +export interface RequestCompletionLog { + event: "api.request.completed"; + service: "api-ground-codes"; + route: string; + method: string; + status: string; + durationMs: number; + runtimeCommit: string; +} + +export interface MetricsOptions { + clock?: MetricsClock; + writeLog?: (record: RequestCompletionLog) => void; +} + +const systemClock: MetricsClock = { + nowMs: () => Date.now(), + monotonicMs: () => performance.now(), }; -const requestStartTimes = new WeakMap(); +const defaultWriteLog = (record: RequestCompletionLog) => { + console.log(record); +}; const getStatusCode = (status: unknown): string => { if (typeof status === "number") return String(status); @@ -33,24 +52,41 @@ const getStatusCode = (status: unknown): string => { return "200"; }; +const getRouteLabel = (request: Request, matchedRoute: string | undefined) => + matchedRoute || (request.method === "OPTIONS" ? "/*" : ""); + const recordRequest = ( + requestMetrics: RequestMetricsSnapshot, request: Request, + matchedRoute: string | undefined, status: unknown, durationMs: number, + writeLog: (record: RequestCompletionLog) => void, ) => { - const pathname = new URL(request.url).pathname; - if (pathname === "/metrics") return; - + const route = getRouteLabel(request, matchedRoute); const roundedDurationMs = Math.max(0, Math.round(durationMs * 100) / 100); const statusCode = getStatusCode(status); + const { runtimeCommit } = getRuntimeMetadata(); + + writeLog({ + event: "api.request.completed", + service: "api-ground-codes", + route, + method: request.method, + status: statusCode, + durationMs: roundedDurationMs, + runtimeCommit, + }); + + if (route === "/metrics") return; requestMetrics.total += 1; requestMetrics.totalMs += roundedDurationMs; - requestMetrics.byPath[pathname] = (requestMetrics.byPath[pathname] ?? 0) + 1; + requestMetrics.byPath[route] = (requestMetrics.byPath[route] ?? 0) + 1; const routeMetrics = - requestMetrics.routes[pathname] ?? - (requestMetrics.routes[pathname] = { + requestMetrics.routes[route] ?? + (requestMetrics.routes[route] = { count: 0, totalMs: 0, minMs: Number.POSITIVE_INFINITY, @@ -66,7 +102,7 @@ const recordRequest = ( (routeMetrics.byStatus[statusCode] ?? 0) + 1; }; -const serializeRoutes = () => +const serializeRoutes = (requestMetrics: RequestMetricsSnapshot) => Object.fromEntries( Object.entries(requestMetrics.routes).map(([path, routeMetrics]) => [ path, @@ -87,39 +123,83 @@ const serializeRoutes = () => ]), ); -export const metricsEndpoint = new Elysia() - .onRequest(({ request }) => { - requestStartTimes.set(request, performance.now()); - }) - .onAfterHandle({ as: "global" }, ({ request, set }) => { - const startedAt = requestStartTimes.get(request) ?? performance.now(); - recordRequest(request, set.status, performance.now() - startedAt); - }) - .onError({ as: "global" }, ({ request, set, code }) => { - const startedAt = requestStartTimes.get(request) ?? performance.now(); - recordRequest(request, set.status ?? code, performance.now() - startedAt); - }) - .get("/metrics", ({ set }) => { - set.headers["cache-control"] = "no-store"; - - return { - service: "api-ground-codes", - scope: "worker-isolate", - startedAt: requestMetrics.startedAt, - uptimeSeconds: Math.round( - (Date.now() - Date.parse(requestMetrics.startedAt)) / 1000, - ), - requests: { - total: requestMetrics.total, - avgMs: - requestMetrics.total === 0 - ? 0 - : Math.round( - (requestMetrics.totalMs / requestMetrics.total) * 100, - ) / 100, - byPath: requestMetrics.byPath, - routes: serializeRoutes(), - }, - regionLoads: getRegionLoadMetrics(), - }; +export const createMetricsEndpoint = (options: MetricsOptions = {}) => { + const clock = options.clock ?? systemClock; + const writeLog = options.writeLog ?? defaultWriteLog; + let startedAtMs: number | undefined; + const requestMetrics: RequestMetricsSnapshot = { + total: 0, + totalMs: 0, + byPath: {}, + routes: {}, + }; + const requestStartTimes = new WeakMap(); + const requestRoutes = new WeakMap(); + + const completeRequest = ( + request: Request, + matchedRoute: string | undefined, + status: unknown, + ) => { + const startedAt = requestStartTimes.get(request); + if (startedAt === undefined) return; + + requestStartTimes.delete(request); + requestRoutes.delete(request); + recordRequest( + requestMetrics, + request, + matchedRoute, + status, + clock.monotonicMs() - startedAt, + writeLog, + ); + }; + + const endpoint = new Elysia() + .onRequest(({ request }) => { + startedAtMs ??= clock.nowMs(); + requestStartTimes.set(request, clock.monotonicMs()); + }) + .onAfterHandle({ as: "global" }, ({ request, route }) => { + requestRoutes.set(request, route); + }) + .onError({ as: "global" }, ({ request, route }) => { + requestRoutes.set(request, route); + }) + .get("/metrics", ({ set }) => { + set.headers["cache-control"] = "no-store"; + const requestStartedAtMs = startedAtMs ?? clock.nowMs(); + startedAtMs ??= requestStartedAtMs; + const { runtimeCommit } = getRuntimeMetadata(); + + return { + service: "api-ground-codes", + scope: "worker-isolate", + startedAt: new Date(requestStartedAtMs).toISOString(), + uptimeSeconds: Math.max( + 0, + Math.round((clock.nowMs() - requestStartedAtMs) / 1000), + ), + runtimeCommit, + requests: { + total: requestMetrics.total, + avgMs: + requestMetrics.total === 0 + ? 0 + : Math.round( + (requestMetrics.totalMs / requestMetrics.total) * 100, + ) / 100, + byPath: requestMetrics.byPath, + routes: serializeRoutes(requestMetrics), + }, + regionLoads: getRegionLoadMetrics(), + }; + }); + + return Object.assign(endpoint, { + completeResponse(request: Request, response: Response) { + completeRequest(request, requestRoutes.get(request), response.status); + }, }); +}; diff --git a/apps/api-ground-codes/src/endpoints/swagger.ts b/apps/api-ground-codes/src/endpoints/swagger.ts index 0ab24621..f3ee269e 100644 --- a/apps/api-ground-codes/src/endpoints/swagger.ts +++ b/apps/api-ground-codes/src/endpoints/swagger.ts @@ -46,36 +46,37 @@ const scalarReferenceConfig = { favicon: "/favicon.ico", }; -export const swaggerEndpoint = swagger({ - path: "/openapi-json", - exclude: publicDocPathsToHide, - scalarConfig: scalarReferenceConfig, - documentation: { - info: { - title: "Ground Codes API Documentation", - description: - 'Production API documentation for Ground Codes. Use the versioned `/v1/*` endpoints for new integrations. Quick start: POST `/v1/encode` with `{ "lat": 37.566, "lng": 126.978, "language": "english", "regionLevel": 2 }`, then POST `/v1/search` with the returned code or share it as `https://ground.codes/{encoded-code}`. Earth share URLs are code-only; Moon and Mars use `/moon/{encoded-code}` and `/mars/{encoded-code}`.', - version: "1.0.0", - }, - tags: [ - { - name: "Code", - description: "Encode & Decode endpoint", - }, - { - name: "Health", - description: "Endpoint to check the health of the server.", - }, - ], - // 서버 설정 추가 - servers: [ - { - url: "/", - description: "Current server", +export const createSwaggerEndpoint = () => + swagger({ + path: "/openapi-json", + exclude: publicDocPathsToHide, + scalarConfig: scalarReferenceConfig, + documentation: { + info: { + title: "Ground Codes API Documentation", + description: + 'Production API documentation for Ground Codes. Use the versioned `/v1/*` endpoints for new integrations. Quick start: POST `/v1/encode` with `{ "lat": 37.566, "lng": 126.978, "language": "english", "regionLevel": 2 }`, then POST `/v1/search` with the returned code or share it as `https://ground.codes/{encoded-code}`. Earth share URLs are code-only; Moon and Mars use `/moon/{encoded-code}` and `/mars/{encoded-code}`.', + version: "1.0.0", }, - ], - }, -}); + tags: [ + { + name: "Code", + description: "Encode & Decode endpoint", + }, + { + name: "Health", + description: "Endpoint to check the health of the server.", + }, + ], + // 서버 설정 추가 + servers: [ + { + url: "/", + description: "Current server", + }, + ], + }, + }); const openApiReferenceHtml = ` diff --git a/apps/api-ground-codes/src/index.test.ts b/apps/api-ground-codes/src/index.test.ts new file mode 100644 index 00000000..aa30f460 --- /dev/null +++ b/apps/api-ground-codes/src/index.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { startServer } from "./index.js"; + +describe("API server entrypoint", () => { + test("starts the application with the configured port and existing console contract", () => { + const messages: string[] = []; + const ports: Array = []; + let clearCount = 0; + const application = { + server: { hostname: "127.0.0.1", port: 4310 }, + }; + + const result = startServer({ + port: "4310", + clearConsole: () => { + clearCount += 1; + }, + writeLog: (message) => messages.push(message), + createApplication: (port) => { + ports.push(port); + return application; + }, + }); + + expect(result).toBe(application); + expect(ports).toEqual(["4310"]); + expect(clearCount).toBe(1); + expect(messages).toEqual([ + "🚀 Initializing Ground Codes API server...", + "🦊 Elysia is running at http://127.0.0.1:4310", + ]); + }); + + test("keeps the environment and numeric default port semantics", () => { + const previousPort = process.env.PORT; + const ports: Array = []; + const start = () => + startServer({ + clearConsole: () => undefined, + writeLog: () => undefined, + createApplication: (port) => { + ports.push(port); + return {}; + }, + }); + + try { + process.env.PORT = "4321"; + start(); + delete process.env.PORT; + start(); + expect(ports).toEqual(["4321", 3000]); + } finally { + if (previousPort === undefined) delete process.env.PORT; + else process.env.PORT = previousPort; + } + }); +}); diff --git a/apps/api-ground-codes/src/index.ts b/apps/api-ground-codes/src/index.ts index 63b33463..70c29ff8 100644 --- a/apps/api-ground-codes/src/index.ts +++ b/apps/api-ground-codes/src/index.ts @@ -1,15 +1,35 @@ import { createApp } from "./app.js"; -void (async function () { - console.clear(); +interface ServerApplication { + server?: { hostname?: string; port?: string | number } | null; +} + +interface StartServerOptions { + port?: string | number; + createApplication?: (port: string | number) => ServerApplication; + clearConsole?: () => void; + writeLog?: (message: string) => void; +} + +export const startServer = ({ + port = process.env.PORT ?? 3000, + createApplication = createApp, + clearConsole = console.clear, + writeLog = console.log, +}: StartServerOptions = {}) => { + clearConsole(); // * Print initialization message - console.log("🚀 Initializing Ground Codes API server..."); + writeLog("🚀 Initializing Ground Codes API server..."); // * Create the app - const app = createApp(process.env.PORT ?? 3000); + const app = createApplication(port); - console.log( + writeLog( `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`, ); -})(); + + return app; +}; + +if (import.meta.main) startServer(); diff --git a/apps/api-ground-codes/src/metrics.test.ts b/apps/api-ground-codes/src/metrics.test.ts new file mode 100644 index 00000000..46a8ea21 --- /dev/null +++ b/apps/api-ground-codes/src/metrics.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "./app.js"; +import type { RequestCompletionLog } from "./endpoints/metrics.js"; + +const assertFiniteNumbers = (value: unknown): void => { + if (typeof value === "number") { + expect(Number.isFinite(value)).toBe(true); + return; + } + + if (Array.isArray(value)) { + value.forEach(assertFiniteNumbers); + return; + } + + if (value !== null && typeof value === "object") { + Object.values(value).forEach(assertFiniteNumbers); + } +}; + +describe("operational metrics clock", () => { + test("starts on the first request and keeps finite request counters", async () => { + let wallMs = 0; + let monotonicMs = 0; + const app = createApp({ + rateLimit: null, + metrics: { + clock: { + nowMs: () => wallMs, + monotonicMs: () => monotonicMs, + }, + writeLog: () => undefined, + }, + }); + + const firstRequestAtMs = Date.parse("2026-07-13T00:00:00.000Z"); + wallMs = firstRequestAtMs; + monotonicMs = 1_000; + await app.handle(new Request("http://localhost/healthz")); + + wallMs = firstRequestAtMs + 600; + const firstResponse = await app.handle( + new Request("http://localhost/metrics"), + ); + const first = await firstResponse.json(); + + expect(first.startedAt).toBe("2026-07-13T00:00:00.000Z"); + expect(first.uptimeSeconds).toBe(1); + expect(first.requests.total).toBe(1); + expect(first.requests.byPath).toEqual({ "/healthz": 1 }); + expect(first.runtimeCommit).toMatch(/^[0-9a-f]{40}$/); + assertFiniteNumbers(first.requests); + + wallMs = firstRequestAtMs + 5_400; + monotonicMs += 5_400; + await app.handle(new Request("http://localhost/readyz")); + + const secondResponse = await app.handle( + new Request("http://localhost/metrics"), + ); + const second = await secondResponse.json(); + + expect(second.startedAt).toBe(first.startedAt); + expect(second.uptimeSeconds).toBe(5); + expect(second.requests.total).toBe(2); + expect(second.requests.byPath).toEqual({ + "/healthz": 1, + "/readyz": 1, + }); + assertFiniteNumbers(second.requests); + }); +}); + +describe("request completion logs", () => { + test("completes requests through both server and Worker entrypoints", async () => { + const records: RequestCompletionLog[] = []; + const app = createApp({ + rateLimit: null, + metrics: { writeLog: (record) => records.push(record) }, + }); + + await app.fetch(new Request("http://localhost/healthz")); + await app.handle(new Request("http://localhost/readyz")); + + expect(records.map(({ route }) => route)).toEqual(["/healthz", "/readyz"]); + }); + + test("preserves completion records through the real Bun listener", async () => { + const records: RequestCompletionLog[] = []; + const app = createApp({ + port: 0, + rateLimit: null, + metrics: { writeLog: (record) => records.push(record) }, + }); + + try { + const serverUrl = app.server?.url; + expect(serverUrl).toBeDefined(); + const response = await fetch(new URL("/healthz", serverUrl)); + + expect(response.status).toBe(200); + expect(records.map(({ route }) => route)).toEqual(["/healthz"]); + + app.compile(); + const recompiledResponse = await fetch(new URL("/readyz", serverUrl)); + + expect(recompiledResponse.status).toBe(200); + expect(records.map(({ route }) => route)).toEqual([ + "/healthz", + "/readyz", + ]); + } finally { + await app.stop(); + } + }); + + test("writes the structured record before the Worker response resolves", async () => { + const originalConsoleLog = console.log; + const consoleCalls: unknown[][] = []; + console.log = (...args: unknown[]) => { + consoleCalls.push(args); + }; + + try { + const app = createApp({ rateLimit: null }); + const response = await app.handle( + new Request("http://localhost/healthz"), + ); + + expect(response.status).toBe(200); + expect(consoleCalls).toHaveLength(1); + expect(consoleCalls[0]).toEqual([ + { + event: "api.request.completed", + service: "api-ground-codes", + route: "/healthz", + method: "GET", + status: "200", + durationMs: expect.any(Number), + runtimeCommit: expect.stringMatching(/^[0-9a-f]{40}$/), + }, + ]); + expect(typeof consoleCalls[0][0]).toBe("object"); + } finally { + console.log = originalConsoleLog; + } + }); + + test("emits one privacy-safe record for each encode and search error", async () => { + const encodedLogs: string[] = []; + let monotonicMs = 10_000; + const app = createApp({ + rateLimit: null, + metrics: { + clock: { + nowMs: () => Date.parse("2026-07-13T00:00:00.000Z"), + monotonicMs: () => monotonicMs, + }, + writeLog: (record: RequestCompletionLog) => { + encodedLogs.push(JSON.stringify(record)); + }, + }, + }); + + const sentinels = [ + "91.1234567", + "-181.7654321", + "encode-query-private-code", + "198.51.100.73", + "Bearer encode-private-authorization", + "search-private-query-code", + "42.7654321", + "203.0.113.91", + "Bearer search-private-authorization", + ]; + + monotonicMs = 10_125.25; + const encodeResponse = await app.handle( + new Request("http://localhost/v1/encode?code=encode-query-private-code", { + method: "POST", + headers: { + authorization: "Bearer encode-private-authorization", + "content-type": "application/json", + "x-forwarded-for": "198.51.100.73", + }, + body: JSON.stringify({ lat: 91.1234567, lng: -181.7654321 }), + }), + ); + + monotonicMs = 10_250.75; + const searchResponse = await app.handle( + new Request("http://localhost/v1/search", { + method: "POST", + headers: { + authorization: "Bearer search-private-authorization", + "content-type": "application/json", + "x-forwarded-for": "203.0.113.91", + }, + body: JSON.stringify({ + query: "search-private-query-code", + biasLat: 42.7654321, + }), + }), + ); + expect(encodeResponse.status).toBe(400); + expect(searchResponse.status).toBe(400); + expect(encodedLogs).toHaveLength(2); + + const records = encodedLogs.map( + (encodedLog) => JSON.parse(encodedLog) as RequestCompletionLog, + ); + expect(records).toEqual([ + { + event: "api.request.completed", + service: "api-ground-codes", + route: "/v1/encode", + method: "POST", + status: "400", + durationMs: expect.any(Number), + runtimeCommit: expect.stringMatching(/^[0-9a-f]{40}$/), + }, + { + event: "api.request.completed", + service: "api-ground-codes", + route: "/v1/search", + method: "POST", + status: "400", + durationMs: expect.any(Number), + runtimeCommit: expect.stringMatching(/^[0-9a-f]{40}$/), + }, + ]); + records.forEach((record) => { + expect(Object.keys(record).sort()).toEqual( + [ + "durationMs", + "event", + "method", + "route", + "runtimeCommit", + "service", + "status", + ].sort(), + ); + expect(Number.isFinite(record.durationMs)).toBe(true); + expect(record.durationMs).toBeGreaterThanOrEqual(0); + }); + + const serializedLogs = encodedLogs.join("\n"); + sentinels.forEach((sentinel) => { + expect(serializedLogs).not.toContain(sentinel); + }); + }); + + test("uses the route template for the legacy dynamic ground-code path", async () => { + const encodedLogs: string[] = []; + const app = createApp({ + rateLimit: null, + metrics: { + writeLog: (record: RequestCompletionLog) => { + encodedLogs.push(JSON.stringify(record)); + }, + }, + }); + const pathSentinel = "legacyPrivatePathSentinel"; + + const response = await app.handle( + new Request(`http://localhost/${pathSentinel}`), + ); + expect(encodedLogs).toHaveLength(1); + expect(encodedLogs[0]).not.toContain(pathSentinel); + expect(JSON.parse(encodedLogs[0])).toEqual({ + event: "api.request.completed", + service: "api-ground-codes", + route: "/:path", + method: "GET", + status: String(response.status), + durationMs: expect.any(Number), + runtimeCommit: expect.stringMatching(/^[0-9a-f]{40}$/), + }); + }); + + test("records CORS preflight responses that short-circuit the app", async () => { + const records: RequestCompletionLog[] = []; + const app = createApp({ + rateLimit: null, + corsOrigins: ["https://allowed.example"], + metrics: { + writeLog: (record) => records.push(record), + }, + }); + + const response = await app.handle( + new Request("http://localhost/v1/encode", { + method: "OPTIONS", + headers: { + origin: "https://allowed.example", + "access-control-request-method": "POST", + }, + }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe( + "https://allowed.example", + ); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + route: "/*", + method: "OPTIONS", + status: "204", + }); + + const metricsResponse = await app.handle( + new Request("http://localhost/metrics"), + ); + const metrics = await metricsResponse.json(); + expect(metrics.requests.total).toBe(1); + expect(metrics.requests.byPath).toEqual({ "/*": 1 }); + expect(metrics.requests.routes["/*"].byStatus).toEqual({ "204": 1 }); + }); + + test("records rate-limit responses that short-circuit the route", async () => { + const records: RequestCompletionLog[] = []; + const app = createApp({ + rateLimit: { max: 1, windowMs: 60_000 }, + metrics: { + writeLog: (record) => records.push(record), + }, + }); + const request = () => + new Request("http://localhost/readyz", { + headers: { "x-forwarded-for": "192.0.2.99" }, + }); + + const firstResponse = await app.handle(request()); + const limitedResponse = await app.handle(request()); + expect(firstResponse.status).toBe(200); + expect(limitedResponse.status).toBe(429); + expect(records).toHaveLength(2); + expect(records.map(({ route, status }) => ({ route, status }))).toEqual([ + { route: "/readyz", status: "200" }, + { route: "/readyz", status: "429" }, + ]); + + const metricsResponse = await app.handle( + new Request("http://localhost/metrics", { + headers: { "x-forwarded-for": "192.0.2.100" }, + }), + ); + const metrics = await metricsResponse.json(); + expect(metrics.requests.total).toBe(2); + expect(metrics.requests.routes["/readyz"].byStatus).toEqual({ + "200": 1, + "429": 1, + }); + }); + + test("records the final response status for redirects", async () => { + const records: RequestCompletionLog[] = []; + const app = createApp({ + rateLimit: null, + metrics: { + writeLog: (record) => records.push(record), + }, + }); + + const response = await app.handle( + new Request("http://localhost/openapi/json"), + ); + expect(response.status).toBe(302); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + route: "/openapi/json", + method: "GET", + status: "302", + }); + + const metricsResponse = await app.handle( + new Request("http://localhost/metrics"), + ); + const metrics = await metricsResponse.json(); + expect(metrics.requests.total).toBe(1); + expect(metrics.requests.routes["/openapi/json"].byStatus).toEqual({ + "302": 1, + }); + }); +}); diff --git a/apps/api-ground-codes/src/postgis-region-store.test.ts b/apps/api-ground-codes/src/postgis-region-store.test.ts new file mode 100644 index 00000000..9f79df12 --- /dev/null +++ b/apps/api-ground-codes/src/postgis-region-store.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, test } from "bun:test"; +import { setRegionStore } from "ground-codes/src/index.ts"; +import { + calculateDistanceKm, + getDatasetName, + getFallbackSearchLevels, + normalizeLookupKey, + selectProminentRegionRow, + toRegionSearchResult, + type RegionRow, +} from "./postgis-region-selection.js"; +import { + PostgisRegionStore, + installPostgisRegionStore, +} from "./postgis-region-store.js"; + +const makeRow = (overrides: Partial = {}): RegionRow => ({ + source_index: 1, + name: "Sample", + code: "sample", + lat: 0, + lng: 0, + body: "earth", + region_level: 2, + population: 1_000, + country_code: "KR", + ...overrides, +}); + +const createClient = (responses: Array) => { + const queries: Array<{ text: string; values?: unknown[] }> = []; + let connectCount = 0; + let endCount = 0; + + return { + queries, + get connectCount() { + return connectCount; + }, + get endCount() { + return endCount; + }, + async connect() { + connectCount += 1; + }, + async end() { + endCount += 1; + }, + async query(text: string, values?: unknown[]) { + queries.push({ text, values }); + const response = responses.shift() ?? []; + if (response instanceof Error) throw response; + return { rows: response as T[] }; + }, + }; +}; + +const createStore = (client: ReturnType) => + new PostgisRegionStore({ + connectionString: "postgresql://coverage.invalid/database", + clientFactory: () => client, + }); + +describe("PostGIS region selection", () => { + test("normalizes lookup keys and row values", () => { + expect(normalizeLookupKey(" Ærø Œuvre Ð Þing Straße ")).toBe( + "aero oeuvre d thing strasse", + ); + expect( + toRegionSearchResult( + makeRow({ + lat: "37.5", + lng: "126.9", + population: "1200", + distance_km: "3.5", + }), + ), + ).toMatchObject({ + lat: 37.5, + lng: 126.9, + population: 1200, + distanceKm: 3.5, + }); + expect( + toRegionSearchResult(makeRow({ population: null, distance_km: null })), + ).toMatchObject({ population: undefined, distanceKm: undefined }); + }); + + test("calculates wrapped body distances", () => { + expect(calculateDistanceKm(0, 179, 0, -179, "earth")).toBeCloseTo(222, 0); + expect(calculateDistanceKm(0, 0, 0, 1, "moon")).toBeCloseTo(30.323, 2); + }); + + test("prefers a nearby prominent region and handles empty input", () => { + const local = makeRow({ + name: "Local", + lat: 0.1, + population: 200_000, + }); + const metro = makeRow({ + name: "Metro", + lat: 0.12, + population: 2_000_000, + }); + + expect( + selectProminentRegionRow([], { lat: 0, lng: 0 }, "earth"), + ).toBeNull(); + expect( + selectProminentRegionRow([local, metro], { lat: 0, lng: 0 }, "earth")?.row + .name, + ).toBe("Metro"); + expect( + selectProminentRegionRow( + [local, { ...metro, country_code: "US" }], + { lat: 0, lng: 0 }, + "earth", + )?.row.name, + ).toBe("Local"); + }); + + test("builds fallback levels and dataset names", () => { + expect(getFallbackSearchLevels("earth", 2)).toEqual([1, 3]); + expect(getFallbackSearchLevels("mars", 2)).toEqual([3]); + expect(getFallbackSearchLevels("moon", 2)).toEqual([]); + expect(getFallbackSearchLevels("earth", 1)).toEqual([]); + expect(getDatasetName("earth", 2, "english")).toBe("region-2"); + expect(getDatasetName("moon", 2, "korean")).toBe("region-2-moon-korean"); + }); +}); + +describe("PostGIS region store", () => { + test("finds and localizes the closest region", async () => { + const base = makeRow({ code: "seoul", name: "Seoul", lat: 0.1 }); + const localized = makeRow({ + code: "seoul", + name: "서울", + lat: 0.1, + }); + const client = createClient([[base], [localized]]); + const store = createStore(client); + + await expect( + store.findClosestRegion( + { lat: 0, lng: 0 }, + { regionLevel: 2, language: "korean" }, + ), + ).resolves.toMatchObject({ name: "서울", code: "seoul" }); + expect(client.queries).toHaveLength(2); + expect(client.queries[0]?.values).toEqual([0, 0, "region-2", 50]); + expect(client.queries[1]?.values).toEqual(["region-2-korean", "seoul"]); + expect(client.connectCount).toBe(1); + expect(client.endCount).toBe(1); + }); + + test("returns null when no closest region exists", async () => { + const client = createClient([[]]); + + await expect( + createStore(client).findClosestRegion({ lat: 0, lng: 0 }), + ).resolves.toBeNull(); + expect(client.endCount).toBe(1); + }); + + test("selects a closer Earth fallback for a distant level-2 result", async () => { + const client = createClient([ + [makeRow({ name: "Level 2", lat: 5, region_level: 2 })], + [makeRow({ name: "Level 1", lat: 1, region_level: 1 })], + [makeRow({ name: "Level 3", lat: 2, region_level: 3 })], + ]); + + await expect( + createStore(client).findClosestRegion( + { lat: 0, lng: 0 }, + { regionLevel: 2 }, + ), + ).resolves.toMatchObject({ name: "Level 1", regionLevel: 1 }); + expect(client.queries.map((query) => query.values?.[2])).toEqual([ + "region-2", + "region-1", + "region-3", + ]); + }); + + test("keeps nearby and non-level-2 closest results", async () => { + const nearbyClient = createClient([[makeRow({ lat: 0.01 })]]); + const levelOneClient = createClient([ + [makeRow({ lat: 5, region_level: 1 })], + ]); + + await expect( + createStore(nearbyClient).findClosestRegion( + { lat: 0, lng: 0 }, + { regionLevel: 2 }, + ), + ).resolves.toMatchObject({ regionLevel: 2 }); + await expect( + createStore(levelOneClient).findClosestRegion( + { lat: 0, lng: 0 }, + { regionLevel: 1 }, + ), + ).resolves.toMatchObject({ regionLevel: 1 }); + expect(nearbyClient.queries).toHaveLength(1); + expect(levelOneClient.queries).toHaveLength(1); + }); + + test("filters and limits surrounding regions by computed distance", async () => { + const client = createClient([ + [ + makeRow({ name: "Near", lat: 0.01 }), + makeRow({ name: "Far", lat: 5 }), + makeRow({ name: "Also near", lat: 0.02 }), + ], + ]); + + const result = await createStore(client).findRegionsAround( + { lat: 0, lng: 0 }, + { maxDistance: 10, maxResults: 1, language: "korean" }, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ name: "Near" }); + expect(client.queries[0]?.values).toEqual([ + 0, + 0, + "earth", + 2, + "korean", + 100, + ]); + }); + + test("searches normalized queries, deduplicates, and falls back levels", async () => { + const duplicate = makeRow({ name: "Séoul", code: "seoul" }); + const fallback = makeRow({ + name: "Seoul District", + code: "seoul-district", + region_level: 1, + }); + const client = createClient([[duplicate, duplicate], [fallback]]); + + const result = await createStore(client).findRegionsByQuery(" Séoul ", { + maxResults: 3, + }); + + expect(result.map((region) => region.name)).toEqual([ + "Séoul", + "Seoul District", + ]); + expect(client.queries[0]?.values).toEqual([ + "earth", + 2, + "english", + "seoul", + "%seoul%", + 3, + ]); + expect(client.queries[1]?.values?.[1]).toBe(1); + }); + + test("uses distance ordering only with a complete finite bias", async () => { + const client = createClient([[makeRow({ distance_km: 2 })]]); + + await createStore(client).findRegionsByQuery("sample", { + biasLat: 37.5, + biasLng: 126.9, + maxResults: 1, + }); + + expect(client.queries[0]?.text).toContain("ST_DistanceSphere"); + expect(client.queries[0]?.values).toEqual([ + "earth", + 2, + "english", + "sample", + "%sample%", + 37.5, + 126.9, + 1, + ]); + const partialBiasClient = createClient([[makeRow()]]); + await createStore(partialBiasClient).findRegionsByQuery("sample", { + biasLat: 37.5, + }); + expect(partialBiasClient.queries[0]?.text).not.toContain( + "ST_DistanceSphere(geom", + ); + }); + + test("skips the database for empty queries and always closes on failure", async () => { + const unusedClient = createClient([]); + await expect( + createStore(unusedClient).findRegionsByQuery(" "), + ).resolves.toEqual([]); + expect(unusedClient.connectCount).toBe(0); + + const failingClient = createClient([new Error("query failed")]); + await expect( + createStore(failingClient).findRegionsAround({ lat: 0, lng: 0 }), + ).rejects.toThrow("query failed"); + expect(failingClient.endCount).toBe(1); + }); + + test("installs a PostGIS store for the runtime", () => { + try { + expect( + installPostgisRegionStore("postgresql://coverage.invalid/db"), + ).toBeInstanceOf(PostgisRegionStore); + } finally { + setRegionStore(null); + } + }); +}); diff --git a/apps/api-ground-codes/src/worker.test.ts b/apps/api-ground-codes/src/worker.test.ts new file mode 100644 index 00000000..8b588677 --- /dev/null +++ b/apps/api-ground-codes/src/worker.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import * as workerModule from "./worker.js"; + +interface TestApp { + handle(request: Request): Promise; +} + +type CreateWorker = (dependencies?: { + createApplication?: () => TestApp; + installRegionStore?: (connectionString: string) => void; + installRuntimeMetadata?: (env: Record) => void; +}) => { + fetch(request: Request, env: Record): Promise; +}; + +const getCreateWorker = () => + (workerModule as unknown as { createWorker?: CreateWorker }).createWorker; + +describe("Cloudflare Worker initialization", () => { + test("does not construct a Response while evaluating the worker module", () => { + const guardedImport = Bun.spawnSync( + [ + "bun", + "-e", + ` + const NativeResponse = globalThis.Response; + globalThis.Response = new Proxy(NativeResponse, { + construct() { + throw new Error("Response constructed during module evaluation"); + }, + }); + await import("./worker.ts"); + `, + ], + { cwd: import.meta.dir, stderr: "pipe", stdout: "pipe" }, + ); + + expect(guardedImport.exitCode).toBe(0); + }); + + test("creates one application lazily after installing request environment", async () => { + const createWorker = getCreateWorker(); + expect(createWorker).toBeFunction(); + if (!createWorker) return; + + const events: string[] = []; + const app: TestApp = { + async handle() { + events.push("handle"); + return new Response("ok"); + }, + }; + const worker = createWorker({ + createApplication: () => { + events.push("create"); + return app; + }, + installRuntimeMetadata: () => events.push("metadata"), + installRegionStore: () => events.push("region"), + }); + + expect(events).toEqual([]); + + const env = { + HYPERDRIVE: { connectionString: "postgres://example" }, + }; + expect( + await ( + await worker.fetch(new Request("http://localhost/readyz"), env) + ).text(), + ).toBe("ok"); + expect( + await ( + await worker.fetch(new Request("http://localhost/healthz"), env) + ).text(), + ).toBe("ok"); + + expect(events).toEqual([ + "metadata", + "region", + "create", + "handle", + "metadata", + "handle", + ]); + }); +}); diff --git a/apps/api-ground-codes/src/worker.ts b/apps/api-ground-codes/src/worker.ts index c24ae87c..f436dc9a 100644 --- a/apps/api-ground-codes/src/worker.ts +++ b/apps/api-ground-codes/src/worker.ts @@ -11,20 +11,6 @@ export interface Env { CORS_ORIGINS?: string; } -const app = createApp(); -let installedConnectionString: string | null = null; - -const installRegionStoreFromEnv = (env: Env) => { - const connectionString = - env.HYPERDRIVE?.connectionString ?? env.SUPABASE_DB_URL; - if (!connectionString || connectionString === installedConnectionString) { - return; - } - - installPostgisRegionStore(connectionString); - installedConnectionString = connectionString; -}; - const installRuntimeMetadataFromEnv = (env: Env) => { if (env.API_RUNTIME_TAG) { process.env.API_RUNTIME_TAG = env.API_RUNTIME_TAG; @@ -34,10 +20,39 @@ const installRuntimeMetadataFromEnv = (env: Env) => { } }; -export default { - async fetch(request: Request, env: Env): Promise { - installRuntimeMetadataFromEnv(env); - installRegionStoreFromEnv(env); - return await app.handle(request); - }, +interface WorkerApplication { + handle(request: Request): Response | Promise; +} + +interface WorkerDependencies { + createApplication?: () => WorkerApplication; + installRegionStore?: (connectionString: string) => void; + installRuntimeMetadata?: (env: Env) => void; +} + +export const createWorker = ({ + createApplication = createApp, + installRegionStore = installPostgisRegionStore, + installRuntimeMetadata = installRuntimeMetadataFromEnv, +}: WorkerDependencies = {}) => { + let app: WorkerApplication | undefined; + let installedConnectionString: string | null = null; + + return { + async fetch(request: Request, env: Env): Promise { + installRuntimeMetadata(env); + + const connectionString = + env.HYPERDRIVE?.connectionString ?? env.SUPABASE_DB_URL; + if (connectionString && connectionString !== installedConnectionString) { + installRegionStore(connectionString); + installedConnectionString = connectionString; + } + + app ??= createApplication(); + return await app.handle(request); + }, + }; }; + +export default createWorker(); diff --git a/apps/grok-spiral/package.json b/apps/grok-spiral/package.json index 09390a74..4cf91c71 100644 --- a/apps/grok-spiral/package.json +++ b/apps/grok-spiral/package.json @@ -9,9 +9,9 @@ "start": "next start", "lint": "eslint app lib next.config.ts --max-warnings 0", "check-types": "tsc --noEmit", - "pages:build": "npx @cloudflare/next-on-pages", - "preview": "pnpm run pages:build && wrangler pages dev", - "deploy": "pnpm run pages:build && wrangler pages deploy" + "pages:build": "pnpm exec next-on-pages", + "preview": "pnpm run pages:build && pnpm exec wrangler pages dev", + "deploy": "pnpm run pages:build && pnpm exec wrangler pages deploy" }, "dependencies": { "@repo/ui": "workspace:*", @@ -32,6 +32,7 @@ "eslint": "^9", "eslint-config-next": "15.5.18", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vercel": "47.0.4" } } diff --git a/apps/web/hooks/use-disable-zoom.test.ts b/apps/web/hooks/use-disable-zoom.test.ts index d7fa3f17..d31c7a38 100644 --- a/apps/web/hooks/use-disable-zoom.test.ts +++ b/apps/web/hooks/use-disable-zoom.test.ts @@ -1,5 +1,46 @@ -import { describe, expect, test } from "bun:test"; -import { shouldPreventBrowserZoom } from "./use-disable-zoom"; +import { describe, expect, mock, test } from "bun:test"; +import { + installBrowserZoomPrevention, + shouldPreventBrowserZoom, +} from "./use-disable-zoom"; + +type ListenerRegistration = { + type: string; + listener: EventListener; + options?: boolean | AddEventListenerOptions | EventListenerOptions; +}; + +const createEventTarget = () => { + const active = new Map>(); + const added: ListenerRegistration[] = []; + const removed: ListenerRegistration[] = []; + + return { + added, + removed, + addEventListener( + type: string, + listener: EventListener, + options?: boolean | AddEventListenerOptions, + ) { + added.push({ type, listener, options }); + const listeners = active.get(type) ?? new Set(); + listeners.add(listener); + active.set(type, listeners); + }, + removeEventListener( + type: string, + listener: EventListener, + options?: boolean | EventListenerOptions, + ) { + removed.push({ type, listener, options }); + active.get(type)?.delete(listener); + }, + dispatch(type: string, event: Event) { + active.get(type)?.forEach((listener) => listener(event)); + }, + }; +}; describe("browser zoom prevention", () => { test("prevents modified wheel zoom gestures", () => { @@ -9,10 +50,69 @@ describe("browser zoom prevention", () => { test("prevents multi-touch pinch gestures", () => { expect(shouldPreventBrowserZoom({ touches: { length: 2 } })).toBe(true); + expect(shouldPreventBrowserZoom({ scale: 1.5 })).toBe(true); }); test("allows ordinary one-finger touch and wheel movement", () => { expect(shouldPreventBrowserZoom({ touches: { length: 1 } })).toBe(false); + expect(shouldPreventBrowserZoom({ scale: 1 })).toBe(false); expect(shouldPreventBrowserZoom({})).toBe(false); }); + + test("registers zoom blockers and removes every listener on cleanup", () => { + const windowTarget = createEventTarget(); + const documentTarget = createEventTarget(); + const cleanup = installBrowserZoomPrevention(windowTarget, documentTarget); + + expect( + windowTarget.added.map(({ type, options }) => ({ type, options })), + ).toEqual([ + { type: "wheel", options: { passive: false } }, + { type: "keydown", options: undefined }, + ]); + expect( + documentTarget.added.map(({ type, options }) => ({ type, options })), + ).toEqual([ + { type: "touchmove", options: { passive: false } }, + { type: "gesturestart", options: { passive: false } }, + { type: "gesturechange", options: { passive: false } }, + { type: "gestureend", options: { passive: false } }, + ]); + + const dispatchCases = [ + [windowTarget, "wheel", { ctrlKey: true }], + [windowTarget, "keydown", { ctrlKey: true, key: "+" }], + [documentTarget, "touchmove", { touches: { length: 2 } }], + [documentTarget, "gesturestart", {}], + ] as const; + for (const [target, type, properties] of dispatchCases) { + const event = { ...properties, preventDefault: mock() }; + target.dispatch(type, event as unknown as Event); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + } + + cleanup(); + expect(windowTarget.removed).toHaveLength(2); + expect(documentTarget.removed).toHaveLength(4); + for (const [added, removed] of [ + ...windowTarget.added.map( + (added, index) => [added, windowTarget.removed[index]] as const, + ), + ...documentTarget.added.map( + (added, index) => [added, documentTarget.removed[index]] as const, + ), + ]) { + expect(removed).toMatchObject({ + type: added.type, + options: undefined, + }); + expect(removed?.listener).toBe(added.listener); + } + + for (const [target, type, properties] of dispatchCases) { + const event = { ...properties, preventDefault: mock() }; + target.dispatch(type, event as unknown as Event); + expect(event.preventDefault).not.toHaveBeenCalled(); + } + }); }); diff --git a/apps/web/hooks/use-disable-zoom.ts b/apps/web/hooks/use-disable-zoom.ts index e29757a3..738c749a 100644 --- a/apps/web/hooks/use-disable-zoom.ts +++ b/apps/web/hooks/use-disable-zoom.ts @@ -14,54 +14,72 @@ export function shouldPreventBrowserZoom(event: BrowserZoomEventLike) { } /** - * A hook that prevents browser zoom functionality by intercepting - * wheel events with Ctrl/Meta key and keyboard shortcuts (Ctrl/Meta + +/-) + * Install browser zoom prevention and return a complete listener cleanup. */ -export function useDisableZoom() { - useEffect(() => { - const handleWheel = (e: WheelEvent) => { - if (shouldPreventBrowserZoom(e)) { - e.preventDefault(); - } - }; +export function installBrowserZoomPrevention( + windowTarget: Pick< + Window, + "addEventListener" | "removeEventListener" + > = window, + documentTarget: Pick< + Document, + "addEventListener" | "removeEventListener" + > = document, +) { + const handleWheel = (e: WheelEvent) => { + if (shouldPreventBrowserZoom(e)) { + e.preventDefault(); + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (shouldPreventBrowserZoom(e)) { + e.preventDefault(); + } + }; - const handleTouchMove = (e: TouchEvent) => { - if (shouldPreventBrowserZoom(e)) { - e.preventDefault(); - } - }; + const handleGesture = (e: Event) => { + e.preventDefault(); + }; - const handleGesture = (e: Event) => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + (e.ctrlKey || e.metaKey) && + (e.key === "+" || e.key === "-" || e.key === "=") + ) { e.preventDefault(); - }; + } + }; - const handleKeyDown = (e: KeyboardEvent) => { - if ( - (e.ctrlKey || e.metaKey) && - (e.key === "+" || e.key === "-" || e.key === "=") - ) { - e.preventDefault(); - } - }; + windowTarget.addEventListener("wheel", handleWheel, { passive: false }); + windowTarget.addEventListener("keydown", handleKeyDown); + documentTarget.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + documentTarget.addEventListener("gesturestart", handleGesture, { + passive: false, + }); + documentTarget.addEventListener("gesturechange", handleGesture, { + passive: false, + }); + documentTarget.addEventListener("gestureend", handleGesture, { + passive: false, + }); - window.addEventListener("wheel", handleWheel, { passive: false }); - window.addEventListener("keydown", handleKeyDown); - document.addEventListener("touchmove", handleTouchMove, { passive: false }); - document.addEventListener("gesturestart", handleGesture, { - passive: false, - }); - document.addEventListener("gesturechange", handleGesture, { - passive: false, - }); - document.addEventListener("gestureend", handleGesture, { passive: false }); + return () => { + windowTarget.removeEventListener("wheel", handleWheel); + windowTarget.removeEventListener("keydown", handleKeyDown); + documentTarget.removeEventListener("touchmove", handleTouchMove); + documentTarget.removeEventListener("gesturestart", handleGesture); + documentTarget.removeEventListener("gesturechange", handleGesture); + documentTarget.removeEventListener("gestureend", handleGesture); + }; +} - return () => { - window.removeEventListener("wheel", handleWheel); - window.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("touchmove", handleTouchMove); - document.removeEventListener("gesturestart", handleGesture); - document.removeEventListener("gesturechange", handleGesture); - document.removeEventListener("gestureend", handleGesture); - }; - }, []); +/** + * A hook that prevents browser zoom functionality by intercepting + * wheel events with Ctrl/Meta key and keyboard shortcuts (Ctrl/Meta + +/-) + */ +export function useDisableZoom() { + useEffect(() => installBrowserZoomPrevention(), []); } diff --git a/apps/web/lib/i18n/ground-code-language.test.ts b/apps/web/lib/i18n/ground-code-language.test.ts index b52f5cca..555e3f29 100644 --- a/apps/web/lib/i18n/ground-code-language.test.ts +++ b/apps/web/lib/i18n/ground-code-language.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { locales } from "@/i18n"; import { getGroundCodeLanguage } from "./ground-code-language"; describe("ground code language mapping", () => { @@ -110,4 +111,17 @@ describe("ground code language mapping", () => { expect(getGroundCodeLanguage("fa")).toBe("persian"); expect(getGroundCodeLanguage("yue")).toBe("cantonese"); }); + + test("resolves every supported UI locale without falling through", () => { + for (const locale of locales) { + const language = getGroundCodeLanguage(locale); + expect(language).toBeString(); + expect(language.length).toBeGreaterThan(0); + if (locale === "en") { + expect(language).toBe("english"); + } else { + expect(language).not.toBe("english"); + } + } + }); }); diff --git a/apps/web/lib/map/celestial-bodies.test.ts b/apps/web/lib/map/celestial-bodies.test.ts index f8398f2a..5b30ba0a 100644 --- a/apps/web/lib/map/celestial-bodies.test.ts +++ b/apps/web/lib/map/celestial-bodies.test.ts @@ -1,5 +1,15 @@ import { describe, expect, test } from "bun:test"; -import { PLANETARY_LANDMARK_LABELS } from "./celestial-bodies"; +import { + EARTH_DEFAULT_VIEW, + PLANETARY_BODY_CONFIGS, + PLANETARY_LANDMARK_LABELS, + createPlanetaryMapType, + getDefaultPlanetaryLayerId, + getDefaultViewForBody, + getPlanetaryLayerConfig, + parseCelestialBody, + parsePlanetaryLayerId, +} from "./celestial-bodies"; import { PLANETARY_LANDMARK_LOCALIZED_LABELS } from "./planetary-landmark-labels"; describe("planetary landmark labels", () => { @@ -117,3 +127,103 @@ describe("planetary landmark labels", () => { ).toBe("кратер Езеро"); }); }); + +describe("celestial body map configuration", () => { + test("normalizes bodies and resolves their default views", () => { + expect(parseCelestialBody("earth")).toBe("earth"); + expect(parseCelestialBody("moon")).toBe("moon"); + expect(parseCelestialBody("mars")).toBe("mars"); + expect(parseCelestialBody("pluto")).toBe("earth"); + expect(parseCelestialBody(null)).toBe("earth"); + + expect(getDefaultViewForBody("earth")).toBe(EARTH_DEFAULT_VIEW); + expect(getDefaultViewForBody("moon")).toEqual({ + center: PLANETARY_BODY_CONFIGS.moon.center, + zoom: PLANETARY_BODY_CONFIGS.moon.zoom, + }); + }); + + test("falls back to each body's configured default map layer", () => { + expect(getDefaultPlanetaryLayerId("moon")).toBe("KaguyaTC_Ortho"); + expect(getPlanetaryLayerConfig("moon", "LOLA_bw").id).toBe("LOLA_bw"); + expect(getPlanetaryLayerConfig("moon", "missing").id).toBe( + "KaguyaTC_Ortho", + ); + expect(parsePlanetaryLayerId("mars", null)).toBe("MDIM21_color"); + }); + + test("creates wrapped and clamped WMS map tiles", () => { + const originalGoogleDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "google", + ); + const existingGoogle = { sentinel: "existing-google" }; + Object.defineProperty(globalThis, "google", { + configurable: true, + enumerable: false, + value: existingGoogle, + writable: false, + }); + const existingGoogleDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "google", + ); + if (!existingGoogleDescriptor) { + throw new Error("expected seeded Google descriptor"); + } + class Size { + constructor( + readonly width: number, + readonly height: number, + ) {} + } + class ImageMapType { + constructor(readonly options: google.maps.ImageMapTypeOptions) {} + } + + try { + Object.defineProperty(globalThis, "google", { + configurable: true, + value: { maps: { ImageMapType, Size } }, + }); + + try { + const mapType = createPlanetaryMapType("mars", "THEMIS") as unknown as { + options: google.maps.ImageMapTypeOptions; + }; + const tileUrl = mapType.options.getTileUrl?.({ x: -1, y: 99 }, 2); + + expect(mapType.options.name).toBe("THEMIS IR Day"); + expect(mapType.options.tileSize).toMatchObject({ + width: 256, + height: 256, + }); + expect(tileUrl).toContain("LAYERS=THEMIS"); + expect(tileUrl).toContain("REQUEST=GetMap"); + const bounds = new URL(tileUrl ?? "").searchParams + .get("BBOX") + ?.split(",") + .map(Number); + expect(bounds).toEqual([ + 90, + expect.closeTo(-85.05112878), + 180, + expect.closeTo(-66.51326044), + ]); + } finally { + Object.defineProperty(globalThis, "google", existingGoogleDescriptor); + } + + expect(Object.getOwnPropertyDescriptor(globalThis, "google")).toEqual( + existingGoogleDescriptor, + ); + expect(globalThis.google).toBe(existingGoogle); + } finally { + if (originalGoogleDescriptor) { + Object.defineProperty(globalThis, "google", originalGoogleDescriptor); + } else { + Reflect.deleteProperty(globalThis, "google"); + } + } + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index e3ee2c44..ae27f2c9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,23 +10,25 @@ "lint": "eslint app components e2e hooks i18n i18n.ts lib next.config.ts playwright.config.ts playwright.production.config.ts --max-warnings 0", "check-types": "tsc --noEmit", "test": "bun test ./lib ./app ./components ./hooks", + "test:coverage": "cd ../.. && bun test --coverage --coverage-reporter=lcov --coverage-reporter=text --coverage-dir=coverage/web ./apps/web/lib ./apps/web/app ./apps/web/components ./apps/web/hooks && pnpm exec c8 --all --exclude-after-remap --include apps/web/lib/code/ground-codes.ts --include apps/web/lib/code/share-url.ts --include apps/web/lib/i18n/ground-code-language.ts --include apps/web/lib/map/celestial-bodies.ts --include apps/web/lib/map/google-maps-availability.ts --include apps/web/hooks/use-disable-zoom.ts --exclude '**/' --reporter=lcov --reporter=text --reports-dir coverage/web-branches pnpm --filter ground-codes exec tsx --test ../../scripts/coverage-branch-probes/web.test.ts && node scripts/merge-branch-coverage.mjs web", "test:e2e": "playwright test", "test:e2e:smoke": "playwright test e2e/ground-code-share.spec.ts --grep @smoke", "test:e2e:layout": "playwright test e2e/ground-code-share.spec.ts --grep @layout", "test:e2e:full": "playwright test", "test:e2e:prod": "playwright test --config=playwright.production.config.ts", "qa:visual": "playwright test e2e/visual-qa.spec.ts --config=playwright.production.config.ts", - "pages:build": "npx @cloudflare/next-on-pages && node scripts/patch-pages-routes.mjs", - "preview": "pnpm run pages:build && wrangler pages dev", - "deploy": "pnpm run pages:build && wrangler pages deploy" + "pages:build": "pnpm exec next-on-pages && node scripts/patch-pages-routes.mjs", + "preview": "pnpm run pages:build && pnpm exec wrangler pages dev", + "deploy": "pnpm run pages:build && pnpm exec wrangler pages deploy" }, "dependencies": { "@react-google-maps/api": "^2.20.8", "@repo/ui": "workspace:*", + "@swc/helpers": "0.5.17", "@types/google.maps": "^3.64.1", - "cesium": "^1.141.0", + "cesium": "1.143.0", "next": "15.5.18", - "next-intl": "^4.0.2", + "next-intl": "4.13.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.6.0" @@ -44,6 +46,7 @@ "eslint": "^9", "eslint-config-next": "15.5.18", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vercel": "47.0.4" } } diff --git a/docs/operations/incident-runbook.md b/docs/operations/incident-runbook.md new file mode 100644 index 00000000..4a64538d --- /dev/null +++ b/docs/operations/incident-runbook.md @@ -0,0 +1,226 @@ +# Production Incident Runbook + +Use this runbook for a production-smoke failure, SLO alert, elevated API error +rate, or a bad Worker/Pages deployment. + +## 0. Reproduce release artifacts without publishing + +Production workflows and the root `.nvmrc` select Node.js 22. The remaining +release tools are pnpm 11.4.0, Bun 1.3.1, next-on-pages 1.13.16, Vercel CLI +47.0.4, and the repository-local Wrangler 4.110.0. Select Node.js 22 with your +version manager, then fail closed if the release toolchain does not match those +pins: + +```sh +set -eu +test "$(tr -d '\n' < .nvmrc)" = "22" +test "$(node -p 'process.versions.node.split(".")[0]')" = "22" +test "$(pnpm --version)" = "11.4.0" +test "$(bun --version)" = "1.3.1" +test "$(pnpm --filter web exec next-on-pages --version)" = "1.13.16" +test "$(pnpm --filter web exec vercel --version)" = "47.0.4" +test "$(pnpm --filter grok-spiral exec vercel --version)" = "47.0.4" +test "$(pnpm exec wrangler --version)" = "4.110.0" +``` + +From a clean checkout, install twice and confirm both status commands print +nothing. This detects any manifest or lockfile mutation while proving the +second install is reproducible: + +```sh +set -eu +git status --porcelain -- package.json pnpm-lock.yaml pnpm-workspace.yaml \ + ':(glob)**/package.json' +pnpm install --frozen-lockfile +pnpm install --frozen-lockfile +git status --porcelain -- package.json pnpm-lock.yaml pnpm-workspace.yaml \ + ':(glob)**/package.json' +``` + +Build the same release targets with lockfile-resolved tools. The Pages commands +must report `Vercel CLI 47.0.4` and `Completed pnpm exec vercel build`. Do not +substitute a global tool or use `npx`, `pnpm dlx`, or an unpinned `latest` tool: + +```sh +set -eu +pnpm --filter web pages:build +pnpm --filter grok-spiral pages:build +pnpm --filter api-ground-codes build +WORKER_DRY_RUN_DIR="$(mktemp -d)" +trap 'rm -rf -- "$WORKER_DRY_RUN_DIR"' EXIT +pnpm exec wrangler deploy --dry-run \ + --config apps/api-ground-codes/wrangler.toml \ + --outdir "$WORKER_DRY_RUN_DIR" +rm -rf -- "$WORKER_DRY_RUN_DIR" +trap - EXIT +``` + +The Worker dry-run is local and does not require Cloudflare credentials. The +`--dry-run` flag on the first physical command line is the publication safety +boundary even when Wrangler authentication is configured. Its temporary output +directory is unique to the command and is removed on both success and failure. + +For an additional lockfile proof, repeat the Pages builds with package fetching +disabled. Both commands must succeed from the frozen install: + +```sh +set -eu +rm -rf -- apps/web/.next apps/web/.vercel/output +npm_config_offline=true pnpm --offline --filter web pages:build +rm -rf -- apps/web/.next apps/web/.vercel/output +rm -rf -- apps/grok-spiral/.next apps/grok-spiral/.vercel/output +npm_config_offline=true pnpm --offline --filter grok-spiral pages:build +rm -rf -- apps/grok-spiral/.next apps/grok-spiral/.vercel/output +``` + +The `pnpm exec wrangler deploy` command must report `--dry-run: exiting now.` +It bundles the Worker locally and does not publish a deployment. + +## 1. Declare and contain + +1. Open an [incident issue][new-incident]. Record the UTC start time, incident + owner, affected objective, user impact, and links to the triggering signal. +2. Check the [Production Smoke history][smoke] and preserve the failed run's + profile, check timings, logs, and commit. Do not paste request data into the + issue. +3. Read [`/metrics`][metrics] and `/readyz`. Record `runtimeCommit`, service, + scope, startup time, counters, and the observation time. `/metrics` is a + single Worker-isolate snapshot, not globally aggregated history. +4. Pause further production changes until an owner chooses mitigation or + rollback. Keep the incident issue current with decisions and timestamps. + +## 2. Query Cloudflare observability + +In Cloudflare Workers Observability, select the `api-ground-codes` Worker and +the incident window, then start with the exact structured-log filter: + +```text +event = "api.request.completed" +``` + +This is the `event=api.request.completed` signal; use Cloudflare's field/value +filter controls if its query view does not accept the displayed syntax. + +Narrow or group the result without inspecting request content: + +- **Status family:** `status` is an exact three-digit string. Run one of these + executable prefix expressions, then compare counts and rates: + - `startsWith(status, "5")` + - `startsWith(status, "4")` + - `startsWith(status, "3")` + - `startsWith(status, "2")` + - `status = "5xx"` is invalid because no completion log has the literal + status value `5xx`. +- **Route duration:** filter or group by the route template, for example + `route = "/v1/encode"` or `route = "/v1/search"`, and sort `durationMs` + descending. Use `durationMs >= 2000` to inspect objective misses. +- **Deployment:** filter `runtimeCommit` to the 40-character SHA reported by + `/metrics`; compare it with the preceding known-good SHA. +- **Application shape:** the Worker calls `console.log(record)` with the + `RequestCompletionLog` object rather than a pre-serialized JSON string. Its + application payload fields are only `event`, `service`, `route`, `method`, + `status`, `durationMs`, and `runtimeCommit`, so Cloudflare can index them. + Cloudflare platform metadata may appear alongside that payload in the Logs + event envelope; it is not emitted by the application and is not part of the + seven-field privacy contract. + +Logs must never include coordinates, search strings, ground codes, IP +addresses, headers, or credentials. Do not add these values to Cloudflare +queries, GitHub issues, screenshots, step summaries, or webhook messages. + +## 3. Roll back to the last known-good SHA + +1. Identify the last full [Production Smoke run][smoke] that passed before the + incident. Verify its SHA against the applicable [API Worker][worker-history] + or [Web Pages][web-history] deployment history; use the deployed artifact, + not an unverified local build. +2. Before an API rollback, account for external state: Worker rollback does + not roll back PostGIS, R2, or other external state. Verify that the older + code is compatible with the current schema and data, including current R2 + object formats. If compatibility cannot be demonstrated, use a forward fix + or documented data recovery instead of rolling back code alone. +3. For an API incident, find the Worker version tagged with the last known-good + SHA, then run the repository-local [Wrangler rollback][worker-rollback]. Use + the Cloudflare Workers Deployments view to select it; do not export or attach + the full deployment object to the incident. Record only the version ID, + tag/SHA, and deployment status. The rollback command changes production + immediately, so only the incident owner should run it after completing the + compatibility check above: + + ```sh + set -eu + set +x + : "${CLOUDFLARE_API_TOKEN:?set a scoped token from the secret store}" + : "${KNOWN_GOOD_SHA:?set the verified 40-character SHA}" + : "${WORKER_VERSION_ID:?set the verified known-good version ID}" + pnpm exec wrangler rollback "$WORKER_VERSION_ID" \ + --config apps/api-ground-codes/wrangler.toml \ + --message "Incident rollback to $KNOWN_GOOD_SHA" + ``` + + Confirm `/readyz` and `/metrics` report the known-good API `runtimeCommit`, + then run the manual full [Production Smoke workflow][smoke]: + + ```sh + set -eu + curl --fail --silent --show-error https://api.ground.codes/readyz + curl --fail --silent --show-error https://api.ground.codes/metrics + gh workflow run production-smoke.yml --ref main \ + -f profile=full \ + -f force_failure=false + ``` + +4. For a Web or Grok Spiral incident, record the selected + Pages deployment ID and commit. Wrangler 4.110.0 can list Pages deployments + but does not expose a + Pages rollback subcommand, so use the authenticated + [Pages rollback API][pages-rollback-api]. Set `PAGES_PROJECT` to + `ground-codes` for Web or + `grok-spiral` for Grok Spiral. Only a successful production deployment is a + valid target. Use the Cloudflare Pages Deployments view to find it; do not + export or attach the full deployment object to the incident. Record only the + deployment ID, status, and commit: + + ```sh + set -eu + set +x + : "${CLOUDFLARE_ACCOUNT_ID:?set the Cloudflare account ID}" + : "${CLOUDFLARE_API_TOKEN:?set a Pages Write token from the secret store}" + : "${PAGES_PROJECT:?set ground-codes or grok-spiral}" + : "${PAGES_DEPLOYMENT_ID:?set the verified known-good deployment ID}" + curl --silent --show-error --fail-with-body --output /dev/null \ + --request POST \ + "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PAGES_PROJECT}/deployments/${PAGES_DEPLOYMENT_ID}/rollback" \ + --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" + ``` + + Verify that deployment ID is active in Pages history. For Web, fetch a fresh + Web response from `https://ground.codes/` and run a full production smoke. + For Grok Spiral, fetch `https://grok-spiral.ground.codes/` and use its + separate Pages history. `/metrics.runtimeCommit` identifies only the API + Worker and is not evidence of either active Pages deployment. + +5. If the platform rollback cannot be used, revert the bad change on `main` + with a normal reviewed commit and let the relevant deployment workflow + publish it. Never force-push or move `main` to the old SHA. +6. Record the bad SHA, known-good SHA, deployment/run URLs, operator, and UTC + timestamps in the incident issue. + +## 4. Validate and close + +Keep the issue open until user impact has stopped, readiness and Web-root +checks are healthy, and Cloudflare filters show no continuing error spike. For +an API incident, verify the active `runtimeCommit`; for a Web incident, verify +the active Pages deployment ID and commit plus a fresh Web response. A full +post-deploy production smoke must pass before closure; link that run in the +issue. Record the error-budget impact, root cause, follow-up owner, and due date +before closing. + +[new-incident]: https://github.com/hmmhmmhm/ground.codes/issues/new?labels=incident&title=Production%20incident%3A%20 +[smoke]: https://github.com/hmmhmmhm/ground.codes/actions/workflows/production-smoke.yml +[worker-history]: https://github.com/hmmhmmhm/ground.codes/actions/workflows/deploy-api.yml +[web-history]: https://github.com/hmmhmmhm/ground.codes/actions/workflows/deploy-web.yml +[grok-history]: https://github.com/hmmhmmhm/ground.codes/actions/workflows/deploy-grok-spiral.yml +[metrics]: https://api.ground.codes/metrics +[worker-rollback]: https://developers.cloudflare.com/workers/versions-and-deployments/rollbacks/ +[pages-rollback-api]: https://developers.cloudflare.com/api/resources/pages/subresources/projects/subresources/deployments/methods/rollback/ diff --git a/docs/operations/region-data-delivery.md b/docs/operations/region-data-delivery.md new file mode 100644 index 00000000..0e3e45fb --- /dev/null +++ b/docs/operations/region-data-delivery.md @@ -0,0 +1,197 @@ +# Region Data Delivery Operations + +This runbook covers the Cloudflare R2 boundary used to publish and retrieve +Ground Codes region data. Publication is private and authenticated. Runtime, +CI, and local materialization read immutable objects through the public custom +domain. + +## Infrastructure boundary + +| Setting | Value | +| ------------------------- | ---------------------------------------------------------- | +| R2 bucket | `ground-codes-region-data` | +| Public read origin | `https://region-data.ground.codes` | +| S3 endpoint | `https://.r2.cloudflarestorage.com` | +| S3 region | `auto` | +| R2 jurisdiction | `default` | +| Minimum custom-domain TLS | `1.2` | +| Managed development URL | Disabled | + +The [custom domain][r2-public-buckets] exposes object GET and HEAD requests but +does not expose a bucket listing. The managed `r2.dev` URL stays disabled so +the custom domain is the only public read path. Writes use the S3-compatible +endpoint and an [R2 token][r2-authentication] with `Object Read & Write` +permission scoped only to +`ground-codes-region-data`; normal CI and public pull requests never receive +that credential. + +Published releases are append-only. Data objects live under their committed +`objects/.json.gz` keys and a release manifest is written last. Never +overwrite a conflicting object or manifest, delete an old release as part of +a normal deployment, or grant the publisher bucket-administration access. + +## GitHub Actions settings + +The repository contains these settings. Record and inspect names or presence, +not values. + +| Kind | Name | Consumer | +| -------- | ---------------------------------- | ------------------------------------ | +| Secret | `CLOUDFLARE_ACCOUNT_ID` | Publisher S3 endpoint | +| Secret | `R2_REGION_DATA_ACCESS_KEY_ID` | Publisher authentication | +| Secret | `R2_REGION_DATA_SECRET_ACCESS_KEY` | Publisher authentication | +| Variable | `REGION_DATA_BASE_URL` | Read-only sync in CI and deploy jobs | + +`REGION_DATA_BASE_URL` is `https://region-data.ground.codes`. Set each secret +in a separate stdin session so its value is absent from command arguments, +shell history, repository files, and logs: + +```sh +set +x +gh secret set CLOUDFLARE_ACCOUNT_ID +gh secret set R2_REGION_DATA_ACCESS_KEY_ID +gh secret set R2_REGION_DATA_SECRET_ACCESS_KEY +gh variable set REGION_DATA_BASE_URL --body \ + https://region-data.ground.codes +``` + +Paste one value into each secret command's stdin and finish it with EOF. Do not +use `--body` for secrets. The base URL is public and may be passed as an +argument. + +Verify only the setting names: + +```sh +gh secret list --app actions --json name \ + --jq '.[].name' | rg \ + '^(CLOUDFLARE_ACCOUNT_ID|R2_REGION_DATA_ACCESS_KEY_ID|R2_REGION_DATA_SECRET_ACCESS_KEY)$' +gh variable get REGION_DATA_BASE_URL +``` + +## Provision and verify + +Use the repository-local Wrangler version and an authenticated Cloudflare +session: + +```sh +pnpm exec wrangler r2 bucket create ground-codes-region-data +pnpm exec wrangler r2 bucket list +pnpm exec wrangler r2 bucket info ground-codes-region-data +pnpm exec wrangler r2 bucket domain add ground-codes-region-data \ + --domain region-data.ground.codes \ + --zone-id "$CLOUDFLARE_ZONE_ID" \ + --min-tls 1.2 +pnpm exec wrangler r2 bucket domain get ground-codes-region-data \ + --domain region-data.ground.codes +pnpm exec wrangler r2 bucket dev-url get ground-codes-region-data +``` + +The bucket and domain API responses must identify the expected names, the +custom domain must be enabled with active ownership and SSL, and the managed +development URL must report disabled. Do not paste complete API responses into +issues or logs. + +Before the first release, both the root and an unknown object must return a +non-success status and no XML or JSON object listing. After publication, repeat +the unknown-object check and make a GET for one exact object key from the +committed manifest; expect the unknown path to remain non-successful and the +published object to return `200` with `Content-Type: application/gzip`. + +```sh +curl --silent --show-error --output /dev/null --write-out '%{http_code}\n' \ + https://region-data.ground.codes/ +curl --silent --show-error --output /dev/null --write-out '%{http_code}\n' \ + https://region-data.ground.codes/not-a-published-object +``` + +## Provisioning evidence + +The boundary was provisioned and re-read on 2026-07-15: + +- Wrangler reported `ground-codes-region-data` in the default jurisdiction + with Standard storage and an empty initial inventory. +- `region-data.ground.codes` was enabled with active ownership and SSL, minimum + TLS 1.2, while the managed `r2.dev` URL remained disabled. +- The custom-domain root and an unknown object both returned `404`; neither + response exposed a bucket listing. +- The `ground-codes-region-data-publisher` token was active, its S3 credentials + listed the target bucket, and the same credentials received `403` for a + different account bucket. This proves the token is bucket-scoped rather than + an R2 administrator credential. +- GitHub reported all three secret names present and + `REGION_DATA_BASE_URL=https://region-data.ground.codes`. + +For the initial operator-run publication, the owner explicitly approved a +local escrow copy at +`$HOME/Documents/personal-agent/secrets/r2-region-data.env`. It is outside a +Git worktree, its parent directory is mode `700`, and the file is mode `600`. +Do not copy it into this repository, attach it to an issue or artifact, or use +it in read-only CI. Revalidate its permissions during rotation and remove or +replace it when the associated Cloudflare token is revoked. + +## Initial release evidence + +The first release was generated and shadow-verified on 2026-07-15 with Node +22.23.1. The `region-dist` and `region-db` trees at branch commit `8547f41` +matched `origin/main` commit `827847ddf42f5432f104b40eaf3ec9791d5e0581` +before generation. + +| Measurement | Result | +| ------------------------- | ------------------------------------------------------------------------: | +| Version | `sha256-4b6d31b92ce300ca4ca6d98fb24d966fad61b098639e51f33134b5922ccd3190` | +| Manifest SHA-256 | `bf3068b4d416287d617332b3d77f92f9d8c6dbcbdf4c32ab7b9b07056a36a9f1` | +| Logical entries | 2,031 | +| `region-dist` entries | 901 | +| `region-db` entries | 1,130 | +| Deduplicated gzip objects | 1,044 | +| Uncompressed bytes | 8,971,117,492 | +| Compressed object bytes | 738,078,307 | +| Pointer bytes | 187 | +| Manifest bytes | 759,643 | + +The first publication uploaded all 1,044 objects and 738,078,307 bytes, then +uploaded the manifest. An immediate second publication uploaded zero objects +and zero bytes, skipped all 1,044 verified objects, and did not upload the +existing byte-identical manifest. + +Two independent runs started from an empty shadow directory with no R2 write +credentials in the environment. Each public sync selected and downloaded all +2,031 entries, skipped zero local files, and materialized 8,971,117,492 bytes. +Each subsequent source-vs-shadow exact verification reported the same version +and inventory with zero missing, extra, changed, or unreadable paths. A public +object GET returned `200 application/gzip`, the release manifest returned +`200 application/json`, and an unpublished object path returned `404`. + +## Credential rotation + +1. Create a replacement R2 S3 token with `Object Read & Write` permission + scoped only to `ground-codes-region-data`. +2. In separate stdin sessions, replace + `R2_REGION_DATA_ACCESS_KEY_ID` and + `R2_REGION_DATA_SECRET_ACCESS_KEY` in GitHub Actions. +3. Run the publisher against the current release. It must complete + idempotently without uploading or changing existing objects. +4. Revoke the previous token in Cloudflare, then verify that only the + replacement token remains active. Record the rotation time and operator, + never either credential value. + +Rotate immediately after suspected disclosure, operator access removal, or a +scope error. Routine rotation should also confirm that the account ID and base +URL settings are unchanged. + +## Rollback and deprovisioning + +A release rollback changes only the committed release pointer in a normal, +reviewed pull request. Old immutable manifests and objects remain readable, so +restoring the previous pointer does not require an R2 mutation. + +For an infrastructure rollback, first stop publisher workflows, then revoke +the publisher token and remove the two R2 credential secrets. Disable or remove +the custom domain only after confirming that no active deployment references +it. Keep the bucket and immutable releases until the repository pointer and all +deployments have moved away from R2 and the retention decision has been +reviewed. Bucket deletion is a separate destructive operation and is never a +routine rollback step. + +[r2-authentication]: https://developers.cloudflare.com/r2/api/tokens/ +[r2-public-buckets]: https://developers.cloudflare.com/r2/buckets/public-buckets/ diff --git a/docs/operations/service-objectives.md b/docs/operations/service-objectives.md new file mode 100644 index 00000000..1a92eb5d --- /dev/null +++ b/docs/operations/service-objectives.md @@ -0,0 +1,57 @@ +# Production Service Objectives + +These initial objectives cover the public API and Web entry point. Review them +after each incident and after enough production history exists to set tighter +targets. + +## Objectives + +| Signal | Monthly objective | Check ID | +| --------------------- | -------------------------------------------------------------------------- | ---------------------- | +| API readiness | 99.9% monthly readiness availability | `api.readiness` | +| Web root | 99.9% monthly Web-root availability | `web.root` | +| Representative encode | Every expected request passes in under 2 seconds | `earth.english.encode` | +| Representative search | Every expected request passes in under 2 seconds | `earth.english.search` | +| Incident recovery | A full post-deploy production smoke must pass before an incident is closed | Full profile | + +## Reproducible monthly measurement + +Evaluate one UTC calendar month at a time. Expected slots are the 30-minute +cron invocations at 00 and 30 minutes of every UTC hour; manual, daily-full, +and post-deploy runs are supplemental evidence and are not added to the monthly +denominator. A missing, cancelled, not created, or non-passing scheduled slot +is a failed slot for every required check it did not pass. + +For each availability check ID, calculate monthly availability as: + +```text +passed expected slots / all expected slots × 100 +``` + +The failed-slot error budget is +`floor(all expected slots × (1 - 0.999))`. A 30-day month has 1,440 expected +slots, so at most 1 failed slot still meets 99.9%. This synthetic calculation +does not claim that a failed slot represents 30 minutes of actual downtime. + +Calculate latency compliance separately for `earth.english.encode` and +`earth.english.search`. An expected slot complies only when the exact check ID +passes and its recorded `durationMs` < 2,000 ms. Monthly latency compliance is +`passing-and-fast expected slots / all expected slots`; every missing, errored, +or slow check is a latency miss. Do not assign a numeric duration to a check +that did not complete. + +## Evaluation and escalation + +Use the [Production Smoke workflow][smoke] as the synthetic source of record. +Preserve the expected-slot list, matched workflow run, exact check ID, outcome, +and elapsed time with the monthly calculation. The daily and post-deploy full +profiles provide broader regression evidence. + +Open an incident immediately when readiness or the Web root fails, when a +post-deploy full smoke fails, or when the remaining monthly error budget is +exhausted. Record every latency miss; open an incident after two consecutive +latency misses or immediately when user impact is visible. Follow the +[incident runbook](./incident-runbook.md) for triage, rollback, evidence +capture, and closure. + +[smoke]: https://github.com/hmmhmmhm/ground.codes/actions/workflows/production-smoke.yml diff --git a/docs/quality/coverage.md b/docs/quality/coverage.md new file mode 100644 index 00000000..487aa766 --- /dev/null +++ b/docs/quality/coverage.md @@ -0,0 +1,42 @@ +# Coverage policy + +The coverage baseline was measured on 2026-07-14 with Bun 1.3.1 and c8 11.0.0. +The API branch baseline was recalibrated after the completion-boundary change +against authoritative GitHub Actions run `29303671837` on Ubuntu with Node 22. +The operations boundary was expanded on 2026-07-15 and measured with Node +22.23.1 and c8 11.0.0 after adding the R2 delivery modules and their behavior +tests. +Line and function minimums are fixed at 0.8 for every target. Branch minimums +are each measured ratio rounded down by less than 0.001. + +| Target | Lines | Functions | Branches | Branch minimum | +| ------------ | -----------------------------: | ---------------------------: | ---------------------------: | -------------: | +| ground-codes | 3288/4053 (0.8112509252405625) | 78/88 (0.8863636363636364) | 695/998 (0.6963927855711423) | 0.696 | +| api | 1847/2005 (0.9211970074812967) | 115/130 (0.8846153846153846) | 256/353 (0.7252124645892352) | 0.725 | +| web | 647/648 (0.9984567901234568) | 36/37 (0.972972972972973) | 289/485 (0.5958762886597938) | 0.595 | +| operations | 2500/2635 (0.9487666034155597) | 135/136 (0.9926470588235294) | 757/862 (0.8781902552204176) | 0.878 | + +## Source boundaries + +- ground-codes includes `packages/ground-codes/src/**/*.ts`, excluding only + declarations. +- api includes all 22 maintained `apps/api-ground-codes/src/**/*.ts` runtime + sources, excluding tests and declarations. +- web includes the six critical runtime sources for the Ground Codes client, + share URLs, locale mapping, celestial-body maps, Google Maps availability, + and browser zoom prevention declared in `scripts/coverage-policy.json`. +- operations includes the audit, smoke, workflow, governance, and coverage + policy modules plus the R2 `manifest.mjs`, `generate-release.mjs`, `sync.mjs`, + `publish.mjs`, and `verify.mjs` modules declared in + `scripts/coverage-policy.json`. + +Bun LCOV remains authoritative for API and Web line/function coverage. Genuine +Node/tsx behavior probes collect c8 branches over the complete declared target, +and the validated BRDA/BRF/BRH records are merged into the Bun reports. Test +files, declarations, generated tables, build output, and third-party assets are +outside these maintained-source boundaries; ordinary runtime modules are not. + +After authoritative CI calibration, thresholds may only increase. This API +correction replaces the pre-CI local baseline; it does not permit routine +downward ratcheting. A changed source boundary or instrumentation tool requires +a newly documented measurement and must not be used to hide a regression. diff --git a/docs/security/dependency-audit.md b/docs/security/dependency-audit.md new file mode 100644 index 00000000..d1f97af6 --- /dev/null +++ b/docs/security/dependency-audit.md @@ -0,0 +1,159 @@ +# Production Dependency Audit + +Audit date: 2026-07-13 + +## Policy + +Production dependency changes must leave `pnpm security:audit` with zero +critical and zero high-severity findings. High and critical findings are never +waived, including findings introduced only through transitive dependencies. A +remaining moderate finding must have a recorded dependency path, runtime +applicability analysis, mitigating control, and concrete follow-up issue. + +## Remediation + +The Web runtime dependencies are pinned exactly: + +| Package | Previous manifest range | Audited or peer-compatible version | +| -------------- | ----------------------- | ---------------------------------- | +| `@swc/helpers` | Transitive `0.5.15` | `0.5.17` | +| `cesium` | `^1.141.0` | `1.143.0` | +| `next-intl` | `^4.0.2` | `4.13.2` | + +The root lock policy uses selector-scoped overrides so only the vulnerable or +peer-incompatible transitive resolutions are replaced: + +| Superseded resolution | Locked replacement | Rationale | +| --------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@swc/helpers@0.5.15` | `0.5.17` | Aligns the `next-intl` SWC path with `@swc/core@1.15.43`, whose optional helper peer requires `>=0.5.17`. | +| `dompurify@3.4.2` | `3.4.12` | Replaces the vulnerable Cesium engine resolution without changing other DOMPurify lines. | +| `picomatch@2.3.1` | `2.3.2` | Replaces the vulnerable matcher resolution used below workspace UI tooling. | +| `postcss@8.4.31` | `8.4.49` | Replaces the audited vulnerable Next.js PostCSS resolution while retaining Next.js 15.5 compatibility. | +| `postcss@8.5.3` | `8.5.19` | Moves the Tailwind PostCSS resolution to an audited release. | +| `protobufjs@8.2.0` | `8.7.1` | Cesium 1.143 already moves the active graph to `^8.6.5`, resolved as `8.7.1`; this selector remains a defense-in-depth lock against reintroducing the vulnerable `8.2.0` resolution through drift. | + +`pnpm-lock.yaml` records the exact direct versions and override results. The +production-audit policy contract also verifies that none of the five vulnerable +transitive resolution keys can return and that the `next-intl` SWC path binds a +peer-compatible helper version. + +## Audit Result + +| State | Critical | High | Moderate | Low | Packages reported | +| ------------------ | -------: | ---: | -------: | --: | -------------------------------------------------------------- | +| Before remediation | 0 | 2 | 12 | 3 | `dompurify`, `next-intl`, `picomatch`, `postcss`, `protobufjs` | +| After remediation | 0 | 0 | 1 | 0 | `postcss` | + +The after-remediation gate passes because critical and high are both zero. The +remaining moderate is documented below; it is not a high/critical waiver. + +### Remaining moderate: PostCSS + +- Package and advisory: `postcss@8.4.49`, + [GHSA-qx2v-qp2m-jg93](https://github.com/advisories/GHSA-qx2v-qp2m-jg93) / + CVE-2026-41305, CVSS 6.1. Versions below `8.5.10` are affected by unescaped + `` output when attacker-controlled CSS is parsed, stringified, and + embedded in an HTML `