diff --git a/.dockerignore b/.dockerignore index 335c6918..5d705fba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,8 +26,8 @@ **/Dockerfile **/.dockerignore -# Deploy/infra lives in the civfix-infra repo now; the only thing left here is the email-worker -# (a standalone Cloudflare Worker) which must never be baked into the api/worker image. +# infra/ holds only the email-worker (a standalone Cloudflare Worker), which must never be baked into +# the api/worker image. infra # Tests + fixtures are not needed for a production image build (build stage only runs tsup, not tests) diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index ab00ff97..af2ba393 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -1,6 +1,6 @@ name: Build release images -# Builds the two artifacts a civfix-backend release consists of — the api and the media-worker — and +# Builds the two artifacts a civfix-backend release consists of (the api and the media-worker) and # pushes them to GHCR. This is the ONLY place either image is built: the VPS deploy pulls what this # workflow produced (civfix-infra ops/deploy.sh) instead of rebuilding from source on the box, so # staging and production run byte-identical bytes rather than two builds of one commit. @@ -12,13 +12,13 @@ name: Build release images # # NATIVE ARM64, NOT QEMU. Both VPSes are Oracle Ampere (arm64) boxes. `ubuntu-24.04-arm` is a GitHub # larger runner (available on the Team plan, billed per-minute rather than from the free allotment) and -# builds ~5-10x faster than cross-building an image with this much native compilation in it — the +# builds ~5-10x faster than cross-building an image with this much native compilation in it: the # media-worker alone pulls a static ffmpeg and builds sharp/libvips bindings. `platform` is an input so # a future amd64 box is a one-line change at the call site rather than a rewrite. # # TAGGING. The immutable tag is `sha-`, and that shape is a CROSS-REPO CONTRACT: # civfix-infra's ops/deploy.sh reconstructs exactly this tag from the commit it checked out. Do not -# shorten it, do not switch to a `metadata-action` tag scheme, and do not remove it — a deploy that +# shorten it, do not switch to a `metadata-action` tag scheme, and do not remove it: a deploy that # cannot find its own release image fails closed and refuses to deploy. `main` is a moving convenience # tag for humans; nothing reads it. # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be13307e..d02a2a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,6 @@ on: pull_request: branches: [main] -# Cancel superseded runs on the same ref. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -33,13 +32,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # Stage 0.7 (UI-unification) backend-isolation guard: fail CI if the backend ever pulls in + # The backend depends ONLY on the framework-free @civfix/shared contract; fail if it ever pulls in # frontend runtime deps (react / react-dom / react-native / react-native-web / @civfix/ui). - # The backend depends ONLY on the framework-free @civfix/shared contract; this keeps it that way. - name: Assert backend stays React/RN-free run: node scripts/assert-no-frontend.mjs - # H13 gate: fail the PR on a HIGH/CRITICAL advisory in the PRODUCTION dependency tree. `--prod` + # Fail the PR on a HIGH/CRITICAL advisory in the PRODUCTION dependency tree. `--prod` # deliberately ignores dev-only tooling (a DoS in a test runner is not a production exposure). # When a finding has no upstream fix yet, add a `pnpm.overrides` entry in the root package.json # (with a comment explaining why the forced version is safe) rather than removing this step. @@ -61,8 +59,8 @@ jobs: integration: name: integration (postgis + redis) runs-on: ubuntu-latest - # Integration tests that require Docker-backed services run here. They are populated by later - # steps; for now this job installs, builds, and is ready to run them against live containers. + # Placeholder: the Docker-backed integration suite runs inside the build job's `pnpm test` + # (testcontainers). This job stays because its name is a required status check on main. services: postgres: image: postgis/postgis:16-3.4 @@ -110,8 +108,6 @@ jobs: - name: Build run: pnpm build - # Later steps add integration test scripts; this is the hook for them. Kept as a no-op-safe - # placeholder so the job is green until those tests exist. - name: Integration tests run: echo "integration tests are added by later steps; services are up at DATABASE_URL / REDIS_URL" diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index fc553198..a198c9f2 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -3,10 +3,10 @@ name: Deploy (staging) # Deploys the civfix backend to the STAGING VPS (193.122.142.107). TWO jobs, in order: # # 1. `images` builds the api + media-worker images for this commit and pushes them to GHCR -# (build-images.yml). This is the ONLY build of this release — production is later +# (build-images.yml). This is the ONLY build of this release: production is later # promoted onto these exact digests rather than rebuilding. # 2. `deploy` SSHes the box, whose forced command runs /opt/civfix/infra/ops/deploy.sh. That script -# git-pulls the infra repo (single `main` branch — the same tree prod runs; the box's env +# git-pulls the infra repo (single `main` branch, the same tree prod runs; the box's env # marker at /etc/civfix/env selects env/staging.sh and secrets/staging/) + this backend # (at CIVFIX_REF, which env/staging.sh defaults to `main`), PULLS the images job 1 just # published, SOPS-decrypts the secrets, then blue/green swaps the compose stack. @@ -16,8 +16,8 @@ name: Deploy (staging) # Mirrors deploy.yml (prod). Issue: https://github.com/civfix/issue-tracker/issues/108 # # Triggers: -# - Every push to main. `main` IS the staging lane — there is no `dev` branch any more; feature -# branches PR into main, and prod is promoted from a `v*` release cut off main. +# - Every push to main. `main` IS the staging lane; feature branches PR into main, and prod is +# promoted from a `v*` release cut off main. # - Manually via the Actions "Run workflow" button (workflow_dispatch). # Both deploy the staging env's CIVFIX_REF (from env/staging.sh, default `main`); the SSH forced command # ignores any client-sent command, so a deploy can never target an arbitrary ref. @@ -28,14 +28,15 @@ name: Deploy (staging) # Against the old stop-then-start deploy the API genuinely goes down for tens of seconds and this job # fails every time. This backend branch must merge AFTER the civfix-infra change, never alone. # -# THE AVAILABILITY CHECK IS AN ASSERTION, NOT A "did it come back" WAIT — see the deploy.yml header for +# THE AVAILABILITY CHECK IS AN ASSERTION, NOT A "did it come back" WAIT; see the deploy.yml header for # the reasoning. GET /healthz is polled once a second for the whole deploy (pure and # rate-limit-allowlisted, so a 1s poll cannot trip the limiter); the job fails when the longest gap # between serving samples exceeds MAX_OUTAGE_S seconds, with a 503 carrying the x-civfix-draining # response header (the retiring color, by design) not counted as an outage. /readyz (db + redis, not # allowlisted) stays a separate gate afterwards, and the availability + deploy-rc assertion runs last: -# both steps are `if: always()`, so a red availability result cannot skip the readiness gate and a -# failed ssh still fails the job. It all runs after the flip, so it reports rather than prevents. +# both steps are `if: success() || failure()`, so a red availability result cannot skip the readiness +# gate and a failed ssh still fails the job. It all runs after the flip, so it reports rather than +# prevents. # # Required repo secret: # STAGING_DEPLOY_SSH_KEY Private half of the dedicated staging CI deploy key (ed25519). Its public half @@ -56,7 +57,7 @@ permissions: # NOTE THE SCOPE: the serialization is on the `deploy` JOB, not on the workflow. # # It has to be. Workflow-level concurrency queues the whole RUN, and a queued run starts none of its -# jobs — including its image build. So while one box was waiting for the image of a newer commit (it +# jobs, including its image build. So while one box was waiting for the image of a newer commit (it # deploys the branch tip, not the commit that triggered the run), the run that would have built that # image could not start until the waiting deploy finished. The wait could never succeed, and every # back-to-back merge burned the full retry budget and then failed. @@ -157,7 +158,7 @@ jobs: # No remote command is passed: the deploy key's forced command runs deploy.sh (CIVFIX_REF=main). # Its stdout/stderr stream here and its exit code becomes `rc`, which the assertion step - # turns into a job failure. Keepalives keep the connection up during the (quiet) build. + # turns into a job failure. Keepalives keep the connection up while deploy.sh is quiet. if ssh -i ~/.ssh/deploy_key \ -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \ -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index befe4a28..c5738667 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,9 +3,9 @@ name: Deploy (production) # Deploys the civfix backend to the production VPS by SSHing in and running the on-box deploy script # (/opt/civfix/ops/deploy.sh, a shim that execs /opt/civfix/infra/ops/deploy.sh). That script git-pulls # the infra repo (single `main` branch; the box's env marker at /etc/civfix/env selects env/prod.sh and -# secrets/prod/) + this backend (origin/main), SOPS-decrypts the secrets, then rebuilds and blue/green -# swaps the docker compose stack. The build happens ON the VPS (4-vCPU arm64 box); this workflow only -# triggers it and then proves the public API stayed up. +# secrets/prod/) + this backend, SOPS-decrypts the secrets, PULLS the promoted release images and +# blue/green swaps the docker compose stack. This workflow only triggers it and then proves the public +# API stayed up. # # PRODUCTION IS PROMOTED, NOT BUILT. A merge to main deploys STAGING (deploy-staging.yml) and publishes # the release images; this workflow takes those exact digests and puts them in front of production. @@ -14,15 +14,15 @@ name: Deploy (production) # Triggers: # - A PUBLISHED GitHub release whose tag matches `v*`. Cutting a release IS the production deploy. # - Manually via the Actions "Run workflow" button (workflow_dispatch), which re-deploys the current -# newest release — the recovery path when a deploy failed after the release was already published. +# newest release: the recovery path when a deploy failed after the release was already published. # Neither carries a ref: the SSH forced command discards any client-sent command, and the on-box # deploy.sh resolves env/prod.sh's CIVFIX_REF=@latest-release to the highest `v*` tag MERGED INTO # origin/main. Production therefore cannot run code that did not go through the staging lane. # # BECAUSE THE BOX RESOLVES "NEWEST RELEASE" ITSELF, the promote job below refuses to run when the tag # being released is not the newest one. Publishing a release for an older tag would otherwise deploy the -# NEWER code while reporting success against the older tag — a silent wrong-deploy, and exactly the shape -# a panicked "re-release the last good version" rollback takes. Roll back on the box instead: +# NEWER code while reporting success against the older tag: a silent wrong-deploy, and exactly the +# shape a panicked "re-release the last good version" rollback takes. Roll back on the box instead: # sudo -u civfix CIVFIX_REF= /opt/civfix/infra/ops/deploy.sh # or, for a bad-but-healthy release, ops/rollback.sh (~10s, flips back to the retired color). # @@ -47,18 +47,19 @@ name: Deploy (production) # memoized 5s server-side. # # STEP ORDER IS LOAD-BEARING: the probe step only measures and records; the readiness gate runs next -# with `if: always()`; the availability + deploy-rc assertion runs LAST, also `if: always()`. A red -# availability result therefore can never skip /readyz, and a failed ssh still fails the job. +# with `if: success() || failure()`; the availability + deploy-rc assertion runs LAST, with the same +# condition. A red availability result therefore can never skip /readyz, and a failed ssh still +# fails the job. # -# It all runs AFTER the flip, so it cannot prevent a bad deploy — it fails the job loudly. Rolling back -# is still `CIVFIX_REF=` on the box (or `docker compose start api-`). +# It all runs AFTER the flip, so it cannot prevent a bad deploy; it fails the job loudly. Roll back on +# the box as described above. # # Required repo secret: # DEPLOY_SSH_KEY Private half of the dedicated CI deploy key (ed25519). Its public half is the # `deployer` user on the VPS, whose authorized_keys forces this key to run ONLY # `sudo -n -u civfix -H /opt/civfix/ops/deploy.sh` (sudoers-scoped to that one -# command). A leaked key can at most trigger a deploy of origin/main — never a -# shell, other commands, or root. +# command). A leaked key can at most trigger a deploy of the newest release, never +# a shell, other commands, or root. # # The VPS origin IP and its SSH host key are pinned in the job below (neither is secret; SSH 22 is # firewalled to key-only/no-root and ingress 80/443 is Cloudflare-only). @@ -108,19 +109,19 @@ jobs: # compare against. git fetch --quiet --force --tags origin '+refs/heads/main:refs/remotes/origin/main' - # THE SAME SELECTION THE BOX MAKES — civfix-infra env/prod.sh CIVFIX_RELEASE_TAG_REGEX and + # THE SAME SELECTION THE BOX MAKES: civfix-infra env/prod.sh CIVFIX_RELEASE_TAG_REGEX and # ops/deploy.sh. Keep this regex byte-identical to that one: the box resolves the release # independently, so if the two rules disagree this job blesses one commit and the box deploys # another. # # git sorts the whole `v*` set and the grep filters afterwards; that is sound because # filtering preserves relative order, so the first surviving line is still the highest. The - # regex is what excludes prereleases — git's -v:refname ranks `v2.0.0-beta.1` ABOVE `v2.0.0`, + # regex is what excludes prereleases: git's -v:refname ranks `v2.0.0-beta.1` ABOVE `v2.0.0`, # the opposite of semver. The glob alone is not a release rule either: `v*` also matches # `vtest` and `v1.2`, which would outrank every real release and wedge this job permanently. RELEASE_RE='^v[0-9]+\.[0-9]+\.[0-9]+$' # `|| true`: under `set -euo pipefail` a grep that matches nothing exits 1 and errexit kills - # the step AT THIS ASSIGNMENT, making the ::error:: annotation below unreachable — the job + # the step AT THIS ASSIGNMENT, making the ::error:: annotation below unreachable, so the job # would fail with a bare exit code and no explanation. The guard reports it instead. newest="$(git tag --list 'v*' --sort=-v:refname --merged origin/main | grep -E "$RELEASE_RE" | head -n 1)" || true [ -n "$newest" ] || { echo "::error::no release tag matching $RELEASE_RE is merged into origin/main." >&2; exit 1; } @@ -129,7 +130,7 @@ jobs: if ! printf '%s' "$tag" | grep -qE "$RELEASE_RE"; then echo "::error::'$tag' is not a vMAJOR.MINOR.PATCH release tag; refusing to deploy production." >&2 - echo "::error::Prereleases are excluded by design — they never reach prod." >&2 + echo "::error::Prereleases are excluded by design; they never reach prod." >&2 exit 1 fi @@ -161,7 +162,7 @@ jobs: - name: Promote the staging images to this release # NO REBUILD, BY DESIGN. `imagetools create` adds a tag to a manifest that already exists in the - # registry — a registry-side operation, no pull, no push of layers — so the bytes production runs + # registry (a registry-side operation, no pull, no push of layers), so the bytes production runs # are provably the bytes staging exercised. A missing image means this commit never completed a # staging build, and the deploy is refused here rather than half-way through on the box. env: @@ -186,7 +187,7 @@ jobs: fi echo "$image: $digest" # `:$TAG` is the durable, immutable record of this release. `:prod` is a MOVING tag and is - # for humans reading the package list only — nothing consumes it: the box pulls + # for humans reading the package list only; nothing consumes it: the box pulls # `sha-<40>` and immediately re-pins to the digest (civfix-infra/ops/deploy.sh). Never make # anything depend on `:prod`. docker buildx imagetools create \ @@ -277,7 +278,7 @@ jobs: # No remote command is passed: the deploy key's forced command runs /opt/civfix/ops/deploy.sh. # Its stdout/stderr stream here and its exit code becomes `rc`, which the assertion step - # turns into a job failure. Keepalives keep the connection up during the (quiet) build. + # turns into a job failure. Keepalives keep the connection up while deploy.sh is quiet. if ssh -i ~/.ssh/deploy_key \ -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \ -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 \ diff --git a/NOTICE b/NOTICE index 4473c965..e674ef82 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -civfix — the API and media worker +civfix: the API and media worker Copyright (c) 2026 Reach Out Los Angeles Inc. and contributors This program is free software: you can redistribute it and/or modify it under diff --git a/README.md b/README.md index 6c3369bc..1196de7b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ civfix-backend/ - Node >= 22 (see `.nvmrc`) - pnpm 9.12.0 (this repo is pnpm-only) - Docker is only needed later for integration tests / running infra; not required to build or test. - The Docker-gated suites SKIP themselves on a machine with no Docker so `pnpm test` stays green — but + The Docker-gated suites SKIP themselves on a machine with no Docker so `pnpm test` stays green, but only there: with `CI` (set by GitHub Actions) or `CIVFIX_REQUIRE_PG=1` in the environment, a failed container start FAILS the run instead of silently dropping every integration test from it. Set `CIVFIX_ALLOW_PG_SKIP=1` to opt a CI job back into skipping. @@ -201,7 +201,7 @@ every deploy** (longer if a rollback follows). So: - **Every migration in a release is ADDITIVE and backward-compatible**: add tables/columns/indexes, add nullable or DEFAULTed columns, add constraints only as `NOT VALID` first. The old image must - keep working untouched against the post-migration schema — including `INSERT`s that never mention + keep working untouched against the post-migration schema, including `INSERT`s that never mention the new column. - **Destructive DDL ships ONE RELEASE LATER, never with the code change**: dropping or renaming a column/table, `SET NOT NULL` on an existing column, narrowing a type, or removing an enum value @@ -217,14 +217,14 @@ every deploy** (longer if a rollback follows). So: Deployment is owned by the **civfix-infra** repo (`github.com/civfix/civfix-infra`): the compose files, the Caddy + Cloudflare Tunnel edge, the local Postgres/Redis services, the SOPS-encrypted secrets, and the -deploy scripts all live there — NOT in this repo. That repo has ONE branch, `main`, serving both +deploy scripts all live there, NOT in this repo. That repo has ONE branch, `main`, serving both environments: the box picks its environment from a host marker file (`/etc/civfix/env`), which selects `env/prod.sh` + `secrets/prod/` on the production box (`ssh civfix`) and `env/staging.sh` + `secrets/staging/` on the staging box (`ssh civfix-dev`). There is no per-environment infra branch. This repo provides the two service **Dockerfiles** (`services/{api,media-worker}/Dockerfile`) AND the workflow that builds them. The images are built once per commit by -`.github/workflows/build-images.yml` — natively on arm64, to match the Oracle Ampere boxes — and pushed +`.github/workflows/build-images.yml` (natively on arm64, to match the Oracle Ampere boxes) and pushed to `ghcr.io/civfix/{api,media-worker}` as `sha-`. The VPS pulls them; it no longer compiles. The infra compose still carries each image's `build.context`, which is the escape hatch (`CIVFIX_ALLOW_LOCAL_BUILD=1`) rather than the normal path. @@ -245,7 +245,7 @@ There is no `dev` branch: feature branches PR into `main`, `main` is the staging GitHub release is the production deploy. Both workflows SSH in under a forced command that runs the same on-box script. The manual equivalent, for infra-only changes and recovery, is a single command on either box (it pulls civfix-infra + this repo, decrypts secrets, pulls the release images, and brings the stack -up — see civfix-infra/README.md): +up; see civfix-infra/README.md): ``` sudo -u civfix /opt/civfix/infra/ops/deploy.sh @@ -254,8 +254,8 @@ sudo -u civfix /opt/civfix/infra/ops/deploy.sh DEPLOY SEQUENCE (enforced by `depends_on` in the compose file): 1. The `migrate` one-shot service runs `node dist/db/migrate.js` (it reuses the API image, which carries - the runner + the `drizzle/*.sql`), applies the whole `0000..0096` chain in lexical order — every - deploy runs migrations; they are never applied by hand — then exits 0. + the runner + the `drizzle/*.sql`), applies the whole `0000..0096` chain in lexical order (every + deploy runs migrations; they are never applied by hand), then exits 0. 2. `api` and `media-worker` start only after `migrate` completes successfully AND `redis` is healthy. The API exposes `GET /healthz` (liveness, pure, `Cache-Control: no-store`) and `GET /readyz` @@ -274,18 +274,18 @@ API drains first (`src/lifecycle.ts`), in three bounded phases: `forceCloseConnections: false`; its default (`'idle'`) calls `closeAllConnections()` and DESTROYS sockets with a request in flight, which is exactly the dropped request the drain exists to prevent. Instead the shutdown closes only IDLE keep-alive sockets, on a 250 ms sweep, while awaiting - `app.close()` — so parked connections cannot stretch the close and active requests still finish. - One second before the wait expires anything still open is forced down — WebSocket clients are + `app.close()`, so parked connections cannot stretch the close and active requests still finish. + One second before the wait expires anything still open is forced down: WebSocket clients are `terminate()`d (a graceful close frame an unresponsive peer never answers would otherwise hold - `app.close()` for ws's own 30s timeout) and remaining sockets destroyed — and the shutdown moves + `app.close()` for ws's own 30s timeout) and remaining sockets destroyed. The shutdown then moves on to teardown regardless, never awaiting a close that cannot settle. 3. **Teardown (15s watchdog).** pg-boss, the chat pub/sub subscriber, Redis and the Postgres pool, - plus the error-reporting flush — all INSIDE the watchdog, so nothing trails the budget — then + plus the error-reporting flush, all INSIDE the watchdog so nothing trails the budget, then exit. (Open WebSockets are not migrated: they stay on the retiring color and are cut when the server closes, then reconnect to the new color.) The worker drains the queue then closes its DB pool. -`SHUTDOWN_DRAIN_MS` defaults to **0 — no drain — in every environment, deliberately**: a drain longer +`SHUTDOWN_DRAIN_MS` defaults to **0 (no drain) in every environment, deliberately**: a drain longer than the container's `stop_grace_period` gets SIGKILLed mid-teardown, which is worse than not draining at all. The deployed value is set in the civfix-infra compose `api` service `environment:` block, on the same service that carries `stop_grace_period: 45s`, so the drain and its SIGKILL deadline cannot land in @@ -297,7 +297,7 @@ inside the 45s grace**. `test/unit/shutdown-drain.test.ts` asserts that arithmet `COMPOSE_STOP_GRACE_PERIOD_SECONDS = 45` constant, so raising any phase without raising the compose grace period fails CI. A hard deadline of the same length (drain + close wait + teardown watchdog) is armed the moment the signal lands, so the process exits inside the grace period even if a phase -somehow outlives its own bound — the guarantee the old single close watchdog provided. +somehow outlives its own bound: the guarantee the old single close watchdog provided. Resource budget (compose `mem_limit`/`cpus`): api 1.5G / 2 cpu, media-worker 2.5G / 1.5 cpu, redis 1G. @@ -415,6 +415,6 @@ license headers. The corresponding source for the API and the media worker is this repository, ; the community web app and the operator dashboard link to their own repositories at the deployed commit. Contributions are accepted under -the [Contributor License Agreement](CLA.md) — see +the [Contributor License Agreement](CLA.md); see [CONTRIBUTING.md](CONTRIBUTING.md). civfix is a project of Reach Out Los Angeles Inc.; the civfix name and logos are its trademarks and are not covered by the license. diff --git a/docs/account-status.md b/docs/account-status.md index 94dd49f8..10d55a0a 100644 --- a/docs/account-status.md +++ b/docs/account-status.md @@ -12,7 +12,7 @@ Four values, one meaning each. The console (`POST /v1/admin/users/:id/status`) a `banned` is enforced from **Postgres truth**, not only from the Redis marker: `resolveSessionByHash` treats a session row (or a cached projection) whose `account_status` is `banned` as dead, deletes it, and -re-sets the ban marker from that read — so a marker lost to a Redis flush or a partially-failed ban +re-sets the ban marker from that read, so a marker lost to a Redis flush or a partially-failed ban cannot resurrect a banned account. ## Transitions diff --git a/docs/erasure-behavior.md b/docs/erasure-behavior.md index f21732d2..b3a0e8bb 100644 --- a/docs/erasure-behavior.md +++ b/docs/erasure-behavior.md @@ -29,10 +29,10 @@ response can be answered truthfully. It is the source of record for the cleared again so the still-live account keeps working (a failure to clear it is logged: the account stays locked out until the marker expires or an operator restores its status). -1. `UserStore.softDeleteAndAnonymize(userId)` — see +1. `UserStore.softDeleteAndAnonymize(userId)`; see `services/api/src/auth/pg-stores.ts`. **One transaction**, so a partial erasure is not a reachable state: - - Sets `users.deleted_at = COALESCE(deleted_at, now())` (idempotent — a repeat + - Sets `users.deleted_at = COALESCE(deleted_at, now())` (idempotent: a repeat delete keeps the original tombstone time). - Sets `users.allow_direct_messages = false`. - **Scrubs** the identity columns: `email = NULL`, `email_verified = false`, @@ -40,7 +40,7 @@ response can be answered truthfully. It is the source of record for the `bio`, `avatar_url`, `avatar_media_id`, `social_links`, `primary_organization_id` → `NULL`. - **Unlists** the user's `public` reports (→ `hidden`). - - **Transfers, then cancels** the events they organize — the host-transfer + - **Transfers, then cancels** the events they organize via the host-transfer ladder below. Only what nobody could take over is cancelled. - **Releases the organizations they owned**, scrubs their pending team invitations and revokes the pending organization invitations they sent or @@ -51,7 +51,7 @@ response can be answered truthfully. It is the source of record for the - **Unlinks their historical donations from the profile** (`donations.user_id = NULL`, `profile_unlinked_at` stamped). The platform no longer processes donations, but the rows written while it did are retained. See the ⚖️ DECISION below. - - **Revokes and scrubs their issued service-hours certificates** — see the + - **Revokes and scrubs their issued service-hours certificates**; see the dedicated section below. - **Deletes** every durable session row, every device push token and every notification row of the user, so no session outlives the tombstone and no @@ -76,25 +76,25 @@ response can be answered truthfully. It is the source of record for the | Data | After `DELETE /me` | |---|---| | Live sessions / login | **Revoked**: the ban marker is set before the erasure (the deletion is refused if it cannot be), every session row is deleted inside the erasure transaction; afterwards (best-effort) the epoch is bumped and the cached sessions evicted; cookies cleared. | -| DM reachability | **Off** — `allow_direct_messages = false`. | -| `display_name`, `handle`, `email`, `bio`, `avatar_url`, `avatar_media_id`, `social_links`, `donation_url`, `primary_organization_id` | **Scrubbed** on the `users` row — nulled, or replaced with the `Deleted User` label / a generated placeholder handle. | +| DM reachability | **Off**: `allow_direct_messages = false`. | +| `display_name`, `handle`, `email`, `bio`, `avatar_url`, `avatar_media_id`, `social_links`, `donation_url`, `primary_organization_id` | **Scrubbed** on the `users` row: nulled, or replaced with the `Deleted User` label / a generated placeholder handle. | | OAuth identity links | **Deleted** (best-effort, step 3); otherwise a provider sign-in walks back into the tombstone once the ban marker's TTL lapses. | | Device push tokens | **Deleted** inside the erasure transaction. | | Notifications | **Deleted** inside the erasure transaction (they can hold verbatim chat/DM previews and are not civic record). | | Reports the user filed | **Kept** as rows; the user's `public` ones are flipped to `hidden` (see public rendering below). | | Discussion comments, chat, DMs the user wrote | **Kept** (soft-deleted only where the user deleted them individually). | | Cleanups organized / joined | **Kept**; an `upcoming`/`active` event they organize is TRANSFERRED where anyone can take it over, and cancelled only when nobody can (ladder below). | -| Organizations they belonged to | Membership rows **deleted**, and `users.primary_organization_id` (the affiliation badge pin, 0.43.0) is nulled in the same transaction. An organization they OWNED promotes its earliest live admin; one left with nobody is **soft-deleted** and its events lose their `organization_id`. The events keep their own `donation_url` — since the platform stopped processing donations that link belongs to the host, not to the organization's verification. | +| Organizations they belonged to | Membership rows **deleted**, and `users.primary_organization_id` (the affiliation badge pin, 0.43.0) is nulled in the same transaction. An organization they OWNED promotes its earliest live admin; one left with nobody is **soft-deleted** and its events lose their `organization_id`. The events keep their own `donation_url`: since the platform stopped processing donations that link belongs to the host, not to the organization's verification. | | Event team invitations they sent or received | Pending ones **revoked**, the invitee address **scrubbed**. | | Organization invitations they sent or received | Pending ones **revoked** (`org.invite_revoked` audit row per invite, `meta.reason = 'account_deleted'`), so an admin's invite cannot seat anyone after the admin is gone. An invite addressed only by email to the departing user's former address is not matched and expires on its own TTL. | | Posts published under an organization (`posts.organization_id`, 0.43.0) | **Kept**, exactly like every other post: the FK names the organization, not the person, and the author de-links the same way. Nothing about the org link identifies the departing account. | | `event_consents` | **Kept, untouched by every lane.** It carries no contact detail of its own (the subject is a foreign key) and it is the artifact THAT consent existed; the account row it points at is tombstoned rather than deleted. | -| `cleanup_registrations`, `cleanup_registration_seats`, check-ins | **Kept** — they are the roster record of someone else's event. Only the departing person's own free text (`cleanup_answers` values, `attendee_name`, `host_note`) is scrubbed in the same transaction. | +| `cleanup_registrations`, `cleanup_registration_seats`, check-ins | **Kept**: they are the roster record of someone else's event. Only the departing person's own free text (`cleanup_answers` values, `attendee_name`, `host_note`) is scrubbed in the same transaction. | | `cleanup_waitlist` | Live entries (`waiting`/`offered`) **cancelled**, and the seats an `offered` entry reserved are released back to the ticket type. | -| `broadcast_deliveries`, `cleanup_broadcast_mutes`, `broadcast_unsubscribes`, `host_exports` | **Kept, with the FK intact**, exactly like reports, posts and chat. These tables carry no contact detail of their own — only a reference — and the identity behind the reference is the tombstoned account. Their DDL declares `ON DELETE SET NULL` / `ON DELETE CASCADE`, but civfix erasure is a SOFT delete: the `users` row is never deleted, so **those FK actions never fire.** Do not describe the outcome as a cascade. A guest contact scrub separately NULLs the address the broadcast pipeline would have re-read at send time, so a delivery planned before the scrub is marked `suppressed(contact_scrubbed)` and never sent. | +| `broadcast_deliveries`, `cleanup_broadcast_mutes`, `broadcast_unsubscribes`, `host_exports` | **Kept, with the FK intact**, exactly like reports, posts and chat. These tables carry no contact detail of their own (only a reference), and the identity behind the reference is the tombstoned account. Their DDL declares `ON DELETE SET NULL` / `ON DELETE CASCADE`, but civfix erasure is a SOFT delete: the `users` row is never deleted, so **those FK actions never fire.** Do not describe the outcome as a cascade. A guest contact scrub separately NULLs the address the broadcast pipeline would have re-read at send time, so a delivery planned before the scrub is marked `suppressed(contact_scrubbed)` and never sent. | | `donations` | **Kept.** `user_id` NULLed and `profile_unlinked_at` stamped immediately. On a CHARGED donation `donor_email` / `donor_name` survive until `charged_at + 7 years`; on one that never charged they are NULLed at once. ⚖️ DECISION below. | | `org_payouts` | **Kept, with the FK intact.** The row records that an organization moved its own money, not anything about the person who pressed the button; `requested_by` declares `ON DELETE SET NULL`, but civfix erasure is a SOFT delete, so that action never fires and the actor stays the tombstoned account. There is no contact detail in the table to scrub. | -| `cleanup_slot_claims` (which signup slot they took, P9) | **Kept**. The row is `(cleanup_id, user_id, slot_id, claimed_at)` — roster data with no free-text PII, held exactly like the `cleanup_members` row it accompanies, and with no `ON DELETE CASCADE` to `users` by design (`drizzle/0063_cleanup_slots.sql`). The attendee/roster read joins `users` with `deleted_at IS NULL`, so a tombstoned claimant disappears from the visible roster; the row still counts toward the slot's `claimed` total. | +| `cleanup_slot_claims` (which signup slot they took, P9) | **Kept**. The row is `(cleanup_id, user_id, slot_id, claimed_at)`: roster data with no free-text PII, held exactly like the `cleanup_members` row it accompanies, and with no `ON DELETE CASCADE` to `users` by design (`drizzle/0063_cleanup_slots.sql`). The attendee/roster read joins `users` with `deleted_at IS NULL`, so a tombstoned claimant disappears from the visible roster; the row still counts toward the slot's `claimed` total. | | `media_assets.uploader` (who created an upload, 0181) | **Kept**, like `reports.reporter_user_id`: it names the tombstoned account, never an email or IP. Nulling it would make the departed user's unbound uploads claimable by anyone inside the claim window. | | `service_hours_certificates` (issued PDF transcripts, P5) | **Revoked + scrubbed**, rows kept, **R2 objects deleted**. See the next section. | @@ -108,8 +108,8 @@ one erasure transaction): 1. **The owning organization's owner** takes over any `upcoming`/`active` event whose `organization_id` points at a live organization with a live owner. A `cleanup_members` row with `role = 'organizer'` is upserted for them. -2. Otherwise **the senior cohost** — the earliest `joined_at` cohost whose account - is still live — is promoted to organizer. Only `cohost` is a successor: +2. Otherwise **the senior cohost** (the earliest `joined_at` cohost whose account + is still live) is promoted to organizer. Only `cohost` is a successor: `coordinator` and `staff` were invited to run a day, not to own an event, so a coordinator-only event is cancelled at rung 5 rather than handed over. 3. An `event.host_transferred` audit row is written for every event that moved. @@ -122,7 +122,7 @@ one erasure transaction): post-commit and best-effort: a failure is logged, never rolled back. Organizations follow the same shape. The departing owner **steps down to admin -first** and is only then replaced by the earliest live admin — the reverse order +first** and is only then replaced by the earliest live admin; the reverse order would violate `organization_members_owner_uidx`, the partial unique index that enforces one owner per organization. An organization left with no owner at all is soft-deleted, and its events' `organization_id` is NULLed. Their own `donation_url` @@ -132,8 +132,8 @@ organization's verification granted, and no money ever passed through civfix. ### The attendee side of the ladder `scrubAttendeeContributions` runs in the same transaction, immediately after the -host transfer. Registrations, seats and check-ins are **kept** — they are the -roster record of someone ELSE's event — and only the departing person's own free +host transfer. Registrations, seats and check-ins are **kept** (they are the +roster record of someone ELSE's event), and only the departing person's own free text goes: answer values (`cleanup_answers.value_text` / `value_json`, stamped `scrubbed_at`), seat `attendee_name`, and the host's private `host_note` about them. @@ -148,16 +148,16 @@ Two rungs of that scrub exist for a reason: released type ids are handed to a post-commit `waitlist.promote` job. - **A `cohost`, `coordinator` or `staff` row on someone else's event is stepped down to `member`.** The organizer rung already demotes the departing organizer; - without this one a tombstone stays on the team roster as "Deleted User — cohost" and + without this one a tombstone stays on the team roster as a "Deleted User" cohost and keeps receiving the realtime host-team signals (`hostTeamUserIds`). Each - step-down writes an `event.team_role_changed` audit row with a **null actor** — + step-down writes an `event.team_role_changed` audit row with a **null actor**: nobody performed it; the erasure did. `event_consents` is deliberately untouched: it is the artifact THAT consent existed, it carries no contact detail of its own, and the account row it points at is tombstoned rather than deleted. -### ⚖️ DECISION — a deleted account's donation record survives, and so does the donor's email, for seven years +### ⚖️ DECISION: a deleted account's donation record survives, and so does the donor's email, for seven years Erasure NULLs `donations.user_id` and stamps `profile_unlinked_at`, so the donation is no longer linked to a person's profile immediately. On a donation that actually @@ -167,10 +167,10 @@ A donation that **never charged** is the opposite case and is scrubbed IMMEDIATE `donor_email` / `donor_name` in the same statement. There is no receipt to re-issue and no chargeback to defend for a payment that never happened, so none of the carve-outs below apply to it. (An abandoned checkout by someone who has NOT asked for erasure is handled separately: `markExpired` -stamps `retention_until` at 30 days, so the retention sweep drains it on its own — without that +stamps `retention_until` at 30 days, so the retention sweep drains it on its own; without that stamp the sweep's `retention_until IS NOT NULL` predicate would never have reached those rows.) -The alternative — clearing contact at erasure — destroys the ability to re-issue a +The alternative, clearing contact at erasure, destroys the ability to re-issue a receipt the donor may need for a tax filing, and destroys the counterparty record that defends a chargeback. Both are obligations civfix owes to the charity and to the donor themselves, and both are covered by the financial-record carve-outs @@ -178,7 +178,7 @@ the donor themselves, and both are covered by the financial-record carve-outs survives indefinitely and is not reversible to an identity. A **guest donor's email is the one place a non-account-holder's contact detail is -kept for years** — every other guest contact is scrubbed at 30 days +kept for years**; every other guest contact is scrubbed at 30 days (`docs/retention-cleanup.md`). A guest contact scrub also NULLs the address the broadcast pipeline would have re-read at send time, so a delivery planned before the scrub is marked `suppressed(contact_scrubbed)` and never sent. @@ -196,12 +196,12 @@ This is the only table in the product that holds a **frozen copy of the holder's name** (`holder_name`, `holder_handle`) alongside an **itemised record of where they volunteered and when** (`snapshot`, the whole rendered transcript model: per-entry event titles, jurisdictions, occurred-at timestamps and crediting-host -names) — plus a rendered **PDF in R2 that prints all of it**. +names), plus a rendered **PDF in R2 that prints all of it**. Account deletion therefore does three things to it, transactionally with the tombstone (`softDeleteAndAnonymize`, `services/api/src/auth/pg-stores.ts`): -1. **Revokes** every one of the user's certificates — +1. **Revokes** every one of the user's certificates: `revoked_at = COALESCE(revoked_at, now())`, `revoked_reason = COALESCE(revoked_reason, 'account_closed')`. Rows are **not deleted**: `drizzle/0064_service_hours_certificates.sql` makes @@ -211,7 +211,7 @@ tombstone (`softDeleteAndAnonymize`, `services/api/src/auth/pg-stores.ts`): projection already reports for a tombstoned holder. 2. **Blanks the identity columns**: `holder_name` → the `Deleted User` label (the column is `NOT NULL`), `holder_handle` → `NULL`, `snapshot` → `{}`. What - survives is only what `verify` needs to keep answering — `code`, `issued_at`, + survives is only what `verify` needs to keep answering: `code`, `issued_at`, `total_hours`, `entry_count`, the period bounds and `document_sha256`. 3. **Best-effort deletes the R2 object** for each row, after the commit. Same primitive the holder's own `revoke()` uses. Wired in production through @@ -227,7 +227,7 @@ the download link is dead. `test/integration/account-deletion-cascade-pg.test.ts holds this behavior. **DSAR:** the table IS included in the data export -(`services/api/src/services/data-export-service.ts`, `certificates` section) — +(`services/api/src/services/data-export-service.ts`, `certificates` section): every column except `snapshot` (a ~200 KB TOASTed copy of what the PDF already prints, and what the ledger already holds) and `r2_key` (an internal object key, never disclosed anywhere). @@ -245,10 +245,10 @@ Verified call sites (all public projections): | Surface | File | Behavior for a deleted author | |---|---|---| -| Public **reports** (detail / list / map pins / search) | `services/api/src/services/report-service.ts` (`ReportDTO`, `ReportPinDTO`) | The public report DTO **carries no reporter identity at all** — there is no reporter name/handle/avatar field on `ReportDTO`/`ReportPinDTO`. So a surviving public report exposes zero author PII regardless of deletion. (Reporter identity exists only on the admin `AdminReportDTO`.) | +| Public **reports** (detail / list / map pins / search) | `services/api/src/services/report-service.ts` (`ReportDTO`, `ReportPinDTO`) | The public report DTO **carries no reporter identity at all**: there is no reporter name/handle/avatar field on `ReportDTO`/`ReportPinDTO`. So a surviving public report exposes zero author PII regardless of deletion. (Reporter identity exists only on the admin `AdminReportDTO`.) | | Report **discussion** comments | `services/api/src/services/discussion-service.ts` (`toAuthorDTO`) | Renders "Deleted User", no handle, `deleted: true`. | | Cleanup group **chat** | `services/api/src/services/chat-repository.drizzle.ts` (`toMessageDTO`) | Renders "Deleted User", no handle/avatar, bio nulled, `deleted: true`. | -| **Direct messages** | `services/api/src/services/dm-repository.drizzle.ts` | Uses `publicAuthorIdentity` — "Deleted User". The surviving party KEEPS the thread in their inbox (the thread list no longer filters the peer on `deleted_at IS NULL`), with the peer rendered as "Deleted User", no handle/avatar/bio, `deleted: true`. | +| **Direct messages** | `services/api/src/services/dm-repository.drizzle.ts` | Uses `publicAuthorIdentity` ("Deleted User"). The surviving party KEEPS the thread in their inbox (the thread list no longer filters the peer on `deleted_at IS NULL`), with the peer rendered as "Deleted User", no handle/avatar/bio, `deleted: true`. | | **Profiles / people directory / follow lists** | `services/api/src/services/social-repository.drizzle.ts` | Soft-deleted users are **excluded** (`deleted_at IS NULL`): the profile read returns *not found*, and they never appear in the directory, follower/following lists, search, or @-mention pickers. | | @-mention resolution | `services/api/src/services/social-repository.drizzle.ts` | Excludes soft-deleted users. | | Cleanup report galleries | `services/api/src/services/cleanup-repository.drizzle.ts` | Joins exclude `deleted_at IS NOT NULL` rows. | @@ -259,7 +259,7 @@ report the host can no longer see (unlisted by the reporter's account deletion, moderator-`rejected`, owner-hidden, or anon-`held`) is absent from the `linkedReportIds` their client sends back, so `reconcileLinkedReports` (`services/api/src/services/cleanup-repository.drizzle.ts`) -only deletes links whose report passes that same filter — an omitted invisible link +only deletes links whose report passes that same filter; an omitted invisible link is left intact and writes no `report_unlinked` timeline row. **Conclusion:** after account deletion, a user's PUBLISHED reports and discussion @@ -267,7 +267,7 @@ comments survive but render the author as "Deleted User" (reports expose no auth at all); their profile, directory presence, and mention-ability are removed. The identity columns on the `users` row are themselves scrubbed, so the admin panel no longer holds the real name or email either. The de-link is complete on every -public surface — no further author-anonymization work was required by this review. +public surface; no further author-anonymization work was required by this review. ## Open product/counsel DECISIONS (not implemented here) @@ -280,7 +280,7 @@ implemented as code in this pass: leaves (rendered authorless) is a defensible free-expression / public-record stance, but it must be a **written, disclosed decision**. If counsel decides an erasure request should instead trigger a true hard purge across DB + R2 + - backups, that is a separate, larger workstream — do not infer it from this soft + backups, that is a separate, larger workstream; do not infer it from this soft delete. The rendered service-hours PDFs are the one hard-deleted artifact (see that section for why). ⚖️ DECISION REQUIRED. 2. **Reports already forwarded to a city** persist in external municipal systems @@ -288,13 +288,13 @@ implemented as code in this pass: disclosed. ⚖️ DECISION REQUIRED. 3. **Per-report takedown** (an owner asking to remove one specific published report without deleting their account) is handled via the content-report / moderation - path — see `docs/report-takedown.md`. + path; see `docs/report-takedown.md`. --- -## Batch-2 appended sections (usersverify — merge into prose) +## Batch-2 appended sections (usersverify; merge into prose) -### F137 — deletion decoupled from having an email +### F137: deletion decoupled from having an email `DELETE /me` no longer requires the account to have an email address. Behaviour: - **Email on file** → the email-OTP gate still runs (re-prove control of the @@ -310,7 +310,7 @@ When none is on file it now returns an **accurate** 422 that points the user at support/DSAR contact (`support@{MAIL_REPLY_DOMAIN}`, a monitored catch-all inbox) instead of telling them to use an add-email flow that does not exist. -### F088 (delete half) — notifications purged on account deletion +### F088 (delete half): notifications purged on account deletion Account deletion erases the user's notification rows (`DELETE FROM notifications WHERE user_id = $1`), now inside the erasure @@ -320,7 +320,7 @@ private to the deleted user (they can hold verbatim chat/DM previews) and are no civic record, so they are erased. (The time-based retention sweep for notifications is the media-worker half of F088.) -### F139 — DSAR export completeness + truncation remedy +### F139: DSAR export completeness + truncation remedy `POST /me/data-export` now includes two previously-omitted personal-data stores: `posts` (user-authored feed content) and the `volunteer_hours` ledger. The @@ -329,7 +329,7 @@ From the no-reply mailbox); it now directs the user to the monitored support/DSA address. Excluded/redacted as before: push-token secrets (`[REDACTED]`), certificate `snapshot`/`r2_key`, and all OTP/session/OAuth secrets. -### F018 — bounded, off-request-path export assembly +### F018: bounded, off-request-path export assembly Export assembly moved off the request path into a `data.export` pg-boss job (API-side work loop). The OOM/undeliverable-mail fix is a hard **byte budget** diff --git a/docs/inbound-mail-effects.md b/docs/inbound-mail-effects.md index ddd196c1..d7d669d9 100644 --- a/docs/inbound-mail-effects.md +++ b/docs/inbound-mail-effects.md @@ -50,7 +50,7 @@ The verdict is kept with the message: `meta.authVerdict` on the thread's `delive ## Stage 2 publishes the city's reply text (product decision) -The `report_timeline` row (stage 1) and the reporter's push (stage 3) still carry **fixed copy only** — +The `report_timeline` row (stage 1) and the reporter's push (stage 3) still carry **fixed copy only**: `JURISDICTION_REPLY_NOTE` and `JURISDICTION_REPLY_NOTIFICATION_BODY`. Stage 2 is the exception: the report-chat system message now carries the city's own words in its `body`, so residents read the reply in the report chat rather than waiting for an operator to relay it. The reporter's push says "See their @@ -65,12 +65,12 @@ publishes a reply without having disclosed it. An operator who has overridden th forward-template settings owns that copy: the override replaces the default wholesale, so a custom template that drops the sentence sends an undisclosed packet. -**Accepted residual — retroactive publication of pre-0.53.0 threads.** Mail threads opened before this +**Accepted residual: retroactive publication of pre-0.53.0 threads.** Mail threads opened before this delivery were sent under the older default copy, which promised operator-only handling. A city reply arriving on one of those existing threads is published into the report chat anyway: correlation keys off the thread, and nothing records which template revision a packet was rendered from. Those cities were told their reply went to operators and it now reaches residents. This is knowingly accepted rather than -fixed — suppressing it would mean stamping a template revision on every historical +fixed: suppressing it would mean stamping a template revision on every historical `mail_messages`/`mail_threads` row and gating stage 2 on it, and the alternative of not publishing at all defeats the feature. The exposure decays as old threads go quiet. @@ -90,14 +90,14 @@ parentheses; `
` keeps its own line breaks. The Inbox stores the same text f
 
 A line is a cut point when it is:
 
-- an attribution — `On … wrote:`, `El … escribió:`, `Am … schrieb …:`, `Le … a écrit :` or
-  `2026년 … 작성:` — anchored at the end so prose like "On Tuesday our crew wrote: see below" is not
+- an attribution (`On … wrote:`, `El … escribió:`, `Am … schrieb …:`, `Le … a écrit :` or
+  `2026년 … 작성:`), anchored at the end so prose like "On Tuesday our crew wrote: see below" is not
   mistaken for one. Up to three lines are joined when the joined text holds an `@`, because Gmail
   wraps a long attribution. The join stops only at a line where another attribution begins, so a reply
   line starting with `On`, `El`, `Am` or `Le` right above one is kept, while a wrapped sender name that
   starts with one of those words still joins;
-- an Outlook separator — `-----Original Message-----`, tolerant of the dash count;
-- an unquoted Outlook header block — `/^From:\s.+$/` followed within two lines by `Sent:`, `Date:` or
+- an Outlook separator: `-----Original Message-----`, tolerant of the dash count;
+- an unquoted Outlook header block: `/^From:\s.+$/` followed within two lines by `Sent:`, `Date:` or
   `To:`;
 - any line holding one of our own mail identifiers: a thread reply address (`report-`, `reply-` or
   `event-` plus a token, on `MAIL_REPLY_DOMAIN`) or an outbound Message-ID (`out-@…`). Every
@@ -105,8 +105,8 @@ A line is a cut point when it is:
   holds for a client or language the patterns above miss, and no reply address reaches the chat.
 
 Everything from the first cut point onward is dropped, then a trailing run of `>`-prefixed, blank or
-separator (`___`, `---`) lines is dropped. A non-trailing quote is kept — a reply that quotes and then
-answers keeps both halves — unless it holds one of our identifiers.
+separator (`___`, `---`) lines is dropped. A non-trailing quote is kept (a reply that quotes and then
+answers keeps both halves) unless it holds one of our identifiers.
 
 If the result is empty (a reply that was nothing but quoted history), stage 2 falls back to the previous
 behavior: the note only, with `body: null`.
@@ -118,13 +118,13 @@ The chat emitter is injectable (`InboundEffectDeps.chatEmitter`, plumbed through
 
 The message insert is deduped on `message_id`, so re-delivering the `.eml` after a failure only ever
 produces a `replay`. Firing the effects with `.catch(() => {})` after that insert therefore lost the
-status transition and the reporter notification permanently — no retry, no log.
+status transition and the reporter notification permanently: no retry, no log.
 
 The columns on `mail_messages` (migration `0100`) are a **lease**, not a flag:
 
 | Column | Meaning |
 |---|---|
-| `effects_claimed_at` | A runner holds the message. **Reclaimable**: the sweep re-drives any claim older than `EFFECTS_LEASE_MS` (10 min). A process death between claim and completion — a deploy restart, OOM, the drain watchdog — must not strand the row, which is the exact failure the re-drive exists to prevent. |
+| `effects_claimed_at` | A runner holds the message. **Reclaimable**: the sweep re-drives any claim older than `EFFECTS_LEASE_MS` (10 min). A process death between claim and completion (a deploy restart, OOM, the drain watchdog) must not strand the row, which is the exact failure the re-drive exists to prevent. |
 | `effects_applied_at` | Set **only** on completion. `IS NULL` is the "still owed" set the partial index serves; the lease comparison stays in the query because `now()` is not `IMMUTABLE`. |
 | `effects_stage` | How far the ordered pipeline got: `0` none, `1` timeline, `2` chat, `3` reporter notified. A re-drive resumes from here. |
 
@@ -144,7 +144,7 @@ re-drive errors.
 
 The pending predicate is `direction = 'in' AND effects_applied_at IS NULL` with no `created_at` floor.
 Without a backfill, the first `inbound.sweep` after deploy would match **every historical inbound reply**
-and re-drive it at stage 0 — a second "The city responded to this report" timeline row, another chat
+and re-drive it at stage 0: a second "The city responded to this report" timeline row, another chat
 system message, another push, and thread status forced back to `replied` over a closed or bounced thread,
 100 per run until drained. Migration `0100` therefore ends with a one-shot idempotent settle
 (`effects_applied_at = COALESCE(effects_applied_at, created_at), effects_stage = 3` for every inbound row
@@ -154,21 +154,21 @@ still NULL): their effects already ran under the old fire-and-forget path.
 
 Each stage is recorded by a separate `setMessageEffectsStage` UPDATE **after** the effect it describes
 commits. A crash in the gap between an effect committing and its stage write means the re-drive repeats
-that one step once — most visibly a duplicate "The city responded to this report" timeline row.
+that one step once, most visibly a duplicate "The city responded to this report" timeline row.
 
 **This is accepted rather than fixed.** Closing it needs the effect and its marker in one transaction,
 i.e. the marker living on the effect's own row: `report_timeline` has no `meta`/jsonb column
-(`drizzle/0001_core.sql`, `src/db/schema/timeline.ts` — `id, report_id, status, note, kind, body,
+(`drizzle/0001_core.sql`, `src/db/schema/timeline.ts`: `id, report_id, status, note, kind, body,
 actor_id, created_at`), so it would take DDL on a table this slice does not own, plus a matching marker
 on the report-chat message and the notification rows.
 
 The exposure is bounded and one-sided:
 
-- The window is a single UPDATE, not a network call — orders of magnitude smaller than the
+- The window is a single UPDATE, not a network call, orders of magnitude smaller than the
   claim-to-completion window the lease exists for.
 - It can only ever **duplicate one already-published entry**. The timeline row and the push carry fixed
   copy, so a repeat there leaks nothing; a repeated stage 2 posts the same city reply into the same report
-  chat a second time — visible noise, never new disclosure.
+  chat a second time: visible noise, never new disclosure.
 - It cannot skip a step: the stage is only ever advanced after the effect committed, so the failure mode
   is "at least once", never "never".
 
@@ -229,10 +229,10 @@ non-delivery**:
   still advances to `acknowledged` with its timeline row and chat message). A late rejection leaves the
   recorded `failed` standing.
 - `assertRoutable` refuses a re-route (409, `SEND_IN_FLIGHT_CONFLICT`) while the newest attempt is in
-  flight — including the `retargeted` branch. `MailService.reply`/`resend` apply the same guard.
+  flight, including the `retargeted` branch. `MailService.reply`/`resend` apply the same guard.
 - `runAutoForwardWith` classifies the deadline error distinctly and does **not** retry it. Retrying would
   put a second multi-MB packet in front of a government contact for a message that very likely sent.
-- The operator sees a **409 CONFLICT** with "the send is still in progress", not a 500 — so the console's
+- The operator sees a **409 CONFLICT** with "the send is still in progress", not a 500, so the console's
   existing conflict handling applies and the error tracker is not spammed for an expected outcome.
 
 ## Per-attempt, not per-thread
@@ -258,8 +258,8 @@ attempt reads as failed.
 
 `assertOutboundSendPolicy` runs in `loadEnv`, so the process refuses to boot when the knobs would let one
 send outlive the guard: `OUTBOUND_SEND_MIN_THROUGHPUT_BPS` must be ≥ 1024, `OCI_EMAIL_SMTP_TIMEOUT_MS`
-must be ≤ 60 000, and the largest computable deadline — sized for `MAX_PACKET_TOTAL_BYTES` (8 MiB) after
-base64 expansion (+33%, which is what actually crosses the wire) — must fit inside the 900 s window. At
+must be ≤ 60 000, and the largest computable deadline, sized for `MAX_PACKET_TOTAL_BYTES` (8 MiB) after
+base64 expansion (+33%, which is what actually crosses the wire), must fit inside the 900 s window. At
 the defaults that largest deadline is ≈ 88 s, comfortably inside it.
 
 | Env var | Default | Notes |
diff --git a/docs/location-coarsening-assessment.md b/docs/location-coarsening-assessment.md
index 4707020f..ad97cc68 100644
--- a/docs/location-coarsening-assessment.md
+++ b/docs/location-coarsening-assessment.md
@@ -1,4 +1,4 @@
-# Location coarsening for public display — assessment & recommendation
+# Location coarsening for public display: assessment & recommendation
 
 **Audience:** internal (engineering + product + privacy counsel). Not served publicly.
 **Last updated:** 2026-06-20 (privacy/backend-hardening).
@@ -28,7 +28,7 @@ ranked home feed's proximity pool is one bounded KNN scan. It is NOT a new
 class of data and NOT a new exposure: the value is copied server-side from a
 coordinate this platform already publishes at full precision on the linked
 report or event, it is never populated from a client-supplied coordinate, and
-it is never projected into any DTO — it only orders the feed, and the feed
+it is never projected into any DTO; it only orders the feed, and the feed
 emits `PostDTO`, which carries no post-level coordinate at all. The only
 derived value that leaves the server is the ranking score.
 
@@ -36,8 +36,8 @@ Consequence for any future coarsening decision: rounding the projected
 `lat`/`lng` would NOT cover `posts.geom`, which would keep ordering the feed at
 full precision. That is almost certainly the desired behaviour (proximity
 ranking is the point), but it must be a stated decision rather than an
-oversight, and a policy that requires coarsening at REST — not just in
-transit — has to cover this column, `reports.geom`, `cleanups.geom` and
+oversight, and a policy that requires coarsening at REST (not just in
+transit) has to cover this column, `reports.geom`, `cleanups.geom` and
 `users.last_activity_geom` together.
 
 ## Is a backend-only coarsening possible without a shared-contract change?
@@ -76,7 +76,7 @@ the map, is a product + counsel call.
 1. **Decide a public-display precision** (product + counsel). Suggested default:
    round public-display coordinates to **3 decimal places (~110 m)** for the
    browse map + search, and keep the **report DETAIL** at full precision for the
-   owner but coarsened for non-owners — OR snap to the existing H3 cell centroid
+   owner but coarsened for non-owners, OR snap to the existing H3 cell centroid
    for a uniform privacy cell.
 2. **Implement as a backend-only transform** once the radius is chosen: a
    `coarsenForPublicDisplay(lat, lng)` helper applied in `toMapPinDTO` and in
diff --git a/docs/media-pipeline-hardening.md b/docs/media-pipeline-hardening.md
index 517017e0..b71f4d52 100644
--- a/docs/media-pipeline-hardening.md
+++ b/docs/media-pipeline-hardening.md
@@ -24,7 +24,7 @@ processed bytes to `served_key`, records it in the same patch that flips the
 row to `ready`/`held`, and then deletes the upload object (best-effort, with
 the same tombstone + retry as every other reap).
 
-**Every read path serves `served_key` and treats a NULL as "not found"** — the
+**Every read path serves `served_key` and treats a NULL as "not found"**: the
 same 404 as a non-ready asset (`services/media-served-key.ts` holds the one SQL
 fragment; `getMedia` holds the TypeScript twin). Serving `r2_key` would mean
 serving an object the uploader can still overwrite with unchecked bytes after
@@ -35,7 +35,7 @@ records the upload's ETag at finalize (on the row as `media_assets.upload_etag`,
 migration 0184, so a stuck-sweep requeue carries it too) and passes it in the job
 payload, the worker compares it to what it downloaded, and it re-HEADs the upload key
 immediately before publishing. A mismatch (or a vanished object) is a
-`rejected` outcome — never a throw, per the pipeline's never-throw invariant.
+`rejected` outcome, never a throw, per the pipeline's never-throw invariant.
 
 ### Deploy step: the served-key backfill (REQUIRED, once)
 
@@ -106,7 +106,7 @@ user, with no capabilities and no environment:
   ```
 
   This is load-bearing, not decoration. The worker process must hold ambient `CAP_SETUID`/`CAP_SETGID`
-  to change a child's uid at all, and **ambient capabilities survive both fork and execve** — so a plain
+  to change a child's uid at all, and **ambient capabilities survive both fork and execve**, so a plain
   `uid: 1001` spawn handed ffmpeg `CAP_SETUID`, and a decoder RCE could `setuid(1000)` straight back to
   `node` and read `/proc//environ` (a 1000→1001 change clears nothing; the kernel only clears
   capabilities when leaving uid 0, and `no-new-privileges` does not touch the ambient set). `setpriv`
@@ -123,26 +123,26 @@ user, with no capabilities and no environment:
   EPERM and the worker would crash-loop while `/readyz` (the API's) stayed green.
 - **The bounding set.** `--bounding-set=-all` is deliberately NOT passed (in the entrypoint or the
   decoder wrapper): `PR_CAPBSET_DROP` needs `CAP_SETPCAP` for the same reason, so requesting it would
-  fail the entrypoint and every decode. It is inert anyway — with `no_new_privs` and an empty
+  fail the entrypoint and every decode. It is inert anyway: with `no_new_privs` and an empty
   inheritable/ambient set, a non-empty bounding set grants a child nothing (file capabilities and setuid
   bits cannot elevate). If `SETPCAP` is ever added to `cap_add`, set `MEDIA_SANDBOX_DROP_BOUNDING=1` and
   the flag is passed and asserted as zero too.
 - **The child is untrusted output, not just untrusted input.** The image lane returns a JSON envelope,
   and the parent holds the credentials, so the parent accepts no paths, names or content types from it:
-  it reads two FIXED file names inside the scratch dir it created (each must be a regular file — not a
-  symlink — owned by the sandbox uid) and validates every scalar against the worker's own limits and
+  it reads two FIXED file names inside the scratch dir it created (each must be a regular file, not a
+  symlink, owned by the sandbox uid) and validates every scalar against the worker's own limits and
   allowlists before anything reaches a storage PUT or a DB row. Without that, a compromised child could
   name `../../proc/self/environ` as its output and have the parent publish `DATABASE_URL` and the R2
   keys to the public bucket.
 - **A decoder that will not START is an infra fault; one that DIES is a verdict.** `SandboxSpawnError`
-  (missing entry, unreadable `node_modules`, setpriv EPERM — "no process ever ran") propagates out of
+  (missing entry, unreadable `node_modules`, setpriv EPERM: "no process ever ran") propagates out of
   the pipeline and becomes `MediaInfraError`, so the job retries. It is the second deliberate throwable
   in the worker, alongside `MediaInfraError` itself. "Never ran" is proven by the absence of a pid (or a
-  failing `spawn` syscall), NOT by an errno allowlist: `EAGAIN` from the pids limit — exactly what a fork
-  bomb produces — `EMFILE` and `ENOMEM` are transient infra conditions, and rejecting on them would
+  failing `spawn` syscall), NOT by an errno allowlist: `EAGAIN` from the pids limit (exactly what a fork
+  bomb produces), `EMFILE` and `ENOMEM` are transient infra conditions, and rejecting on them would
   delete a resident's upload because the container briefly ran out of process slots. Everything else is
   a verdict on the bytes and is
-  `rejected`: a non-zero exit, a timeout, an over-large output, and — the distinction that matters —
+  `rejected`: a non-zero exit, a timeout, an over-large output, and (the distinction that matters)
   **death by signal**. execa reports `exitCode: undefined` for both "never spawned" and "killed by a
   signal", so a SIGSEGV/SIGABRT from a malformed file, or the cgroup OOM killer's SIGKILL on a decode
   bomb, would otherwise be retried ~36 times over five hours, re-crashing a decoder each attempt. The
@@ -153,19 +153,19 @@ user, with no capabilities and no environment:
   directory owner, which is what lets it unlink the child's files and remove the dir.
 
   Every decoder of every concurrent job runs as the SAME uid 1001, and a compromised child can fork a
-  survivor, so "the child is dead" is not a safety argument — a sibling or a survivor can `rename()`
+  survivor, so "the child is dead" is not a safety argument: a sibling or a survivor can `rename()`
   inside another job's scratch dir. Three things close that:
     1. **The dir is sealed before the parent looks at it.** Once the child exits, Node (the owner)
        `chmod 0700`s the dir, so no uid-1001 process can rename anything into it any more.
     2. **Outputs are opened once, never re-resolved.** `open(O_RDONLY|O_NOFOLLOW|O_NONBLOCK)`, then the
        checks (`isFile`, owner is the sandbox uid, `nlink === 1`) and the read run against THAT handle.
        `lstat`-then-`readFile` was a real race: a symlink swapped in between made the credentialed
-       parent read `/proc/self/environ` — `DATABASE_URL` and the R2 keys — and publish it as the
+       parent read `/proc/self/environ` (`DATABASE_URL` and the R2 keys) and publish it as the
        asset's bytes. `O_NOFOLLOW` fails a symlink with ELOOP and `O_NONBLOCK` refuses to block on a
        FIFO. The same read path is used for the video lane's remux and poster outputs.
     3. **Survivors are killed, and killed EARLY.** Each decoder is spawned in its own process group
        (`detached`), and the group is SIGKILLed the moment the leader exits and again on a timer at the
-       tool's own `timeoutMs` — both BEFORE the parent waits for the stdio drain. That ordering is the
+       tool's own `timeoutMs`, both BEFORE the parent waits for the stdio drain. That ordering is the
        whole point: execa does not settle until every pipe reaches EOF, so a grandchild that inherited
        fd 1/2 used to keep the call pending for as long as it lived (a 500 ms budget measured settling
        after 30 s), which voided every per-tool timeout and let an exploit's forks pile up while the job
@@ -183,8 +183,8 @@ user, with no capabilities and no environment:
 - **Boot self-check (`src/sandbox/preflight.ts`).** In production the worker asserts it is not root,
   resolves both binaries, hands over one scratch dir, runs the REAL image lane on a built-in 1×1 PNG and
   the pinned ffprobe through the wrapper (so a missing `dist/image-lane.js`, a `node_modules` the sandbox
-  uid cannot read, or a broken protocol refuses the boot instead of rejecting every upload), and — this
-  is the part that matters — spawns
+  uid cannot read, or a broken protocol refuses the boot instead of rejecting every upload), and (this
+  is the part that matters) spawns
   `cat /proc/self/status` **through the real wrapper** and requires the child to report
   `Uid: 1001 1001 1001 1001`, `Gid: 1001 1001 1001 1001` and `CapInh/CapPrm/CapEff/CapAmb` all zero
   (`CapBnd` must not exceed the container's own `{CAP_SETUID, CAP_SETGID}`). An `id -u` check would have
@@ -201,15 +201,15 @@ runs it inside the built image under the production capability set, so the entry
 contract is proven on every build rather than at deploy time.
 
 When a real NSFW model is vendored, it must score INSIDE the child (or in a second sandboxed call over
-the child's stripped output) — never in the parent. Decoding or running a model in the credentialed
+the child's stripped output), never in the parent. Decoding or running a model in the credentialed
 process would re-open exactly the hole this section closes.
 
 Never place an executable in `/tmp`: the compose tmpfs is `noexec`.
 
 ## 3. Where ffmpeg comes from (H8)
 
-`ffprobe-static@3.1.0` ships an FFmpeg **4.0.2 (2018)** binary — the tool that
-must interpret an untrusted container — with no security-patch channel. It is a
+`ffprobe-static@3.1.0` ships an FFmpeg **4.0.2 (2018)** binary (the tool that
+must interpret an untrusted container) with no security-patch channel. It is a
 **devDependency** now and is pruned out of the production image.
 
 The image installs a pinned, checksum-verified release instead
@@ -255,11 +255,11 @@ hosts. Neither the AWS SDK nor Node's `fetch` reads those variables, so:
 - `media-worker/src/download.ts` fetches the presigned GET through undici's
   `EnvHttpProxyAgent` dispatcher under the same condition.
 
-Both are exact no-ops when `HTTPS_PROXY` is unset — the API container, local dev,
+Both are exact no-ops when `HTTPS_PROXY` is unset: the API container, local dev,
 tests and the `dev/` stack are unchanged.
 
 `@sentry/node` (GlitchTip) reads only the **lowercase** `https_proxy`/`no_proxy`, so the worker's
-compose environment must set both cases if a `GLITCHTIP_DSN` is ever configured for it — and the DSN's
+compose environment must set both cases if a `GLITCHTIP_DSN` is ever configured for it, and the DSN's
 host must be added to the allowlist, or error reports fail silently. The worker has no DSN today.
 
 **A new outbound host the worker acquires must be added to
@@ -272,7 +272,7 @@ call fails closed.
 
 The worker deletes the upload object as soon as it publishes to `served_key`, but the client's presigned
 PUT for that key stays valid for its full 15-minute TTL. A re-PUT inside the window used to recreate the
-object *after* the only delete that would ever happen — the row is bound and terminal by then, so the
+object *after* the only delete that would ever happen: the row is bound and terminal by then, so the
 orphan sweep (unbound rows only) never matches it and the rejected-media cleanup never runs again. With
 `R2_PUBLIC_BASE` set, that is a permanent, unvetted object at a URL the uploader knows.
 
@@ -282,4 +282,4 @@ So every terminal `media.checks` outcome (and the stuck sweep's give-up path) en
 and differs from `r2_key`. A `validating` row, a legacy row whose `served_key` **is** `r2_key`
 (pre-0097 backfill shape), and a key another row still references are all left alone. A failed delete is
 tombstoned and retried by the orphan sweep's leak lane. The queue is created only by the worker, with an
-explicit `short` policy — the API never enqueues it.
+explicit `short` policy; the API never enqueues it.
diff --git a/docs/migrations-expand-contract.md b/docs/migrations-expand-contract.md
index 7ed7def1..b1548b26 100644
--- a/docs/migrations-expand-contract.md
+++ b/docs/migrations-expand-contract.md
@@ -12,7 +12,7 @@ The production deploy is blue/green (`civfix-infra` compose + `ops/deploy.sh`):
 4. the PREVIOUS color gets SIGTERM and keeps serving its in-flight work for the drain window
    (`SHUTDOWN_DRAIN_MS`, 8s) plus the bounded close that follows it.
 
-Between (1) and (4) the **previous release's code is serving live traffic against the new schema** —
+Between (1) and (4) the **previous release's code is serving live traffic against the new schema**:
 for minutes in a normal deploy, and indefinitely if the deploy is rolled back with `CIVFIX_REF=`
 (the rollback moves the code back; migrations are forward-only and are NOT reverted).
 
@@ -29,12 +29,12 @@ A migration that removes or tightens something therefore breaks the still-runnin
 ## What that means in practice
 
 - **Additive only, in the same release**: new tables, new nullable/DEFAULTed columns, new indexes
-  (`CONCURRENTLY` and out-of-band on a hot table — reports, chat_messages, media_assets, users), new
+  (`CONCURRENTLY` and out-of-band on a hot table: reports, chat_messages, media_assets, users), new
   constraints added `NOT VALID` and validated later.
 - **A rename is two releases**: (1) add the new column, dual-write, backfill; (2) after the release
   that stopped reading the old column is fully deployed, drop it.
 - **A drop is one release after the code stopped depending on it.** "The code no longer uses it" is
-  not enough — the release that removes the usage has to be the one running everywhere first.
+  not enough; the release that removes the usage has to be the one running everywhere first.
 - **`NOT NULL` is two releases**: backfill + `CHECK (col IS NOT NULL) NOT VALID`, then `VALIDATE` and
   `SET NOT NULL` in a later release.
 - **Backfills** live in their own migration, are idempotent and re-runnable, and the reading code
diff --git a/docs/out-of-band-indexes.md b/docs/out-of-band-indexes.md
index f9db8263..a32bafaa 100644
--- a/docs/out-of-band-indexes.md
+++ b/docs/out-of-band-indexes.md
@@ -12,13 +12,13 @@ holds `ACCESS EXCLUSIVE` for the whole build and queues live traffic behind it.
 So: **hot-table indexes are built out of band, by hand, against the live
 database, before or right after the deploy that needs them.** The migration
 that introduces the query path carries only an idempotent guard that logs a
-`WARNING` while the index is missing. Nothing breaks without the index — the
+`WARNING` while the index is missing. Nothing breaks without the index: the
 queries stay correct, they just fall back to a sequential scan.
 
 Run each command **outside** any transaction (a bare `psql` session is already
 outside one; do not wrap it in `BEGIN`). `CONCURRENTLY` builds do not block
 reads or writes, take roughly two table passes, and leave an `INVALID` index
-behind if interrupted — re-run `DROP INDEX CONCURRENTLY IF EXISTS ;` then
+behind if interrupted; re-run `DROP INDEX CONCURRENTLY IF EXISTS ;` then
 the create again if `\d users` shows one.
 
 ```sh
@@ -53,7 +53,7 @@ safe to re-run and safe to run while the API serves traffic):
 sudo -n docker exec compose-api-1 node dist/db/backfill-user-activity.js
 ```
 
-Verify — **against the real statement, with a viewer point that is an outer
+Verify **against the real statement, with a viewer point that is an outer
 reference, not a constant**. A constant-point `EXPLAIN` is not a valid check
 here: Postgres builds the KNN (`amcanorderbyop`) path only when the non-indexed
 operand of `<->` is Var-free, so a hand-written query with a literal point
@@ -82,7 +82,7 @@ CROSS JOIN LATERAL (
 ```
 
 The plan must show `Index Scan using users_last_activity_gist` **with an
-`Order By:` line under it** — that line is what distinguishes a KNN scan that
+`Order By:` line under it**: that line is what distinguishes a KNN scan that
 stops at `LIMIT 200` from a box scan of the whole 2.5° radius followed by a
 top-N sort. `Index Cond:` alone is the degraded plan.
 
@@ -100,11 +100,11 @@ Both index names are exported from
 `services/api/src/services/social-repository.drizzle.ts` and asserted by
 `test/integration/suggest-follows-pg.test.ts`, which builds the indexes itself
 (non-concurrently, on an empty testcontainer) and `EXPLAIN`s the **actual**
-statement through the exported `explainSuggestFollows`. The offline half —
-that the emitted SQL really is a `CROSS JOIN LATERAL` and not a same-level
-cross join — is `test/unit/social-suggest-sql.test.ts`.
+statement through the exported `explainSuggestFollows`. The offline half
+(that the emitted SQL really is a `CROSS JOIN LATERAL` and not a same-level
+cross join) is `test/unit/social-suggest-sql.test.ts`.
 
-### `posts.geom` backfill (migration 0176, issue #100) — data, not an index
+### `posts.geom` backfill (migration 0176, issue #100): data, not an index
 
 `posts` is NOT a hot table, so `posts_geom_gist` and
 `posts_author_public_recent_idx` are built inline by migrations 0176 and 0177
@@ -114,8 +114,8 @@ over the whole table inside the migration's single transaction is a lock
 hazard. The migration RAISEs a `WARNING` when any backfillable post is still
 unpopulated.
 
-Run it once the deploy is healthy — keyset-paged, idempotent, safe to re-run
-and safe while the API serves traffic:
+Run it once the deploy is healthy (keyset-paged, idempotent, safe to re-run
+and safe while the API serves traffic):
 
 ```sh
 sudo -n docker exec compose-api-1 node dist/db/backfill-post-geom.js
@@ -146,11 +146,11 @@ hook, and the backfill only touches rows where `geom IS NULL`. If a report or
 cleanup is later moved to a new coordinate, every post already linked to it
 keeps ranking against the OLD point indefinitely. That is acceptable for feed
 proximity (the post was about the place as it was), but it is a deliberate
-property, not an oversight — if live tracking is ever wanted, the relocation
+property, not an oversight; if live tracking is ever wanted, the relocation
 paths must update the derived posts explicitly.
 
 If `posts` has grown large enough that an inline `CREATE INDEX` would be
-disruptive, build both indexes with `CONCURRENTLY` BEFORE deploying — the
+disruptive, build both indexes with `CONCURRENTLY` BEFORE deploying; the
 migrations' `IF NOT EXISTS` guards then no-op:
 
 ```sql
@@ -201,5 +201,5 @@ avatar `NOT EXISTS` probes are evaluated on the (now small) candidate set.
 
 ## Done
 
-_(none yet — move an entry here, with the date it was built on prod, once
+_(none yet; move an entry here, with the date it was built on prod, once
 `\d ` confirms the index exists.)_
diff --git a/docs/retention-cleanup.md b/docs/retention-cleanup.md
index 371f6211..fc5ae53b 100644
--- a/docs/retention-cleanup.md
+++ b/docs/retention-cleanup.md
@@ -33,18 +33,18 @@ so a backlog drains over several daily runs rather than one long-locking DELETE.
 ## Where it lives
 
 - Job logic: `services/media-worker/src/jobs/retention-sweep.ts`
-  (`runRetentionSweep`) — pure deps, injectable clock/log/report, **never throws**
+  (`runRetentionSweep`): pure deps, injectable clock/log/report, **never throws**
   (a per-table failure is counted + reported to GlitchTip; the other tables still
   run). Mirrors the orphan-sweep job shape exactly.
-- Registration: `services/media-worker/src/worker.ts` — `RETENTION_SWEEP_JOB`
+- Registration: `services/media-worker/src/worker.ts`: `RETENTION_SWEEP_JOB`
   queue + worker + `jobs.schedule(RETENTION_SWEEP_JOB, RETENTION_SWEEP_CRON)`.
-- Schedule: `services/media-worker/src/config.ts` — `RETENTION_SWEEP_CRON`
+- Schedule: `services/media-worker/src/config.ts`: `RETENTION_SWEEP_CRON`
   = `37 4 * * *` (daily, 04:37 UTC, off-peak).
 - Tests: `services/media-worker/test/unit/retention-sweep.test.ts`.
 
 ## Scheduling mechanism
 
-This follows the EXISTING periodic-work pattern — pg-boss cron via the worker's
+This follows the EXISTING periodic-work pattern: pg-boss cron via the worker's
 Jobs seam (`jobs.schedule(name, cron)`), the same mechanism as the orphan sweep
 and chat-partition maintenance. When the worker boots against a real
 `DATABASE_URL` it registers the cron with pg-boss; in all-fake offline boot
@@ -59,7 +59,7 @@ frequent cadence is wanted.
 ## Not covered here (deliberately)
 
 Message/report *aging* (deleting old chat/DM/report rows after N days) is a
-separate **product/counsel retention DECISION** — this sweep only reaps auth
+separate **product/counsel retention DECISION**; this sweep only reaps auth
 artifacts that are unambiguously dead once expired. Orphaned media already has its
 own `orphan.sweep`.
 
@@ -73,7 +73,7 @@ No sweep, erasure path or demo purge touches it.
 
 ---
 
-## F088 (mw half) — notifications now aged in retention.sweep
+## F088 (mw half): notifications now aged in retention.sweep
 
 `notifications` is now a table drained by `retention.sweep`
 (`services/media-worker/src/jobs/retention-sweep.ts`): rows with
@@ -81,32 +81,32 @@ No sweep, erasure path or demo purge touches it.
 (`RETENTION_NOTIFICATIONS_MS`, overridable via `notificationsRetentionMs`).
 
 RATIONALE: chat/DM message previews are copied verbatim into notification
-`body`, so an unbounded notifications table retained those copies forever —
+`body`, so an unbounded notifications table retained those copies forever,
 surviving message deletion and every other sweep. A 90-day TTL bounds that
 retention. Notifications are private to their recipient and are NOT civic
 record, so aged deletion is safe (unlike published reports).
 
 DECIDED TTL: 90 days (single lane for all notification types). If a shorter
 TTL is later wanted for `FEED_HIDDEN_NOTIFICATION_TYPES` (chat/DM bells never
-shown in the feed), split into a second drain — no schema change needed.
+shown in the feed), split into a second drain; no schema change needed.
 
 INDEX: `notifications_created_idx` (migration `0094_notifications_created_idx.sql`)
-now covers the bare `created_at` drain — this was previously listed as deferred.
+now covers the bare `created_at` drain; this was previously listed as deferred.
 The F089 `notifications_feed_idx` is `(user_id, created_at DESC, id DESC) WHERE
 type <> …` and does NOT cover it. The `DELETE /me` erasure cleanup step and the
 `docs/erasure-behavior.md` row are owned by the users/erasure workstream.
 
-UPDATE (H19, audit 2026-09) — the inflow side is now bounded too. Group/report
+UPDATE (H19, audit 2026-09): the inflow side is now bounded too. Group/report
 room activity used to write ONE row per member per message. Two windows now
 govern it, deliberately separate
 (`services/api/src/services/chat-room-fanout-notifier.ts`):
 
-* `ROOM_ACTIVITY_COALESCE_WINDOW_MS` (10 min) is the BELL window — a recipient
+* `ROOM_ACTIVITY_COALESCE_WINDOW_MS` (10 min) is the BELL window: a recipient
   keeps ONE unread bell per room per window and gets ONE push; later messages
   UPDATE that row's title/body via `refreshUnreadNotification` instead of
   inserting. A recipient who READS (opens) the room has no unread row left, so
   the next message rings again immediately.
-* `ROOM_FANOUT_THROTTLE_MS` (15 s) is the COST window — how often a room may pay
+* `ROOM_FANOUT_THROTTLE_MS` (15 s) is the COST window: how often a room may pay
   for a fan-out at all (member list + presence + block/mute batches + the
   per-recipient upsert). It never decides whether a bell is due, so a refresh or
   a re-ring is at most 15 s stale even on a single-process deployment.
@@ -116,7 +116,7 @@ The upsert is ONE statement pair inside a single short `sql.begin`
 two runners that find the same unread row serialise, so the second refreshes
 rather than inserting. RESIDUAL, accepted deliberately: when NO unread row
 exists yet and two runners race (the Redis window claim fails open, or one
-fan-out outlives the 15 s claim TTL), both can insert — there is no unique
+fan-out outlives the 15 s claim TTL), both can insert; there is no unique
 constraint to lean on and `notifications` is a hot table where new DDL is not
 worth it. The blast radius is ONE extra unread row per member per race, both
 rows carry the same room link, opening the room clears them together, and the
@@ -133,12 +133,12 @@ The fan-out itself can now run off the sender's WS frame, on the pg-boss queue
 in `src/server.ts`, queue declared in `src/adapters/jobs.pgboss.ts` with the
 same explicit `policy: "short"` every API queue uses). Its `singletonKey` is
 `::`, so simultaneous enqueues from different API
-processes collapse into one run. The job payload is IDS ONLY — `{kind, roomId,
-messageId}` — deliberately: `pgboss.job` has its own archive retention that was
+processes collapse into one run. The job payload is IDS ONLY (`{kind, roomId,
+messageId}`), deliberately: `pgboss.job` has its own archive retention that was
 never reviewed for message bodies, so the handler re-reads the message through
 the room-scoped repository (which also preserves the "Deleted User" rendering)
 instead of carrying a preview through the queue. The handler also drops a
-message that was TOMBSTONED between the enqueue and the run — the finders
+message that was TOMBSTONED between the enqueue and the run: the finders
 hydrate a tombstone rather than returning null, so a deleted message must not
 still bell the room.
 
@@ -146,17 +146,17 @@ A caller opts in by passing `dispatchToJob` (and optionally the cross-process
 `claimWindow`) to `makeRoomFanoutNotifier`; with neither, the notifier fans out
 inline exactly as before, and an enqueue that throws (queue not started) also
 falls back inline so bells are never silently dropped. The REST poll lane stays
-inline on purpose — a poll create is one event, not a burst. Wiring the WS send
+inline on purpose; a poll create is one event, not a burst. Wiring the WS send
 lane (`src/routes/chat-gateway-wiring.ts`) is the remaining step.
 
 ---
 
-## `geocode_cache` — bounding the reverse-geocode cache (0179)
+## `geocode_cache`: bounding the reverse-geocode cache (0179)
 
 `geocode_cache` (migration `0179_address_resolution.sql`) is written by the
 address ladder on every resolve, and the write path is reachable by an
 UNAUTHENTICATED caller (`POST /map/resolve-address`, 30/min/IP). Its TTLs are
-applied on READ — an expired row is served as a miss and OVERWRITTEN in place —
+applied on READ (an expired row is served as a miss and OVERWRITTEN in place),
 so nothing in `services/api/src/services/geocode-cache.ts` ever deletes a row,
 and a point that is resolved once and never visited again stays forever. Rows are
 tiny and derived (a public coordinate → a public address line, no user, report or
@@ -166,7 +166,7 @@ is still unbounded, so the sweep owns the deletion side.
 | What | Value |
 |---|---|
 | Table | `geocode_cache` |
-| Predicate | `resolved_at < now() - 180 days` (`GEOCODE_CACHE_TTL_MS`, the POSITIVE TTL — imported from `@civfix/api/geocode-cache` so the two can never drift) |
+| Predicate | `resolved_at < now() - 180 days` (`GEOCODE_CACHE_TTL_MS`, the POSITIVE TTL, imported from `@civfix/api/geocode-cache` so the two can never drift) |
 | Index | `geocode_cache_resolved_at_idx` (created by 0179) |
 | Cron | the existing `retention.sweep` (`37 4 * * *`, daily) |
 | Batching | `DELETE … WHERE point_key IN (SELECT point_key … LIMIT n)`, drained page-wise like the other lanes |
@@ -179,18 +179,18 @@ single index scan.
 
 ---
 
-## F106 — `inbound/failed/` poison-message store (adminmail-a)
+## F106: `inbound/failed/` poison-message store (adminmail-a)
 
 The inbound-mail processor parks parse-poisoned or over-cap `.eml` objects under
 the R2 prefix `inbound/failed/` (`moveToFailed` in
 `services/api/src/services/admin/inbound-processor.ts`). These are complete raw
 messages (sender, body, attachments, headers) from residents/municipalities, so
 they carry PII and MUST NOT accumulate forever. There is deliberately NO read
-path for the prefix — it exists only so the boot/cron sweep never loops on a
+path for the prefix; it exists only so the boot/cron sweep never loops on a
 poison object.
 
 - **Retention:** delete `inbound/failed/**` objects older than **14 days**.
-- **How it is enforced:** OWNED BY OPS — an R2 lifecycle rule on the inbound
+- **How it is enforced:** OWNED BY OPS: an R2 lifecycle rule on the inbound
   bucket, or a bounded pass inside the retention sweep. This code change adds
   the visibility signal only (`runInboundSweep` now returns `parked`, logged by
   `inbound.sweep`), not the reaper. See the wiring request in
@@ -200,7 +200,7 @@ poison object.
 
 ---
 
-## Guest event RSVP — new TTLs (contract 0.38.0, DECISIONS §18)
+## Guest event RSVP: new TTLs (contract 0.38.0, DECISIONS §18)
 
 Guest RSVP is the first PII the platform holds for people who are NOT users,
 and phone numbers are a class it has never held at all, so the three new tables
@@ -208,10 +208,10 @@ from `services/api/drizzle/0096_cleanup_guests.sql` each carry an explicit rule.
 
 | Table | Rule | Enforced by |
 |---|---|---|
-| `cleanup_guests` (`email`, `phone`, `contact_key`) | NULLed ~**30 days** after the event's `scheduled_at` passes. For a **cancelled** event the clock is the guest row's own `created_at`, NOT the moment of cancellation — a guest who RSVPd more than 30 days before the host cancelled is scrubbed on the next sweep, and one who RSVPd yesterday keeps their contact until 30 days after that RSVP. Also NULLed **immediately** when the guest cancels their own RSVP. | `guest.retention.sweep` cron + `guestRsvpCancel` |
-| `cleanup_guests` (the row itself) | Kept indefinitely, contact-free. It is the record that someone RSVPd (and whether they withdrew); `contact_scrubbed_at` marks that the means of contacting them is gone. | — |
+| `cleanup_guests` (`email`, `phone`, `contact_key`) | NULLed ~**30 days** after the event's `scheduled_at` passes. For a **cancelled** event the clock is the guest row's own `created_at`, NOT the moment of cancellation: a guest who RSVPd more than 30 days before the host cancelled is scrubbed on the next sweep, and one who RSVPd yesterday keeps their contact until 30 days after that RSVP. Also NULLed **immediately** when the guest cancels their own RSVP. | `guest.retention.sweep` cron + `guestRsvpCancel` |
+| `cleanup_guests` (the row itself) | Kept indefinitely, contact-free. It is the record that someone RSVPd (and whether they withdrew); `contact_scrubbed_at` marks that the means of contacting them is gone. | none |
 | `guest_otps` | Deleted **24 hours** after `created_at`. A guest OTP is dead the moment it is consumed or expires (5 min); 24h is pure operational slack. | `guest.retention.sweep` cron |
-| `sms_opt_outs` | **Indefinite, deliberately.** A suppression list that expires re-enables texting someone who replied STOP. Never add a TTL here. | — |
+| `sms_opt_outs` | **Indefinite, deliberately.** A suppression list that expires re-enables texting someone who replied STOP. Never add a TTL here. | none |
 
 ### Where it lives
 
@@ -225,7 +225,7 @@ from `services/api/drizzle/0096_cleanup_guests.sql` each carry an explicit rule.
   run that hits the page ceiling logs the remaining backlog and continues on the
   next run. A single capped page would NOT meet the TTLs below once daily inflow
   exceeded it, so the drain is load-bearing, not an optimization.
-- Registration: `services/api/src/services/guest-jobs.ts` — the
+- Registration: `services/api/src/services/guest-jobs.ts`, the
   `guest.retention.sweep` queue + worker + `jobs.schedule(...)`, started from
   `server.ts` alongside the other API crons.
 - Schedule: `GUEST_RETENTION_CRON` env var (default `15 4 * * *`, 04:15 UTC).
@@ -236,7 +236,7 @@ own repository rather than the auth artifacts that sweep owns.
 
 ### Deletion semantics
 
-`cleanup_guests` has **no** `users` FK by design — a guest is not an account and
+`cleanup_guests` has **no** `users` FK by design: a guest is not an account and
 account deletion does not touch guest rows. `ON DELETE CASCADE` on `cleanup_id`
 means deleting an event (which the product does not currently do) removes its
 guests with it. There is no way to look a guest up by contact, so there is no
@@ -245,7 +245,7 @@ is capability-based (the manage token), needing no identity check.
 
 ---
 
-## H10 — `inbound_emails` retention (audit-fix 2026-09)
+## H10: `inbound_emails` retention (audit-fix 2026-09)
 
 `inbound_emails` holds the catch-all inbox: complete third-party correspondence
 (`body_text`, sanitized `body_html`, allowlisted headers) plus the R2 keys of
@@ -254,7 +254,7 @@ largest unbounded PII store on the box, with no way to honour a DSAR from a
 resident who had emailed support.
 
 **DECIDED RULE (product owner, 2026-09-02):** an **archived** thread is purged
-**180 days after `archived_at`**. Unread and read rows are untouched — they are
+**180 days after `archived_at`**. Unread and read rows are untouched: they are
 still open operator work, and ageing them out would silently drop live
 correspondence. The attachment objects in the inbound R2 bucket are deleted with
 the row.
@@ -297,30 +297,30 @@ seam.
 NOT re-check `archived_at`. By the time it runs, that page's attachment objects are already gone, so
 re-qualifying the row could only leave a row pointing at deleted objects. The window is one page of a
 single sweep run, and the only way a row could stop qualifying inside it is an operator un-archiving it in
-that instant — for which "the row goes" is the consistent outcome, not a lost decision.
+that instant, for which "the row goes" is the consistent outcome, not a lost decision.
 
 **OPS:** the worker's SOPS env (`civfix-infra/secrets/media-worker.sops.env`)
 currently carries `R2_PUBLIC_BASE` but NOT `R2_INBOUND_BUCKET`, so the bucket
 cannot be identified there yet. The worker does **not** refuse to boot over
 this: it logs a warning, leaves `inboundStorage` undefined, and the lane is
-**skipped entirely** — rows are KEPT, never deleted with unreachable
+**skipped entirely**: rows are KEPT, never deleted with unreachable
 attachments. Add `R2_INBOUND_BUCKET` (the same value the API uses) to the
 worker's env to activate the lane. A failed object delete is counted
 (`inboundEmailObjectsLeaked`) and reported to GlitchTip, and the row is **kept** so the next run retries
-it — a row is only deleted once every one of its objects is gone. A page where nothing at all could be
+it; a row is only deleted once every one of its objects is gone. A page where nothing at all could be
 reaped ends the drain rather than re-selecting the same rows.
 
 **Not covered:** `mail_threads` / `mail_messages` (the operator outreach record
 for reports and events) are civic-record correspondence about a public report and
 are deliberately kept, like the reports themselves.
 
-### `mail_events` — no TTL (accepted)
+### `mail_events`: no TTL (accepted)
 
 `mail_events` is the per-delivery trail (`sent` / `failed` / `delivered` /
 `bounced` / `complained` / `opened`) behind every outbound packet. It has **no
 retention rule and that is deliberate**:
 
-- It carries **no message content** — only a type, a timestamp, the from/to
+- It carries **no message content**, only a type, a timestamp, the from/to
   addresses and a small meta jsonb (`reason`, `deadlineMs`, `bytes`,
   `failedRecipient`, `late`). It is the least PII-bearing table in the mail
   cluster.
@@ -338,37 +338,37 @@ through `mail_events_thread_idx` (`thread_id`); the table has no `message_id`
 index, so a per-attempt subquery filtered only on `message_id` seq-scans it.
 `services/api/src/services/admin/outbound-send-sql.ts` is the single place those
 expressions are built, and every `mail_events` subquery there carries the thread
-filter — asserted offline by
+filter, asserted offline by
 `services/api/test/unit/outbound-send-sql.test.ts`.
 
 **Pending decision (not taken here):** if outbound volume ever makes the table
 large, the safe TTL is "delete events whose thread has no outbound message newer
-than N months", never a flat `created_at` cutoff — the newest attempt's events
+than N months", never a flat `created_at` cutoff; the newest attempt's events
 must outlive the in-flight and stale-claim windows by a wide margin.
 
 ---
 
-## Event host platform — new TTLs (contract 0.40.0)
+## Event host platform: new TTLs (contract 0.40.0)
 
 Organizations, ticketed registration and host broadcasts each add
 stores with their own rule. Everything below is enforced by the
 `host.retention.sweep` cron (`HOST_RETENTION_CRON`, default `35 4 * * *`) unless
 another lane is named; every lane is bounded (`RETENTION_BATCH_SIZE` = 1000 rows ×
-`RETENTION_MAX_PAGES` = 50 pages per run, `retention-lanes.ts`) and NEVER throws —
+`RETENTION_MAX_PAGES` = 50 pages per run, `retention-lanes.ts`) and NEVER throws;
 a failed lane is logged and the next lane still runs.
 
 ### Organizations and event team
 
 | Table | Rule | Enforced by |
 |---|---|---|
-| `org_verifications.ein_number` | NULLed **90 days** after `reviewed_at`; `ein_scrubbed_at` records it. The row (kind, decision, document list, reviewer) is kept — it is the audit trail of a verification decision. | `host.retention.sweep` → `organizationService.scrubDecidedEins(limit)` |
-| `org_verifications.documents` | Kept as an id list; the underlying media are `purpose = 'verification'` assets and follow the verification-media rules. Never exported to the organization or to `/me/data-export`. | — |
+| `org_verifications.ein_number` | NULLed **90 days** after `reviewed_at`; `ein_scrubbed_at` records it. The row (kind, decision, document list, reviewer) is kept: it is the audit trail of a verification decision. | `host.retention.sweep` → `organizationService.scrubDecidedEins(limit)` |
+| `org_verifications.documents` | Kept as an id list; the underlying media are `purpose = 'verification'` assets and follow the verification-media rules. Never exported to the organization or to `/me/data-export`. | none |
 | `cleanup_team_invites.invited_email` | NULLed **7 days after `expires_at`** (`TEAM_INVITE_EMAIL_SCRUB_DELAY_MS`), and immediately on accept, decline or revoke; `email_scrubbed_at` records it. The row is kept as the record of who was given standing on the event. | `host.retention.sweep` → `team_invite_emails` lane (`comms-jobs.ts`, applying `TEAM_INVITE_EMAIL_SCRUB_DELAY_MS` from `host-team-service.ts` to `hostTeamRepository.scrubInviteEmails`) + the accept/decline/revoke statements |
 | `cleanup_team_invites.status` | `pending` → `expired` at or after `expires_at` (`expires_at <= now`, the same inclusive boundary the accept path applies, so the sweep never disagrees with a live accept). `declined` (the invitee refused) and `revoked` (the host withdrew) are set on the spot and are terminal; neither blocklists a re-invite. | `host.retention.sweep` → `team_invite_expiry` lane (`comms-jobs.ts` → `hostTeamRepository.expireStaleInvites`); `HostTeamService` does not own a retention entry point. |
 | `organization_invites.status` | `pending` → `expired` at or after `expires_at`. `declined` (0.43.0 - the invitee refused, from the in-app inbox `POST /me/org-invites/:inviteId/decline`) and `revoked` (the organization withdrew) are set on the spot and are terminal; neither blocklists a re-invite. The invited address rides on the row until the invite leaves `pending`; it is the same class as `cleanup_team_invites.invited_email` and is due the same scrub treatment when that lane is generalized. | the accept/decline/revoke statements; `listInvites` expires stale rows on read |
-| `users.primary_organization_id` | **No TTL, indefinite.** It is a display PREFERENCE (which membership shows as the profile's affiliation badge), not a data class: it holds no contact detail, it is derivable from `organization_members` when null, and it is nulled in the same transaction that removes the matching membership and by account erasure. | — |
-| `posts.organization_id` | **No TTL, indefinite** — part of the post, retained exactly as the post is. It names an organization, never a person; the acting human stays `posts.author_id` and de-links on erasure like any other author. | — |
-| `event_consents` | **Never scrubbed, never swept.** Deleted only with its event (`ON DELETE CASCADE`); `registration_id` is `ON DELETE SET NULL` (0121) so the consent artifact survives a registration that does not. It holds no contact detail of its own — the subject is a foreign key — and it is the artifact THAT consent existed, including the `surface` it was captured on. | — |
+| `users.primary_organization_id` | **No TTL, indefinite.** It is a display PREFERENCE (which membership shows as the profile's affiliation badge), not a data class: it holds no contact detail, it is derivable from `organization_members` when null, and it is nulled in the same transaction that removes the matching membership and by account erasure. | none |
+| `posts.organization_id` | **No TTL, indefinite**: part of the post, retained exactly as the post is. It names an organization, never a person; the acting human stays `posts.author_id` and de-links on erasure like any other author. | none |
+| `event_consents` | **Never scrubbed, never swept.** Deleted only with its event (`ON DELETE CASCADE`); `registration_id` is `ON DELETE SET NULL` (0121) so the consent artifact survives a registration that does not. It holds no contact detail of its own (the subject is a foreign key), and it is the artifact THAT consent existed, including the `surface` it was captured on. | none |
 
 ### Registration and check-in
 
@@ -378,7 +378,7 @@ a failed lane is logged and the next lane still runs.
 | `cleanup_registration_seats.checked_in_at` | coarsened to the DAY at **30 days** | `host.retention.sweep` -> `registrations` lane; `checkin_coarsened_at` stamped |
 | `cleanup_registration_seats.attendee_name` | NULLed **30 days** after the event | `host.retention.sweep` -> `registrations` lane; partial index |
 | `cleanup_registrations.host_note` | NULLed **90 days** after the event | `host.retention.sweep` -> `registrations` lane; partial index |
-| `cleanup_registrations`, `cleanup_registration_seats` (the rows) | Kept. They are the roster record of someone else's event, and a check-in is a civic-participation record. | — |
+| `cleanup_registrations`, `cleanup_registration_seats` (the rows) | Kept. They are the roster record of someone else's event, and a check-in is a civic-participation record. | none |
 
 ### Communications, analytics and exports
 
@@ -388,8 +388,8 @@ a failed lane is logged and the next lane still runs.
 | `broadcasts` subject + body + CTA | scrubbed **180 d** after `finished_at`; the counts and the row are kept indefinitely as the audit record that a message was sent | `host.retention.sweep` → `broadcast_content` |
 | `host_exports` rows | **90 d** from `requested_at`, and only once the row no longer names an object (`r2_key IS NULL`) | `host.retention.sweep` → `host_exports` |
 | host export OBJECTS | **24 h** (`HOST_EXPORT_TTL_HOURS`) for a ready export; a failed run's object at the next reaper pass; objects are deleted BEFORE their rows | `host.export.reap` |
-| `event_metrics_daily` | **never** — aggregates with no identifier of any kind, and the only long-run record a host has | — |
-| `broadcast_unsubscribes`, `email_suppressions` | **indefinite, deliberately** — a suppression list that expires re-enables mailing someone who said stop (same reasoning as `sms_opt_outs`) | — |
+| `event_metrics_daily` | **never**: aggregates with no identifier of any kind, and the only long-run record a host has | none |
+| `broadcast_unsubscribes`, `email_suppressions` | **indefinite, deliberately**: a suppression list that expires re-enables mailing someone who said stop (same reasoning as `sms_opt_outs`) | none |
 
 `host.export.reap` (`HOST_EXPORT_REAP_CRON`, default hourly at :40; up to 200 rows per
 lane per pass) runs two lanes in `services/api/src/services/host/export-service.ts`:
@@ -412,7 +412,7 @@ when `r2_key IS NULL`, so it never drops the last pointer to an object still in
 ### Donation links, retired payments tables, and legal
 
 civfix no longer processes donations. A donation link is now a single nullable
-`text` column on `cleanups`, `organizations` and `users` — an outbound https
+`text` column on `cleanups`, `organizations` and `users`: an outbound https
 pointer, no money, no donor, no retention clock. It is cleared on account erasure
 for `users`, and nowhere else.
 
@@ -428,7 +428,7 @@ Retained, no longer swept: `donations`, `donation_refunds`, `donation_disputes`,
 `org_eligibility`, `org_eligibility_checks`, `eligibility_source_revisions`.
 Account erasure still unlinks a departing user from `donations`
 (`user_id = NULL`, `profile_unlinked_at` stamped, contact scrubbed on a donation
-that never charged) — see `docs/erasure-behavior.md`.
+that never charged); see `docs/erasure-behavior.md`.
 
 | Table | Rows deleted | Source schema |
 |---|---|---|
@@ -437,7 +437,7 @@ that never charged) — see `docs/erasure-behavior.md`.
 
 ⚠ **The media-worker orphan sweep must never reap `receipts/` or `compliance/`.**
 Neither prefix has `media_assets` rows, so today's row-driven reaper cannot reach
-them — but a prefix-listing reaper would silently destroy issued receipts and
+them, but a prefix-listing reaper would silently destroy issued receipts and
 §8.01 eligibility evidence. Any change to that sweep must exclude both prefixes
 explicitly.
 
diff --git a/docs/security/2026-07-24-full-backend-security-review.md b/docs/security/2026-07-24-full-backend-security-review.md
index 3ef1e0e1..34dc1adb 100644
--- a/docs/security/2026-07-24-full-backend-security-review.md
+++ b/docs/security/2026-07-24-full-backend-security-review.md
@@ -1,8 +1,8 @@
-# Full backend security review — 2026-07-24
+# Full backend security review: 2026-07-24
 
 Scope: the entire `civfix-backend` monorepo (`services/api`, `services/media-worker`, `packages/config`, `infra/`) at commit `0adfd1e`, branch `public-home-feed`. Every HTTP endpoint, every WebSocket frame type, the media pipeline, the inbound-mail webhook, the public form intake, and the platform/config layer were read adversarially by eight parallel auditors. Findings below are evidence-based: each cites a file:line that was read, not inferred.
 
-**Headline:** one CRITICAL (a hardcoded, default-on authentication bypass shipped in `README.md`), thirteen HIGH, twenty-two MEDIUM, sixteen LOW. No SQL injection exists anywhere in the codebase — a dedicated sweep enumerated all ~340 raw-SQL construction sites and every one is parameterized or built from a closed TypeScript union.
+**Headline:** one CRITICAL (a hardcoded, default-on authentication bypass shipped in `README.md`), thirteen HIGH, twenty-two MEDIUM, sixteen LOW. No SQL injection exists anywhere in the codebase: a dedicated sweep enumerated all ~340 raw-SQL construction sites and every one is parameterized or built from a closed TypeScript union.
 
 ---
 
@@ -11,39 +11,39 @@ Scope: the entire `civfix-backend` monorepo (`services/api`, `services/media-wor
 ### C1. Hardcoded universal login backdoor, ON BY DEFAULT in production
 
 - **Location:** `services/api/src/env.ts:224`, `services/api/src/auth/otp.ts:85-86,224-229`, `services/api/src/auth/auth-services.ts:87-89`, `README.md:279-291`
-- `REVIEWER_OTP_BYPASS: parseBool(source.REVIEWER_OTP_BYPASS, true)` — default `true`, no production guard, and the wiring uses `!== false` so a typo'd value leaves it on.
+- `REVIEWER_OTP_BYPASS: parseBool(source.REVIEWER_OTP_BYPASS, true)`: default `true`, no production guard, and the wiring uses `!== false` so a typo'd value leaves it on.
 - The credentials `reviewer@civfix.org` / `000000` are string constants in source **and documented in the committed README**.
 - The bypass short-circuits *before* every OTP throttle (`otp.ts:224` runs ahead of `verifyThrottleTripped` at `:234`), so it is not even rate-limited beyond the route's 10/min/IP.
 - **Attack:** `POST /v1/auth/otp/verify {"email":"reviewer@civfix.org","code":"000000"}` → a real 30-day session for a `profile_complete`, `email_verified` citizen account, auto-created on first use. No mailbox access, no prior `otp/request`, no code interception. With `X-Client: mobile` the raw bearer token is returned in the body.
 - Per project memory, `REVIEWER_OTP_BYPASS` default-on is live on `api.civfix.org`.
 - **Fix:** default to `false`; hard-fail boot when `true` in production without an explicit second opt-in; remove the fixed code from source and README; replace with a per-review, time-boxed secret.
-- **Operational:** set `REVIEWER_OTP_BYPASS=false` in the production sops env and redeploy *today*, then audit the `@reviewer` account's activity — this is exploitable right now by anyone who reads the public repo.
+- **Operational:** set `REVIEWER_OTP_BYPASS=false` in the production sops env and redeploy *today*, then audit the `@reviewer` account's activity; this is exploitable right now by anyone who reads the public repo.
 
 ---
 
 ## HIGH
 
-### H1. Native Apple/Google sign-in has no replay protection — the nonce is supplied by the same request
-`routes/auth.routes.ts:79-93`, `auth/jwks.ts:222-226`. The `nonce` is optional and, when present, is compared against a value taken from the attacker's own request body — a tautology. The server never issues or stores a nonce. Any stolen ID token minted for one of the four accepted audiences (`auth-services.ts:96-110`) grants a full session as the victim for the token's `exp` window.
+### H1. Native Apple/Google sign-in has no replay protection: the nonce is supplied by the same request
+`routes/auth.routes.ts:79-93`, `auth/jwks.ts:222-226`. The `nonce` is optional and, when present, is compared against a value taken from the attacker's own request body, a tautology. The server never issues or stores a nonce. Any stolen ID token minted for one of the four accepted audiences (`auth-services.ts:96-110`) grants a full session as the victim for the token's `exp` window.
 **Fix:** server-issued single-use nonces in Redis; require `nonce`; compare against the *stored* value.
 
-### H2. Operator authority is a persistent DB role — the CF-Access/`ADMIN_EMAILS` gate is login-time only
-`auth/admin-guard.ts:16-19`, `routes/admin/auth.routes.ts:108`, `routes/auth.routes.ts:295`. Once `users.role = 'operator'` is written, **any** auth path mints a session carrying `roles:["operator"]` — including the public citizen OTP/Google/Apple login. Removing an email from `ADMIN_EMAILS` or disabling the Cloudflare Access account never demotes the row. An off-boarded operator signs in through the consumer app and has full console access; with `X-Client: mobile` the bearer transport also skips CSRF.
+### H2. Operator authority is a persistent DB role: the CF-Access/`ADMIN_EMAILS` gate is login-time only
+`auth/admin-guard.ts:16-19`, `routes/admin/auth.routes.ts:108`, `routes/auth.routes.ts:295`. Once `users.role = 'operator'` is written, **any** auth path mints a session carrying `roles:["operator"]`, including the public citizen OTP/Google/Apple login. Removing an email from `ADMIN_EMAILS` or disabling the Cloudflare Access account never demotes the row. An off-boarded operator signs in through the consumer app and has full console access; with `X-Client: mobile` the bearer transport also skips CSRF.
 **Fix:** re-check `isAdminEmail` (or a verified `Cf-Access-Jwt-Assertion`) on every `/admin/*` request; tag operator sessions with their mint origin and reject citizen-flow sessions in `requireOperator`.
 
 ### H3. Any operator can mint a permanent operator backdoor
 `routes/admin/users.routes.ts:159-165`, `services/admin/admin-user-service.ts:326-331`. `POST /admin/users/:id/role` accepts `role:"operator"` with no allowlist check, no `actor !== target` guard, and no protection of existing operators from demotion or ban. Chained with C1, one call turns the world-readable `reviewer@civfix.org`/`000000` credential into full admin.
 **Fix:** refuse `operator` from this endpoint (or require the target's email to be allowlisted); reject self-targeting; block role/status mutations against current operators.
 
-### H4. Rate limiter fails OPEN — a Redis blip disables every HTTP rate limit
-`plugins/rate-limit.ts:28` sets `skipOnError: true` globally. When the Redis store errors, every `config.rateLimit` bucket vanishes simultaneously: OTP request (5/min), OTP verify (10/min), OAuth (20/min), anon report create, media presign, data export. It also masks a *misconfigured* `REDIS_URL` — the app boots and silently serves with zero rate limiting.
+### H4. Rate limiter fails OPEN: a Redis blip disables every HTTP rate limit
+`plugins/rate-limit.ts:28` sets `skipOnError: true` globally. When the Redis store errors, every `config.rateLimit` bucket vanishes simultaneously: OTP request (5/min), OTP verify (10/min), OAuth (20/min), anon report create, media presign, data export. It also masks a *misconfigured* `REDIS_URL`: the app boots and silently serves with zero rate limiting.
 **Fix:** `skipOnError: false` for the auth/anon/media scopes (register a second fail-closed limiter instance); add a boot-time Redis `PING` assertion in production.
 
 ### H5. Session token accepted as a URL query parameter on the WS upgrade
 `ws/handshake.ts:38-51`. `wss://…/ws?token=<30-day session>` writes a full-privilege credential into every upstream access log, APM trace, and browser history entry. The API's own pino serializer strips the query (`server.ts:103`), which hides the leak locally while it persists at the edge. A correct single-use `?ticket=` mechanism already exists.
 **Fix:** delete the `query.token` branch.
 
-### H6. WS `leave` frame is completely unauthorized — presence injection into any room + broadcast amplification
+### H6. WS `leave` frame is completely unauthorized: presence injection into any room + broadcast amplification
 `ws/frame-handler.ts:206-209`, `:85-103`; `adapters/chat-presence.ts:133-146`. `handleJoin`/`handleSend`/`handleTyping` all call `authorizeRoom`; `handleLeave` calls nothing. And `presence.leave` computes `userGone` from *absence*, so a never-present user always trips the announce branch. Any authenticated user injects `{"type":"leave","roomKind":"dm","cleanupId":""}` into rooms they have no relationship with. There is no per-frame rate limit, so this is also an unbounded Redis-publish amplifier.
 **Fix:** gate on `session.joined.has(roomKey)`; make `presence.leave` return `userGone:false` when the `zrem` removed nothing; add a global per-connection frame bucket.
 
@@ -52,11 +52,11 @@ Scope: the entire `civfix-backend` monorepo (`services/api`, `services/media-wor
 **Fix:** server-side allowlist of client-authorable kinds (`text`, `share_pin`, `task_complete`, `rsvp_change`); reject `system`/`poll` at the gateway and refuse `kind='system'` with a non-null `sender_id` at the repo.
 
 ### H8. Post report-attachment bypasses the report visibility gate
-`services/post-repository.drizzle.ts:609` (attach) and `:323-345` (read). The attach check validates `visibility` but not `status`; the read-time hydration validates neither. Two consequences: (a) an anonymous submitter attaches their own **`held`** (pre-moderation) report to a public post and its title, exact `lat/lng`, address and photo render in the signed-out public feed; (b) once attached, the owner's later `unlist` is silently ineffective — `loadReports` re-reads the row on every render and never re-checks visibility.
+`services/post-repository.drizzle.ts:609` (attach) and `:323-345` (read). The attach check validates `visibility` but not `status`; the read-time hydration validates neither. Two consequences: (a) an anonymous submitter attaches their own **`held`** (pre-moderation) report to a public post and its title, exact `lat/lng`, address and photo render in the signed-out public feed; (b) once attached, the owner's later `unlist` is silently ineffective: `loadReports` re-reads the row on every render and never re-checks visibility.
 **Fix:** enforce `status='published' AND visibility='public'` in both places, ideally via a shared fragment next to `report-visibility.ts`.
 
 ### H9. `GET /media/:id` performs no authorization at all
-`routes/media.routes.ts:92-96`, `services/media-intake-service.ts:226-246` — the viewer parameter is named `_viewer` and discarded. The only checks are `status==='ready'` and a single carve-out for `purpose==='verification'`. Anyone holding a media id (no session required) gets a fresh presigned URL for private DM and group-chat attachments, media on held/unlisted reports, and attachments on deleted posts — forever, with no revocation.
+`routes/media.routes.ts:92-96`, `services/media-intake-service.ts:226-246`: the viewer parameter is named `_viewer` and discarded. The only checks are `status==='ready'` and a single carve-out for `purpose==='verification'`. Anyone holding a media id (no session required) gets a fresh presigned URL for private DM and group-chat attachments, media on held/unlisted reports, and attachments on deleted posts, forever, with no revocation.
 **Fix:** resolve the asset's binding (`report_id`/`chat_message_id`/`post_id`) and authorize against that subject's existing visibility rules; 404 for anonymous callers on chat-bound assets.
 
 ### H10. Inbound email (raw `.eml` + attachments) is written to the PUBLIC CDN bucket when `R2_INBOUND_BUCKET` is unset
@@ -64,15 +64,15 @@ Scope: the entire `civfix-backend` monorepo (`services/api`, `services/media-wor
 **Fix:** require `R2_INBOUND_BUCKET` at boot whenever `R2_PUBLIC_BASE` is set, assert it differs from `R2_BUCKET`, and never construct `inboundStorage` with a `publicBase`.
 
 ### H11. Push-token registration has no proof of device ownership
-`routes/notifications.routes.ts:124-129`, `services/notification-repository.drizzle.ts:218-247`. Whoever registers a device token first owns it. An attacker who obtains a victim's Expo/APNs token (client logs, refurbished device, crash report) binds it to their own account and can then push attacker-authored title+body to the victim's lock screen with the app's branding. The victim's subsequent legitimate registration hits the `WHERE` guard, returns `"conflict"` — and `notification-service.ts:293-299` **logs a warning and returns `{ok:true}`**, so the client believes it succeeded and silently never receives push again.
+`routes/notifications.routes.ts:124-129`, `services/notification-repository.drizzle.ts:218-247`. Whoever registers a device token first owns it. An attacker who obtains a victim's Expo/APNs token (client logs, refurbished device, crash report) binds it to their own account and can then push attacker-authored title+body to the victim's lock screen with the app's branding. The victim's subsequent legitimate registration hits the `WHERE` guard, returns `"conflict"`, and `notification-service.ts:293-299` **logs a warning and returns `{ok:true}`**, so the client believes it succeeded and silently never receives push again.
 **Fix:** prove possession via a nonce round-trip (silent push → client echo) or device attestation; return a real error on conflict.
 
 ### H12. Attacker-chosen `deviceId` drives a mass token revocation
-`services/notification-service.ts:303-309`, `notification-repository.drizzle.ts:238-247`. The code comment asserts "device_id is the device's own secret" — the server never verifies this; it is an unconstrained `z.string().optional()` from a JSON body. One request revokes **every active push token on that device belonging to every other user**. A harvested device-id list yields a fleet-wide push blackout.
+`services/notification-service.ts:303-309`, `notification-repository.drizzle.ts:238-247`. The code comment asserts "device_id is the device's own secret"; the server never verifies this; it is an unconstrained `z.string().optional()` from a JSON body. One request revokes **every active push token on that device belonging to every other user**. A harvested device-id list yields a fleet-wide push blackout.
 **Fix:** derive device identity from an attested source, or scope the revoke to rows the same token previously occupied; constrain the field and alert on cross-account revokes.
 
 ### H13. Reachable HIGH/CRITICAL dependency advisories in the production tree
-`pnpm audit --prod` → 44 vulnerabilities (1 critical, 23 high). Most reachable: `linkify-it@5.0.1` and `nodemailer`/`mailparser` sit directly on the unauthenticated inbound-mail path (quadratic DoS; raw-option bypass of `disableFileAccess` → arbitrary file read + SSRF). `node-apn@3.0.0` is abandoned and pins `node-forge@0.7.6` (seven advisories including RSA/Ed25519 **signature forgery**) and `jsonwebtoken@8.5.1` — unfixable by range bump. Also `websocket-driver@0.7.4` (CRITICAL, via firebase-admin), `find-my-way`/`fast-uri` (via fastify), `sharp@0.34.5` (libvips CVEs), `drizzle-orm@0.36.4` (identifier-escaping SQLi — **verified not reachable**, no `sql.raw`/`sql.identifier` call sites exist).
+`pnpm audit --prod` → 44 vulnerabilities (1 critical, 23 high). Most reachable: `linkify-it@5.0.1` and `nodemailer`/`mailparser` sit directly on the unauthenticated inbound-mail path (quadratic DoS; raw-option bypass of `disableFileAccess` → arbitrary file read + SSRF). `node-apn@3.0.0` is abandoned and pins `node-forge@0.7.6` (seven advisories including RSA/Ed25519 **signature forgery**) and `jsonwebtoken@8.5.1`, unfixable by range bump. Also `websocket-driver@0.7.4` (CRITICAL, via firebase-admin), `find-my-way`/`fast-uri` (via fastify), `sharp@0.34.5` (libvips CVEs), `drizzle-orm@0.36.4` (identifier-escaping SQLi: **verified not reachable**, no `sql.raw`/`sql.identifier` call sites exist).
 **Fix:** bump `nodemailer@^8.0.4`, `drizzle-orm@^0.45.2`, `fastify@latest`, `sharp@^0.35.0`; replace `node-apn` or add `pnpm.overrides` forcing `node-forge@^1.4.0` + `jsonwebtoken@^9`; add `pnpm audit --prod --audit-level high` as a CI gate.
 
 ---
@@ -81,28 +81,28 @@ Scope: the entire `civfix-backend` monorepo (`services/api`, `services/media-wor
 
 | # | Finding | Location |
 |---|---|---|
-| M1 | A live WebSocket is never re-authorized — revoked, logged-out and **banned** users keep full chat access until they close the tab | `ws/socket-lifecycle.ts:95-235` |
-| M2 | WS ticket redemption is `get`+`del`, not atomic — the same ticket authenticates N concurrent connections; also stored in plaintext in Redis (sessions are hashed) | `auth/ws-ticket.ts:28-35` |
-| M3 | No absolute session lifetime — sliding expiry has no ceiling, so one request every ≤15 days keeps a stolen token alive forever on a passwordless product | `auth/session-service.ts:258-274` |
-| M4 | `gov-claims` approve calls `setRole` without `revokeAll` — a prior operator keeps operator authority in every live session | `services/admin/gov-claims-service.ts:310-314` |
+| M1 | A live WebSocket is never re-authorized: revoked, logged-out and **banned** users keep full chat access until they close the tab | `ws/socket-lifecycle.ts:95-235` |
+| M2 | WS ticket redemption is `get`+`del`, not atomic: the same ticket authenticates N concurrent connections; also stored in plaintext in Redis (sessions are hashed) | `auth/ws-ticket.ts:28-35` |
+| M3 | No absolute session lifetime: sliding expiry has no ceiling, so one request every ≤15 days keeps a stolen token alive forever on a passwordless product | `auth/session-service.ts:258-274` |
+| M4 | `gov-claims` approve calls `setRole` without `revokeAll`: a prior operator keeps operator authority in every live session | `services/admin/gov-claims-service.ts:310-314` |
 | M5 | `POST /admin/reports/:id/route` emails a full report packet (reporter name, exact coords, photos) to an operator-supplied **arbitrary** address; the audit write is outside the transaction and swallowed on failure | `routes/admin/reports.routes.ts:160-183` |
 | M6 | Admin inbox serves attacker-controlled raw `bodyHtml` from arbitrary inbound senders with no server-side sanitization; the CSRF cookie is JS-readable, so console XSS is a full authz bypass | `services/admin/inbound-processor.ts:247-250` |
 | M7 | Forged inbound email can impersonate a jurisdiction: no SPF/DKIM/DMARC check anywhere, and a spoofable `X-Thread-Token` **header** selects the thread. Flips report status, writes attacker text into the public report chat as an official city reply, and pushes "Your report got a response" to the reporter | `adapters/inbound-mail.cf.ts:82-98`, `services/admin/inbound-thread-correlation.ts:46-103` |
-| M8 | `/forms/home-turf` is a spam/phishing amplifier — attacker-chosen recipient + attacker-controlled body, DKIM-signed by civfix's own domain. The hourly cap silently disappears when `REDIS_URL` is empty | `routes/forms.routes.ts:166-172,226-242,124-130` |
-| M9 | The NSFW moderation gate is inert in production — `useRealNsfw` defaults false and no model is vendored, so `nsfwScore` returns 0 and the entire held/review path is dead code. Unauthenticated anon uploads auto-publish | `adapters/abuse-checks.ts:15-21`, `media-worker/src/seams.ts:186-191` |
-| M10 | Presigned uploads are fully unauthenticated — no account, no anon session, no byte quota. 30/min × 50 MB = 90 GB/hour/IP, reclaimed at 200 rows/hour against 43,200 created/day | `routes/media.routes.ts:47-76`, `media-worker/src/config.ts:85` |
+| M8 | `/forms/home-turf` is a spam/phishing amplifier: attacker-chosen recipient + attacker-controlled body, DKIM-signed by civfix's own domain. The hourly cap silently disappears when `REDIS_URL` is empty | `routes/forms.routes.ts:166-172,226-242,124-130` |
+| M9 | The NSFW moderation gate is inert in production: `useRealNsfw` defaults false and no model is vendored, so `nsfwScore` returns 0 and the entire held/review path is dead code. Unauthenticated anon uploads auto-publish | `adapters/abuse-checks.ts:15-21`, `media-worker/src/seams.ts:186-191` |
+| M10 | Presigned uploads are fully unauthenticated: no account, no anon session, no byte quota. 30/min × 50 MB = 90 GB/hour/IP, reclaimed at 200 rows/hour against 43,200 created/day | `routes/media.routes.ts:47-76`, `media-worker/src/config.ts:85` |
 | M11 | Block bypass: report-room and group-room notification fan-out never checks blocks (the mention/reply bells do), so a blocked user pushes ~30 notifications/min to their target via a shared public room | `services/report-chat-notifier.ts:39-64`, `group-chat-notifier.ts:64-92` |
-| M12 | Group membership requires no consent and the block filter is actor-relative only — a third party can force a blocked pair into the same room, repeatedly | `services/chat-group-service.ts:130-138,216-225` |
-| M13 | No per-frame WS rate limit on `join`/`typing`/`ack` — each is a DB + Redis round-trip; 30 concurrent sockets per host | `ws/socket-lifecycle.ts:211-217` |
+| M12 | Group membership requires no consent and the block filter is actor-relative only: a third party can force a blocked pair into the same room, repeatedly | `services/chat-group-service.ts:130-138,216-225` |
+| M13 | No per-frame WS rate limit on `join`/`typing`/`ack`: each is a DB + Redis round-trip; 30 concurrent sockets per host | `ws/socket-lifecycle.ts:211-217` |
 | M14 | Anonymous caller forces 2000 full report rows + 2000 media presigns per request by sending `zoom=22` with a world bbox; `Cache-Control` defeated by jittering the bbox; no per-route limit | `routes/reports.routes.ts:319-329`, `services/report-clustering.ts:71-73` |
-| M15 | Postgres connections do not require TLS — `postgres.js` defaults to `ssl:false` and nothing validates `sslmode` in `DATABASE_URL` | `db/client.ts:31-40` |
-| M16 | `appleSignIn`, `googleSignIn` and `checkHandle` have no per-route rate limit — handle enumeration and ID-token grinding at the global 300/min/IP | `routes/auth.routes.ts:79,89,205` |
+| M15 | Postgres connections do not require TLS: `postgres.js` defaults to `ssl:false` and nothing validates `sslmode` in `DATABASE_URL` | `db/client.ts:31-40` |
+| M16 | `appleSignIn`, `googleSignIn` and `checkHandle` have no per-route rate limit: handle enumeration and ID-token grinding at the global 300/min/IP | `routes/auth.routes.ts:79,89,205` |
 | M17 | Removing an event attendee is unenforceable: no ban table, `join` is unconditional, and their live socket is never evicted from the room | `services/cleanup-service.ts:396-401,471-506` |
-| M18 | Notification bombing via promote/demote — alternating the two legal roles defeats the idempotency check; ~300 lock-screen pushes/min/IP, no per-route limit | `services/cleanup-service.ts:435-469` |
-| M19 | The slur filter is not applied to any event field or to the cancellation reason — both are public and both fan out to every attendee | `services/cleanup-service.ts:224-349` |
-| M20 | Resource-request cooldown is an in-process `Map` keyed on `cleanupId` — a fresh throwaway event resets it, making the app a relay into municipal inboxes at ~150 emails/min | `services/cleanup-service.ts:62-78,508-568` |
+| M18 | Notification bombing via promote/demote: alternating the two legal roles defeats the idempotency check; ~300 lock-screen pushes/min/IP, no per-route limit | `services/cleanup-service.ts:435-469` |
+| M19 | The slur filter is not applied to any event field or to the cancellation reason: both are public and both fan out to every attendee | `services/cleanup-service.ts:224-349` |
+| M20 | Resource-request cooldown is an in-process `Map` keyed on `cleanupId`: a fresh throwaway event resets it, making the app a relay into municipal inboxes at ~150 emails/min | `services/cleanup-service.ts:62-78,508-568` |
 | M21 | Volunteer hours: a verified host credits themselves with no second party; the upsert overwrites in place with no history of prior values | `services/volunteer-hours-service.ts:89-147` |
-| M22 | Global limiter keys on IP only — an authenticated attacker escapes by rotating IPs while the *account* is never counted | `plugins/rate-limit.ts:19-30` |
+| M22 | Global limiter keys on IP only: an authenticated attacker escapes by rotating IPs while the *account* is never counted | `plugins/rate-limit.ts:19-30` |
 
 ---
 
@@ -132,7 +132,7 @@ Scope: the entire `civfix-backend` monorepo (`services/api`, `services/media-wor
 | L20 | `TRUST_PROXY` parser accepts `true`, which would trust any client `X-Forwarded-For` and defeat every per-IP control. Default is correct (RFC1918+loopback CIDRs); the footgun is that nothing rejects `true` in production | `env/parsers.ts:105` |
 | L21 | HSTS is set without `preload` and with a 180-day max-age | `plugins/helmet.ts:32` |
 | L22 | Admin jurisdictions directory accepts an unbounded `OFFSET` (lower-clamped only) | `services/admin/pagination.ts:60-66` |
-| L23 | Unbounded arrays in `entries` (volunteer hours) and `bring` (events) — capped only by the 256 KB body limit | shared `LogEventHoursRequestSchema`, `CreateCleanupRequestSchema` |
+| L23 | Unbounded arrays in `entries` (volunteer hours) and `bring` (events), capped only by the 256 KB body limit | shared `LogEventHoursRequestSchema`, `CreateCleanupRequestSchema` |
 | L24 | Event-cancellation notifications bypass the notification pipeline (no prefs, no quiet hours, no localization) | `services/cleanup-repository.drizzle.ts:539-544` |
 | L25 | Attendee roster is enumerable by any user via join → read → leave | `services/cleanup-service.ts:414-433` |
 | L26 | Pagination cursors are opaque but unauthenticated (no authorization data encoded, so defense-in-depth only) | `db/cursor-helpers.ts:24-79` |
@@ -145,21 +145,21 @@ These were specifically probed and found sound. Listed so the next reviewer know
 
 **Injection.** No SQL injection exists. A dedicated sweep enumerated every raw-SQL site: zero `sql.raw`/`sql.identifier` call sites; three `sql.unsafe` sites, all on module constants or filesystem-read migration text; every `${sql(x)}` identifier interpolation resolves to a closed TypeScript union; every `ORDER BY` is a compile-time literal or a switch over enum-validated values; `LIMIT`/`OFFSET` always bound and clamped; all 25 `ILIKE` sites pair `escapeLike` with `ESCAPE '\\'`; no `to_tsquery` anywhere; PostGIS envelopes and `make_interval` use bind params. Also clean: no `eval`/`new Function`/`child_process` string concatenation, no dynamic `import()` with user input, no Redis `KEYS`/`SCAN`, no log injection, no ReDoS in the slur matcher, and a custom `scripts/check-dynamic-sql.mjs` CI gate enforcing the `sql.unsafe` allowlist.
 
-**Command injection & SSRF in the media pipeline.** `execa` with `shell:false` and a fixed argv array everywhere; binaries from `ffmpeg-static`/`ffprobe-static`. `-protocol_whitelist file` precedes `-i`. The worker fetches exactly one URL — a presign over a key read from the database, additionally validated by `isSafeR2Key`. Web-push targets are DNS-resolved with *all* addresses required to be public and the connection pinned to the validated address, closing the rebinding TOCTOU.
+**Command injection & SSRF in the media pipeline.** `execa` with `shell:false` and a fixed argv array everywhere; binaries from `ffmpeg-static`/`ffprobe-static`. `-protocol_whitelist file` precedes `-i`. The worker fetches exactly one URL: a presign over a key read from the database, additionally validated by `isSafeR2Key`. Web-push targets are DNS-resolved with *all* addresses required to be public and the connection pinned to the validated address, closing the rebinding TOCTOU.
 
 **Media hardening.** Server-generated UUID R2 keys (no traversal, no user-controlled key); content-type and content-length cryptographically bound into the signed PUT; `text/html` and `image/svg+xml` unsignable; magic-byte validation, not extension or client MIME; EXIF/GPS stripped on re-encode and video metadata dropped via `-map_metadata -1`; pixel-flood guarded twice; per-stage timeouts with `SIGKILL`; no transcode (stream-copy only); `mkdtemp` scratch dirs with sanitized names and boot-time sweep.
 
-**Auth primitives.** OTP codes use `randomBytes` with rejection sampling, argon2id-hashed at rest, constant-time compare, single-use, 5-min TTL, TOCTOU-safe attempt counter, identical responses for registered and unregistered addresses. Session tokens are 256-bit and stored only as SHA-256; fixation impossible; `setRole` revokes all sessions; the banned-user veto runs before expiry sliding; the auth hook fails **closed** on store errors. JWKS pins RS256 with a required `kid`, validates `iss`/`aud`/`exp`/`nbf`, uses `redirect:"error"`, and rate-floors force-refresh. CF Access does a real `jwtVerify` — the header cannot be spoofed. `ADMIN_EMAILS` empty ⇒ nobody can log in (fail-closed). OAuth web flow has signed-cookie state, PKCE, and a validated redirect allowlist; email-based account linking requires `emailVerified`.
+**Auth primitives.** OTP codes use `randomBytes` with rejection sampling, argon2id-hashed at rest, constant-time compare, single-use, 5-min TTL, TOCTOU-safe attempt counter, identical responses for registered and unregistered addresses. Session tokens are 256-bit and stored only as SHA-256; fixation impossible; `setRole` revokes all sessions; the banned-user veto runs before expiry sliding; the auth hook fails **closed** on store errors. JWKS pins RS256 with a required `kid`, validates `iss`/`aud`/`exp`/`nbf`, uses `redirect:"error"`, and rate-floors force-refresh. CF Access does a real `jwtVerify`; the header cannot be spoofed. `ADMIN_EMAILS` empty ⇒ nobody can log in (fail-closed). OAuth web flow has signed-cookie state, PKCE, and a validated redirect allowlist; email-based account linking requires `emailVerified`.
 
-**Anonymous reporting.** Anon tokens are HMAC'd with constant-time compare; the 5-report cap is enforced transactionally; claim codes are 256-bit, per-report, single-use, atomic, with no enumeration; prod boot refuses the dev signing key. Held anon reports carry `reporter_user_id NULL` — there is no identity to recover. Map pins, search, and clusters all project through `selectPublicPins`, which hard-codes the visibility predicate (the sole leak is H8).
+**Anonymous reporting.** Anon tokens are HMAC'd with constant-time compare; the 5-report cap is enforced transactionally; claim codes are 256-bit, per-report, single-use, atomic, with no enumeration; prod boot refuses the dev signing key. Held anon reports carry `reporter_user_id NULL`: there is no identity to recover. Map pins, search, and clusters all project through `selectPublicPins`, which hard-codes the visibility predicate (the sole leak is H8).
 
 **Messaging authorization.** No handler anywhere trusts a body/query-supplied `userId` for authorization. Every HTTP chat/DM/group/report endpoint and every WS frame except `leave` re-verifies membership against the session identity; edit/delete/pin/react check ownership *and* room scope; pagination cursors are re-anchored inside the target room so a foreign cursor cannot seek across rooms; broadcast channels are per-room-key; the group role matrix (owner un-removable, admins removable only by owner, actor-power checked before target lookup) is correct; WS handshake checks Origin *before* auth and requires an allowlisted Origin for cookie-bearing upgrades (correct CSWSH defense).
 
-**Platform.** CORS is exact-match `Set.has`, never reflects with `credentials:true`, and fails closed in prod. CSP is `default-src 'none'` with `frame-ancestors 'none'`. 500s and 404s are stealth in production. pino redacts authorization/cookie/set-cookie/csrf/password/token/otp/email and strips query strings; GlitchTip deep-redacts including PII. No hardcoded secrets, no committed `.env`, no fallback secret reachable in production; the env Proxy's `toJSON` is redacted; boot fails on any missing required secret. Both Dockerfiles run as `USER node` with no secrets in layers. Body limit 256 KB, `requestTimeout` 15s, `connectionTimeout` 10s — slowloris bounded. `x-request-id` is charset-validated before echo. The migration runner is a separate CLI, never mounted.
+**Platform.** CORS is exact-match `Set.has`, never reflects with `credentials:true`, and fails closed in prod. CSP is `default-src 'none'` with `frame-ancestors 'none'`. 500s and 404s are stealth in production. pino redacts authorization/cookie/set-cookie/csrf/password/token/otp/email and strips query strings; GlitchTip deep-redacts including PII. No hardcoded secrets, no committed `.env`, no fallback secret reachable in production; the env Proxy's `toJSON` is redacted; boot fails on any missing required secret. Both Dockerfiles run as `USER node` with no secrets in layers. Body limit 256 KB, `requestTimeout` 15s, `connectionTimeout` 10s; slowloris bounded. `x-request-id` is charset-validated before echo. The migration runner is a separate CLI, never mounted.
 
 **Email.** Envelope addresses are *rejected* (not sanitized) on CR/LF/NUL; subjects and custom headers stripped and length-clamped; every interpolated HTML block escapes. The inbound webhook HMACs the exact raw bytes with a constant-time compare, fails closed when unconfigured, constrains the object key by regex, and forces attachments to `application/octet-stream`.
 
-**Admin surface.** All 78 admin endpoints were inventoried; the `requireOperatorPreHandler` hook's Fastify encapsulation scope was verified to cover all 14 data routers — zero routes escape the guard. Path params are UUID-validated before SQL; no mass assignment (path id spread last, explicit Zod schemas); no admin endpoint returns user email/phone/IP/tokens; no outbound `fetch` exists in the admin domain (no SSRF); hard deletes are narrow and audited in-transaction.
+**Admin surface.** All 78 admin endpoints were inventoried; the `requireOperatorPreHandler` hook's Fastify encapsulation scope was verified to cover all 14 data routers: zero routes escape the guard. Path params are UUID-validated before SQL; no mass assignment (path id spread last, explicit Zod schemas); no admin endpoint returns user email/phone/IP/tokens; no outbound `fetch` exists in the admin domain (no SSRF); hard deletes are narrow and audited in-transaction.
 
 ---
 
@@ -167,27 +167,27 @@ These were specifically probed and found sound. Listed so the next reviewer know
 
 # Remediation status (as of 2026-07-24)
 
-**Everything above is fixed in the working tree** except the deferrals listed at the end. 137 files changed, ~6.9k insertions. Verification: `pnpm typecheck` 5/5 tasks, `pnpm lint` 3/3, `pnpm test` **2043 passed / 0 failed** (350 skipped are the Docker-gated integration suites), `node scripts/check-dynamic-sql.mjs` clean, `pnpm audit --prod --audit-level high` exit 0 with no suppressions. Nothing is committed — the changes are uncommitted on branch `public-home-feed`.
+**Everything above is fixed in the working tree** except the deferrals listed at the end. 137 files changed, ~6.9k insertions. Verification: `pnpm typecheck` 5/5 tasks, `pnpm lint` 3/3, `pnpm test` **2043 passed / 0 failed** (350 skipped are the Docker-gated integration suites), `node scripts/check-dynamic-sql.mjs` clean, `pnpm audit --prod --audit-level high` exit 0 with no suppressions. Nothing is committed: the changes are uncommitted on branch `public-home-feed`.
 
 Dependency posture went from **44 vulnerabilities (1 critical, 23 high)** to **5 moderate**, all transitive under `firebase-admin`/`@sentry/node` with no fix available upstream.
 
 ## Pre-push audit of the change set itself
 
-Before any of this was committed, a second adversarial pass read the whole diff through six independent lenses (secrets, availability/outage risk, migrations, test quality, cross-agent coherence, and whether any fix introduced a *new* vulnerability), with skeptics attempting to refute each blocker. It found **23 further findings: 1 blocker and 4 highs**. The green build had proved none of them — several reproduce only when `isProd()` is true, and one was actively masked by a test fake that copied the bug.
+Before any of this was committed, a second adversarial pass read the whole diff through six independent lenses (secrets, availability/outage risk, migrations, test quality, cross-agent coherence, and whether any fix introduced a *new* vulnerability), with skeptics attempting to refute each blocker. It found **23 further findings: 1 blocker and 4 highs**. The green build had proved none of them: several reproduce only when `isProd()` is true, and one was actively masked by a test fake that copied the bug.
 
 This is the part worth internalizing: six agents each did competent work inside their own file list, and the defects clustered exactly at the seams between them, and in the *new* code the fixes introduced.
 
-**Blocker — the orphan sweep would have destroyed user data on the first run after deploy.** `findOrphans` defined an orphan as `report_id IS NULL` alone, but `media_assets` has three binding columns plus a reverse binding for avatars, and every non-report lane leaves `report_id` NULL. Avatars, social-post photos and chat/DM attachments all matched the predicate. The predicate was pre-existing; what this change set added was the amplification — a 24h→6h TTL and a 200-row cap replaced by a drain loop of up to 50,000 rows per hourly run — turning a latent bug into fleet-wide irreversible deletion of rows *and* their R2 objects, with `avatar_media_id` silently NULLed by an `ON DELETE SET NULL` FK. The in-memory test fake reproduced the same broken predicate, which is why the suite was green. Fixed: the predicate now means "bound to nothing, in either direction", and the fake mirrors it.
+**Blocker: the orphan sweep would have destroyed user data on the first run after deploy.** `findOrphans` defined an orphan as `report_id IS NULL` alone, but `media_assets` has three binding columns plus a reverse binding for avatars, and every non-report lane leaves `report_id` NULL. Avatars, social-post photos and chat/DM attachments all matched the predicate. The predicate was pre-existing; what this change set added was the amplification: a 24h→6h TTL and a 200-row cap replaced by a drain loop of up to 50,000 rows per hourly run, turning a latent bug into fleet-wide irreversible deletion of rows *and* their R2 objects, with `avatar_media_id` silently NULLed by an `ON DELETE SET NULL` FK. The in-memory test fake reproduced the same broken predicate, which is why the suite was green. Fixed: the predicate now means "bound to nothing, in either direction", and the fake mirrors it.
 
 **Highs, all of which would have broken production on deploy:**
-- CSRF was added to `/media/upload` and `/media/:id/finalize`, but the published `@civfix/shared` client declares `csrf: false` for both, so it never sends the header — every signed-in web photo upload would have 403'd. Reverted, since the finding it addressed was LOW and the browser attack is already blocked by the CORS preflight; re-add it in the same release that ships the contract change.
-- The mandatory OAuth nonce would have 422'd native Apple/Google sign-in for every installed mobile build, recoverable only by an App Store release. Now gated behind `OAUTH_REQUIRE_NONCE` (default off) — a presented nonce is always validated, so updated clients are protected immediately; the old same-request tautology is not reachable under any setting.
+- CSRF was added to `/media/upload` and `/media/:id/finalize`, but the published `@civfix/shared` client declares `csrf: false` for both, so it never sends the header: every signed-in web photo upload would have 403'd. Reverted, since the finding it addressed was LOW and the browser attack is already blocked by the CORS preflight; re-add it in the same release that ships the contract change.
+- The mandatory OAuth nonce would have 422'd native Apple/Google sign-in for every installed mobile build, recoverable only by an App Store release. Now gated behind `OAUTH_REQUIRE_NONCE` (default off): a presented nonce is always validated, so updated clients are protected immediately; the old same-request tautology is not reachable under any setting.
 - The admin session route still read the pre-rename CSRF cookie, so in production the reuse branch never fired and every session bootstrap rotated the token out from under the tab holding it. Both admin mint sites now derive the session-bound token, which also closes the admin legacy-fallback gap.
-- The new hand-written HTML sanitizer passed through **any tag whose name contains an underscore** — `` survived byte-identical, and browsers honour event handlers on `HTMLUnknownElement`, so with a JS-readable CSRF cookie that is a full admin authorization bypass. It also deleted the entire remainder of the document on any void drop-tag, so `` — present in essentially every real email — reduced the stored body to `""`. Both fixed, and while fixing them a **quadratic blowup was found in the replacement regex** (7.6s on a 512 KB body, on an unauthenticated path); the pass is now a linear scan.
+- The new hand-written HTML sanitizer passed through **any tag whose name contains an underscore**: `` survived byte-identical, and browsers honour event handlers on `HTMLUnknownElement`, so with a JS-readable CSRF cookie that is a full admin authorization bypass. It also deleted the entire remainder of the document on any void drop-tag, so `` (present in essentially every real email) reduced the stored body to `""`. Both fixed, and while fixing them a **quadratic blowup was found in the replacement regex** (7.6s on a 512 KB body, on an unauthenticated path); the pass is now a linear scan.
 
 Three claims were investigated and **refuted**, and are recorded here so this document does not overstate: that the new code hard-fails against the old schema during the manual-migration window; that the H9 authorizer has no test; and that the `javascript:` URI gate was missed on the public writer.
 
-Fixed before pushing: the blocker, all four highs, and six mediums (rate-limit prefix coverage, the identity-vs-IP keying regression, the poll fan-out block gate, the cleanup pin-rail presigner, and both sanitizer defects). One of those — the rate-limit key — was a regression this change set introduced: keying on identity *instead of* IP meant one host with N accounts got N × every budget, weaker than what it replaced. The global bucket is IP-keyed again, with identity counted as an additional dimension on the sensitive bucket.
+Fixed before pushing: the blocker, all four highs, and six mediums (rate-limit prefix coverage, the identity-vs-IP keying regression, the poll fan-out block gate, the cleanup pin-rail presigner, and both sanitizer defects). One of those (the rate-limit key) was a regression this change set introduced: keying on identity *instead of* IP meant one host with N accounts got N × every budget, weaker than what it replaced. The global bucket is IP-keyed again, with identity counted as an additional dimension on the sensitive bucket.
 
 **Still outstanding from this pass** (all medium/low, none a data-loss or outage risk), re-verified
 file-by-file against the tree at the close of the final review pass. Each item names the file and the
@@ -202,7 +202,7 @@ mechanism, so a later fix is verifiable from here rather than leaving this list
 - **The account-deletion OTP shares the sign-in cooldown.** Deletion re-proves the email through the same
   `POST /auth/otp/request`, so it shares the one `otp:rl:email:` 60s key (`src/auth/otp.ts:245-255`),
   and a user who just signed in is told to wait on a GDPR erasure path (`src/routes/users.routes.ts:210-213`).
-  A server-side namespace needs a `purpose` field on `EmailOtpRequestRequestSchema` — now listed under the
+  A server-side namespace needs a `purpose` field on `EmailOtpRequestRequestSchema`, now listed under the
   `@civfix/shared` follow-ups below rather than only in a code comment.
 - **`openDm` still returns a literal `unread: 0`** (`src/services/dm-service.ts:149`) two lines below the
   real `conversationMutes` lookup this pass wired in, so opening an existing thread with unread peer
@@ -215,79 +215,79 @@ mechanism, so a later fix is verifiable from here rather than leaving this list
   (`src/services/jurisdiction-service.ts:263-276`), so a code is consumed even on the `ON CONFLICT` path.
   This was accepted as A15 on the understanding that the rate limit bounded it; the cost is
   write/fetch amplification per call, not just per new jurisdiction. `jurisdictions.code` is
-  `integer UNIQUE` (0030), so exhaustion needs ~2.1B calls — the real issue is the uncached loop.
+  `integer UNIQUE` (0030), so exhaustion needs ~2.1B calls; the real issue is the uncached loop.
 
 Four items the earlier draft of this paragraph listed have since been **fixed**, and are recorded here so
 nobody re-does them: the attendee-ban race now takes real row locks (`FOR NO KEY UPDATE` on the `cleanups`
-row in `cleanup-repository.drizzle.ts:495` vs `FOR SHARE` in `joinCleanupTx:569` — conflicting modes, so an
+row in `cleanup-repository.drizzle.ts:495` vs `FOR SHARE` in `joinCleanupTx:569`: conflicting modes, so an
 in-flight join serializes against the removal); the WS typing map is a bounded LRU
 (`TYPING_THROTTLE_MAX_ROOMS = 256`, `src/ws/types.ts:20`, eviction at `src/ws/frame-handler.ts:514`); the
 false invariant comment in `role-change.ts` is gone and both admin writers do call `applyRoleChange`, with
 `provisionOperator` documented as the sole exception; and the README's reviewer-bypass table now documents
 all three variables including `REVIEWER_OTP_BYPASS_ACK`. That draft also claimed "five stale comments or
-test descriptions" without naming one: the count was never substantiated and is **withdrawn** — the
+test descriptions" without naming one: the count was never substantiated and is **withdrawn**: the
 comment-vs-code drift the final review pass did find was enumerated with `file:line` and routed to the
 owning area, not tallied here.
 
 ## Operator actions required BEFORE the next deploy
 
-The app now fails closed on several of these — it will **refuse to boot** rather than run insecurely.
+The app now fails closed on several of these: it will **refuse to boot** rather than run insecurely.
 
-0. **`REVIEWER_OTP_BYPASS_ACK`** — enabling the reviewer bypass in production now requires this third variable (`=true`) *in addition to* `REVIEWER_OTP_BYPASS` and a `REVIEWER_OTP_CODE` of >=20 chars. With the bypass on and this missing, `loadEnv` throws and the box does not start. Documented in the README's reviewer-bypass table (all three variables, plus the safe order for turning the bypass back off: switch first, then remove the code).
-1. **`REVIEWER_OTP_BYPASS`** — default is now `false`. If the sops env sets it truthy, the box will not start unless **both** `REVIEWER_OTP_BYPASS_ACK=true` and `REVIEWER_OTP_CODE=<≥20 random chars>` are also set. Recommended: set it to `false` and audit the `@reviewer` account's activity — the old credential was public.
+0. **`REVIEWER_OTP_BYPASS_ACK`**: enabling the reviewer bypass in production now requires this third variable (`=true`) *in addition to* `REVIEWER_OTP_BYPASS` and a `REVIEWER_OTP_CODE` of >=20 chars. With the bypass on and this missing, `loadEnv` throws and the box does not start. Documented in the README's reviewer-bypass table (all three variables, plus the safe order for turning the bypass back off: switch first, then remove the code).
+1. **`REVIEWER_OTP_BYPASS`**: default is now `false`. If the sops env sets it truthy, the box will not start unless **both** `REVIEWER_OTP_BYPASS_ACK=true` and `REVIEWER_OTP_CODE=<≥20 random chars>` are also set. Recommended: set it to `false` and audit the `@reviewer` account's activity: the old credential was public.
 2. **`DATABASE_URL` must carry `sslmode=require`** (or `verify-ca`/`verify-full`) in production **for any host that crosses a wire**. Confirm the Postgres endpoint actually accepts TLS first; if it is a same-host container link with no server cert, this breaks connections, not just the boot check.
 
-   **AMENDED 2026-07-25 — this assertion took production down.** As originally shipped it had no exemption, and the deployed stack reaches Postgres as `postgres:5432` over the compose bridge using the stock `postgres:16-bookworm` + PostGIS image, which serves **no certificate**. The `migrate` container calls `loadEnv()` before any SQL, so it exited non-zero within a second and every deploy failed at the migrate step with the API left down — and `sslmode=require` would not have fixed it, because libpq then refuses to connect at all. `loadEnv` now exempts hosts the packets cannot leave the machine to reach (`localhost`, `127.0.0.0/8`, `::1`, or a single-label compose alias); see `isNonRoutableDbHost` in `src/env.ts`. Dotted names and the RFC1918 *addresses* are deliberately NOT exempt — "private IP" has never meant "nobody can see the wire". The residual risk is cleartext on the docker bridge, readable only by something that already has root on the box, at which point the Postgres password in the same env file is already exposed. **To close it properly:** give the postgres service a certificate and `ssl=on`, then put `sslmode=require` back in `DATABASE_URL` — the exemption is written so that needs no code change.
-3. **`R2_INBOUND_BUCKET`** is now required whenever `R2_PUBLIC_BASE` is set, and must name a different, non-public bucket than `R2_BUCKET`. Separately: **audit `https:///inbound/…` for already-published `.eml` objects** — the code fix does not retract what is already exposed.
+   **AMENDED 2026-07-25: this assertion took production down.** As originally shipped it had no exemption, and the deployed stack reaches Postgres as `postgres:5432` over the compose bridge using the stock `postgres:16-bookworm` + PostGIS image, which serves **no certificate**. The `migrate` container calls `loadEnv()` before any SQL, so it exited non-zero within a second and every deploy failed at the migrate step with the API left down, and `sslmode=require` would not have fixed it, because libpq then refuses to connect at all. `loadEnv` now exempts hosts the packets cannot leave the machine to reach (`localhost`, `127.0.0.0/8`, `::1`, or a single-label compose alias); see `isNonRoutableDbHost` in `src/env.ts`. Dotted names and the RFC1918 *addresses* are deliberately NOT exempt: "private IP" has never meant "nobody can see the wire". The residual risk is cleartext on the docker bridge, readable only by something that already has root on the box, at which point the Postgres password in the same env file is already exposed. **To close it properly:** give the postgres service a certificate and `ssl=on`, then put `sslmode=require` back in `DATABASE_URL`; the exemption is written so that needs no code change.
+3. **`R2_INBOUND_BUCKET`** is now required whenever `R2_PUBLIC_BASE` is set, and must name a different, non-public bucket than `R2_BUCKET`. Separately: **audit `https:///inbound/…` for already-published `.eml` objects**: the code fix does not retract what is already exposed.
 4. **`TRUST_PROXY`** must not be the literal `true` in production.
-5. **Redis must be reachable at boot** — `server.ts` now asserts it.
-6. **`OAUTH_REQUIRE_NONCE`** — leave unset (off) until a nonce-sending mobile build is the store floor, then flip it to `true`. Turning it on early 422s native sign-in for every older install.
+5. **Redis must be reachable at boot**: `server.ts` now asserts it.
+6. **`OAUTH_REQUIRE_NONCE`**: leave unset (off) until a nonce-sending mobile build is the store floor, then flip it to `true`. Turning it on early 422s native sign-in for every older install.
 7. **`WS_ALLOW_QUERY_TOKEN` must stay unset.** It is the break-glass switch re-enabling the leaking `?token=` WS path for store-shipped mobile builds that predate the fix.
-8. **Reconcile operator accounts against `ADMIN_EMAILS` before deploying.** `ADMIN_EMAILS` is now the sole source of operator truth, re-checked on every admin request (60s cache). Any operator whose `users.email` is absent from it gets 403 on every admin route within a minute of deploy. Run `SELECT email FROM users WHERE role='operator'` and diff. Watch for NULL emails and case/alias mismatches (the check is trim+lowercase exact match, no plus-address normalization). Operators can no longer be created or removed from the console — onboarding is `ADMIN_EMAILS` + Access sign-in.
-9. **Migrations shipped with this change set** (CORRECTED 2026-09-04: the deploy DOES apply them — the compose `migrate` one-shot runs `node dist/db/migrate.js` before `seed`/`api`/`media-worker`, all three gated on `service_completed_successfully`, so a failed migration blocks the release rather than letting the new images run on the old schema. There is no manual step; the sections below are the failure-signature reference.) Wave 1 added `drizzle/0052_cleanup_bans.sql` and `drizzle/0053_volunteer_hours_audit.sql`; wave 2 added four more (`0054`–`0057`), one of which the new code HARD-DEPENDS on; wave 3 added `0058_sessions_created_at.sql` (the M3 session backfill) and `0059_users_follow_counters.sql`, a SECOND hard dependency — without it every people-facing read fails on an undefined column. All forward-only, and idempotent on re-apply. The full list, ordering constraints and failure signatures are in the operator runbook at the end of this document.
+8. **Reconcile operator accounts against `ADMIN_EMAILS` before deploying.** `ADMIN_EMAILS` is now the sole source of operator truth, re-checked on every admin request (60s cache). Any operator whose `users.email` is absent from it gets 403 on every admin route within a minute of deploy. Run `SELECT email FROM users WHERE role='operator'` and diff. Watch for NULL emails and case/alias mismatches (the check is trim+lowercase exact match, no plus-address normalization). Operators can no longer be created or removed from the console: onboarding is `ADMIN_EMAILS` + Access sign-in.
+9. **Migrations shipped with this change set** (CORRECTED 2026-09-04: the deploy DOES apply them; the compose `migrate` one-shot runs `node dist/db/migrate.js` before `seed`/`api`/`media-worker`, all three gated on `service_completed_successfully`, so a failed migration blocks the release rather than letting the new images run on the old schema. There is no manual step; the sections below are the failure-signature reference.) Wave 1 added `drizzle/0052_cleanup_bans.sql` and `drizzle/0053_volunteer_hours_audit.sql`; wave 2 added four more (`0054`–`0057`), one of which the new code HARD-DEPENDS on; wave 3 added `0058_sessions_created_at.sql` (the M3 session backfill) and `0059_users_follow_counters.sql`, a SECOND hard dependency: without it every people-facing read fails on an undefined column. All forward-only, and idempotent on re-apply. The full list, ordering constraints and failure signatures are in the operator runbook at the end of this document.
 
 ## Coordinated client releases required
 
 | Change | Client | Breaking? |
 |---|---|---|
-| Native Apple/Google sign-in now requires a server-issued nonce from `POST /v1/auth/oauth/nonce` | mobile (+ web if it uses native sign-in) | **Yes** — old clients get 401/422 on native sign-in |
-| `POST /notifications/push` returns **409** when a token belongs to another account; `deviceId` must be a UUID (non-UUID is silently dropped, registration still succeeds) | mobile | Yes — clients assuming 200 must handle it |
+| Native Apple/Google sign-in now requires a server-issued nonce from `POST /v1/auth/oauth/nonce` | mobile (+ web if it uses native sign-in) | **Yes**: old clients get 401/422 on native sign-in |
+| `POST /notifications/push` returns **409** when a token belongs to another account; `deviceId` must be a UUID (non-UUID is silently dropped, registration still succeeds) | mobile | Yes: clients assuming 200 must handle it |
 | CSRF token is now session-bound and cookies gain the `__Host-` prefix in production (legacy names still readable for one session lifetime) | web + mobile | Transitional |
-| ~~`POST /media/upload` and `/media/:id/finalize` require `X-CSRF-Token` on cookie sessions~~ — **reverted before push**, so there is nothing to do in this release: both routes still carry no `csrfProtect` (`routes/media.routes.ts:22-42,145-159` documents why and the exact re-add recipe). The gate returns only in the release that ships `csrf: true` for both endpoints in `@civfix/shared` — see the shared follow-ups below | web (no change now) | No |
+| ~~`POST /media/upload` and `/media/:id/finalize` require `X-CSRF-Token` on cookie sessions~~, **reverted before push**, so there is nothing to do in this release: both routes still carry no `csrfProtect` (`routes/media.routes.ts:22-42,145-159` documents why and the exact re-add recipe). The gate returns only in the release that ships `csrf: true` for both endpoints in `@civfix/shared`; see the shared follow-ups below | web (no change now) | No |
 | Drop the `?token=` WS fallback and treat a failed `wsTicket()` as a retryable connect error; handle `{type:"error",code:"UNAUTHORIZED"}` + close 1008 by re-authenticating | mobile | Yes, once `WS_ALLOW_QUERY_TOKEN` is unset |
 | Inbound webhook signs `.` with `x-cf-timestamp` (legacy body-only signatures still accepted, with a warn) | Cloudflare Email Worker (`civfix-infra`) | Transitional |
 
 ## Follow-ups in `@civfix/shared`
 
-This change set is backend-only, so none of the items below are closed by it — they are the work the
+This change set is backend-only, so none of the items below are closed by it: they are the work the
 backend cannot do alone. The list is complete on purpose: the review reserved every contract item for this
 section, and two of them are user-visible dead ends. Paths are relative to
 `civfix-shared/packages/shared/src/`; line numbers are as of 2026-07-25.
 
-**Group 1 — hardening the server already enforces (contract change is defense in depth).**
+**Group 1: hardening the server already enforces (contract change is defense in depth).**
 
 - Narrow `WsClientMessageSchema`'s `send.kind` (`types/ws.ts:32`, today the full `ChatMessageKindSchema`) to the client-authorable subset (`text | share_pin | task_complete | rsvp_change`).
-- Replace `.url()` with a scheme-checked refinement on `formUrl` — `PatchJurisdictionRequestSchema` / `SaveContactsRequestSchema` (`schemas/admin/jurisdictions.ts:27,187`), `SaveDraftRequestSchema` (`schemas/admin/discovery.ts:169`) and the citizen `schemas/map.ts:106`. `z.string().url()` accepts `javascript:`.
-- Add `.max()` to `LogEventHoursRequestSchema.entries` (`schemas/volunteer.ts:74` — `.min(1)` with no ceiling) and `CreateCleanupRequestSchema.bring` + the update variant (`schemas/cleanups.ts:33,61`).
+- Replace `.url()` with a scheme-checked refinement on `formUrl`: `PatchJurisdictionRequestSchema` / `SaveContactsRequestSchema` (`schemas/admin/jurisdictions.ts:27,187`), `SaveDraftRequestSchema` (`schemas/admin/discovery.ts:169`) and the citizen `schemas/map.ts:106`. `z.string().url()` accepts `javascript:`.
+- Add `.max()` to `LogEventHoursRequestSchema.entries` (`schemas/volunteer.ts:74`: `.min(1)` with no ceiling) and `CreateCleanupRequestSchema.bring` + the update variant (`schemas/cleanups.ts:33,61`).
 
-**Group 2 — contract gaps the server cannot close.** All eleven items the wave-2 worklist reserved for
+**Group 2: contract gaps the server cannot close.** All eleven items the wave-2 worklist reserved for
 reporting, plus the deletion-OTP purpose field and the media-CSRF declaration:
 
 | # | Contract item | What it needs | What it costs today |
 |---|---|---|---|
-| F1 | `endpoints.logout` is `auth:"required"` (`client/endpoints.ts:419`) | `auth:"optional"` + idempotent semantics | A caller holding a dead/absent credential gets 401 and its cookies are never cleared. The handler is already idempotent; the contract — and `test/unit/auth-routes.test.ts:406` ("logout while unauthenticated is 401") — is what pins the 401 |
-| F2 | `ListThreadsResponseSchema` advertises `nextCursor` (`schemas/chat.ts:83`) | **Nothing — closed server-side.** `ThreadsService.list` implements keyset paging and the route forwards the cursor verbatim | The legacy `listThreads(userId, limit)` form still returns `nextCursor: null`, but it is internal/test-only. Recorded so it is not re-raised |
-| F3 | `ActivityListQuerySchema.filter` / `AdminListQuerySchema.sort` are free-form `z.string()` (`schemas/admin/activity.ts:37`, `schemas/admin/common.ts:30-36`) | Narrow to enums (`ActivityKind \| "all"`, `"newest" \| "oldest"`) | `q`/`filter`/`sort`/`cursor` are now really implemented (`services/admin/activity-service.ts`), but an unrecognised value silently degrades to `all` / `newest` instead of 422 — the console cannot tell a typo from an empty page |
-| F4 | `QuietHoursSchema` is `{start,end}` with no timezone (`schemas/notifications.ts:59`) | A `tz` field (IANA name) + a `notification_prefs` migration + `Intl.DateTimeFormat` evaluation | Quiet hours are evaluated against the SERVER's offset (`services/notification-helpers.ts isWithinQuietHours`), so any user outside it gets bells inside their quiet window and silence outside it. There is **no safe server-side fix** — defining the wire format as UTC is the alternative, and it breaks twice a year |
-| F5 | `FinalizeMediaResponseSchema.status` is `z.ZodLiteral<"validating">` (`schemas/media.ts:53`) | Widen to `MediaStatus` | **User-visible dead end.** A rejected upload answers `validating` from finalize (`services/media-intake-service.ts:285-292` documents the forced lie) and 404 from `GET /media/:id` (the H9 non-oracle control) — forever. The client can never distinguish "still scanning" from "rejected" and shows a spinner that never resolves |
+| F1 | `endpoints.logout` is `auth:"required"` (`client/endpoints.ts:419`) | `auth:"optional"` + idempotent semantics | A caller holding a dead/absent credential gets 401 and its cookies are never cleared. The handler is already idempotent; the contract, and `test/unit/auth-routes.test.ts:406` ("logout while unauthenticated is 401"), is what pins the 401 |
+| F2 | `ListThreadsResponseSchema` advertises `nextCursor` (`schemas/chat.ts:83`) | **Nothing: closed server-side.** `ThreadsService.list` implements keyset paging and the route forwards the cursor verbatim | The legacy `listThreads(userId, limit)` form still returns `nextCursor: null`, but it is internal/test-only. Recorded so it is not re-raised |
+| F3 | `ActivityListQuerySchema.filter` / `AdminListQuerySchema.sort` are free-form `z.string()` (`schemas/admin/activity.ts:37`, `schemas/admin/common.ts:30-36`) | Narrow to enums (`ActivityKind \| "all"`, `"newest" \| "oldest"`) | `q`/`filter`/`sort`/`cursor` are now really implemented (`services/admin/activity-service.ts`), but an unrecognised value silently degrades to `all` / `newest` instead of 422: the console cannot tell a typo from an empty page |
+| F4 | `QuietHoursSchema` is `{start,end}` with no timezone (`schemas/notifications.ts:59`) | A `tz` field (IANA name) + a `notification_prefs` migration + `Intl.DateTimeFormat` evaluation | Quiet hours are evaluated against the SERVER's offset (`services/notification-helpers.ts isWithinQuietHours`), so any user outside it gets bells inside their quiet window and silence outside it. There is **no safe server-side fix**: defining the wire format as UTC is the alternative, and it breaks twice a year |
+| F5 | `FinalizeMediaResponseSchema.status` is `z.ZodLiteral<"validating">` (`schemas/media.ts:53`) | Widen to `MediaStatus` | **User-visible dead end.** A rejected upload answers `validating` from finalize (`services/media-intake-service.ts:285-292` documents the forced lie) and 404 from `GET /media/:id` (the H9 non-oracle control), forever. The client can never distinguish "still scanning" from "rejected" and shows a spinner that never resolves |
 | F6 | `InboundEmailDTOSchema` is `.strict()` with no truncation flag (`schemas/admin/inbox.ts:72`) | An additive `attachmentsTruncated` (or a count) | `routes/admin/inbox.routes.ts:91-99` slices to `MAX_INBOX_ATTACHMENTS = 50` and can only log a warn; the operator console shows 50 of N with no indication anything was elided |
-| F7 | `Mailer.sendOtp(to, code)` has no locale slot (`interfaces/mailer.ts:40`) | An optional third `locale` parameter | Passcode emails DO localize today — `auth/otp.ts` passes `account?.locale` through a locally widened `LocaleAwareMailer` and `OciMailer.sendOtp` accepts it — but the shared type is why that widening exists at all |
-| F8 | `Storage.presignGet(key, ttlSec)` has no `forceSigned` (`interfaces/storage.ts:40`) | A `forceSigned` option | `R2Storage.presignGet` returns the UNSIGNED CDN URL whenever `publicBase` is set. The media-worker fix was to stop passing `publicBase` — a wiring convention, not a structural guarantee, so the next construction site can re-introduce public URLs for private objects |
+| F7 | `Mailer.sendOtp(to, code)` has no locale slot (`interfaces/mailer.ts:40`) | An optional third `locale` parameter | Passcode emails DO localize today: `auth/otp.ts` passes `account?.locale` through a locally widened `LocaleAwareMailer` and `OciMailer.sendOtp` accepts it, but the shared type is why that widening exists at all |
+| F8 | `Storage.presignGet(key, ttlSec)` has no `forceSigned` (`interfaces/storage.ts:40`) | A `forceSigned` option | `R2Storage.presignGet` returns the UNSIGNED CDN URL whenever `publicBase` is set. The media-worker fix was to stop passing `publicBase`: a wiring convention, not a structural guarantee, so the next construction site can re-introduce public URLs for private objects |
 | F9 | `FakeInboundMail.extractThreadToken` still honors `X-Thread-Token` (`fakes/inbound-mail.fake.ts:80-86`) | Drop the header arm | The real adapter no longer honors it, so the **fake is more permissive than production**: a spoofable-header attack path passes in tests and fails in prod (or the reverse, for a regression test) |
-| F10 | `ChatService.broadcast` takes no `excludeConnId` (`interfaces/chat-service.ts:33`; fake at `fakes/chat-service.fake.ts:49`), while `broadcastEvent` does | Add the same optional `opts` to `broadcast` | The WS path really does pass it — `ws/frame-handler.ts:374` calls `deps.chat.broadcast(roomKey, message, { excludeConnId: conn.id })` through a locally widened `GatewayChatService` (`ws/types.ts:179-183`). The shared fake silently drops the third argument, so the all-fakes dev path and every fake-backed test echo a message frame back to its own sender — behavior production does not have |
+| F10 | `ChatService.broadcast` takes no `excludeConnId` (`interfaces/chat-service.ts:33`; fake at `fakes/chat-service.fake.ts:49`), while `broadcastEvent` does | Add the same optional `opts` to `broadcast` | The WS path really does pass it: `ws/frame-handler.ts:374` calls `deps.chat.broadcast(roomKey, message, { excludeConnId: conn.id })` through a locally widened `GatewayChatService` (`ws/types.ts:179-183`). The shared fake silently drops the third argument, so the all-fakes dev path and every fake-backed test echo a message frame back to its own sender: behavior production does not have |
 | F11 | `ModerationKindSchema` has no `video` member (`schemas/admin/common.ts:170`) | A new enum member + widening `0007_admin_phase2.sql`'s CHECK + a console label | Held **videos** are enqueued as `kind:"image"` with the real kind carried in the reason string (`media-worker/src/jobs/media-checks.ts:253-262` documents the limit and why it is not a one-line worker fix). Answers the worklist's open question: `"video"` is **not** an accepted value today |
-| — | `EmailOtpRequestRequestSchema` has no `purpose` field | Add `purpose` (e.g. `signin \| account_deletion`) | The account-deletion OTP therefore shares the sign-in `otp:rl:email:` 60s cooldown and 429s a user who just signed in, on a GDPR erasure path (`auth/otp.ts:245-255`, `routes/users.routes.ts:210-213`). The server cannot namespace the key without knowing the caller's purpose |
-| — | `createMediaUpload` / `finalizeMedia` declare `csrf: false` (`client/endpoints.ts:1344-1359`) | Flip both to `csrf: true` in the same release that re-adds the server-side gate | This is why the CSRF preHandler added to `/media/upload` and `/media/:id/finalize` had to be reverted (see the pre-push audit above): the published client never sends the header, so every signed-in web photo upload would 403. Server and contract must move together |
+| none | `EmailOtpRequestRequestSchema` has no `purpose` field | Add `purpose` (e.g. `signin \| account_deletion`) | The account-deletion OTP therefore shares the sign-in `otp:rl:email:` 60s cooldown and 429s a user who just signed in, on a GDPR erasure path (`auth/otp.ts:245-255`, `routes/users.routes.ts:210-213`). The server cannot namespace the key without knowing the caller's purpose |
+| none | `createMediaUpload` / `finalizeMedia` declare `csrf: false` (`client/endpoints.ts:1344-1359`) | Flip both to `csrf: true` in the same release that re-adds the server-side gate | This is why the CSRF preHandler added to `/media/upload` and `/media/:id/finalize` had to be reverted (see the pre-push audit above): the published client never sends the header, so every signed-in web photo upload would 403. Server and contract must move together |
 
 F9 and F10 are shipped *fakes*, not wire schemas, so they additionally bound what the backend's own suite
 can prove: a test written against either one is asserting behavior production does not have.
@@ -298,8 +298,8 @@ can prove: a test written against either one is asserting behavior production do
 |---|---|---|
 | H2 session-origin tagging (the stronger control) | The shipped fix is the `ADMIN_EMAILS` re-check, which closes the hole; origin tagging makes a citizen-flow session *structurally* incapable of operator authority | An `origin` column on `sessions` + migration, threaded through `SessionService`/`CachedSession` |
 | M12 full pending-invite consent | Shipped: invitees blocked either-way with any existing member are filtered out | New `chat_group_invites` table + shared schemas + endpoints + client release |
-| M10 requiring a session for presign | Not implementable as stated — the anon token is only issued by `POST /anon/reports`, which happens *after* upload; requiring it would break first-time anonymous reporting. Shipped instead: a 512 MB/subject/day byte quota, a looping orphan sweep, 6h orphan TTL | A separate anon-session bootstrap endpoint |
-| M9 NSFW default is `flag`, not `hold` | With no model vendored, `hold` holds 100% of all media and the anon hold-release path gates report publication on media readiness — defaulting to `hold` would take anonymous reporting offline. `flag` publishes but raises the abuse flag so every unscored asset reaches the moderation queue; `MEDIA_UNSCORED_POLICY=hold` is one env var away | A real NSFW scorer before public launch |
+| M10 requiring a session for presign | Not implementable as stated: the anon token is only issued by `POST /anon/reports`, which happens *after* upload; requiring it would break first-time anonymous reporting. Shipped instead: a 512 MB/subject/day byte quota, a looping orphan sweep, 6h orphan TTL | A separate anon-session bootstrap endpoint |
+| M9 NSFW default is `flag`, not `hold` | With no model vendored, `hold` holds 100% of all media and the anon hold-release path gates report publication on media readiness: defaulting to `hold` would take anonymous reporting offline. `flag` publishes but raises the abuse flag so every unscored asset reaches the moderation queue; `MEDIA_UNSCORED_POLICY=hold` is one env var away | A real NSFW scorer before public launch |
 | L14 sha256 persist + verify | Stale "content-addressed" comments deleted and the shape-check relabelled; keys are random UUIDs so the described attack never applied | A `media_assets.sha256` column + worker-side re-hash |
 | M17 socket eviction on attendee removal | Ban table and join rejection shipped; a removed attendee can no longer re-join. Their *already-open* socket still receives broadcasts until it closes | A revocation publish on the room channel consumed by `ws/socket-lifecycle.ts` |
 | L24 localization of cancellation bells | Prefs and quiet hours now honored (routed through `NotificationService`); title/body are still English literals | Four `notification.cleanup_cancelled.*` keys in `i18n/messages/{en,es,de,ko}.ts` |
@@ -309,13 +309,13 @@ can prove: a test written against either one is asserting behavior production do
 
 # Operator runbook
 
-Everything an operator has to do by hand, in order, plus the two infra facts and the local-verification recipe that the change set assumes. Read this together with "Operator actions required BEFORE the next deploy" above — that section covers env vars, this one covers schema, infra and verification.
+Everything an operator has to do by hand, in order, plus the two infra facts and the local-verification recipe that the change set assumes. Read this together with "Operator actions required BEFORE the next deploy" above: that section covers env vars, this one covers schema, infra and verification.
 
 ## 1. Migrations, in this order, applied BEFORE the new images run
 
 `src/db/migrate.ts` sorts `drizzle/*.sql` **lexically** and records applied files in `_civfix_migrations`; there is no journal file, so a filename alone enrolls a migration.
 
-**CORRECTED 2026-09-04 — the production deploy runs this automatically.** The compose `migrate` one-shot (`civfix-infra` `compose/docker-compose.yml`) runs the command below on every deploy, and `seed`, `api` and `media-worker` all declare `depends_on: migrate: { condition: service_completed_successfully }`, so a migration failure aborts the release with the previous images still in place. The original text here said the deploy did not run migrations and that they had to be applied by hand; that was wrong. The manual invocation survives only as a re-run / recovery lever:
+**CORRECTED 2026-09-04: the production deploy runs this automatically.** The compose `migrate` one-shot (`civfix-infra` `compose/docker-compose.yml`) runs the command below on every deploy, and `seed`, `api` and `media-worker` all declare `depends_on: migrate: { condition: service_completed_successfully }`, so a migration failure aborts the release with the previous images still in place. The original text here said the deploy did not run migrations and that they had to be applied by hand; that was wrong. The manual invocation survives only as a re-run / recovery lever:
 
 ```sh
 # on the box, as the civfix user, with DATABASE_URL exported (re-run only; the deploy already did this)
@@ -323,13 +323,13 @@ node dist/db/migrate.js        # == pnpm --filter @civfix/api db:migrate
 ```
 
 `drizzle/` holds **167 files**, `0000_extensions.sql` … `0185_inbound_bounce_attempts.sql`. The nine rows
-below are exactly what this change set adds — `0052`–`0060`, contiguous, no gaps — and everything from
+below are exactly what this change set adds (`0052`–`0060`, contiguous, no gaps) and everything from
 `0000` through `0051_social_posts.sql` predates it. (`0060` arrived later than the rest, with the feed
 redesign; it is listed here because this table is the single operator runbook. `0061`–`0064` arrived
 later still, with the service-hours feature set, and have their own table in §1a below; `0065` and `0066`
 are DATA migrations and have §1b and §1c to themselves. `0067`–`0095` arrived later still, with the
 production-readiness fix change set, and have their own table in §1d below. `0105`–`0153` arrived
-last, with the event host platform — organizations, ticketing, broadcasts and donations — and have their
+last, with the event host platform (organizations, ticketing, broadcasts and donations) and have their
 own table in §1e below; the number ranges there are RESERVED per domain, so the gaps `0112`–`0114`,
 `0121`–`0129`, `0138`–`0144` and `0154`–`0159` are deliberate and not missing files.) Both backfills live **inside** their own migration file
 (`0058`'s `sessions.created_at`, `0059`'s follow counters; §2 and §6 below), so there is no separate
@@ -343,30 +343,30 @@ deploy.
 | `0053_volunteer_hours_audit.sql` | append-only hours journal (M21) | Hours upserts fail (the service writes the audit row in the same tx) |
 | `0054_media_purpose_post.sql` | widens the `media_assets.purpose` CHECK to include `'post'` | **Every post-with-media insert fails** (23514); the missing enum value is what shipped the bug |
 | `0055_posts_thread_root_idx.sql` | index on `posts.thread_root_id` | Slow cascades on post deletes only (no correctness impact) |
-| `0056_abuse_flags_worker_open_unique.sql` | collapses duplicate OPEN worker abuse flags, then a partial unique index on `(subject_type, subject_id, reason) WHERE resolved_at IS NULL AND source='worker'` | **HARD DEPENDENCY of the new media-worker** — see below |
+| `0056_abuse_flags_worker_open_unique.sql` | collapses duplicate OPEN worker abuse flags, then a partial unique index on `(subject_type, subject_id, reason) WHERE resolved_at IS NULL AND source='worker'` | **HARD DEPENDENCY of the new media-worker**; see below |
 | `0057_media_reap_tombstones.sql` | `media_reap_tombstones`: R2 keys the orphan sweep failed to delete, retried by later sweeps | Orphan-sweep leaks are logged/reported but not retried (the pre-wave-2 behavior). One extra error per sweep; no data loss |
-| `0058_sessions_created_at.sql` | backfills NULL `sessions.created_at` from `last_seen_at`, then `SET NOT NULL` (M3) | Every legacy NULL-`created_at` session keeps failing closed, i.e. one forced re-login each — see below |
-| `0059_users_follow_counters.sql` | adds `users.follower_count` / `users.following_count` (`int NOT NULL DEFAULT 0`) and backfills both from `follows_people` | **Every people surface fails** (42703, undefined column): profiles, people search, the follower/following rosters, follow suggestions and every post author card read these columns — see below |
-| `0060_moderation_subject_post.sql` | widens the `moderation_items.subject_type` CHECK to include `'post'`, so a reported feed post can be queued | **Every "Report post" fails** (23514): the row is rejected by the CHECK, the request 500s, and the post stays unreportable. Widening only — no existing row can violate it |
+| `0058_sessions_created_at.sql` | backfills NULL `sessions.created_at` from `last_seen_at`, then `SET NOT NULL` (M3) | Every legacy NULL-`created_at` session keeps failing closed, i.e. one forced re-login each; see below |
+| `0059_users_follow_counters.sql` | adds `users.follower_count` / `users.following_count` (`int NOT NULL DEFAULT 0`) and backfills both from `follows_people` | **Every people surface fails** (42703, undefined column): profiles, people search, the follower/following rosters, follow suggestions and every post author card read these columns; see below |
+| `0060_moderation_subject_post.sql` | widens the `moderation_items.subject_type` CHECK to include `'post'`, so a reported feed post can be queued | **Every "Report post" fails** (23514): the row is rejected by the CHECK, the request 500s, and the post stays unreportable. Widening only: no existing row can violate it |
 
-**0056 is order-critical in BOTH directions — apply it, then deploy the worker:**
+**0056 is order-critical in BOTH directions; apply it, then deploy the worker:**
 
 - **New worker, migration missing:** `MediaWorkerRepo.insertAbuseFlag` now ends in `ON CONFLICT (subject_type, subject_id, reason) WHERE … DO NOTHING`. Postgres resolves the arbiter at plan time, so with no matching index every insert raises `42P10` ("no unique or exclusion constraint matching the ON CONFLICT specification"). That is the `persist` phase of `media.checks` → `MediaInfraError` → 5 retries → the job dies and the asset is stuck `validating`, which also leaves anonymous reports stuck `held`. This is the one wave-2 change that hard-fails against the old schema.
 - **Old worker, migration applied:** a *retried* job re-inserting the same open flag now raises `23505` instead of quietly duplicating. Bounded (it only happens to a job whose persist already failed once) and it self-heals the moment the new image lands.
 
-The migration is safe to apply on a live DB: the collapse `DELETE` only touches OPEN rows with `source='worker'` (admin/API flags are untouched — the discovery "start task" path inserts `('report', …, 'manual', 'api')` with no guard and must keep working), and the index is built inside the migration's transaction, so `abuse_flags` is briefly write-locked. Executed against a throwaway `postgis/postgis:16-3.4` (both files applied and re-applied in one transaction each, then the real `makeDrizzleMediaWorkerRepo` driven against them): the collapse removes timestamped *and* `NULL`-`created_at` duplicates while leaving resolved rows, `source='api'` duplicates and other reasons alone; `insertAbuseFlag`'s arbiter is inferred (the retry is a no-op, an `'api'` flag still duplicates freely, and a moderator-cleared flag can be re-raised); and the `media_reap_tombstones` round trip (record → bump → list-under-cap → clear) behaves as documented. The one other ON CONFLICT the worker uses — `enqueueHeldModerationItem` against `0038_moderation_open_unique.sql` — was checked by inspection only: same target columns, same `WHERE status = 'open'` predicate.
+The migration is safe to apply on a live DB: the collapse `DELETE` only touches OPEN rows with `source='worker'` (admin/API flags are untouched: the discovery "start task" path inserts `('report', …, 'manual', 'api')` with no guard and must keep working), and the index is built inside the migration's transaction, so `abuse_flags` is briefly write-locked. Executed against a throwaway `postgis/postgis:16-3.4` (both files applied and re-applied in one transaction each, then the real `makeDrizzleMediaWorkerRepo` driven against them): the collapse removes timestamped *and* `NULL`-`created_at` duplicates while leaving resolved rows, `source='api'` duplicates and other reasons alone; `insertAbuseFlag`'s arbiter is inferred (the retry is a no-op, an `'api'` flag still duplicates freely, and a moderator-cleared flag can be re-raised); and the `media_reap_tombstones` round trip (record → bump → list-under-cap → clear) behaves as documented. The one other ON CONFLICT the worker uses (`enqueueHeldModerationItem` against `0038_moderation_open_unique.sql`) was checked by inspection only: same target columns, same `WHERE status = 'open'` predicate.
 
 ## 1a. Service-hours feature set (`0061`–`0064`)
 
 A later change set than the `0052`–`0060` block above; listed here because this table is the single
-operator runbook. All four are **additive-only** — `ADD COLUMN` (nullable, no default), `CREATE TABLE
+operator runbook. All four are **additive-only**: `ADD COLUMN` (nullable, no default), `CREATE TABLE
 IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`. No rename, no drop, no narrowed CHECK, no `NOT NULL`
-without a default, and **no backfill `UPDATE`** — so the old code keeps serving correctly against the new
+without a default, and **no backfill `UPDATE`**, so the old code keeps serving correctly against the new
 schema and the order relative to the image is free.
 
 | File | What it does | If skipped |
 |---|---|---|
-| `0061_users_show_volunteer_hours.sql` | adds `users.show_volunteer_hours boolean` — a **nullable tri-state**, deliberately not `NOT NULL DEFAULT true` (NULL = never chosen ⇒ aggregate visible, itemised ledger empty; TRUE = itemised opted in; FALSE = hidden) | The privacy toggle and both hours projections fail (42703, undefined column) |
+| `0061_users_show_volunteer_hours.sql` | adds `users.show_volunteer_hours boolean`: a **nullable tri-state**, deliberately not `NOT NULL DEFAULT true` (NULL = never chosen ⇒ aggregate visible, itemised ledger empty; TRUE = itemised opted in; FALSE = hidden) | The privacy toggle and both hours projections fail (42703, undefined column) |
 | `0062_volunteer_hours_user_created_idx.sql` | index `volunteer_hours (user_id, created_at DESC, id DESC)` for the new keyset ledger reads | Correct but unindexed: every transcript page sorts the user's whole ledger |
 | `0063_cleanup_slots.sql` | `cleanup_slots` + `cleanup_slot_claims` (P9 signup slots). The `(cleanup_id, user_id)` PK **is** the one-slot-per-person rule; the composite FK `(slot_id, cleanup_id)` makes cross-event claims structurally impossible | Every slot read/write fails (42P01, undefined table) |
 | `0064_service_hours_certificates.sql` | `service_hours_certificates`: issued PDF transcripts, `code` = the public verification capability, plus the partial unique on `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL` that gives issue idempotency | Issue/list/revoke/verify all fail (42P01); without the partial unique specifically, a double-tap mints two documents |
@@ -389,22 +389,22 @@ signed PDF service transcripts are built from.
   jurisdiction leaderboards go thin or empty and many profiles fall to 0. That is the correction.
 - **The rollup recompute is AUTHORITATIVE**: any `total_hours` with no backing non-voided ledger row is
   erased. Safe because `logEventHours` is the only remaining writer of either table and there is no
-  `source='manual'` writer anywhere — but if you have ever hand-run an `UPDATE user_jurisdiction_hours`,
+  `source='manual'` writer anywhere, but if you have ever hand-run an `UPDATE user_jurisdiction_hours`,
   it will be lost. Re-applying is a no-op (statement 1 matches nothing; statement 2 is a pure recompute).
 - **Statement 0 takes `LOCK TABLE volunteer_hours, user_jurisdiction_hours IN SHARE ROW EXCLUSIVE MODE`,
   and it is load-bearing.** The runner's transaction is READ COMMITTED, so without it the recompute's
   correlated `SUM` cannot see a ledger row a live `logEventHours` committed after the statement began, and
-  the migration would overwrite that attendee's rollup with a total the new credit is missing from — a
+  the migration would overwrite that attendee's rollup with a total the new credit is missing from: a
   silent, permanent lost update (the rollup is maintained by delta and `0065` never re-runs). The lock
   makes concurrent writers queue instead; plain `SELECT`s are unaffected. **Keep the two tables in that
-  order** — it matches the order `logEventHours` takes them, and reversing it can deadlock the migration
+  order**: it matches the order `logEventHours` takes them, and reversing it can deadlock the migration
   mid-deploy. If the lock ever appears to hang, something is holding a long write transaction on those
   tables; find it rather than dropping the lock.
 - **Already-issued certificates are NOT corrected and cannot be**: `service_hours_certificates` rows are
   immutable snapshots and `verify()` reports them verbatim. After applying, list any live certificate that
   itemised a report row (query below), revoke each one with
   `pnpm --filter @civfix/api db:certificate:revoke ` (add `--dry-run` first; it needs `DATABASE_URL`,
-  plus `R2_*` if it should also delete the stored PDF), then notify the holder and let them re-issue —
+  plus `R2_*` if it should also delete the stored PDF), then notify the holder and let them re-issue;
   never hand-edit `snapshot`, `total_hours` or `document_sha256`.
 
   **Do not reach for the product's revoke endpoint.** `POST /service-hours/certificates/:code/revoke` is
@@ -412,7 +412,7 @@ signed PDF service transcripts are built from.
   an operator cannot call it and, if they had the holder call it, `verify()` would publicly report that the
   *volunteer withdrew their own record* rather than that civfix corrected the ledger.
   `scripts/revoke-certificate.ts` is the operator seam: same repository call, reason `ledger_corrected`
-  (or `issued_in_error`), plus the same best-effort R2 delete the holder path performs. It is idempotent —
+  (or `issued_in_error`), plus the same best-effort R2 delete the holder path performs. It is idempotent:
   `revoked_reason = COALESCE(revoked_reason, …)` never rewrites an earlier revocation.
 
   ```sql
@@ -432,7 +432,7 @@ signed PDF service transcripts are built from.
 Like `0065`, this **changes data**: it rebuilds `posts.reply_count` from the actual direct replies. The
 write path used to increment the counter only when `kind='reply'`, but `PostComposeInputSchema` defaults
 `kind` to `'post'` and carries `replyToId` through regardless, so a `{body, replyToId}` post created a real
-reply (non-null `reply_to_id`, returned by `listReplies`) that never bumped the parent — while the delete
+reply (non-null `reply_to_id`, returned by `listReplies`) that never bumped the parent, while the delete
 path already decremented by `reply_to_id`. The code now keys the increment on `reply_to_id`, matching the
 delete path and the reads; this migration repairs the rows that already drifted.
 
@@ -449,7 +449,7 @@ delete path and the reads; this migration repairs the rows that already drifted.
 **No file in `drizzle/` may contain `BEGIN;` / `COMMIT;` / `ROLLBACK;`.** `src/db/migrate.ts:83-97` wraps
 every file in its own transaction on a reserved connection, together with the `_civfix_migrations`
 bookkeeping INSERT. A file that opens its own ends the runner's mid-flight: the bookkeeping row commits
-separately, the runner's trailing `commit` only warns, and the catch branch's `rollback` becomes a no-op —
+separately, the runner's trailing `commit` only warns, and the catch branch's `rollback` becomes a no-op:
 a half-applied file recorded as applied, with no error anywhere.
 `test/unit/migrations-transaction-control.test.ts` asserts this over the real directory.
 
@@ -463,35 +463,35 @@ first, then `VALIDATE` in a separate file so the validation scan never blocks wr
 
 | File | What it does | If skipped |
 |---|---|---|
-| `0067_chat_partition_window.sql` | creates the current + next-two monthly partitions for the RANGE-partitioned `chat_messages` / `dm_messages` (F001) | **HARD** — once wall-clock passes the newest existing partition bound, every chat/DM insert fails (no partition for range); the worker cron keeps the window open thereafter |
+| `0067_chat_partition_window.sql` | creates the current + next-two monthly partitions for the RANGE-partitioned `chat_messages` / `dm_messages` (F001) | **HARD**: once wall-clock passes the newest existing partition bound, every chat/DM insert fails (no partition for range); the worker cron keeps the window open thereafter |
 | `0068_resolve_unscored_nsfw_flags.sql` | DATA recovery: resolves media abuse flags left `unscored` by the pre-fix pipeline (F076) | Assets stuck in the dead "unscored" state stay held/mis-flagged; no new damage, but the backlog never clears itself |
-| `0069_posts_repost_unique_softdelete.sql` | makes unrepost a SOFT delete + partial unique on a live repost `(actor, original) WHERE deleted_at IS NULL` (F003/F148/F054) | **HARD** — repost/unrepost/re-repost double-counts or 500s on the unique the new code assumes |
+| `0069_posts_repost_unique_softdelete.sql` | makes unrepost a SOFT delete + partial unique on a live repost `(actor, original) WHERE deleted_at IS NULL` (F003/F148/F054) | **HARD**: repost/unrepost/re-repost double-counts or 500s on the unique the new code assumes |
 | `0070_posts_reply_to_fk_idx.sql` | index on the `posts.reply_to_id` self-FK (F151) | Slow reply-cascade deletes only; no correctness impact |
 | `0071_posts_toplevel_recent_idx.sql` | partial index for the top-level (replies-excluded) feed page (F013) | Home/public feed pages sequential-scan; slow, correct |
-| `0072_posts_media_fk_swap.sql` | swaps the posts self-FKs and `media_assets→posts` FK to explicit `RESTRICT`/`SET NULL`, added `NOT VALID` (F148) | **HARD** — deletes cascade the wrong way (orphaned or over-deleted rows) versus what the new code expects |
-| `0073_posts_media_fk_validate.sql` | `VALIDATE`s the four constraints `0072` added (F148) | The FKs stay unvalidated — enforced for new rows, but existing violations go undetected |
+| `0072_posts_media_fk_swap.sql` | swaps the posts self-FKs and `media_assets→posts` FK to explicit `RESTRICT`/`SET NULL`, added `NOT VALID` (F148) | **HARD**: deletes cascade the wrong way (orphaned or over-deleted rows) versus what the new code expects |
+| `0073_posts_media_fk_validate.sql` | `VALIDATE`s the four constraints `0072` added (F148) | The FKs stay unvalidated: enforced for new rows, but existing violations go undetected |
 | `0074_repost_count_reconcile.sql` | DATA: recomputes `posts.repost_count` from live reposts (F054) | Denormalized repost counters keep any pre-fix drift until next mutated |
-| `0075_media_chat_created_at.sql` | adds `media_assets.chat_created_at` (F072) | **HARD** — the chat/DM media-stamp path writes this column; absent → 42703 on those inserts |
-| `0076_media_single_claim_check.sql` | `media_single_claim_chk`: an asset binds at most ONE subject, added `NOT VALID` (F017/F049) | **HARD** — the single-claim invariant the attach path relies on is unenforced; double-claims slip through |
+| `0075_media_chat_created_at.sql` | adds `media_assets.chat_created_at` (F072) | **HARD**: the chat/DM media-stamp path writes this column; absent → 42703 on those inserts |
+| `0076_media_single_claim_check.sql` | `media_single_claim_chk`: an asset binds at most ONE subject, added `NOT VALID` (F017/F049) | **HARD**: the single-claim invariant the attach path relies on is unenforced; double-claims slip through |
 | `0077_media_single_claim_validate.sql` | `VALIDATE`s `0076` (F017/F049) | Constraint enforced for new rows only; existing double-claims undetected |
-| `0078_idempotency_owner_unique.sql` | partial unique on `(COALESCE(owner…), key)` for per-owner idempotency (F028) | **HARD** — with `0079`, the per-owner idempotency the new code assumes; a shared key collides across users |
-| `0079_idempotency_drop_key_pk.sql` | drops the old key-ONLY primary key on `idempotency_keys` (F028) | **HARD** — the global key PK keeps two users' same idempotency key colliding |
-| `0080_chat_group_bans.sql` | `chat_group_bans` table (F044) | **HARD** — the group ban/unban path reads/writes this table; absent → 42P01 |
+| `0078_idempotency_owner_unique.sql` | partial unique on `(COALESCE(owner…), key)` for per-owner idempotency (F028) | **HARD**: with `0079`, the per-owner idempotency the new code assumes; a shared key collides across users |
+| `0079_idempotency_drop_key_pk.sql` | drops the old key-ONLY primary key on `idempotency_keys` (F028) | **HARD**: the global key PK keeps two users' same idempotency key colliding |
+| `0080_chat_group_bans.sql` | `chat_group_bans` table (F044) | **HARD**: the group ban/unban path reads/writes this table; absent → 42P01 |
 | `0081_mail_messages_dedupe.sql` | DATA: collapses duplicate inbound `mail_messages` by Message-ID (F102) | Pre-existing inbound duplicates remain; `0082`'s unique index would fail to build until deduped |
-| `0082_mail_messages_message_id_uk.sql` | unique on the RFC `Message-ID` so a re-delivered webhook can't double-insert (F102) | **HARD** — inbound-mail dedup relies on this; a retried webhook re-inserts a reply |
+| `0082_mail_messages_message_id_uk.sql` | unique on the RFC `Message-ID` so a re-delivered webhook can't double-insert (F102) | **HARD**: inbound-mail dedup relies on this; a retried webhook re-inserts a reply |
 | `0083_jurisdiction_contacts_email_lower_idx.sql` | `lower(email)` index for case-insensitive contact/routing lookup (F111) | Reply routing and the contacts directory sequential-scan; slow |
 | `0084_notifications_feed_idx.sql` | index for the notifications feed (inbox-surfaced kinds excluded) (F089) | Notification feed reads sequential-scan; slow |
 | `0085_user_blocks_blocker_created_idx.sql` | `(blocker_id, created_at)` index for the paginated block list (F014) | `GET /me/blocks` sequential-scans the block table; slow |
-| `0086_notification_prefs_tz.sql` | adds `notification_prefs.tz` for timezone-aware quiet hours (F086) | **HARD** — quiet-hours evaluation reads this column; absent → 42703 |
-| `0087_media_finalized_at.sql` | adds `media_assets.finalized_at` for idempotent finalize (F071) | **HARD** — the finalize path stamps/guards on this column; absent → 42703 |
-| `0088_reports_hold_release_checked_at.sql` | adds `reports.hold_release_checked_at` so the held-anon sweep can cursor forward (F019) | **HARD** — the release sweep updates this column; absent → 42703 and a stalled sweep |
+| `0086_notification_prefs_tz.sql` | adds `notification_prefs.tz` for timezone-aware quiet hours (F086) | **HARD**: quiet-hours evaluation reads this column; absent → 42703 |
+| `0087_media_finalized_at.sql` | adds `media_assets.finalized_at` for idempotent finalize (F071) | **HARD**: the finalize path stamps/guards on this column; absent → 42703 |
+| `0088_reports_hold_release_checked_at.sql` | adds `reports.hold_release_checked_at` so the held-anon sweep can cursor forward (F019) | **HARD**: the release sweep updates this column; absent → 42703 and a stalled sweep |
 | `0089_reports_reporter_created_idx.sql` | `(reporter_id, created_at)` index for "my reports" (F026) | The signed-in user's own-reports list sequential-scans; slow |
 | `0090_chat_groups_avatar_media_idx.sql` | index on `chat_groups.avatar_media_id` FK (F002) | Slow avatar-media reference checks only; no correctness impact |
-| `0091_report_claim_code_hash.sql` | adds `reports.claim_code_hash` (the claim secret stored hashed) (F150) | **HARD** — the claim path writes/verifies the hash; absent → 42703 |
+| `0091_report_claim_code_hash.sql` | adds `reports.claim_code_hash` (the claim secret stored hashed) (F150) | **HARD**: the claim path writes/verifies the hash; absent → 42703 |
 | `0092_anon_tokens_claim_code_null.sql` | makes the dead plaintext `anon_tokens.claim_code` nullable so the code stops writing it (F150) | Insert of a new anon token with a non-null-constrained dead column fails once the code stops populating it |
 | `0093_follows_people_pagination_idx.sql` | index for the outbound-follows ("following") pagination (F158) | The following list sequential-scans; slow |
 | `0094_notifications_created_idx.sql` | bare `created_at` index backing the retention sweep's notifications lane (every other index on the table is `user_id`-leading) | The nightly sweep sequential-scans the whole notifications table once per page; slow, correct |
-| `0095_media_stuck_sweep_rotation.sql` | `media_assets.stuck_checked_at` + `stuck_check_count` (rotation watermark + give-up counter for `media.stuck.sweep`, F087b, same pattern as `0088`), the partial index `media_assets_stuck_sweep_idx` serving that sweep's claim query, and a scoped backfill adopting BOUND rows still stuck at `validating` with no `finalized_at` (the set no sweep could reach) | The stuck sweep re-picks the same permanent residents every 15 min — genuinely stuck media starve behind them and hopeless rows are re-enqueued forever |
+| `0095_media_stuck_sweep_rotation.sql` | `media_assets.stuck_checked_at` + `stuck_check_count` (rotation watermark + give-up counter for `media.stuck.sweep`, F087b, same pattern as `0088`), the partial index `media_assets_stuck_sweep_idx` serving that sweep's claim query, and a scoped backfill adopting BOUND rows still stuck at `validating` with no `finalized_at` (the set no sweep could reach) | The stuck sweep re-picks the same permanent residents every 15 min: genuinely stuck media starve behind them and hopeless rows are re-enqueued forever |
 | `0102_users_last_activity.sql` | adds `users.last_activity_geom` + `users.last_activity_at` (audit H18): the materialized "most recent locatable public act" the bounded follow-suggestions candidate scan reads. Catalog-only `ADD COLUMN` (nullable, no default) on a hot table, so no rewrite. **Two follow-up steps, both post-deploy:** build `users_last_activity_gist` + `users_last_activity_at_idx` out of band with `CREATE INDEX CONCURRENTLY` (`docs/out-of-band-indexes.md`), then run `node dist/db/backfill-user-activity.js` | Suggestions still work but rank only on freshly-written activity, and without the indexes the candidate pools sequential-scan `users` |
 | `0103_cleanups_completed_at.sql` | adds `cleanups.completed_at` (audit H9): the completion instant that bounds how many volunteer hours may be credited per attendee (`completed_at - scheduled_at` + 1 h grace, capped at `MAX_EVENT_HOURS`). Catalog-only `ADD COLUMN` (nullable, no default) on a small table; NO backfill by design, so events completed before it keep the old ceiling | Every completed event looks window-less, so hours fall back to the flat 24 h ceiling and the minimum-duration rule cannot be evaluated |
 | `0104_conversation_hides.sql` | new `conversation_hides` table (per-user, per-conversation hide watermark behind the inbox row's "Delete"): composite PK `(user_id, room_kind, room_id)` + `hidden_at`, room_id deliberately un-FK'd exactly like `conversation_mutes` (0042). Brand-new empty table; no existing table touched, no index beyond the PK | `PUT /v1/conversations/hidden` 500s on a missing relation and the threads list stops filtering hidden rows |
@@ -509,7 +509,7 @@ foreign key into `organizations` from `0105`.
 | `0105_organizations.sql` | new `organizations` + `organization_members`: the host identity a person can act on behalf of. Partial unique **one owner per org**, slug unique among live rows, verification-queue index. Brand-new empty tables | Every `/v1/orgs*` endpoint 500s and the whole host capability model collapses to event roles |
 | `0106_org_verifications.sql` | new `org_verifications`: write-only `ein_number` + `documents` jsonb, `ein_scrubbed_at`, partial unique one OPEN application per org | Organizations can never be verified, so no organization can ever take a donation |
 | `0107_cleanups_host_columns.sql` | 13 host columns on `cleanups` (`ends_at`, `timezone`, `visibility`, `cover_media_id`, `gallery_media_ids`, `donation_url`, `page_slug`, registration window, `organization_id`, `reminder_offsets_min`, `host_reply_to`), 6 `NOT VALID` CHECKs and 5 indexes incl. a GIN on `gallery_media_ids`. Catalog-only `ADD COLUMN`s (nullable, no default) on a small table | Event visibility is unenforceable, the signup page has no slug, and every host write 500s on the missing columns |
-| `0108_event_consents.sql` | new `event_consents`: the server-side artifact that a person accepted a named terms + disclosure version at registration. Never scrubbed, deleted only with its event | Registration cannot record consent — a compliance gap, not just a 500 |
+| `0108_event_consents.sql` | new `event_consents`: the server-side artifact that a person accepted a named terms + disclosure version at registration. Never scrubbed, deleted only with its event | Registration cannot record consent: a compliance gap, not just a 500 |
 | `0109_cleanup_team_invites.sql` | new `cleanup_team_invites`: hashed invite tokens, `invited_email` written only when the host typed an address, `email_scrubbed_at` for the 7-day scrub | Cohost / staff invitations 500 |
 | `0110_media_purpose_event_expand.sql` | adds `media_assets_purpose_expanded` as a **NOT VALID** CHECK carrying the widened purpose set (`event_cover`, `event_gallery`, `org_logo`). Expand half of an expand/contract pair | Event covers, galleries and organization logos are refused by the old CHECK |
 | `0111_media_purpose_drop_old_check.sql` | drops the narrow `media_assets_purpose_check` from `0054`. Contract half; must apply AFTER `0110` | The old CHECK still rejects every new purpose, so `0110` buys nothing |
@@ -518,18 +518,18 @@ foreign key into `organizations` from `0105`.
 | `0117_cleanup_waitlist.sql` | new `cleanup_waitlist`: per-ticket-type FIFO queue whose offers RESERVE seats; FIFO + expiry partial indexes and one-live-entry-per-person partial uniques. Brand-new empty table | Waitlist endpoints 500 and `waitlist.promote` / `waitlist.expire.sweep` fail every run |
 | `0118_cleanup_questions.sql` | new `cleanup_questions` + `cleanup_answers`: archive-never-delete question definitions and their answers, with the 30-day scrub partial index. Brand-new empty tables | Question endpoints 500; registration answers cannot be stored or scrubbed |
 | `0119_cleanup_pages.sql` | new `cleanup_pages`: one signup page per event as a jsonb block document (≤24 blocks), theme accent, publish + operator-flag state and the `view_count` counter. Brand-new empty table | `/v1/pages/:slug` and the page builder 500 |
-| `0120_cleanup_pages_admin_idx.sql` | expression index `cleanup_pages ((COALESCE(published_at, updated_at)) DESC, id DESC)` — `0119`'s two indexes are PARTIAL (`status = 'published'`, `flagged_at IS NOT NULL`), so neither can serve the operator queue, which deliberately spans every status | `GET /v1/admin/pages` full-scans and sorts `cleanup_pages` on every operator page |
+| `0120_cleanup_pages_admin_idx.sql` | expression index `cleanup_pages ((COALESCE(published_at, updated_at)) DESC, id DESC)`: `0119`'s two indexes are PARTIAL (`status = 'published'`, `flagged_at IS NOT NULL`), so neither can serve the operator queue, which deliberately spans every status | `GET /v1/admin/pages` full-scans and sorts `cleanup_pages` on every operator page |
 | `0121_event_consents_registration_fk.sql` | adds the FK `0108` promised: `event_consents.registration_id` → `cleanup_registrations(id)` `ON DELETE SET NULL` (the consent record outlives the registration it was captured with). Constraint added and validated in one step on a brand-new empty table | A consent row can point at a registration id that no longer exists |
-| `0122_cleanup_waitlist_user_idx.sql` | partial index `cleanup_waitlist (user_id, created_at DESC) WHERE user_id IS NOT NULL` — `0117` indexes the FIFO/expiry paths and the two ACTIVE-only partial uniques, none of which serve `leaveWaitlist` or the account-erasure release sweep | Leaving a waitlist and the erasure sweep scan every waitlist row on the platform |
+| `0122_cleanup_waitlist_user_idx.sql` | partial index `cleanup_waitlist (user_id, created_at DESC) WHERE user_id IS NOT NULL`: `0117` indexes the FIFO/expiry paths and the two ACTIVE-only partial uniques, none of which serve `leaveWaitlist` or the account-erasure release sweep | Leaving a waitlist and the erasure sweep scan every waitlist row on the platform |
 | `0123_cleanup_page_media.sql` | new `cleanup_page_media (cleanup_id, media_id)`: the explicit binding between an event and the images its signup page embeds. `cleanup_pages.blocks` is jsonb, so before this table an image referenced only from a block was indistinguishable from an abandoned upload and the media-worker orphan sweep deleted it (row + R2 objects) six hours after upload. Brand-new empty table, both FKs `ON DELETE CASCADE` | Every page-block image is reaped by the hourly `orphan.sweep`, and `savePage` rejects any block image that is not already the event cover |
 | `0130_broadcasts.sql` | new `broadcasts`, `broadcast_deliveries`, `cleanup_broadcast_mutes`: the host-messaging relay, its per-recipient delivery ledger and the per-event mute. Brand-new empty tables | Every broadcast endpoint 500s and the `broadcast.*` jobs fail every run |
-| `0131_broadcast_optouts.sql` | new `broadcast_unsubscribes` + `email_suppressions`: the one-click unsubscribe record and the hard-bounce/complaint suppression list, both kept indefinitely by design | One-click unsubscribe 500s — an unsubscribable mail stream is a CAN-SPAM problem, not just an outage |
+| `0131_broadcast_optouts.sql` | new `broadcast_unsubscribes` + `email_suppressions`: the one-click unsubscribe record and the hard-bounce/complaint suppression list, both kept indefinitely by design | One-click unsubscribe 500s: an unsubscribable mail stream is a CAN-SPAM problem, not just an outage |
 | `0132_event_metrics_daily.sql` | new `event_metrics_daily`: the per-event daily aggregate every analytics envelope reads. No identifier of any kind | Host analytics return nothing and `event.metrics.rollup` fails every hour |
 | `0133_host_exports.sql` | new `host_exports`: queued/ready/expired roster, answer, check-in and donation exports, scoped to a cleanup OR an organization | Export requests 500 and `host.export` / `host.export.reap` fail |
 | `0134_host_messaging_switches.sql` | adds `user_moderation.host_messaging_suspended` (+ partial index) and `notification_prefs.host_broadcasts`. Catalog-only `ADD COLUMN`s | The operator kill switch has nowhere to write and a person cannot opt out of host broadcasts |
 | `0135_cleanups_reminder_sweep_idx.sql` | `cleanups_reminder_sweep_idx (scheduled_at) WHERE status = 'upcoming'`, the predicate the 10-minute reminder sweep runs | The reminder sweep sequential-scans `cleanups` every 10 minutes |
 | `0136_monitoring_grants.sql` | role-guarded GRANTs for the `monitoring` role (`DO $$ … IF EXISTS pg_roles`), with `broadcasts` granted at COLUMN level so the exporter can never read a subject or a body. No-op where the role does not exist (staging, local, testcontainers) | The postgres-exporter's host-platform gauges return permission errors; nothing else breaks |
-| `0137_host_exports_org_idx.sql` | partial index `host_exports (organization_id, requested_at DESC, id DESC) WHERE organization_id IS NOT NULL` — `0133` indexed only `cleanup_id` and `requested_by`, and the `donations` kind is org-scoped with `cleanup_id IS NULL` | `GET /v1/orgs/:id/donations/exports` scans every export on the platform and sorts |
+| `0137_host_exports_org_idx.sql` | partial index `host_exports (organization_id, requested_at DESC, id DESC) WHERE organization_id IS NOT NULL`: `0133` indexed only `cleanup_id` and `requested_by`, and the `donations` kind is org-scoped with `cleanup_id IS NULL` | `GET /v1/orgs/:id/donations/exports` scans every export on the platform and sorts |
 | `0145_org_stripe_accounts.sql` | new `org_stripe_accounts`: the connected account behind an organization, `organizations` RESTRICT so an account can never be orphaned | Stripe onboarding 500s |
 | `0146_org_donation_settings.sql` | new `org_donation_settings` + `org_donation_agreement_changes`: the organization's donation configuration and the versioned §318 agreement acceptances | Donation settings 500 and no agreement acceptance can be recorded |
 | `0147_org_eligibility.sql` | new `org_eligibility`, `org_eligibility_checks` (**append-only** evidence rows with per-row `retention_until`) and `eligibility_source_revisions` | Eligibility can never be evaluated, so every organization stays `unknown` and donations stay off |
@@ -560,7 +560,7 @@ foreign key into `organizations` from `0105`.
 | `0171_legal_documents_2026_09_16.sql` | DATA only, no DDL (the Drizzle mirror is unchanged): INSERTs the four `2026-09-16` `legal_documents` rows - `terms`, `privacy`, `cookies`, `subprocessors` - mirroring `LEGAL_DOCUMENTS` in `@civfix/shared` 0.48.1 byte for byte. Removing platform-processed donations rewrote all four public documents (payment/refund/application-fee sections out of the terms, Stripe out of the privacy notice and the subprocessor list, checkout entries out of the cookie notice), and different text is a different document, so each gets a new version and a new sha256 and every person is asked to accept again. `ON CONFLICT (type, version) DO NOTHING`, so a re-run writes nothing. The `2026-09-06` rows are left in place exactly as 0151's header requires - publishing is an INSERT, never an UPDATE, because `consent_records_document_fk` points every consent already collected at them - and the three retired types (`donations`, `org_donation_agreement`, `donation_disclosure`) are deliberately not re-versioned: nothing renders them any more and the type CHECK still accepts their existing rows. Four rows into a cold table: no index build, no lock of consequence | Registration and onboarding 500 on every consent: `assertConsentVersionsCurrent` demands `2026-09-16` from the shared package, the client sends it, and the `consent_records` INSERT then violates `consent_records_document_fk` because no `legal_documents` row carries that version. Nobody can create an account on the new image |
 | `0172_legal_documents_2026_09_21.sql` | DATA only, no DDL (the Drizzle mirror is unchanged): INSERTs the `2026-09-21` `terms` and `privacy` `legal_documents` rows, mirroring `LEGAL_DOCUMENTS` in `@civfix/shared` 0.48.2 byte for byte. The Terms now state that the civfix software is AGPL-3.0 free software (section 11 replaced the proprietary-software clause) and both documents name the operator by its legal name, Reach Out Los Angeles Inc.; different text is a different document, so each gets a new version and a new sha256. `cookies` and `subprocessors` did not change and keep `2026-09-16`. `ON CONFLICT (type, version) DO NOTHING`, so a re-run writes nothing; the `2026-09-16` rows stay because `consent_records_document_fk` points existing consents at them. Two rows into a cold table: no index build, no lock of consequence | Event registration and host signup 500 on every consent: `assertConsentVersionsCurrent` demands `2026-09-21` from the shared package, the client sends it, and the `consent_records` INSERT then violates `consent_records_document_fk` because no `legal_documents` row carries that version |
 | `0173_reports_status_check.sql` | adds `reports_status_chk`, a CHECK pinning `reports.status` to the seven contract values (`submitted`, `held`, `published`, `acknowledged`, `in_progress`, `resolved`, `rejected`), added **NOT VALID** because `reports` is a hot table (`docs/out-of-band-indexes.md`) and the runner is one-transaction-per-file: NOT VALID takes its lock briefly and still enforces the set on every later INSERT/UPDATE, while the existing rows are already inside it (the enum has not moved since contract 0.20.0). The transition GRAPH stays in code - the shared `canTransitionReportStatus`, enforced by `admin-report-service.setStatus` - because only the service knows the actor and the reason. `ALTER TABLE reports VALIDATE CONSTRAINT reports_status_chk;` is an OPTIONAL out-of-band step, named in the file header, never a deploy step | Nothing breaks and nothing 500s: the column keeps accepting any text, so a future writer with a typo'd status can park a report on a value the admin buckets, the map filter and the timeline icon mapper all silently drop |
-| `0174_mail_messages_outbound_snapshot.sql` | adds `mail_messages.html` and `mail_messages.kind`, both nullable `text`, with `ADD COLUMN IF NOT EXISTS` and nothing else: no CHECK, no index, no backfill, so it is catalog-only on a cold table and takes no scan. `html` stores the exact HTML part handed to the mailer so a resend replays the message the city actually received instead of a bare-text, 64KB-truncated copy; `kind` records what an outbound row is (`packet` | `discussion` | `followup` | `digest` | `compose` | `reply` | `resend`) and is NULL on every inbound row and on every row written before this file. No CHECK is deliberate and matches the other free-text discriminators on this table: a value the code stops writing must never turn an old row un-readable. The operator send gate reads `COALESCE(kind, 'packet') = 'packet'`: a NULL kind — every row written before this file — counts AS a packet, deliberately, so every report routed before the deploy keeps its "already routed" gate, while a citizen's `@city` discussion forward (written as `discussion`) stops counting as one | Routing a report 500s on `column "html" does not exist` — the outbound INSERT names both columns unconditionally — so Approve & send, operator replies, resends and the outreach digest all fail; the packet gate itself still refuses correctly, because a missing `kind` column errors before it can misread |
+| `0174_mail_messages_outbound_snapshot.sql` | adds `mail_messages.html` and `mail_messages.kind`, both nullable `text`, with `ADD COLUMN IF NOT EXISTS` and nothing else: no CHECK, no index, no backfill, so it is catalog-only on a cold table and takes no scan. `html` stores the exact HTML part handed to the mailer so a resend replays the message the city actually received instead of a bare-text, 64KB-truncated copy; `kind` records what an outbound row is (`packet` | `discussion` | `followup` | `digest` | `compose` | `reply` | `resend`) and is NULL on every inbound row and on every row written before this file. No CHECK is deliberate and matches the other free-text discriminators on this table: a value the code stops writing must never turn an old row un-readable. The operator send gate reads `COALESCE(kind, 'packet') = 'packet'`: a NULL kind (every row written before this file) counts AS a packet, deliberately, so every report routed before the deploy keeps its "already routed" gate, while a citizen's `@city` discussion forward (written as `discussion`) stops counting as one | Routing a report 500s on `column "html" does not exist`: the outbound INSERT names both columns unconditionally, so Approve & send, operator replies, resends and the outreach digest all fail; the packet gate itself still refuses correctly, because a missing `kind` column errors before it can misread |
 | `0175_forward_template_settings.sql` | new `forward_template_settings`, the singleton row (`id smallint PRIMARY KEY CHECK (id = 1)`) holding the platform-wide default forwarding email template. It sits between a jurisdiction's own `forward_*_template` (0050) and the built-in default in `@civfix/shared`, so a packet resolves jurisdiction → this row → built-in. Both template columns are nullable (NULL = fall through) and `updated_by` is deliberately not a FK to `users`, since the authoritative trail is the `mail.forward_template_set` audit row written in the same transaction. `CREATE TABLE IF NOT EXISTS` on a table with no rows: no lock of consequence | The operator Mail page 500s on its default-template read and save (`relation "forward_template_settings" does not exist`), and every report forwarded to a jurisdiction without its own template 500s too, because the packet builder reads the row before it renders |
 
 | `0176_posts_geom.sql` | adds `posts.geom` (nullable `geometry(Point,4326)`, no default, so a catalog-only ADD COLUMN with no table rewrite) plus the partial GIST `posts_geom_gist` over live public rows, denormalising the linked report's or event's point onto the post so the ranked home feed's proximity pool is one bounded KNN scan instead of two joins. `posts` is NOT on the hot-table list in `docs/out-of-band-indexes.md`, so the index builds inline; build it with `CREATE INDEX CONCURRENTLY` first if `posts` has grown, and the `IF NOT EXISTS` guard makes the migration a no-op. No backfill in the file - a single UPDATE over the whole table inside one transaction is a lock hazard - so a DO block RAISEs a WARNING while attached rows are still unpopulated and `node dist/db/backfill-post-geom.js` (keyset-paged, idempotent, safe under live traffic) fills them after the deploy is healthy. PRIVACY: no new class of data, a copy of a coordinate already published at full precision on the linked report or event, never client-supplied, never served to clients | Nothing breaks: `p.geom IS NULL` simply yields a NULL `distance_km`, the ranker scores no proximity term, and the feed falls back to the in-network and recent-public pools. Skipping only the BACKFILL means existing attached posts score no proximity until it is run |
@@ -577,12 +577,12 @@ foreign key into `organizations` from `0105`.
 
 
 **Deferred to the NEXT release** (expand/contract, `docs/migrations-expand-contract.md`): 0.43.0
-stops every code path from reading `user_verification` but does NOT drop it — a `DROP TABLE` in the
+stops every code path from reading `user_verification` but does NOT drop it: a `DROP TABLE` in the
 same release would make the previous api color raise `relation does not exist` on the feed, the
 roster, the profile and the admin Users page for the whole migrate-to-cutover window, and for good
 after a `CIVFIX_REF=` rollback. Once the 0.43.0 image is running everywhere, ship
 `DROP TABLE IF EXISTS user_verification` as its own migration, and run the inventory query below in
-EVERY environment first (expected: zero rows — the in-app application flow that wrote `documents`
+EVERY environment first (expected: zero rows; the in-app application flow that wrote `documents`
 was removed long ago, and after the DROP the object keys are unrecoverable):
 
 ```sql
@@ -598,14 +598,14 @@ WHERE m.purpose = 'verification'
 
 Any rows it returns are identity documents: delete the objects from R2 and the rows from
 `media_assets` by hand before the drop ships. `media_assets.purpose = 'verification'` itself STAYS
-either way — `org_verifications` documents are claimed with it.
+either way: `org_verifications` documents are claimed with it.
 
 **Deferred to a later release, out of band** (record them in the expand/contract ledger): the six
 `NOT VALID` CHECKs `0107` adds on `cleanups` and `0110`'s `media_assets_purpose_expanded` still need
 `ALTER TABLE … VALIDATE CONSTRAINT`, and so do `0164`'s
 `users_primary_organization_fk` and `posts_organization_fk`, and `0179`'s
 `cleanups_address_source_chk`, `reports_addr_source_chk` and `reports_addr_precision_chk`. They are
-correct as `NOT VALID` — the constraint is enforced for every new row — and validating takes a scan
+correct as `NOT VALID` (the constraint is enforced for every new row), and validating takes a scan
 that does not belong in a deploy transaction.
 | `0096_cleanup_guests.sql` | guest event RSVP (contract 0.38.0): `cleanup_guests` (event-scoped, contact-bearing attendance rows; SHA-256 manage-token hash only; partial unique on the active `(cleanup_id, contact_key)`), `guest_otps` (event-scoped one-time codes, hash only), `sms_opt_outs` (STOP suppression list, kept indefinitely). Three brand-new empty tables; no existing table touched | `POST /v1/cleanups/:id/guest-rsvp/*` and `GET /v1/cleanups/:id/guests` 500 on a missing relation, and `going` cannot include guests |
 | `0097_media_assets_served_key.sql` | adds `media_assets.served_key` (audit C1): the worker-owned key the processed object is published to, so the client-writable upload key is never served after `ready`. Catalog-only nullable `ADD COLUMN` on a hot table. **Post-deploy:** run `node dist/db/backfill-served-key.js` immediately and again ~20 min later; pre-existing `ready` media reads as not-found until it completes | Every existing photo 404s until the backfill runs; new uploads work |
@@ -615,7 +615,7 @@ that does not belong in a deploy transaction.
 
 ## 2. `sessions.created_at` backfill (`0058`, shipped in wave 3)
 
-`sessions.created_at` was nullable (`0001_core.sql` declares it `timestamptz DEFAULT now()` with no `NOT NULL`, so rows predating the column read back NULL) and the absolute 90-day session ceiling is measured FROM it. `PgSessionStore.findById` reads a NULL as the **epoch** — i.e. already past the ceiling, fail closed — because the old `last_seen_at` fallback made the ceiling unreachable (every sliding-expiry write bumped `last_seen_at`, so an active legacy session never expired: the unbounded-sliding hole M3 closes).
+`sessions.created_at` was nullable (`0001_core.sql` declares it `timestamptz DEFAULT now()` with no `NOT NULL`, so rows predating the column read back NULL) and the absolute 90-day session ceiling is measured FROM it. `PgSessionStore.findById` reads a NULL as the **epoch** (i.e. already past the ceiling, fail closed) because the old `last_seen_at` fallback made the ceiling unreachable (every sliding-expiry write bumped `last_seen_at`, so an active legacy session never expired: the unbounded-sliding hole M3 closes).
 
 Consequence until the backfill is applied: **every session row with a NULL `created_at` forces exactly one re-login.** Expected and harmless, but it is the reason for a login spike right after deploy.
 
@@ -631,21 +631,21 @@ ALTER TABLE sessions ALTER COLUMN created_at SET NOT NULL;
 
 Operator notes:
 
-- **Order is load-bearing and already baked into the file:** `SET NOT NULL` before the `UPDATE` aborts with `23502` on the first legacy row and rolls the file back. `last_seen_at` is itself `NOT NULL DEFAULT now()`, so the `now()` arm is unreachable belt-and-braces, and `last_seen_at` is a LATE (never early) estimate of creation — the ceiling it yields is generous to the holder by at most one sliding window.
+- **Order is load-bearing and already baked into the file:** `SET NOT NULL` before the `UPDATE` aborts with `23502` on the first legacy row and rolls the file back. `last_seen_at` is itself `NOT NULL DEFAULT now()`, so the `now()` arm is unreachable belt-and-braces, and `last_seen_at` is a LATE (never early) estimate of creation: the ceiling it yields is generous to the holder by at most one sliding window.
 - **It briefly write-locks `sessions`.** `SET NOT NULL` takes `ACCESS EXCLUSIVE` and, with no pre-validated `CHECK` to reuse, scans the table to prove no NULLs remain; logins and sliding-expiry writes block for that scan. Fine at this table's size (self-pruning via the ceiling + the expiry sweep); if that changes, add a `NOT VALID` CHECK and `VALIDATE` it out-of-band first.
 - Re-applying is a no-op: the `UPDATE` matches nothing and `SET NOT NULL` on an already-`NOT NULL` column succeeds silently.
-- Nothing hard-depends on it (unlike `0056`), so the deploy order is free — the only cost of skipping it is the recurring re-login above.
+- Nothing hard-depends on it (unlike `0056`), so the deploy order is free: the only cost of skipping it is the recurring re-login above.
 - The `?? new Date(0)` fallback in `src/auth/pg-stores.ts` is **deliberately kept** even though the Drizzle mirror (`src/db/schema/sessions.ts`) now types `createdAt` as non-nullable: it fails closed against any row the backfill has not reached, and the blue/green overlap means the PREVIOUS image serves briefly against the new schema on every deploy. Its comment says so.
 
 ## 3. media-worker `stop_grace_period` (civfix-infra, different repo)
 
 `MEDIA_JOB_TIMEOUT_MS` (default 60s) is charged **per phase**, not per job: `jobs/media-checks.ts` wraps the download in one budget and `processMedia` in a second, so one job can legitimately run ~2x it before the persist writes. The worker therefore derives its graceful-stop timeout as `2 x jobTimeoutMs + 5s` (`stopGraceMsFor` in `services/media-worker/src/jobs.ts`) = **125s at the default**, and passes it to `boss.stop({ graceful: true, wait: true, timeout })`.
 
-**The container's `stop_grace_period` in the civfix-infra compose file must exceed that** — set it to ~150s for the media-worker service. Docker's default is 10s, so without this the runtime SIGKILLs the process mid-job and the derivation buys nothing: the asset is left `validating` until a sweep reconciles it. Raising `MEDIA_JOB_TIMEOUT_MS` raises this requirement with it (`.env.example` says so at the knob).
+**The container's `stop_grace_period` in the civfix-infra compose file must exceed that**: set it to ~150s for the media-worker service. Docker's default is 10s, so without this the runtime SIGKILLs the process mid-job and the derivation buys nothing: the asset is left `validating` until a sweep reconciles it. Raising `MEDIA_JOB_TIMEOUT_MS` raises this requirement with it (`.env.example` says so at the knob).
 
 ## 4. Boundary refresh prunes only federal/tribal fixtures
 
-`services/api/scripts/refresh-boundaries.ts` → `pruneNonAuthoritative()` deletes legacy dev-seed rows by geoid prefix, and **only for the federal and tribal layers** (`layer='federal' AND geoid NOT LIKE 'PADUS-%'`, `layer='tribal' AND geoid NOT LIKE 'AIANNH-%'`). There is deliberately **no equivalent for stale place / county / state rows**, because those layers use real TIGER geoids that the refresh re-upserts — nothing distinguishes a stale fixture from a live row, and nothing prunes them.
+`services/api/scripts/refresh-boundaries.ts` → `pruneNonAuthoritative()` deletes legacy dev-seed rows by geoid prefix, and **only for the federal and tribal layers** (`layer='federal' AND geoid NOT LIKE 'PADUS-%'`, `layer='tribal' AND geoid NOT LIKE 'AIANNH-%'`). There is deliberately **no equivalent for stale place / county / state rows**, because those layers use real TIGER geoids that the refresh re-upserts: nothing distinguishes a stale fixture from a live row, and nothing prunes them.
 
 Two consequences to keep in mind:
 
@@ -672,18 +672,18 @@ Notes that cost time to rediscover:
 - `--no-file-parallelism` is not optional on a 4-CPU VM: with vitest `isolate:true` every file boots its own postgis container, and running them in parallel starves the VM into health-check timeouts.
 - Ryuk disabled means containers **leak between runs**. Reap them: `docker rm -f $(docker ps -aq --filter ancestor=postgis/postgis:16-3.4)`.
 - `postgis/postgis:16-3.4` has a native arm64 image, so **Rosetta is not needed** for it. Only reach for `colima start --vm-type=vz --vz-rosetta` if some other image is amd64-only; emulated Postgres is slow enough to cause spurious timeouts.
-- `colima stop` when done — the VM holds 6 GiB.
+- `colima stop` when done: the VM holds 6 GiB.
 
-## 6. Denormalized follow counters (`0059`) — apply BEFORE the API image
+## 6. Denormalized follow counters (`0059`): apply BEFORE the API image
 
 `drizzle/0059_users_follow_counters.sql` adds `users.follower_count` / `users.following_count`, backfills both from `follows_people`, and the new code reads them everywhere a person's totals are rendered (`findPersonById`/`ByHandle`, `listPeople`, `listFollowers`/`listFollowing`, `suggestFollows`, `followerCount` in `social-repository.drizzle.ts`, and `loadAuthors` in `post-repository.drizzle.ts`). It replaces the correlated `count(*)` subqueries that walked a popular account's whole edge list on every render.
 
 **Order matters in both directions:**
 
-- **New API, migration missing:** every one of those reads raises `42703` (undefined column) — profiles, people search, both connection rosters, follow suggestions, the follow/unfollow response and every post author card. Alongside `0056` this is the second wave-2 file that hard-fails against the old schema; unlike `0056` the failure is instant and total rather than queued.
-- **Old API, migration applied:** harmless to serve, but the old image writes `follows_people` rows without bumping the counters, so the numbers drift low for as long as it runs. The file is safe to re-run (see below) — if the gap is more than a moment, **re-apply it after the new image is up** to re-sync.
+- **New API, migration missing:** every one of those reads raises `42703` (undefined column): profiles, people search, both connection rosters, follow suggestions, the follow/unfollow response and every post author card. Alongside `0056` this is the second wave-2 file that hard-fails against the old schema; unlike `0056` the failure is instant and total rather than queued.
+- **Old API, migration applied:** harmless to serve, but the old image writes `follows_people` rows without bumping the counters, so the numbers drift low for as long as it runs. The file is safe to re-run (see below); if the gap is more than a moment, **re-apply it after the new image is up** to re-sync.
 
-Maintenance is application-side, in the same transaction as the edge write (`addFollow` / `removeFollow`), gated on the `INSERT … ON CONFLICT DO NOTHING RETURNING` / `DELETE … RETURNING` actually having changed a row — an idempotent re-follow must not double count. It mirrors the `posts.like_count` pattern from `0051`. There is deliberately **no trigger**, which has one operational consequence: *anything that writes `follows_people` outside those two methods* (a psql session, a data fix, a future bulk import) must bump the counters too, or leave drift behind. Drift detector, safe to run on the live DB:
+Maintenance is application-side, in the same transaction as the edge write (`addFollow` / `removeFollow`), gated on the `INSERT … ON CONFLICT DO NOTHING RETURNING` / `DELETE … RETURNING` actually having changed a row: an idempotent re-follow must not double count. It mirrors the `posts.like_count` pattern from `0051`. There is deliberately **no trigger**, which has one operational consequence: *anything that writes `follows_people` outside those two methods* (a psql session, a data fix, a future bulk import) must bump the counters too, or leave drift behind. Drift detector, safe to run on the live DB:
 
 ```sql
 SELECT u.id, u.handle,
@@ -696,7 +696,7 @@ WHERE u.follower_count  <> (SELECT count(*) FROM follows_people f WHERE f.follow
 
 Re-running the migration file is the repair: its backfill `UPDATE` is guarded by exactly that inequality, so it rewrites only the drifted rows and is a true no-op on a healthy database.
 
-**Semantics to know before reading a number:** edges to and from **soft-deleted** users COUNT, because that is what the aggregates being replaced did (they never joined `users`) and because account deletion is a tombstone that leaves `follows_people` intact. So `follower_count` can legitimately exceed the length of the visible followers roster, which filters `deleted_at IS NULL`. Preserved deliberately rather than changed under a performance refactor — changing it would silently restate every profile's numbers.
+**Semantics to know before reading a number:** edges to and from **soft-deleted** users COUNT, because that is what the aggregates being replaced did (they never joined `users`) and because account deletion is a tombstone that leaves `follows_people` intact. So `follower_count` can legitimately exceed the length of the visible followers roster, which filters `deleted_at IS NULL`. Preserved deliberately rather than changed under a performance refactor: changing it would silently restate every profile's numbers.
 
-Verified against a throwaway `postgis/postgis:16-3.4` with all 60 migration files (`0000`–`0059`) applied (37 assertions, all green): the backfill reproduces the aggregate including both tombstone directions; a second and third apply of the file rewrite nothing; `addFollow` twice moves each counter exactly once and `removeFollow` twice moves it back; a follow of a tombstoned user is refused with the counters untouched; a reciprocal follow-back and a repo-level self-follow (the case a two-statement bump would get wrong) both stay consistent; and every read path above — plus the extracted literal text of `loadAuthors` — returns the maintained values with zero drift at the end. The Docker-gated `test/integration/social-notifications-pg.test.ts` "follow/unfollow: idempotent, created flag, follower counts" case is the standing regression guard.
+Verified against a throwaway `postgis/postgis:16-3.4` with all 60 migration files (`0000`–`0059`) applied (37 assertions, all green): the backfill reproduces the aggregate including both tombstone directions; a second and third apply of the file rewrite nothing; `addFollow` twice moves each counter exactly once and `removeFollow` twice moves it back; a follow of a tombstoned user is refused with the counters untouched; a reciprocal follow-back and a repo-level self-follow (the case a two-statement bump would get wrong) both stay consistent; and every read path above (plus the extracted literal text of `loadAuthors`) returns the maintained values with zero drift at the end. The Docker-gated `test/integration/social-notifications-pg.test.ts` "follow/unfollow: idempotent, created flag, follower counts" case is the standing regression guard.
 
diff --git a/docs/signup-seats-backfill.md b/docs/signup-seats-backfill.md
index 7eb54e38..c9bf9afe 100644
--- a/docs/signup-seats-backfill.md
+++ b/docs/signup-seats-backfill.md
@@ -1,7 +1,7 @@
 # Signup-seat backfill (civfix-backend)
 
 **Audience:** internal (engineering + ops). Not served publicly.
-**Last updated:** 2026-09-13 (DECISIONS §44 — a sign-up on a non-ticketed event is a free registration).
+**Last updated:** 2026-09-13 (DECISIONS §44: a sign-up on a non-ticketed event is a free registration).
 
 ## Why this exists
 
@@ -20,7 +20,7 @@ Sign-ups now mint a **free one-seat registration** in the same transaction as th
 membership upsert (`ensureSignupRegistrationIn` in
 `services/api/src/services/cleanup-repository.drizzle.ts`), and leaving or being
 removed cancels it (`cancelSignupRegistrationIn`, which matches the attendee's
-active registration on `ticket_type_id IS NULL`). Ticketed events are untouched —
+active registration on `ticket_type_id IS NULL`). Ticketed events are untouched:
 minting is gated on the event having no ticket types, and the cancel can never
 reach a ticketed registration, which still goes exclusively through `registerIn`
 and `removeRegistration`.
@@ -52,7 +52,7 @@ DATABASE_URL=postgres://... pnpm --filter @civfix/api db:backfill:signup-seats -
 
 On the box the built entrypoint is `pnpm start:backfill:signup-seats`
 (`node dist/db/backfill-signup-seats.js`), same flags. `TICKET_TOKEN_SECRET` must
-be the environment's real secret — a seat minted under a different secret can
+be the environment's real secret; a seat minted under a different secret can
 never be scanned.
 
 It is an ordinary post-deploy step, not part of the `migrate` one-shot: run it
@@ -67,12 +67,12 @@ Candidates are `cleanup_members` rows where:
   no role test: `claimSlot` runs `ensureSignupRegistrationIn` for whoever claims a
   shift, so an organizer who claims their own gets a seat, and
   `0169_default_event_slot.sql` already gave every member of a board-less open
-  event — organizers among them — a claim on the seeded default slot. Excluding
+  event (organizers among them) a claim on the seeded default slot. Excluding
   organizers here would leave the host of a slot-based event off their own roster
   while every organizer who claims a shift after the release is on it. The rule is
   therefore plain membership: every non-guest member of a qualifying event gets one
   seat;
-- the event is **not cancelled** and **has not ended** (`ends_at > now()`) — a
+- the event is **not cancelled** and **has not ended** (`ends_at > now()`); a
   finished event's roster is history, and minting seats into it would resurrect
   the no-show sweep's candidate set;
 - the event has **zero ticket types**;
@@ -81,7 +81,7 @@ Candidates are `cleanup_members` rows where:
 Each batch is one transaction. The insert carries the same
 `ON CONFLICT (cleanup_id, user_id) WHERE status = 'registered' AND user_id IS NOT NULL
 DO NOTHING` arbiter the runtime path uses, and the seat row is written only for the
-registrations that actually landed — so the CLI is safe to re-run and safe to run
+registrations that actually landed, so the CLI is safe to re-run and safe to run
 while the API is serving traffic. `registered_at` and the seat's `created_at` are
 taken from `cleanup_members.joined_at`, so the roster keeps its real ordering.
 
@@ -114,14 +114,14 @@ documents for the auto-RSVP, because the capacity decision has to be made under
 slot lock and neither the membership row nor the seat may be written on a
 `slot_not_found` / `full` refusal (`sql.begin` commits on a normal return). The
 seat write therefore comes AFTER those refusals and BEFORE the slot claim, so a
-concurrent `registerIn(slotId)` for the same person — which goes registration
-first, slot claim second — cannot form an ABBA cycle with it.
+concurrent `registerIn(slotId)` for the same person (which goes registration
+first, slot claim second) cannot form an ABBA cycle with it.
 
 The `cleanup_ticket_types` probe is an unlocked `SELECT` and takes no row lock of
 its own, but it is not racy either: every minting transaction already holds the
 `cleanups` row `FOR SHARE` when it runs, and `createTicketType` takes that same row
 `FOR NO KEY UPDATE`, which conflicts with `FOR SHARE`. A ticket type therefore
-cannot be created in the window between the probe and the seat insert — the two
+cannot be created in the window between the probe and the seat insert: the two
 transactions serialize on the `cleanups` row. Cancellation does not depend on the
 probe at all: `cancelSignupRegistrationIn` discriminates on the REGISTRATION row
 (`ticket_type_id IS NULL`), the exact shape the sign-up path mints, so a host who
diff --git a/docs/superpowers/specs/2026-06-24-reviewer-otp-bypass-design.md b/docs/superpowers/specs/2026-06-24-reviewer-otp-bypass-design.md
index 17e93291..9ffca759 100644
--- a/docs/superpowers/specs/2026-06-24-reviewer-otp-bypass-design.md
+++ b/docs/superpowers/specs/2026-06-24-reviewer-otp-bypass-design.md
@@ -1,4 +1,4 @@
-# Reviewer-OTP bypass (backend) — design
+# Reviewer-OTP bypass (backend): design
 
 Date: 2026-06-24
 Status: approved
@@ -50,11 +50,11 @@ no code redeploy. The offline/test wiring leaves the bypass off unless a test op
    - `OtpService` constructor accepts optional `reviewer?: { email: string; code: string }`
      (email stored normalized). When absent, behavior is unchanged.
    - `issueOtp`: if the bypass is configured and the normalized email equals the reviewer
-     email, return `{ resendAfterSec: OTP_EMAIL_WINDOW_SECONDS }` immediately — before any
+     email, return `{ resendAfterSec: OTP_EMAIL_WINDOW_SECONDS }` immediately, before any
      rate-limit, store, or mailer work.
    - `verifyOtp`: if configured and the normalized email equals the reviewer email, accept
      only the reviewer code (find-or-create the reviewer account, return userId) and reject
-     anything else with `AppError.unauthorized("Invalid or expired code.")` — before any
+     anything else with `AppError.unauthorized("Invalid or expired code.")`, before any
      throttle/store work.
    - Private `ensureReviewerUser()`: `findByEmail` → return existing id, else `create(...)`
      with the full reviewer profile.
@@ -82,7 +82,7 @@ no code redeploy. The offline/test wiring leaves the bypass off unless a test op
 - `000000` only ever works for exactly `reviewer@civfix.org` (normalized compare).
 - `reviewer@civfix.org` only ever accepts `000000`; no real code is ever mailed/stored for it.
 - Disabling is one env flip, no code redeploy.
-- The reviewer account is an ordinary citizen — monitor or soft-delete anytime.
+- The reviewer account is an ordinary citizen; monitor or soft-delete anytime.
 
 ## Tests (TDD, in-memory stores)
 
diff --git a/docs/volunteer-hours-integrity.md b/docs/volunteer-hours-integrity.md
index b8e33812..e92ff56c 100644
--- a/docs/volunteer-hours-integrity.md
+++ b/docs/volunteer-hours-integrity.md
@@ -13,7 +13,7 @@ the rules below.
 
 ## The gap this closes
 
-Before this change the only anti-inflation control was **"another host must credit you"** — a host
+Before this change the only anti-inflation control was **"another host must credit you"**: a host
 could not credit themselves. Two ordinary accounts satisfy that for each other:
 
 1. A creates an event scheduled an hour ago (backdating up to 24 h is allowed).
@@ -30,7 +30,7 @@ Every step was legal, every hour landed on a transcript, and nothing was flagged
 |---|---|---|---|
 | a | A credit is capped at the event's own window: `COALESCE(completed_at, ends_at) - scheduled_at` **+ 1 h grace**, never above `MAX_EVENT_HOURS` (24) | `volunteer-hours-service.ts` (`creditableHoursForEvent`, `eventDurationMs`) | `VALIDATION` naming the event's real length |
 | b | One person may hold at most **24 h across every event scheduled on the same local calendar day**, read in the event's own IANA zone (`cleanups.timezone`, falling back to `DEFAULT_EVENT_TIME_ZONE`) | `volunteer-hours-repository.drizzle.ts`, inside the crediting transaction under a per-user advisory lock | `CONFLICT` naming what they already hold |
-| c | Reciprocity inside one event is refused **in both directions** — whoever credits second is the one refused (covers the organizer ↔ promoted-co-host swap) | same transaction | `CONFLICT` |
+| c | Reciprocity inside one event is refused **in both directions**; whoever credits second is the one refused (covers the organizer ↔ promoted-co-host swap) | same transaction | `CONFLICT` |
 | d | Two anomaly signals are **flagged to moderation, never blocked**: >60 h credited in a rolling 7 days, and A↔B crediting each other on *different* events within 30 days | detected in the transaction, filed by `volunteer-hours-anomaly.ts` | an open `moderation_items` row, `kind = 'pattern'`, `subject_type = 'user'` |
 | e | Hours can be logged only **once the event has ended** (`now >= ends_at`), and never against an event whose window is **shorter than 15 minutes** | `volunteer-hours-service.ts` (`hasEventEnded`, then the `MIN_EVENT_DURATION_MS` check in `logEventHours`) | `CONFLICT` |
 
@@ -43,7 +43,7 @@ second gate for legacy rows written before that assertion existed.
 **The denominator in rule (a) is fixed the moment the event ends.** Once
 `deriveCleanupStatus` reads `done`, `updateCleanup` refuses any change to `ends_at`
 (as it already refuses the date, title, location and type), so a host cannot widen
-the window a credit is measured against after the fact — the status may never move
+the window a credit is measured against after the fact: the status may never move
 backwards from `done`, and the cap a transcript was printed under stays the cap. An
 event still **underway** may extend `ends_at` (running late is legitimate), bounded
 by `assertEventWindow`'s 15 minute / 24 hour duration limits.
@@ -59,7 +59,7 @@ tripping ledger escalates ONE open item rather than flooding the queue.
 
 - `flag`: `Volunteer hours anomaly`
 - `reason`: `volunteer_hours.weekly_threshold` or `volunteer_hours.reciprocal_credit` (stable machine
-  keys — filter on these)
+  keys; filter on these)
 - `subject_id`: the **credited** user; `desc` carries the hours or the counterpart account id.
 
 An anomaly never fails the credit that produced it: the hours are already committed, and a moderation
@@ -67,13 +67,13 @@ outage must not undo them (the failure is logged and suppressed).
 
 ## Known limits of these rules (accepted, bounded, and deliberately not closed)
 
-None of these is a hole an attacker walks through — each is bounded by the other
-rules — but each is a real edge, so it is written down rather than discovered later.
+None of these is a hole an attacker walks through (each is bounded by the other
+rules), but each is a real edge, so it is written down rather than discovered later.
 
 **Reciprocity is pairwise, so a 3-cycle passes both checks.** Rule (c) refuses
 "X credits Y for this event when Y already credited X for this event", and the
 cross-event detector (d) is the same pairwise shape. Three co-hosts crediting in a
-ring — A→C, C→B, B→A — trip neither: no pair credits *each other*. What still binds
+ring (A→C, C→B, B→A) trip neither: no pair credits *each other*. What still binds
 them is everything that is not about pairs: the per-attendee window cap (a), the
 24 h/day cap (b), the 15-minute minimum (e), and the rolling 7-day flag (d),
 which is per-person and does not care where the hours came from. So a ring can
@@ -90,8 +90,8 @@ zone, not on a rolling window.** The date is `(scheduled_at AT TIME ZONE
 COALESCE(cleanups.timezone, DEFAULT_EVENT_TIME_ZONE))::date`, so "the same day"
 means the day the volunteers actually showed up rather than a UTC day that can
 split a West Coast evening in half. Two overlapping events straddling that local
-midnight can still credit one person 24 h on each side — 48 h inside roughly 47
-wall-clock hours — and an event whose host left `timezone` null is read in the
+midnight can still credit one person 24 h on each side (48 h inside roughly 47
+wall-clock hours), and an event whose host left `timezone` null is read in the
 platform default, which is wrong for a volunteer in another zone by at most the
 offset between the two. Keying on the *credit* time instead would block a host
 legitimately entering last month's events in one sitting. The residue is caught by
@@ -101,22 +101,22 @@ pattern is not).
 **`users.last_activity_geom` is not cleared when its source report is deleted.**
 The column is written on report/event create and on event completion, never on
 delete, so a tombstoned report's point can outlive it. This is staleness in a
-ranking input only: the value is **never served to any client** — it exists solely
-to bound the follow-suggestions candidate scan — and it *is* nulled, with the rest
+ranking input only: the value is **never served to any client** (it exists solely
+to bound the follow-suggestions candidate scan), and it *is* nulled, with the rest
 of the tombstone, when the account itself is erased (`auth/pg-stores.ts`). The next
 report or event the user files overwrites it.
 
 ## Events from before migrations 0103 and 0168
 
-`cleanups.completed_at` arrives in `0103` and is **not backfilled** — inventing a completion instant
+`cleanups.completed_at` arrives in `0103` and is **not backfilled**: inventing a completion instant
 would fabricate a fact that ends up printed on a government document. `0168` then made `ends_at`
 NOT NULL and filled the rows that had none with `scheduled_at + 4 h`, the same
 `DEFAULT_EVENT_DURATION_MS` a create applies when a host omits an end time.
 
 So every row now has a window, and rules (a) and (e) apply to all of them: an old row without a
 completion stamp is measured against its `ends_at`, which for a backfilled row is the declared
-default rather than an observed fact. That default is the honest floor available — it never
-claims more than four hours — and it is the only place in these rules where the denominator is a
+default rather than an observed fact. That default is the honest floor available (it never
+claims more than four hours), and it is the only place in these rules where the denominator is a
 convention rather than something a host chose.
 
 Rules (b), (c) and (d) apply to them unchanged.
@@ -127,7 +127,7 @@ Issuance (`certificate-service.ts` → `entriesForCertificate`) already excludes
 rows and `source = 'report'` rows. There is **no per-row "flagged" or "pending moderation" state** on
 `volunteer_hours`, and none was added: a moderation signal is a suspicion about a pattern, not a verdict
 on a row, and a silently-omitted row would make the printed total disagree with the ledger the holder
-can see. The operator remedy for confirmed abuse is the existing one — void the rows
+can see. The operator remedy for confirmed abuse is the existing one: void the rows
 (`voided_at`), which removes them from every future certificate, and revoke any certificate already
 issued over them (`POST /v1/me/volunteer-hours/certificates/:code/revoke`, or the admin void path).
 
diff --git a/infra/email-worker/README.md b/infra/email-worker/README.md
index 92c1f412..e7394ba4 100644
--- a/infra/email-worker/README.md
+++ b/infra/email-worker/README.md
@@ -36,7 +36,7 @@ addresses outbound mail advertises live on `MAIL_REPLY_DOMAIN` (default `civfix.
 
 ## Layout
 
-Standalone — **not** in the backend pnpm/Turbo workspace (own `wrangler` toolchain).
+Standalone: **not** in the backend pnpm/Turbo workspace (own `wrangler` toolchain).
 
 ```
 src/index.ts   the email() handler
@@ -50,7 +50,7 @@ test/          vitest unit tests (id derivation, HMAC fixture shared with the ba
 1. **`wrangler.toml` → `bucket_name`**: set to the backend's `R2_INBOUND_BUCKET` value, so the
    backend sweep sees what the Worker writes. Never the media bucket `R2_BUCKET`.
 2. **`BACKEND_WEBHOOK_URL`**: must equal the backend `PUBLIC_API_URL` + `/webhooks/inbound-mail`.
-3. **Secret**: `wrangler secret put CF_EMAIL_WEBHOOK_SECRET --env production` — the **same value** the
+3. **Secret**: `wrangler secret put CF_EMAIL_WEBHOOK_SECRET --env production`, the **same value** the
    backend has for `CF_EMAIL_WEBHOOK_SECRET` (the HMAC key). They must be byte-identical.
 
 ## Develop
@@ -92,7 +92,7 @@ pnpm deploy            # wrangler deploy --env production (after `wrangler login
 CI does not deploy the Worker: a change under `src/` reaches Cloudflare only through `pnpm deploy`.
 
 Then, in the Cloudflare dashboard (or API): enable **Email Routing** on `civfix.org` (auto-manages
-MX/TXT and **takes over inbound mail for the domain** — confirm no other inbound provider first), and
+MX/TXT and **takes over inbound mail for the domain**; confirm no other inbound provider first), and
 set the **catch-all** route Action to **Send to a Worker** → `civfix-inbound-email`. The
 `/webhooks/*` path stays un-gated by Cloudflare Access, so the Worker can POST it through the tunnel.
 
diff --git a/infra/email-worker/test/email.test.ts b/infra/email-worker/test/email.test.ts
index 1d217778..d017c157 100644
--- a/infra/email-worker/test/email.test.ts
+++ b/infra/email-worker/test/email.test.ts
@@ -39,7 +39,7 @@ describe("hmacSha256Hex (must match the backend's node:crypto verifier)", () =>
     expect(ours).toBe(reference)
   })
 
-  // Frozen fixture — copy this exact (secret, body, signature) into the backend webhook test so the two
+  // Frozen fixture: copy this exact (secret, body, signature) into the backend webhook test so the two
   // halves can never silently drift. body is the canonical no-whitespace JSON the Worker POSTs.
   it("matches the frozen cross-repo fixture", async () => {
     const secret = "civfix-test-secret"
diff --git a/infra/email-worker/wrangler.toml b/infra/email-worker/wrangler.toml
index b75894a8..15b82dae 100644
--- a/infra/email-worker/wrangler.toml
+++ b/infra/email-worker/wrangler.toml
@@ -1,7 +1,7 @@
 # Cloudflare Email Worker for catch-all *@civfix.org inbound mail.
 #
-# This Worker is NOT part of the pnpm/Turbo workspace (pnpm-workspace.yaml globs shared/, services/*,
-# packages/* — not infra/*). It has its own wrangler toolchain. See README.md + documents/17.
+# This Worker is NOT part of the pnpm/Turbo workspace (pnpm-workspace.yaml globs services/* and
+# packages/*, not infra/*). It has its own wrangler toolchain. See README.md.
 #
 # NOTE: the catch-all -> Worker route and the Email Routing enablement are configured in the Cloudflare
 # dashboard/API, NOT here. The shared secret is a Worker secret (wrangler secret put), NOT a var.
@@ -9,7 +9,7 @@
 name = "civfix-inbound-email"
 main = "src/index.ts"
 compatibility_date = "2025-06-01"   # pinned; bump deliberately
-# nodejs_compat is NOT needed — only WebCrypto + fetch + R2 are used.
+# nodejs_compat is NOT needed: only WebCrypto + fetch + R2 are used.
 
 # Bind the inbound-mail bucket. The code references env.R2_BUCKET; bucket_name is the real R2 bucket the
 # backend ALSO reads (set the backend's R2_INBOUND_BUCKET to this same name) so its sweep over
@@ -21,9 +21,7 @@ bucket_name = "civfix-emails"   # dedicated inbound bucket; backend reads it via
 [vars]
 BACKEND_WEBHOOK_URL = "https://api.civfix.org/webhooks/inbound-mail"   # must equal PUBLIC_API_URL + /webhooks/inbound-mail
 
-# ---------------------------------------------------------------------------
-# Environments — Email Routing routes to ONE deployed worker; use distinct names per env.
-# ---------------------------------------------------------------------------
+# Email Routing routes to ONE deployed worker, so each environment needs a distinct name.
 [env.production]
 name = "civfix-inbound-email"
 [env.production.vars]
diff --git a/package.json b/package.json
index f78b199d..07894d04 100644
--- a/package.json
+++ b/package.json
@@ -11,8 +11,8 @@
       "esbuild",
       "ffmpeg-static"
     ],
-    "//overrides": "H13. node-apn@3.0.0 (the APNs dispatcher in services/api/src/adapters/push-apns.ts, still live whenever APNS_* is configured) is ABANDONED and pins node-forge@0.7.6 — seven advisories including RSA and Ed25519 SIGNATURE FORGERY — plus jsonwebtoken@8.5.1, which signs the APNs auth token. Neither is reachable by a range bump because node-apn is unmaintained, so we force the patched majors here. Both are API-compatible for node-apn's usage (jwt.sign with ES256 + a pem key; forge only for the legacy pkcs12/cert path we never take). Revisit when node-apn is replaced or dropped.",
-    "//overrides-transitive": "H13 (cont). The remaining entries force patched versions of transitive packages whose direct parent still declares a vulnerable range, each bounded INSIDE the vulnerable package's own major so the fix stays a patch/minor for the requester: find-my-way + fast-uri (fastify/ajv, HTTP/2 DDoS + host-confusion/SSRF); brace-expansion 1/2/5, js-yaml 4, nanoid 3, postcss (eslint, minimatch, glob, vite, tsup DoS + path-traversal advisories); vite (vitest's resolution, path traversal + fs.deny bypass); protobufjs 7 (grpc/firebase, proto-parse DoS); deepmerge-ts (html-to-text stack exhaustion); uuid 9/10 (gaxios, dockerode - missing buffer bounds check); and the scoped esbuild entries for tsup, tsx, vite and drizzle-kit's abandoned @esbuild-kit/core-utils (dev-server arbitrary file read). Drop each entry once its parent ships the fixed range itself.",
+    "//overrides": "node-apn@3.0.0 (the APNs dispatcher in services/api/src/adapters/push-apns.ts, still live whenever APNS_* is configured) is ABANDONED and pins node-forge@0.7.6 (seven advisories including RSA and Ed25519 SIGNATURE FORGERY) plus jsonwebtoken@8.5.1, which signs the APNs auth token. Neither is reachable by a range bump because node-apn is unmaintained, so we force the patched majors here. Both are API-compatible for node-apn's usage (jwt.sign with ES256 + a pem key; forge only for the legacy pkcs12/cert path we never take). Revisit when node-apn is replaced or dropped.",
+    "//overrides-transitive": "The remaining entries force patched versions of transitive packages whose direct parent still declares a vulnerable range, each bounded INSIDE the vulnerable package's own major so the fix stays a patch/minor for the requester: find-my-way + fast-uri (fastify/ajv, HTTP/2 DDoS + host-confusion/SSRF); brace-expansion 1/2/5, js-yaml 4, nanoid 3, postcss (eslint, minimatch, glob, vite, tsup DoS + path-traversal advisories); vite (vitest's resolution, path traversal + fs.deny bypass); protobufjs 7 (grpc/firebase, proto-parse DoS); deepmerge-ts (html-to-text stack exhaustion); uuid 9/10 (gaxios, dockerode - missing buffer bounds check); and the scoped esbuild entries for tsup, tsx, vite and drizzle-kit's abandoned @esbuild-kit/core-utils (dev-server arbitrary file read). Drop each entry once its parent ships the fixed range itself.",
     "overrides": {
       "node-forge": "^1.4.0",
       "jsonwebtoken": "^9.0.2",
diff --git a/packages/config/eslint.config.js b/packages/config/eslint.config.js
index 779614de..67fe0262 100644
--- a/packages/config/eslint.config.js
+++ b/packages/config/eslint.config.js
@@ -2,17 +2,8 @@ import eslint from "@eslint/js"
 import tseslint from "typescript-eslint"
 import globals from "globals"
 
-/**
- * Shared ESLint flat-config preset for civfix backend services.
- *
- * Services extend it like:
- *   import { config } from "@civfix/config/eslint"
- *   export default config()
- *
- * `config()` returns a flat-config array. Pass extra config objects to append service-specific
- * overrides. Type-aware rules are enabled per service by setting `parserOptions.projectService`
- * in the service eslint.config.js; this preset stays project-agnostic so it works everywhere.
- */
+// Type-aware rules are enabled per service via `parserOptions.projectService` in the service's
+// eslint.config.js, so this preset stays project-agnostic.
 export function config(...extra) {
   return tseslint.config(
     {
diff --git a/packages/config/prettier.config.js b/packages/config/prettier.config.js
index 79066d4a..4ff72304 100644
--- a/packages/config/prettier.config.js
+++ b/packages/config/prettier.config.js
@@ -1,7 +1,3 @@
-/**
- * Shared Prettier config for civfix backend. Services re-export this:
- *   export { default } from "@civfix/config/prettier"
- */
 export default {
   semi: false,
   singleQuote: false,
diff --git a/scripts/assert-no-frontend.mjs b/scripts/assert-no-frontend.mjs
index 0a0db273..1d40d1c9 100644
--- a/scripts/assert-no-frontend.mjs
+++ b/scripts/assert-no-frontend.mjs
@@ -1,11 +1,7 @@
 #!/usr/bin/env node
-// Stage 0.7 (UI-unification) backend-isolation guard.
-//
-// The civfix backend (Fastify API + media worker) depends ONLY on @civfix/shared, the framework-free
-// contract. It must never pull in frontend runtime deps: React, React Native, react-native-web, or the
-// new @civfix/ui package (which carries React/RN peers). If any of those ever appear, an install or a
-// stray dependency edit introduced a leak between the backend and the UI layer, and this script fails
-// CI so it gets caught before it ships.
+// Backend-isolation guard, run in CI. The backend depends ONLY on @civfix/shared, the framework-free
+// contract. React, React Native, react-native-web or @civfix/ui (which carries React/RN peers)
+// appearing here means an install or a stray dependency edit leaked the UI layer into the backend.
 //
 // Two independent checks:
 //   1. pnpm-lock.yaml (read as TEXT) must contain no RESOLVED package whose key is react, react-dom,
@@ -17,7 +13,7 @@
 //   2. services/api and services/media-worker package.json must not list react / react-dom /
 //      react-native in dependencies or devDependencies.
 //
-// ESM, ASCII only. Run: node scripts/assert-no-frontend.mjs  (exit 0 = clean, exit 1 = leak found).
+// Run: node scripts/assert-no-frontend.mjs  (exit 0 = clean, exit 1 = leak found).
 
 import { readFileSync } from "node:fs"
 import { fileURLToPath } from "node:url"
@@ -25,12 +21,8 @@ import { dirname, join } from "node:path"
 
 const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..")
 
-/** Collected human-readable failures; non-empty => exit 1. */
 const failures = []
 
-// ---------------------------------------------------------------------------
-// Check 1: the pnpm lockfile must not RESOLVE any banned frontend package.
-//
 // In pnpm-lock v9 a resolved package is a key in the top-level `packages:` (and `snapshots:`) maps,
 // written as `  @:` at a two-space indent. A scoped name may be quoted. We anchor each
 // pattern to start-of-line + two spaces + the exact package name + `@`, so:
@@ -44,11 +36,9 @@ const failures = []
 //                             `@` boundary stops `react-native` from matching `react-native-b4a`).
 //   - `react-native-web@...`  has its own dedicated pattern.
 //   - `@civfix/ui@...`        matches whether or not the scoped key is quoted.
-// ---------------------------------------------------------------------------
 const LOCKFILE = "pnpm-lock.yaml"
 const lockfilePath = join(repoRoot, LOCKFILE)
 
-/** Each entry: a banned resolved-package name + the regex that matches its lockfile-v9 key line. */
 const BANNED_LOCKFILE_PACKAGES = [
   { name: "react", re: /^ {2}["']?react@/m },
   { name: "react-dom", re: /^ {2}["']?react-dom@/m },
@@ -76,9 +66,6 @@ for (const { name, re } of BANNED_LOCKFILE_PACKAGES) {
   }
 }
 
-// ---------------------------------------------------------------------------
-// Check 2: backend service manifests must not declare React/RN directly.
-// ---------------------------------------------------------------------------
 const BANNED_MANIFEST_DEPS = ["react", "react-dom", "react-native"]
 const SERVICE_MANIFESTS = ["services/api/package.json", "services/media-worker/package.json"]
 
@@ -99,9 +86,6 @@ for (const rel of SERVICE_MANIFESTS) {
   }
 }
 
-// ---------------------------------------------------------------------------
-// Report.
-// ---------------------------------------------------------------------------
 if (failures.length > 0) {
   console.error("[assert-no-frontend] FAIL: backend pulled in frontend runtime deps:")
   for (const f of failures) console.error(`  - ${f}`)
diff --git a/scripts/check-dynamic-sql.mjs b/scripts/check-dynamic-sql.mjs
index 6e91c9af..c0fca972 100644
--- a/scripts/check-dynamic-sql.mjs
+++ b/scripts/check-dynamic-sql.mjs
@@ -282,7 +282,7 @@ function main() {
       }
       if (!ALLOW_DRIZZLE_CLIENT.has(file) && findDrizzleClientUses(code).length > 0) {
         violations.push(
-          `${file}: raw SQL on drizzle's own client (.$client) — its Date/array/jsonb serializers are ` +
+          `${file}: raw SQL on drizzle's own client (.$client): its Date/array/jsonb serializers are ` +
             "identity passthroughs, so a bound Date throws at Bind time. Take the DbHandle's `sql` tag instead",
         )
       }
@@ -290,7 +290,7 @@ function main() {
       for (const nullTest of findParamNullTests(code)) {
         if (ALLOW_NULL_TEST.has(`${file}:${nullTest}`)) continue
         violations.push(
-          `${file}: uncast parameter in a NULL test — \`${nullTest}\` (add a ::type cast, or drop the redundant guard)`,
+          `${file}: uncast parameter in a NULL test: \`${nullTest}\` (add a ::type cast, or drop the redundant guard)`,
         )
       }
     }
diff --git a/services/api/.env.example b/services/api/.env.example
index 782dc648..f92ab385 100644
--- a/services/api/.env.example
+++ b/services/api/.env.example
@@ -6,7 +6,7 @@
 # Copy to .env (gitignored) and fill real values for production.
 #
 # NODE_ENV: this template ships `development` for LOCAL use (offline boot). In a real DEPLOYMENT do NOT
-# rely on this line — the production image (Dockerfile) and the civfix-infra compose force
+# rely on this line: the production image (Dockerfile) and the civfix-infra compose force
 # NODE_ENV=production (compose `environment` overrides this env_file). Leaving a deployed secrets file at
 # `development` would otherwise disable the auth bundle and 404 every /auth/* and /admin/* route.
 
@@ -23,7 +23,7 @@ DATABASE_URL=                              # [BOOT] postgres://user:pass@host:54
                                            #        sslmode ships credentials and rows in CLEARTEXT, and
                                            #        boot fails in production without a TLS sslmode.
                                            #        EXEMPT: a host the packets cannot leave the machine to
-                                           #        reach — localhost, 127.x, ::1, or a single-label compose
+                                           #        reach: localhost, 127.x, ::1, or a single-label compose
                                            #        alias like `postgres`. The deployed stack reaches
                                            #        Postgres over the private bridge and the image carries NO
                                            #        server certificate, so requiring TLS there would not
@@ -137,7 +137,7 @@ SMS_GUEST_ENABLED=                         # [OPT] 1/true offers the SMS guest-R
 SMS_DAILY_CAP=50                           # [OPT] global cap on EVERY outbound guest text per UTC day (verification code, RSVP confirmation, event update/cancellation fan-out); FAILS CLOSED at the cap and when Redis is down (default 50)
 MAIL_FROM_NOREPLY=no-reply@civfix.org      # [OPT] From for transactional mail / OTP (default no-reply@civfix.org)
 MAIL_FROM_OUTREACH=outreach@civfix.org     # [OPT] canonical outreach identity (default outreach@civfix.org): the stored from_addr + 'sent' meta + the OUT Message-ID domain. NOTE: jurisdiction/outreach mail is now sent FROM the per-thread reply address {kind}-{token}@MAIL_REPLY_DOMAIN, not this address.
-HOME_TURF_MAIL_FROM=donotreply@civfix.org  # [OPT] From for POST /forms/home-turf mail (notification + confirmation; default donotreply@civfix.org — covered by the @civfix.org approved-sender domain)
+HOME_TURF_MAIL_FROM=donotreply@civfix.org  # [OPT] From for POST /forms/home-turf mail (notification + confirmation; default donotreply@civfix.org, covered by the @civfix.org approved-sender domain)
 HOME_TURF_NOTIFY_TO=                       # [BOOT-if-used] recipient of the Home Turf coach-interest notification email. NO DEFAULT: unset DISABLES POST /forms/home-turf (409 CONFLICT, mails nobody, warned once at boot). Set it in the deployment secrets to enable the form.
 
 # ===== admin / operator dashboard (Phase 2) [OPT] =====
@@ -163,7 +163,7 @@ CF_ACCESS_SERVICE_TOKENS=                   # [OPT] comma list of permitted serv
 # ===== Cloudflare features [OPT] =====
 CF_TURNSTILE_SECRET=                       # [OPT] Turnstile secret; disables captcha verify if absent
 # L16 token binding: siteverify echoes the hostname the token was minted on. List the hostnames that may
-# mint one (comma list, case-insensitive; the web origins WITHOUT scheme — e.g. civfix.org,www.civfix.org)
+# mint one (comma list, case-insensitive; the web origins WITHOUT scheme, e.g. civfix.org,www.civfix.org)
 # and a token minted on any other page using our sitekey is rejected even when Cloudflare says success.
 # Leave EMPTY to skip the assertion (logs a one-time notice at boot; the captcha itself still verifies).
 CF_TURNSTILE_HOSTNAMES=                     # [OPT] comma list of hostnames a Turnstile token may come from; empty = not asserted
@@ -174,7 +174,7 @@ CF_API_TOKEN=                              # [OPT] Cloudflare API token for auto
 MAPBOX_TOKEN=                              # [OPT] Mapbox server token; reverse geocoding falls back to Photon if absent
 
 # ===== Apple OAuth (Sign in with Apple) [OPT] =====
-# CLIENT_ID is the NATIVE app bundle id (e.g. org.civfix.community) — the `aud` of the native iOS id_token.
+# CLIENT_ID is the NATIVE app bundle id (e.g. org.civfix.community): the `aud` of the native iOS id_token.
 # The WEB Services ID (org.civfix.web) is configured SEPARATELY as APPLE_OAUTH_WEB_CLIENT_ID below.
 APPLE_OAUTH_CLIENT_ID=                     # [OPT] native app bundle id (e.g. org.civfix.community)
 APPLE_OAUTH_TEAM_ID=                       # [OPT] Apple developer team id
@@ -203,7 +203,7 @@ GOOGLE_OAUTH_ANDROID_CLIENT_ID=           # [OPT] Android OAuth client id (packa
 # Require a server-minted single-use nonce on NATIVE Google/Apple sign-in (POST /v1/auth/oauth/nonce ->
 # POST /v1/auth/{google,apple} with `nonce`). With it OFF, a captured id_token can be replayed for the
 # rest of its (short) validity; with it ON, native sign-in from a client that does not request a nonce
-# FAILS with a 422. DEFAULT: false, and it must stay false until a shipped mobile build sends one —
+# FAILS with a 422. DEFAULT: false, and it must stay false until a shipped mobile build sends one;
 # as of 2026-09 apps/community-mobile/src/components/AuthOptions.tsx calls signInWithApple/
 # signInWithGoogle with no nonce, so flipping this on would break every store build in the wild.
 # Flip to true (prod first) once a nonce-sending mobile release has aged out the old ones.
@@ -257,7 +257,7 @@ REVIEWER_OTP_CODE=                         # [OPT] the reviewer's sign-in secret
 # ===== proxy trust =====
 TRUST_PROXY=                               # [OPT] unset = trust only loopback/RFC1918 upstreams (safe default).
                                            #       A CIDR list is also accepted; a bare number is IGNORED
-                                           #       (falls back to the safe default) — Fastify 5.12.1 removed
+                                           #       (falls back to the safe default): Fastify 5.12.1 removed
                                            #       hop-count trust.
                                            #       `true` is REJECTED at boot in production: it would trust any
                                            #       client-supplied X-Forwarded-For and defeat every per-IP control.
diff --git a/services/api/Dockerfile b/services/api/Dockerfile
index 2da3457b..03706f95 100644
--- a/services/api/Dockerfile
+++ b/services/api/Dockerfile
@@ -5,9 +5,8 @@
 # BUILD CONTEXT MUST BE THE REPO ROOT (civfix-backend/), NOT services/api/. The API is a pnpm workspace
 # package that depends on @civfix/shared (installed from the private registry repo.civfix.org via the
 # root .npmrc), so the build needs the root manifests + pnpm-lock.yaml + .npmrc + packages/ in the
-# context. The compose file sets
-# `build.context: ../..` and `build.dockerfile: services/api/Dockerfile` accordingly. To build by hand
-# from the repo root:
+# context. CI builds it that way (.github/workflows/build-images.yml); to build by hand from the repo
+# root:
 #     docker build -f services/api/Dockerfile -t civfix/api:latest .
 #
 # Stages:
@@ -21,7 +20,6 @@
 # package resolvable in node_modules. `pnpm deploy` produces exactly that (shared injected + prod-only
 # deps), so the runtime image carries no build toolchain.
 
-# ----------------------------------------------------------------------------------------------------
 FROM node:22-bookworm-slim AS base
 ENV PNPM_HOME="/pnpm" \
     PATH="/pnpm:$PATH" \
@@ -31,21 +29,18 @@ ENV PNPM_HOME="/pnpm" \
 RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
 WORKDIR /app
 
-# ----------------------------------------------------------------------------------------------------
 FROM base AS build
-# Copy the whole workspace (context = repo root). The .dockerignore keeps node_modules/dist/tests out.
+# The .dockerignore keeps node_modules/dist/tests out of this copy.
 COPY . .
-# Install the FULL workspace (dev deps included: tsup/turbo are needed to build). A BuildKit cache mount
-# keeps the pnpm store warm across builds. --frozen-lockfile fails the build if the lockfile is stale.
+# Dev deps are needed here: tsup/turbo do the build. --frozen-lockfile fails the build on a stale
+# lockfile.
 RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
     pnpm install --frozen-lockfile
-# Build shared -> api (turbo resolves the order; tsup emits dist/). Only the API graph is needed.
+# The trailing `...` builds the API and its workspace deps only.
 RUN pnpm --filter @civfix/api... build
-# Produce a self-contained, production-pruned deployment of just the API at /out (shared injected).
 RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
     pnpm --filter @civfix/api --prod deploy /out
 
-# ----------------------------------------------------------------------------------------------------
 FROM base AS runtime
 # SERVICE_VERSION is the RELEASE IDENTITY and it is baked in at BUILD time on purpose. It is the
 # GlitchTip `release` (src/server.ts), so without it every production error report is tagged "0.0.0" and
@@ -58,7 +53,6 @@ ENV NODE_ENV=production \
     SERVICE_VERSION=${SERVICE_VERSION}
 # Run as the unprivileged `node` user that the base image already provides.
 WORKDIR /app
-# Copy only the pruned deployment (dist + drizzle SQL + prod node_modules incl. @civfix/shared).
 COPY --from=build --chown=node:node /out /app
 USER node
 EXPOSE 8080
@@ -66,5 +60,4 @@ EXPOSE 8080
 # global fetch (Node 22) so the slim image needs no curl/wget.
 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
     CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
-# main.js calls start(): init error reporting, listen on PORT, install SIGTERM/SIGINT graceful shutdown.
 CMD ["node", "dist/main.js"]
diff --git a/services/api/assets/fonts/PROVENANCE.md b/services/api/assets/fonts/PROVENANCE.md
index 388ff710..7a482c39 100644
--- a/services/api/assets/fonts/PROVENANCE.md
+++ b/services/api/assets/fonts/PROVENANCE.md
@@ -9,7 +9,7 @@ Korean fallback that keeps a Hangul name or event title from printing as `.notde
 records a SHA-256 per file and `test/unit/certificate-fonts.test.ts` re-hashes them on every run, which is
 the same guarantee `scripts/copy-contract-fonts.mjs` gives the web app.
 
-Nothing in this directory was instanced, subsetted, renamed or otherwise modified by us — each file is a
+Nothing in this directory was instanced, subsetted, renamed or otherwise modified by us: each file is a
 byte-for-byte copy of an upstream static release, so the OFL's Reserved Font Name clause is not engaged.
 The required copyright notices and the full license text ship beside the fonts in `OFL.txt`.
 
@@ -19,8 +19,8 @@ Google Fonts publishes Baloo 2, Bricolage Grotesque, Hanken Grotesk and JetBrain
 `github.com/google/fonts` as **variable** TTFs only (`Baloo2[wght].ttf`,
 `BricolageGrotesque[opsz,wdth,wght].ttf`, `HankenGrotesk[wght].ttf`, `JetBrainsMono[wght].ttf`), which is
 exactly the trap described above. The static per-weight instances published by the
-`@expo-google-fonts/*` packages — the same files civfix-mobile already ships and renders with, so the
-printed document matches the app — were vendored instead:
+`@expo-google-fonts/*` packages (the same files civfix-mobile already ships and renders with, so the
+printed document matches the app) were vendored instead:
 
 | file | copied from | package version | font version |
 |---|---|---|---|
@@ -34,12 +34,12 @@ Verified static, not variable, at vendoring time (fontkit): each file reports ze
 `OS/2.usWeightClass` in the table above's file name (400 / 600 / 800).
 
 - Upstream families and licenses:
-  - Baloo 2 — https://github.com/EkType/Baloo2 · https://github.com/google/fonts/tree/main/ofl/baloo2
-  - Bricolage Grotesque — https://github.com/ateliertriay/bricolage ·
+  - Baloo 2: https://github.com/EkType/Baloo2 · https://github.com/google/fonts/tree/main/ofl/baloo2
+  - Bricolage Grotesque: https://github.com/ateliertriay/bricolage ·
     https://github.com/google/fonts/tree/main/ofl/bricolagegrotesque
-  - Hanken Grotesk — https://github.com/marcologous/hanken-grotesk ·
+  - Hanken Grotesk: https://github.com/marcologous/hanken-grotesk ·
     https://github.com/google/fonts/tree/main/ofl/hankengrotesk
-  - JetBrains Mono — https://github.com/JetBrains/JetBrainsMono ·
+  - JetBrains Mono: https://github.com/JetBrains/JetBrainsMono ·
     https://github.com/google/fonts/tree/main/ofl/jetbrainsmono
 
 ## The Korean fallback
@@ -56,11 +56,11 @@ Verified static, not variable, at vendoring time (fontkit): each file reports ze
 **Why `.otf` and not `.ttf`.** Noto Sans KR has no static TTF anywhere upstream: `google/fonts` ships only
 `NotoSansKR[wght].ttf` (variable, 10.4 MB) and `notofonts/noto-cjk` ships statics as CFF-flavoured OpenType
 (`Sans/SubsetOTF/KR/*.otf`). Instancing the variable font would need `fonttools varLib.instancer`, i.e.
-Python, which the `node:22-bookworm-slim` runtime image does not have — and instancing at build time is
+Python, which the `node:22-bookworm-slim` runtime image does not have, and instancing at build time is
 forbidden for exactly that reason. pdfkit/fontkit embed and subset CFF OpenType as happily as TrueType
 (`Type0` / `CIDFontType0`), which the Hangul fixture in `test/unit/certificate-pdf.test.ts` proves on every
 run. The `SubsetOTF` in the upstream path means "Korean subset of the pan-CJK family", not "already
-subsetted for us" — fontkit still subsets it per document.
+subsetted for us"; fontkit still subsets it per document.
 
 Only the Regular weight is vendored (DP §2.2's stated lever): a CJK Bold would add another ~4.8 MB to the
 image for one typographic nuance, so CJK headings render Regular.
@@ -70,7 +70,7 @@ image for one typographic nuance, so CJK headings render Regular.
 1. Replace the file with a fresh upstream **static** copy.
 2. `shasum -a 256 ` and update `MANIFEST.json`.
 3. Update the revision/version row above.
-4. `pnpm --filter @civfix/api test certificate-` — the manifest re-hash, the size bounds (no 0-byte file,
+4. `pnpm --filter @civfix/api test certificate-`: the manifest re-hash, the size bounds (no 0-byte file,
    nothing over 8 MB, which is how a committed Git-LFS pointer gets caught) and the Hangul render all run
    there.
 5. `pnpm --filter @civfix/api exec tsx scripts/render-sample-certificate.ts` and eyeball the output.
diff --git a/services/api/drizzle.config.ts b/services/api/drizzle.config.ts
index 9ba4ea4c..aa69df66 100644
--- a/services/api/drizzle.config.ts
+++ b/services/api/drizzle.config.ts
@@ -1,15 +1,12 @@
 import { defineConfig } from "drizzle-kit"
 
 /**
- * Drizzle Kit config -- FOR DIFF INSPECTION ONLY.
+ * FOR DIFF INSPECTION ONLY. The CANONICAL DDL is the hand-authored ./drizzle/NNNN_*.sql, applied by
+ * src/db/migrate.ts: drizzle-kit cannot express PostGIS geometry, GiST indexes, or declarative
+ * partitioning, so it is NOT the migration generator here. It stays configured only so `pnpm
+ * db:generate` can show how the Drizzle table models differ from a baseline.
  *
- * The CANONICAL DDL for civfix is the hand-authored SQL in ./drizzle (0000_extensions.sql,
- * 0001_core.sql, 0002_chat_partitioning.sql), applied by src/db/migrate.ts. drizzle-kit cannot
- * express PostGIS geometry, GiST indexes, or declarative partitioning, so it is NOT the migration
- * generator here. We keep it configured only so a developer can run `pnpm db:generate` to eyeball
- * how the Drizzle table models differ from a baseline.
- *
- * IMPORTANT - two deliberate choices:
+ * Two deliberate choices:
  *   1. `out` points at ./drizzle/_generated (NOT ./drizzle) so generated artifacts can never collide
  *      with or be picked up alongside the canonical hand SQL that the migrate runner applies.
  *   2. drizzle-kit@0.28 loads the schema through esbuild-register (CJS), which does not rewrite the
diff --git a/services/api/drizzle/0013_perf_indexes.sql b/services/api/drizzle/0013_perf_indexes.sql
index f5c5ede9..05886247 100644
--- a/services/api/drizzle/0013_perf_indexes.sql
+++ b/services/api/drizzle/0013_perf_indexes.sql
@@ -27,7 +27,7 @@
 -- =============================================================================
 
 -- -----------------------------------------------------------------------------
--- dm_threads.user_hi  (findings 1 + 15 — SAME index, created once)
+-- dm_threads.user_hi  (findings 1 + 15: SAME index, created once)
 -- -----------------------------------------------------------------------------
 -- listThreadsForUser / DM inbox filter `WHERE user_lo = X OR user_hi = X`. The
 -- UNIQUE(user_lo, user_hi) already anchors the user_lo branch via its leftmost
@@ -65,7 +65,7 @@ CREATE INDEX IF NOT EXISTS users_created_id_idx
   ON users (created_at DESC, id DESC);
 
 -- -----------------------------------------------------------------------------
--- chat_messages by sender  (finding 4) — PARTITIONED PARENT
+-- chat_messages by sender  (finding 4): PARTITIONED PARENT
 -- -----------------------------------------------------------------------------
 -- listUserMessages: `WHERE m.sender_id = X ... ORDER BY m.created_at DESC, m.id DESC`.
 -- chat_messages is declaratively partitioned (0002); a PLAIN index on the parent
@@ -99,7 +99,7 @@ CREATE INDEX IF NOT EXISTS reports_public_recent_idx
 -- cleanups created_at  (findings 5/6 cleanups-branch)
 -- -----------------------------------------------------------------------------
 -- Activity-feed cleanups UNION branch orders by c.created_at DESC (the COALESCE(
--- c.created_at, c.scheduled_at) wrapper has been dropped — created_at is DEFAULT
+-- c.created_at, c.scheduled_at) wrapper has been dropped; created_at is DEFAULT
 -- now() and effectively non-null, so the fallback never fired). This plain
 -- (created_at DESC) index serves that sort directly. cleanups_scheduled_idx (0001)
 -- already covers any scheduled_at-only ordering, so no extra index is needed for
@@ -162,7 +162,7 @@ CREATE INDEX IF NOT EXISTS report_timeline_acknowledged_idx
   WHERE status = 'acknowledged';
 
 -- -----------------------------------------------------------------------------
--- mail_threads inbox keyset  (finding 13) — EXPRESSION index
+-- mail_threads inbox keyset  (finding 13): EXPRESSION index
 -- -----------------------------------------------------------------------------
 -- listThreads paginates by (COALESCE(last_message_at, created_at) DESC, id DESC)
 -- so a brand-new thread with no message still orders by its creation time. An
diff --git a/services/api/drizzle/0014_search_trgm.sql b/services/api/drizzle/0014_search_trgm.sql
index 28710814..4120a98c 100644
--- a/services/api/drizzle/0014_search_trgm.sql
+++ b/services/api/drizzle/0014_search_trgm.sql
@@ -31,13 +31,13 @@
 CREATE EXTENSION IF NOT EXISTS pg_trgm;
 
 -- -----------------------------------------------------------------------------
--- users.handle + users.display_name  (findings 16, 17, 19 — handle; 7, 8 — both)
+-- users.handle + users.display_name  (findings 16, 17, 19: handle; 7, 8: both)
 -- -----------------------------------------------------------------------------
 -- @handle typeahead: `(handle::text) ILIKE 'prefix%'` (social-repository.drizzle.ts searchByHandlePrefix).
 -- People directory + admin reports/events/users search: infix `ILIKE '%term%'` on
 -- handle and display_name (social-repository.drizzle.ts, admin-report/-event/-user-repository).
 -- handle is CITEXT, and gin_trgm_ops has no citext operator class, so this is an EXPRESSION index on
--- (handle::text). For the planner to USE it, the consuming queries must filter on the SAME expression —
+-- (handle::text). For the planner to USE it, the consuming queries must filter on the SAME expression,
 -- so every handle search above was updated to `(u.handle::text) ILIKE ...` (a bare `handle ILIKE` would
 -- not match the index and, when OR'd with other branches, would force a seq scan for the whole predicate).
 -- ILIKE is case-insensitive on text, so the cast does not change which rows match. display_name is plain
@@ -79,7 +79,7 @@ CREATE INDEX IF NOT EXISTS cleanups_address_trgm
 -- -----------------------------------------------------------------------------
 -- Inbox thread search: `t.org ILIKE '%q%' OR t.subject ILIKE '%q%'`. (The third
 -- search column, latest-message from_addr, is reached through a per-row LATERAL
--- and is intentionally NOT indexed here — it would require denormalizing the
+-- and is intentionally NOT indexed here: it would require denormalizing the
 -- latest from_addr onto mail_threads; left as-is per finding 18.)
 --   mail-repository.drizzle.ts:542.
 CREATE INDEX IF NOT EXISTS mail_threads_org_trgm
diff --git a/services/api/drizzle/0015_jurisdiction_geom_nullable.sql b/services/api/drizzle/0015_jurisdiction_geom_nullable.sql
index 8a0e0021..d9956c14 100644
--- a/services/api/drizzle/0015_jurisdiction_geom_nullable.sql
+++ b/services/api/drizzle/0015_jurisdiction_geom_nullable.sql
@@ -14,7 +14,7 @@
 -- The Census API returns an identity (GEOID + NAME) but NOT the boundary polygon,
 -- so these rows have geom = NULL. The original 0001_core.sql declared geom
 -- NOT NULL (every row was a self-hosted boundary), which would reject the lazy
--- upsert — this migration drops that constraint.
+-- upsert; this migration drops that constraint.
 --
 -- WHY THIS IS SAFE for the resolver + index:
 --   * ST_Contains(NULL, point) is NULL (never true), so a NULL-geom row can NEVER
@@ -25,7 +25,7 @@
 --     index NULL geometries, and the resolver's ranked ORDER BY is untouched.
 --   * No data migration: every EXISTING row already has a non-NULL geom (they were
 --     inserted under the old NOT NULL constraint), so dropping NOT NULL changes no
---     existing data — it only PERMITS future NULL-geom (API-sourced) rows.
+--     existing data; it only PERMITS future NULL-geom (API-sourced) rows.
 --
 -- TRANSACTION NOTE: the runner (src/db/migrate.ts) wraps each file in ONE
 -- transaction, so the single ALTER below commits atomically.
diff --git a/services/api/drizzle/0021_report_type.sql b/services/api/drizzle/0021_report_type.sql
index 18cbbadd..7b7d6201 100644
--- a/services/api/drizzle/0021_report_type.sql
+++ b/services/api/drizzle/0021_report_type.sql
@@ -12,7 +12,7 @@
 -- NOT NULL. Because existing rows predate the column, we add it with a TEMPORARY
 -- default so the ADD COLUMN succeeds on a populated table, BACKFILL each legacy
 -- row to a representative fine type derived from its category (the inverse map
--- below — legacy reports need not preserve an exact fine type), then SET NOT NULL
+-- below; legacy reports need not preserve an exact fine type), then SET NOT NULL
 -- and DROP the temporary default so new inserts MUST supply `type` explicitly.
 --
 -- Representative inverse backfill (category -> type):
diff --git a/services/api/drizzle/0022_chat_reactions.sql b/services/api/drizzle/0022_chat_reactions.sql
index 110956b1..d8105c63 100644
--- a/services/api/drizzle/0022_chat_reactions.sql
+++ b/services/api/drizzle/0022_chat_reactions.sql
@@ -12,7 +12,7 @@
 -- (like|heart|celebrate|support|insightful|concerned), NOT a raw glyph; the
 -- allowed set is enforced in the application layer (REACTION_EMOJIS).
 --
--- IMPORTANT — NO FK TO THE MESSAGE TABLES. chat_messages and dm_messages are
+-- IMPORTANT: NO FK TO THE MESSAGE TABLES. chat_messages and dm_messages are
 -- DECLARATIVELY PARTITIONED BY RANGE(created_at) with COMPOSITE PKs
 -- (id, created_at) (see 0002_chat_partitioning.sql / 0009_dm_and_privacy.sql), so
 -- there is no single-column key to reference: a FK on message_id alone is
diff --git a/services/api/drizzle/0023_message_mentions.sql b/services/api/drizzle/0023_message_mentions.sql
index 2638e663..4c70222a 100644
--- a/services/api/drizzle/0023_message_mentions.sql
+++ b/services/api/drizzle/0023_message_mentions.sql
@@ -16,12 +16,12 @@
 --     CASCADE, parallel to report_message_reactions. Composite PK
 --     (message_id, mentioned_user_id) de-dupes a user named twice in one message.
 --
---   * chat_message_mentions: a user @-mentioned in a CHAT message — the cleanup
+--   * chat_message_mentions: a user @-mentioned in a CHAT message: the cleanup
 --     group chat (chat_messages) OR a 1:1 DM (dm_messages). ONE table serves both
 --     because a message id is a globally-unique uuid across both message tables,
 --     exactly like chat_message_reactions (0022).
 --
--- IMPORTANT — NO FK on chat_message_mentions.message_id. chat_messages and
+-- IMPORTANT: NO FK on chat_message_mentions.message_id. chat_messages and
 -- dm_messages are DECLARATIVELY PARTITIONED BY RANGE(created_at) with COMPOSITE
 -- PKs (id, created_at) (0002_chat_partitioning.sql / 0009_dm_and_privacy.sql), so
 -- there is no single-column key to reference: a FK on message_id alone is
diff --git a/services/api/drizzle/0024_content_reports_widen_moderation.sql b/services/api/drizzle/0024_content_reports_widen_moderation.sql
index cc9c17f6..ae47b625 100644
--- a/services/api/drizzle/0024_content_reports_widen_moderation.sql
+++ b/services/api/drizzle/0024_content_reports_widen_moderation.sql
@@ -12,7 +12,7 @@
 --                                       (the reportable UGC subjects; the
 --                                        existing report/user/chat stay valid).
 --
--- subject_id remains uuid NOT NULL — every reportable id in civfix is a uuid
+-- subject_id remains uuid NOT NULL: every reportable id in civfix is a uuid
 -- (report, discussion comment, chat/dm message, cleanup event, user profile,
 -- media photo), so no column-type change is needed.
 --
diff --git a/services/api/drizzle/0027_notification_prefs_mentions.sql b/services/api/drizzle/0027_notification_prefs_mentions.sql
index 6b2556ca..6fad92eb 100644
--- a/services/api/drizzle/0027_notification_prefs_mentions.sql
+++ b/services/api/drizzle/0027_notification_prefs_mentions.sql
@@ -4,7 +4,7 @@
 -- Add the dedicated `mentions` notification preference to notification_prefs.
 -- This is the per-user toggle for the @-mention bell (raised by the discussion
 -- and chat mention notifiers). NOT NULL DEFAULT true so existing rows opt in
--- automatically (same posture as the other per-channel toggles) — no backfill
+-- automatically (same posture as the other per-channel toggles); no backfill
 -- statement needed, the DEFAULT fills every existing row.
 --
 -- CANONICAL DDL: this hand-authored SQL is the source of truth. The Drizzle
@@ -13,7 +13,7 @@
 --
 -- Ordering rules:
 --   * Requires 0001_core.sql (notification_prefs table) already applied.
---   * Single additive ADD COLUMN IF NOT EXISTS — idempotent on re-apply. The
+--   * Single additive ADD COLUMN IF NOT EXISTS, idempotent on re-apply. The
 --     migrate runner records applied files in _civfix_migrations and wraps each
 --     file in one transaction.
 -- =============================================================================
diff --git a/services/api/drizzle/0028_boundary_vintage.sql b/services/api/drizzle/0028_boundary_vintage.sql
index 31971ab4..86312e1e 100644
--- a/services/api/drizzle/0028_boundary_vintage.sql
+++ b/services/api/drizzle/0028_boundary_vintage.sql
@@ -6,7 +6,7 @@
 -- are loaded on demand by running `pnpm db:boundaries:refresh` on a workstation (with GDAL) over an SSH
 -- tunnel to prod Postgres: it downloads the public-domain sources, converts them with ogr2ogr, ingests
 -- each layer (idempotent upsert), backfills NULL reports, then records WHAT was loaded (vintage + per-layer
--- counts + when) in this row. Purely informational — the load itself is idempotent and doesn't depend on it.
+-- counts + when) in this row. Purely informational: the load itself is idempotent and doesn't depend on it.
 --
 -- SINGLETON. `id` is a boolean PK pinned true (CHECK id), so there is at most one row = the one active
 -- vintage. Upserts use `INSERT ... ON CONFLICT (id) DO UPDATE`.
diff --git a/services/api/drizzle/0030_reference_codes.sql b/services/api/drizzle/0030_reference_codes.sql
index 13b267e2..40ea0a83 100644
--- a/services/api/drizzle/0030_reference_codes.sql
+++ b/services/api/drizzle/0030_reference_codes.sql
@@ -27,20 +27,20 @@
 -- index tolerates any number of NULLs). The DATA backfill that fills these codes
 -- is a SEPARATE keyset-batched script (db:backfill:reference-codes), NOT run
 -- inside this migration's transaction (a long table rewrite would block boot).
--- reference_code stays NULLABLE forever — never add NOT NULL.
+-- reference_code stays NULLABLE forever; never add NOT NULL.
 -- =============================================================================
 
 -- -----------------------------------------------------------------------------
 -- jurisdiction code (D2): ONE Postgres sequence is the single source of truth.
 -- Backfilled rows get a stable ordinal by geoid here; lazily-upserted rows get
 -- nextval(jurisdiction_code_seq) later (wired into jurisdiction-service by a
--- separate agent — this migration only creates the sequence + column + index).
+-- separate agent; this migration only creates the sequence + column + index).
 -- -----------------------------------------------------------------------------
 CREATE SEQUENCE IF NOT EXISTS jurisdiction_code_seq;
 
 ALTER TABLE jurisdictions ADD COLUMN IF NOT EXISTS code integer;
 
--- Assign a stable ordinal by geoid (jurisdictions is ~28k rows — small, OK
+-- Assign a stable ordinal by geoid (jurisdictions is ~28k rows, small, OK
 -- in-migration). Only touches rows still NULL, so a repeat apply is a no-op.
 UPDATE jurisdictions j
 SET code = sub.rn
@@ -109,7 +109,7 @@ ALTER TABLE user_moderation ADD COLUMN IF NOT EXISTS report_verified_at timestam
 ALTER TABLE user_moderation ADD COLUMN IF NOT EXISTS report_verified_by uuid REFERENCES users (id);
 
 -- -----------------------------------------------------------------------------
--- grandfather (D8) — run ONCE here. user_moderation rows are created LAZILY (on
+-- grandfather (D8), run ONCE here. user_moderation rows are created LAZILY (on
 -- the first moderation action for a user), so a plain UPDATE would miss every
 -- already-trusted user who has never been moderated. We therefore UPSERT: insert
 -- a fresh row (relying on user_moderation's column DEFAULTS for account_status /
diff --git a/services/api/drizzle/0031_timeline_event_mail.sql b/services/api/drizzle/0031_timeline_event_mail.sql
index e753b1d4..430fa4fa 100644
--- a/services/api/drizzle/0031_timeline_event_mail.sql
+++ b/services/api/drizzle/0031_timeline_event_mail.sql
@@ -3,7 +3,7 @@
 -- -----------------------------------------------------------------------------
 -- Issue #56: richer report timeline + EVENT (cleanup) mail threading.
 --   * report_timeline.kind / report_timeline.body (D13): stop truncating inbound
---     reply bodies — `note` stays the short preview, `body` holds the full text,
+--     reply bodies: `note` stays the short preview, `body` holds the full text,
 --     `kind` tags the entry type. Both NULLABLE so existing INSERTs stay green.
 --   * mail_threads.cleanup_id (D10/D19): nullable FK so an event resource-request
 --     thread routes a city reply back onto the cleanup (event) it belongs to,
@@ -35,6 +35,6 @@ ALTER TABLE mail_threads ADD COLUMN IF NOT EXISTS cleanup_id uuid
   REFERENCES cleanups (id) ON DELETE SET NULL;
 
 -- Partial index over the linked rows only, mirroring mail_threads_report_idx
--- (0020) — a city reply resolves to its event thread by cleanup_id.
+-- (0020): a city reply resolves to its event thread by cleanup_id.
 CREATE INDEX IF NOT EXISTS mail_threads_cleanup_idx
   ON mail_threads (cleanup_id) WHERE cleanup_id IS NOT NULL;
diff --git a/services/api/drizzle/0034_user_social_links.sql b/services/api/drizzle/0034_user_social_links.sql
index 9d942c8e..57ac18c1 100644
--- a/services/api/drizzle/0034_user_social_links.sql
+++ b/services/api/drizzle/0034_user_social_links.sql
@@ -7,7 +7,7 @@
 -- columns so adding a platform later is a contract + UI change with NO migration.
 --
 -- SECURITY: only the bare username/handle (or, for WhatsApp, the E.164 digits) is
--- ever stored — never a full URL — and each value is validated against a strict
+-- ever stored (never a full URL), and each value is validated against a strict
 -- charset by the shared SocialLinksSchema at the write boundary. Clients build the
 -- canonical https:// link from a fixed template (socialLinkUrl), so there is no
 -- open-redirect / javascript: surface in the stored data.
diff --git a/services/api/drizzle/0037_perf_indexes_audit.sql b/services/api/drizzle/0037_perf_indexes_audit.sql
index 8469d39a..392ef801 100644
--- a/services/api/drizzle/0037_perf_indexes_audit.sql
+++ b/services/api/drizzle/0037_perf_indexes_audit.sql
@@ -13,18 +13,18 @@
 -- opclass form, per the 0014 convention documented in reports.ts/audit.ts).
 -- =============================================================================
 
--- F22: media_assets.r2_key — orphan-sweep probes referenced-by-others by r2_key.
+-- F22: media_assets.r2_key: orphan-sweep probes referenced-by-others by r2_key.
 CREATE INDEX IF NOT EXISTS media_assets_r2_key_idx
   ON media_assets (r2_key);
 
--- F50: media_assets.chat_message_id — the column is NULL for the vast majority of
+-- F50: media_assets.chat_message_id: the column is NULL for the vast majority of
 -- rows (only chat-attached media set it), so make the existing index PARTIAL.
 DROP INDEX IF EXISTS media_assets_chat_message_idx;
 CREATE INDEX IF NOT EXISTS media_assets_chat_message_idx
   ON media_assets (chat_message_id)
   WHERE chat_message_id IS NOT NULL;
 
--- F42: audit_log.action / .target — the operator audit view filters by infix
+-- F42: audit_log.action / .target: the operator audit view filters by infix
 -- ILIKE, which the plain btree audit_log_action_idx cannot serve. Trigram GIN
 -- makes the substring search index-assisted (pg_trgm enabled in 0014).
 CREATE INDEX IF NOT EXISTS audit_log_action_trgm_idx
@@ -33,7 +33,7 @@ CREATE INDEX IF NOT EXISTS audit_log_target_trgm_idx
   ON audit_log USING gin (target gin_trgm_ops)
   WHERE target IS NOT NULL;
 
--- F63: reports.addr — public report search ILIKEs the street address.
+-- F63: reports.addr: public report search ILIKEs the street address.
 CREATE INDEX IF NOT EXISTS reports_addr_trgm_idx
   ON reports USING gin (addr gin_trgm_ops)
   WHERE addr IS NOT NULL;
@@ -48,17 +48,17 @@ CREATE INDEX IF NOT EXISTS users_avatar_media_idx
   ON users (avatar_media_id)
   WHERE avatar_media_id IS NOT NULL;
 
--- F49: chat_message_reactions.user_id — ON DELETE CASCADE FK, no covering index.
+-- F49: chat_message_reactions.user_id: ON DELETE CASCADE FK, no covering index.
 CREATE INDEX IF NOT EXISTS chat_message_reactions_user_idx
   ON chat_message_reactions (user_id);
 
--- F55: dm_messages.sender_id — GDPR export / "my DMs" full-scanned every monthly
+-- F55: dm_messages.sender_id: GDPR export / "my DMs" full-scanned every monthly
 -- partition. Keyset-shaped (sender, created_at DESC, id DESC). Propagates to
 -- partitions (declaratively partitioned parent).
 CREATE INDEX IF NOT EXISTS dm_messages_sender_created_idx
   ON dm_messages (sender_id, created_at DESC, id DESC);
 
--- F56: report_discussion_messages.author_user_id — GDPR export seq-scanned the
+-- F56: report_discussion_messages.author_user_id: GDPR export seq-scanned the
 -- comments table (author is NULL for system-authored entries -> partial).
 CREATE INDEX IF NOT EXISTS report_discussion_messages_author_created_idx
   ON report_discussion_messages (author_user_id, created_at DESC)
diff --git a/services/api/drizzle/0046_chat_pins.sql b/services/api/drizzle/0046_chat_pins.sql
index 6e2001f5..425b84da 100644
--- a/services/api/drizzle/0046_chat_pins.sql
+++ b/services/api/drizzle/0046_chat_pins.sql
@@ -2,18 +2,18 @@
 -- 0046_chat_pins.sql
 -- -----------------------------------------------------------------------------
 -- Message pinning (P3): any message in any room may be pinned. A room can hold
--- MULTIPLE pins at once, so pin state lives ON the message row — pinned_at is
+-- MULTIPLE pins at once, so pin state lives ON the message row: pinned_at is
 -- both the flag (NULL = not pinned) and the sort key for the room's pin list;
 -- pinned_by records who pinned it (audit + "Pinned by X" copy).
 --
 -- NO foreign key on pinned_by: chat_messages is RANGE-partitioned (composite
--- PK(id, created_at)) and we keep the two mirrors' column shapes identical —
+-- PK(id, created_at)) and we keep the two mirrors' column shapes identical,
 -- same stance as reply_to_id in 0045. App-level integrity holds (the pin route
 -- only ever writes the authed user's id).
 --
 -- PARTIAL indexes keep the per-room "list pins" query cheap: pinned rows are a
 -- tiny fraction of a room's messages, so a full (room, pinned_at) index would
--- be almost entirely dead weight — the WHERE pinned_at IS NOT NULL predicate
+-- be almost entirely dead weight; the WHERE pinned_at IS NOT NULL predicate
 -- indexes only the pins. On the partitioned chat_messages parent, CREATE INDEX
 -- cascades to every partition (and attaches to future ones); partial NON-unique
 -- indexes are legal on partitioned tables (only unique indexes must embed the
diff --git a/services/api/drizzle/0047_chat_groups.sql b/services/api/drizzle/0047_chat_groups.sql
index acce1158..34b6f49f 100644
--- a/services/api/drizzle/0047_chat_groups.sql
+++ b/services/api/drizzle/0047_chat_groups.sql
@@ -2,13 +2,13 @@
 -- 0047_chat_groups.sql
 -- -----------------------------------------------------------------------------
 -- P4 (Groups): user-created standalone chat rooms. chat_groups is the room row;
--- a CHANNEL is just a group with kind='channel' — same table, same messages,
+-- a CHANNEL is just a group with kind='channel': same table, same messages,
 -- same membership. Nothing here enforces the channel posting rules (only
 -- owner/admins post in a channel); that gate is app-level and lands in P5.
 --
--- chat_group_members mirrors report_chat_members (0041) — composite
+-- chat_group_members mirrors report_chat_members (0041): composite
 -- PK(group_id, user_id), joined_at, and the same last_read_at read watermark
--- (NULL = never read; unread baseline falls back to joined_at) — with a
+-- (NULL = never read; unread baseline falls back to joined_at), with a
 -- three-tier role ladder (owner|admin|member) instead of 0041's two.
 --
 -- chat_messages grows a THIRD scope: group_id. The 0036 two-way XOR
@@ -17,7 +17,7 @@
 -- group_id, so the swapped constraint validates cleanly across all partitions.
 --
 -- group_id gets a REAL foreign key: FKs FROM the partitioned chat_messages
--- parent TO a normal table are supported (PG 11+; the box is PG 16) — the same
+-- parent TO a normal table are supported (PG 11+; the box is PG 16), the same
 -- stance as cleanup_id (0002, inline) and report_id (0036, guarded ADD). Only
 -- FKs pointing AT chat_messages are impossible (why reply_to_id / pinned_by are
 -- FK-less); chat_groups is NOT partitioned, so referencing it is fine. No
@@ -74,7 +74,7 @@ END $$;
 
 -- Constraint swap: the 0036 XOR becomes exactly-one-of-three. DROP + ADD on the
 -- partitioned parent both recurse to every partition; the ADD re-validates
--- existing rows (all satisfy it — see header). num_nonnulls counts the set
+-- existing rows (all satisfy it; see header). num_nonnulls counts the set
 -- scopes directly, so this reads as "exactly one scope" instead of chained XORs.
 ALTER TABLE chat_messages DROP CONSTRAINT IF EXISTS chat_messages_scope_chk;
 ALTER TABLE chat_messages ADD CONSTRAINT chat_messages_scope_chk
@@ -86,7 +86,7 @@ ALTER TABLE chat_messages ADD CONSTRAINT chat_messages_scope_chk
 CREATE INDEX IF NOT EXISTS chat_messages_group_created_idx
   ON chat_messages (group_id, created_at DESC);
 
--- Partial pin-list index for group rooms — the third twin of the 0046 pair
+-- Partial pin-list index for group rooms: the third twin of the 0046 pair
 -- (only pinned rows are indexed; see 0046 for the rationale).
 CREATE INDEX IF NOT EXISTS chat_messages_group_pinned_idx
   ON chat_messages (group_id, pinned_at DESC)
diff --git a/services/api/drizzle/0048_chat_polls.sql b/services/api/drizzle/0048_chat_polls.sql
index c036eb88..2e3d6792 100644
--- a/services/api/drizzle/0048_chat_polls.sql
+++ b/services/api/drizzle/0048_chat_polls.sql
@@ -2,7 +2,7 @@
 -- 0048_chat_polls.sql
 -- -----------------------------------------------------------------------------
 -- P6 (Polls): a poll is a chat message with kind='poll'. The poll body lives in
--- this trio, keyed on the message's id — chat_polls is 1:1 with the poll
+-- this trio, keyed on the message's id: chat_polls is 1:1 with the poll
 -- message, chat_poll_options is the ordered choice list, chat_poll_votes records
 -- one row per (poll, option, voter).
 --
@@ -10,15 +10,15 @@
 -- stance as chat_message_reactions (0)/chat pins: chat_messages is PARTITIONED,
 -- and an FK pointing AT the partitioned parent is impossible in postgres. Message
 -- ids are globally unique (gen_random_uuid across every partition), so keying on
--- the bare id is safe — the app writes the poll row in the same txn as the
+-- the bare id is safe: the app writes the poll row in the same txn as the
 -- message and never orphans it.
 --
--- created_by gets a REAL FK to users with NO ON DELETE action — the same stance
+-- created_by gets a REAL FK to users with NO ON DELETE action, the same stance
 -- as chat_groups.owner_id (0047): accounts are SOFT-deleted everywhere in the
 -- product (users.routes deleteAccount -> softDeleteAndAnonymize; admin bans keep
 -- the row), so this FK can never block a deletion path; a tombstoned author just
 -- renders as "Deleted User". Vote rows, by contrast, DO cascade on user delete
--- (ON DELETE CASCADE) — a departed voter's ballots vanish rather than lingering.
+-- (ON DELETE CASCADE): a departed voter's ballots vanish rather than lingering.
 --
 -- chat_poll_options carries a composite PK (poll_id, idx) so a poll's choices are
 -- addressed by a small stable ordinal. chat_poll_votes references that composite
diff --git a/services/api/drizzle/0049_cleanup_cohosts.sql b/services/api/drizzle/0049_cleanup_cohosts.sql
index 74dd672b..62d7837b 100644
--- a/services/api/drizzle/0049_cleanup_cohosts.sql
+++ b/services/api/drizzle/0049_cleanup_cohosts.sql
@@ -5,7 +5,7 @@
 -- between 'organizer' (the immutable creator) and 'member' (a plain attendee).
 --
 -- VERIFIED before writing this migration: cleanup_members.role (0001_core.sql)
--- is a bare `text NOT NULL` with NO CHECK constraint — the codebase convention
+-- is a bare `text NOT NULL` with NO CHECK constraint; the codebase convention
 -- (see the 0033 note: "kept flexible like `role`") is that role-ish columns are
 -- enforced by the shared Zod enum + the CLEANUP_MEMBER_ROLE_VALUES mirror in
 -- src/db/schema/types.ts (drift-guarded by test/unit/enums.test.ts), not by DDL.
@@ -13,7 +13,7 @@
 -- migration records the widened value set on the column itself so the DDL story
 -- stays self-describing.
 --
--- Volunteer hours (WS5): also VERIFIED that no DDL change is needed — the 0035
+-- Volunteer hours (WS5): also VERIFIED that no DDL change is needed; the 0035
 -- partial unique index volunteer_hours_event_uidx ON (cleanup_id, user_id)
 -- WHERE source = 'event' already supports the per-attendee upsert
 -- (ON CONFLICT (cleanup_id, user_id) WHERE source = 'event' DO UPDATE), which
diff --git a/services/api/drizzle/0050_jurisdiction_forward_template.sql b/services/api/drizzle/0050_jurisdiction_forward_template.sql
index 002f62bb..e2ccf2d2 100644
--- a/services/api/drizzle/0050_jurisdiction_forward_template.sql
+++ b/services/api/drizzle/0050_jurisdiction_forward_template.sql
@@ -5,10 +5,10 @@
 --
 -- Adds two nullable text columns to `jurisdictions` that let an operator override
 -- the built-in default packet civfix forwards to a routing contact:
---   forward_subject_template — the Subject line template (max 300, app-side via
+--   forward_subject_template: the Subject line template (max 300, app-side via
 --     the shared PatchJurisdictionRequest bound); interpolated with the report's
 --     {referenceCode}/{category}/{place}/… tokens (FORWARD_TEMPLATE_VARIABLES).
---   forward_body_template — the body template (max 8000, app-side bound); split
+--   forward_body_template: the body template (max 8000, app-side bound); split
 --     on blank lines into paragraph blocks and rendered through the civfix card.
 --
 -- Both are NULLABLE: NULL = use the built-in refined default (see mail-format.ts
diff --git a/services/api/drizzle/0051_social_posts.sql b/services/api/drizzle/0051_social_posts.sql
index 7c33f089..80ab3a19 100644
--- a/services/api/drizzle/0051_social_posts.sql
+++ b/services/api/drizzle/0051_social_posts.sql
@@ -3,7 +3,7 @@
 -- -----------------------------------------------------------------------------
 -- First-class social POSTS + interaction tables (like / save / mention) for the
 -- X-style civic feed. Repost / quote / reply are POSTS rows (kind + repost_of/
--- reply_to), NOT separate tables — keeps the timeline a single scan. Post media
+-- reply_to), NOT separate tables, which keeps the timeline a single scan. Post media
 -- reuses media_assets via a new post_id column + purpose='post'. Attachable event
 -- (cleanups) / report (reports) cards are FK columns hydrated into LinkedEventRef
 -- / LinkedReportRef.
@@ -15,11 +15,11 @@
 --
 -- Unlike chat_message_reactions/mentions (which cannot FK the RANGE-partitioned
 -- chat/dm message tables), post_likes/saves/mentions DO FK posts (a plain uuid PK)
--- with ON DELETE CASCADE — cleaner than the chat precedent. Counts are
+-- with ON DELETE CASCADE, cleaner than the chat precedent. Counts are
 -- denormalized on posts and bumped in the SAME txn as the interaction insert/
 -- delete (precedent: cleanups.bags, report chat counts).
 --
--- Also adds notification_prefs.post_interactions (DEFAULT true) — the per-user
+-- Also adds notification_prefs.post_interactions (DEFAULT true): the per-user
 -- toggle gating the post_like/post_repost/post_reply/post_quote bells (post_mention
 -- rides the existing `mentions` toggle). Additive with a TRUE default so existing
 -- users keep receiving interaction notifications (plan §8.9).
@@ -118,7 +118,7 @@ CREATE INDEX IF NOT EXISTS media_assets_post_idx
   ON media_assets (post_id) WHERE post_id IS NOT NULL;
 
 -- -----------------------------------------------------------------------------
--- notification_prefs.post_interactions — per-user toggle for the like/repost/
+-- notification_prefs.post_interactions: per-user toggle for the like/repost/
 -- reply/quote bells. DEFAULT true so existing rows keep receiving them (§8.9).
 -- -----------------------------------------------------------------------------
 ALTER TABLE notification_prefs
diff --git a/services/api/drizzle/0052_cleanup_bans.sql b/services/api/drizzle/0052_cleanup_bans.sql
index 4505dac4..067e3148 100644
--- a/services/api/drizzle/0052_cleanup_bans.sql
+++ b/services/api/drizzle/0052_cleanup_bans.sql
@@ -4,8 +4,8 @@
 -- SECURITY (audit 2026-07-24, M17): removing an event attendee was unenforceable.
 --
 -- removeMember only DELETEd the cleanup_members row and joinCleanupTx was an
--- unconditional self-service INSERT, so the removed user simply re-joined — in a
--- loop, instantly, with no cooldown — and walked straight back into the event
+-- unconditional self-service INSERT, so the removed user simply re-joined (in a
+-- loop, instantly, with no cooldown) and walked straight back into the event
 -- group chat (cleanup_members is what gates chat access). There was no ban record
 -- of any kind anywhere in the schema.
 --
@@ -24,7 +24,7 @@
 -- Conventions (match the rest of the suite): timestamptz, additive
 -- IF NOT EXISTS so a partial or repeat apply is safe; the migrate runner
 -- (src/db/migrate.ts) records applied files and wraps each file in one
--- transaction. Forward-only — there is no down migration in this suite.
+-- transaction. Forward-only: there is no down migration in this suite.
 --
 -- Ordering rules: requires 0001_core.sql (users, cleanups, cleanup_members).
 -- =============================================================================
@@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS cleanup_bans (
   PRIMARY KEY (cleanup_id, user_id)
 );
 
--- "Which events is this user banned from" — the reverse lookup, for a future
+-- "Which events is this user banned from": the reverse lookup, for a future
 -- moderation view. The join-path probe rides the composite PK.
 CREATE INDEX IF NOT EXISTS cleanup_bans_user_idx ON cleanup_bans (user_id);
 
diff --git a/services/api/drizzle/0053_volunteer_hours_audit.sql b/services/api/drizzle/0053_volunteer_hours_audit.sql
index f1845969..93820204 100644
--- a/services/api/drizzle/0053_volunteer_hours_audit.sql
+++ b/services/api/drizzle/0053_volunteer_hours_audit.sql
@@ -7,7 +7,7 @@
 -- logEventHours upserts on the (cleanup_id, user_id) WHERE source='event' partial
 -- unique index with `DO UPDATE SET hours = EXCLUDED.hours`, so the previous value
 -- was destroyed on every re-log. The only trace of who did it was
--- volunteer_hours.logged_by_user_id, which is likewise overwritten — meaning a
+-- volunteer_hours.logged_by_user_id, which is likewise overwritten, meaning a
 -- host could inflate a credit, and later quietly restore it, leaving the row
 -- indistinguishable from one that had never been touched. Hours feed the public
 -- jurisdiction leaderboard, so this is a falsifiable public record.
@@ -27,7 +27,7 @@
 -- Conventions (match the rest of the suite): timestamptz, additive
 -- IF NOT EXISTS so a partial or repeat apply is safe; the migrate runner
 -- (src/db/migrate.ts) records applied files and wraps each file in one
--- transaction. Forward-only — there is no down migration in this suite.
+-- transaction. Forward-only: there is no down migration in this suite.
 --
 -- Ordering rules: requires 0001_core.sql (users, cleanups) and
 -- 0035_volunteer_hours.sql (volunteer_hours + its partial unique indexes).
@@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS volunteer_hours_audit (
   -- worthless.
   actor_user_id     uuid          NOT NULL REFERENCES users (id),
   -- The credit BEFORE this upsert; NULL means there was no prior credit (first
-  -- time this attendee was logged for this event) — distinct from a stored 0.
+  -- time this attendee was logged for this event), distinct from a stored 0.
   previous_hours    numeric(6, 2),
   -- The credit AFTER this upsert.
   new_hours         numeric(6, 2) NOT NULL,
diff --git a/services/api/drizzle/0054_media_purpose_post.sql b/services/api/drizzle/0054_media_purpose_post.sql
index b57bf5ca..2f9224f1 100644
--- a/services/api/drizzle/0054_media_purpose_post.sql
+++ b/services/api/drizzle/0054_media_purpose_post.sql
@@ -20,7 +20,7 @@
 -- safe (the migrate runner wraps each file in one transaction).
 --
 -- Ordering rules: requires 0016_user_verification.sql (the purpose column).
--- Forward-only — there is no down migration in this suite.
+-- Forward-only: there is no down migration in this suite.
 -- =============================================================================
 
 ALTER TABLE media_assets
diff --git a/services/api/drizzle/0058_sessions_created_at.sql b/services/api/drizzle/0058_sessions_created_at.sql
index 5b9a2f7a..37a50993 100644
--- a/services/api/drizzle/0058_sessions_created_at.sql
+++ b/services/api/drizzle/0058_sessions_created_at.sql
@@ -5,11 +5,11 @@
 -- on every session row.
 --
 -- 0001_core.sql declares sessions.created_at as `timestamptz DEFAULT now()` with
--- no NOT NULL, so rows predating the column read back NULL — and the absolute
+-- no NOT NULL, so rows predating the column read back NULL, and the absolute
 -- 90-day session ceiling M3 introduced is measured FROM created_at.
 -- PgSessionStore.findById therefore reads a NULL as the epoch: already past the
 -- ceiling, i.e. FAIL CLOSED. The obvious alternative (fall back to last_seen_at)
--- made the ceiling unreachable — last_seen_at is bumped by every sliding-expiry
+-- made the ceiling unreachable: last_seen_at is bumped by every sliding-expiry
 -- write, so an active legacy session was capped from its own last activity and
 -- never expired, which is exactly the unbounded-sliding hole M3 closes.
 --
@@ -17,7 +17,7 @@
 -- the ambiguity for good: backfill the NULLs, then forbid new ones.
 --
 -- The backfill value is last_seen_at, the only surviving evidence of a row's age.
--- It starts at insert time and only moves forward, so it is a LATE — never early —
+-- It starts at insert time and only moves forward, so it is a LATE (never early)
 -- estimate of creation: the ceiling it yields is generous to the holder by at most
 -- one sliding window, and the sliding expires_at still bounds the row
 -- independently. now() is a third COALESCE arm purely as belt-and-braces;
@@ -26,7 +26,7 @@
 -- ORDER MATTERS: the UPDATE must precede the ALTER. SET NOT NULL first aborts
 -- (23502) on the first legacy row and rolls the whole file back.
 --
--- The WHERE clause keeps the backfill off already-populated rows — sessions is a
+-- The WHERE clause keeps the backfill off already-populated rows; sessions is a
 -- hot, write-heavy table and a blanket UPDATE would rewrite every row to the value
 -- it already holds. created_at stays the first COALESCE arm anyway so the
 -- statement is correct with or without that WHERE.
@@ -38,17 +38,17 @@
 -- CANONICAL DDL: this hand-authored SQL is the source of truth. The Drizzle mirror
 -- in src/db/schema/sessions.ts is updated in the same change (createdAt gains
 -- .notNull()). The fail-closed `?? new Date(0)` in src/auth/pg-stores.ts is KEPT
--- as belt-and-braces (the deploy does not auto-migrate) — see its comment.
+-- as belt-and-braces (the deploy does not auto-migrate); see its comment.
 --
--- Conventions (match the rest of the suite): timestamptz; idempotent — the UPDATE
+-- Conventions (match the rest of the suite): timestamptz; idempotent: the UPDATE
 -- matches nothing on a second run and SET NOT NULL on an already-NOT NULL column
 -- is a no-op. The migrate runner (src/db/migrate.ts) records applied files and
 -- wraps each file in one transaction, so the backfill and the constraint commit
--- together or not at all. Forward-only — there is no down migration in this suite.
+-- together or not at all. Forward-only: there is no down migration in this suite.
 --
 -- Lock note: SET NOT NULL takes ACCESS EXCLUSIVE on sessions and, there being no
 -- pre-validated CHECK for the planner to reuse, scans the table to prove no NULLs
--- remain — logins and sliding-expiry writes block for the length of that scan.
+-- remain; logins and sliding-expiry writes block for the length of that scan.
 -- sessions is small and self-pruning by construction (the 90-day ceiling plus the
 -- expiry sweep); if it ever isn't, add a `NOT VALID` CHECK and VALIDATE it
 -- out-of-band first, which makes this statement cheap.
diff --git a/services/api/drizzle/0061_users_show_volunteer_hours.sql b/services/api/drizzle/0061_users_show_volunteer_hours.sql
index 474a052a..39a4ed39 100644
--- a/services/api/drizzle/0061_users_show_volunteer_hours.sql
+++ b/services/api/drizzle/0061_users_show_volunteer_hours.sql
@@ -6,7 +6,7 @@
 -- unconditionally at social.routes.ts:87-99). There has never been an opt-out.
 -- This column is that opt-out.
 --
--- *** THE COLUMN IS A NULLABLE TRI-STATE — NOT `NOT NULL DEFAULT true`. ***
+-- *** THE COLUMN IS A NULLABLE TRI-STATE, NOT `NOT NULL DEFAULT true`. ***
 --
 --   NULL  = never chosen  -> aggregate volunteerHours + byJurisdiction + the
 --                            leaderboard stay VISIBLE (byte-identical to today);
@@ -17,14 +17,14 @@
 -- WHY NOT `NOT NULL DEFAULT true` (do not "fix" this back): a default of true is
 -- right about the AGGREGATE and wrong about what this feature set ADDS.
 -- GET /people/:id/volunteer-hours returns ITEMISED rows carrying eventTitle,
--- jurisdictionName, occurredAt and creditedBy — a public, paginated record of
+-- jurisdictionName, occurredAt and creditedBy: a public, paginated record of
 -- where a named person physically was, on which dates. That is a NEW category of
 -- disclosure, not a preserved one, and DEFAULT true would switch it on
 -- retroactively for every existing account with no notice and no opt-in.
 --
 -- BINDING PREDICATES (three-valued logic is the whole point here):
 --   * aggregate / leaderboard / profile total: `show_volunteer_hours IS NOT FALSE`
---     — NEVER a bare truth test. `AND show_volunteer_hours` is NULL for every
+--     NEVER a bare truth test. `AND show_volunteer_hours` is NULL for every
 --     account that exists today, which would silently empty the leaderboard.
 --   * itemised items[] on someone ELSE's profile: `show_volunteer_hours IS TRUE`.
 --   * own profile always shows everything, regardless of this column.
@@ -32,13 +32,13 @@
 -- same tri-state: ABSENT = never chosen. Emit it only when the column is non-null.
 --
 -- WHY A COLUMN ON users AND NOT A user_privacy SIDE TABLE: allow_direct_messages
--- is the precedent — the same class of per-user visibility flag, read on the same
+-- is the precedent: the same class of per-user visibility flag, read on the same
 -- hot profile/leaderboard projections. A side table would add a LEFT JOIN to every
 -- one of them for a single bit.
 --
 -- LOCK PROFILE: ADD COLUMN with NO default is metadata-only on every supported
 -- PostgreSQL (no table rewrite, a brief ACCESS EXCLUSIVE only). There is
--- deliberately NO backfill UPDATE here — 0059_users_follow_counters.sql's banner
+-- deliberately NO backfill UPDATE here; 0059_users_follow_counters.sql's banner
 -- documents that ADD COLUMN + a backfill UPDATE in the same file holds ACCESS
 -- EXCLUSIVE for the whole backfill and blocks READS on users, which on this table
 -- would take the whole API down. NULL is a meaningful value here, so there is
@@ -49,7 +49,7 @@
 --
 -- Conventions (match the rest of the suite): additive IF NOT EXISTS so a partial
 -- or repeat apply is safe; the migrate runner (src/db/migrate.ts) records applied
--- files and wraps each file in ONE transaction. Forward-only — there is no down
+-- files and wraps each file in ONE transaction. Forward-only: there is no down
 -- migration in this suite.
 --
 -- Ordering rules: requires 0001_core.sql (users).
diff --git a/services/api/drizzle/0062_volunteer_hours_user_created_idx.sql b/services/api/drizzle/0062_volunteer_hours_user_created_idx.sql
index 231b46b8..9d695c10 100644
--- a/services/api/drizzle/0062_volunteer_hours_user_created_idx.sql
+++ b/services/api/drizzle/0062_volunteer_hours_user_created_idx.sql
@@ -2,7 +2,7 @@
 -- 0062_volunteer_hours_user_created_idx.sql
 -- -----------------------------------------------------------------------------
 -- P4 (per-entry service-hours ledger read): until now nothing ever READ
--- volunteer_hours row-by-row — the profile total and the leaderboard both go
+-- volunteer_hours row-by-row; the profile total and the leaderboard both go
 -- through the user_jurisdiction_hours rollup, and the ledger was write-only. The
 -- new transcript endpoints (GET /me/volunteer-hours/entries and
 -- GET /people/:id/volunteer-hours) page the ledger newest-first with the house
@@ -18,7 +18,7 @@
 -- a SHARE lock and blocks WRITES to volunteer_hours for its duration. That table is
 -- small today (one row per credited attendee per event plus 0.1h per report). If it
 -- has grown by the time this ships, build the index out of band with
--- CREATE INDEX CONCURRENTLY first — the IF NOT EXISTS below then no-ops. The
+-- CREATE INDEX CONCURRENTLY first; the IF NOT EXISTS below then no-ops. The
 -- migrate runner (src/db/migrate.ts) documents this exact escape hatch.
 --
 -- CANONICAL DDL: this hand-authored SQL is the source of truth. The Drizzle mirror
@@ -26,7 +26,7 @@
 --
 -- Conventions (match the rest of the suite): additive IF NOT EXISTS so a partial
 -- or repeat apply is safe; the migrate runner wraps each file in ONE transaction
--- (so no CREATE INDEX CONCURRENTLY here). Forward-only — no down migration.
+-- (so no CREATE INDEX CONCURRENTLY here). Forward-only, no down migration.
 --
 -- Ordering rules: requires 0035_volunteer_hours.sql.
 -- =============================================================================
diff --git a/services/api/drizzle/0063_cleanup_slots.sql b/services/api/drizzle/0063_cleanup_slots.sql
index efc53d90..a8874b5d 100644
--- a/services/api/drizzle/0063_cleanup_slots.sql
+++ b/services/api/drizzle/0063_cleanup_slots.sql
@@ -12,7 +12,7 @@
 -- different role. sort_order is presentational ONLY; identity is the uuid.
 --
 -- WHY cleanup_slot_claims IS KEYED (cleanup_id, user_id): that composite PK IS
--- the v1 product rule "one slot per person per event" — enforced by the schema,
+-- the v1 product rule "one slot per person per event", enforced by the schema,
 -- not by application code, so a concurrent double-claim is a constraint conflict
 -- rather than a race. Re-claiming is an ON CONFLICT DO UPDATE (a MOVE), never a
 -- duplicate row. Same PK shape as cleanup_members, deliberately.
@@ -58,7 +58,7 @@
 --
 -- Conventions: timestamptz, additive IF NOT EXISTS so a partial or repeat apply
 -- is safe; src/db/migrate.ts wraps each file in ONE transaction (so no
--- CREATE INDEX CONCURRENTLY here). Forward-only — no down migration.
+-- CREATE INDEX CONCURRENTLY here). Forward-only, no down migration.
 --
 -- Ordering rules: requires 0001_core.sql (users, cleanups, cleanup_members).
 -- =============================================================================
diff --git a/services/api/drizzle/0064_service_hours_certificates.sql b/services/api/drizzle/0064_service_hours_certificates.sql
index 6104f240..98fa7416 100644
--- a/services/api/drizzle/0064_service_hours_certificates.sql
+++ b/services/api/drizzle/0064_service_hours_certificates.sql
@@ -22,13 +22,13 @@
 -- render nothing the second time. Revoking frees the slot so the holder can
 -- re-issue over the same ledger.
 --
--- v1 ISSUES OVER THE WHOLE LEDGER — there are deliberately no issue-time filter
+-- v1 ISSUES OVER THE WHOLE LEDGER: there are deliberately no issue-time filter
 -- columns (no jurisdiction_geoid FK, no caller-supplied from/to). period_start
 -- and period_end are the DERIVED min/max of the included rows. Filters multiply
 -- documents and complicate the fingerprint for a v1 nobody asked for. There is
 -- also no `recipient` ("Issued for: Lincoln High School"): it would have to enter
 -- the fingerprint, turning every recipient into a separate document, and it adds
--- a slur-gate for no verification value — the code is what a registrar checks.
+-- a slur-gate for no verification value; the code is what a registrar checks.
 -- Both are recorded follow-ups, not omissions.
 --
 -- revoked_at, not a DELETE: a revoked certificate must keep answering "this code
@@ -36,7 +36,7 @@
 -- materially different answer for the person holding the paper. The R2 object IS
 -- deleted on revoke, so the download link dies with it.
 --
--- Erasure: NO `ON DELETE CASCADE` to users — account deletion is a soft tombstone
+-- Erasure: NO `ON DELETE CASCADE` to users; account deletion is a soft tombstone
 -- (docs/erasure-behavior.md, and see 0063's banner) and the verify projection
 -- filters on users.deleted_at instead.
 --
@@ -58,7 +58,7 @@
 -- documented in src/db/migrate.ts does not apply here.
 --
 -- Conventions (match the rest of the suite): timestamptz, additive
--- IF NOT EXISTS so a partial or repeat apply is safe. Forward-only — no down
+-- IF NOT EXISTS so a partial or repeat apply is safe. Forward-only, no down
 -- migration.
 --
 -- Ordering rules: requires 0001_core.sql (users) and 0035_volunteer_hours.sql
diff --git a/services/api/drizzle/0065_void_report_volunteer_hours.sql b/services/api/drizzle/0065_void_report_volunteer_hours.sql
index a4b659ea..1784068b 100644
--- a/services/api/drizzle/0065_void_report_volunteer_hours.sql
+++ b/services/api/drizzle/0065_void_report_volunteer_hours.sql
@@ -6,7 +6,7 @@
 -- the denormalized rollup from the surviving ledger.
 --
 -- SUPERSEDES 0035_volunteer_hours.sql's banner. That file describes
--- volunteer_hours as holding "a report auto-award (0.1h, once per report)" —
+-- volunteer_hours as holding "a report auto-award (0.1h, once per report)",
 -- that sentence is HISTORY as of this migration and MUST NOT be reinstated. The
 -- only writer of credited hours now is `logEventHours` (an event's organizer or
 -- cohost crediting an attendee for time actually served). `awardReportHours` was
@@ -15,18 +15,18 @@
 --
 -- WHY VOID RETROACTIVELY rather than only stopping the write: the rollup feeds a
 -- PUBLIC per-jurisdiction leaderboard, the "Total volunteer hours" number on every
--- profile, and — worst — signed, publicly verifiable PDF service transcripts that
+-- profile, and (worst) signed, publicly verifiable PDF service transcripts that
 -- get handed to schools, employers and courts. Leaving 0.1h-per-filing in place
 -- means every one of those overstates service, permanently and invisibly. The void
 -- is reversible in principle (clear `voided_at`); a wrongly-issued transcript is
 -- not. `hours numeric(6,2) CHECK (hours > 0)` (0035:29) forbids zeroing the rows,
--- so `voided_at` — dormant since 0035 and already filtered by EVERY read path — is
+-- so `voided_at` (dormant since 0035 and already filtered by EVERY read path) is
 -- the in-schema remedy, and the explanatory `note` travels with each row.
 --
 -- STATEMENT 2 IS A RECOMPUTE, NOT A SUBTRACTION. Subtracting the report totals is
 -- correct exactly once and silently wrong if replayed; a recompute is idempotent,
 -- and it also repairs any pre-existing rollup drift. It IS authoritative: any
--- total_hours with no backing non-voided ledger row is erased. Verified safe —
+-- total_hours with no backing non-voided ledger row is erased. Verified safe:
 -- `awardReportHours` (removed here) and `logEventHours` were the ONLY writers of
 -- user_jurisdiction_hours anywhere in the codebase, there is no `source='manual'`
 -- writer at all, and no script or worker touches either table, so no hand-made
@@ -55,9 +55,9 @@
 --       WHERE r->>'source' = 'report'
 --     );
 --
--- (valid because TranscriptModelRow carries `source` — certificate-model.ts). The
+-- (valid because TranscriptModelRow carries `source`; see certificate-model.ts). The
 -- window is tiny: 0064 shipped 2026-07-28. Remedy per row is operational, NOT part
--- of this migration — run, per code:
+-- of this migration; run, per code:
 --
 --   pnpm --filter @civfix/api db:certificate:revoke  --dry-run
 --   pnpm --filter @civfix/api db:certificate:revoke 
@@ -81,14 +81,14 @@
 -- own begin/commit on a reserved connection together with the _civfix_migrations
 -- bookkeeping INSERT; a file that opens its own transaction ends the runner's one
 -- mid-flight. test/unit/migrations-transaction-control.test.ts keeps it that way.
--- `LOCK TABLE` (statement 0) is NOT transaction control — it is a plain statement
+-- `LOCK TABLE` (statement 0) is NOT transaction control; it is a plain statement
 -- that joins the runner's transaction and is released by the runner's COMMIT.
 --
 -- WHY STATEMENT 0 LOCKS. The runner's transaction is READ COMMITTED (migrate.ts
 -- sets no isolation level) and the API may still be serving. Under READ COMMITTED
 -- an UPDATE that collides with a concurrently-committed row re-evaluates against
 -- the NEW version of that row but keeps its ORIGINAL snapshot for every OTHER
--- table — so statement 2's correlated SUM over `volunteer_hours` cannot see a
+-- table, so statement 2's correlated SUM over `volunteer_hours` cannot see a
 -- ledger row committed after the statement began. A verified host calling
 -- `logEventHours` in that window inserts its ledger row and adds its delta to
 -- `user_jurisdiction_hours`; statement 2 then overwrites that rollup with a total
@@ -125,7 +125,7 @@
 -- =============================================================================
 
 -- 0. Serialize against live `logEventHours` writers for the rest of this
---    transaction. See "WHY STATEMENT 0 LOCKS" above — without it statement 2 is a
+--    transaction. See "WHY STATEMENT 0 LOCKS" above: without it statement 2 is a
 --    READ COMMITTED lost update.
 LOCK TABLE volunteer_hours, user_jurisdiction_hours IN SHARE ROW EXCLUSIVE MODE;
 
diff --git a/services/api/drizzle/0068_resolve_unscored_nsfw_flags.sql b/services/api/drizzle/0068_resolve_unscored_nsfw_flags.sql
index 7aefc799..060e8171 100644
--- a/services/api/drizzle/0068_resolve_unscored_nsfw_flags.sql
+++ b/services/api/drizzle/0068_resolve_unscored_nsfw_flags.sql
@@ -1,7 +1,7 @@
 -- =============================================================================
 -- 0068_resolve_unscored_nsfw_flags.sql
 -- -----------------------------------------------------------------------------
--- FINDING F076 (critical) — recovery migration. The media pipeline's "unscored"
+-- FINDING F076 (critical): recovery migration. The media pipeline's "unscored"
 -- branch was pushing an abuse_flags row (source='worker', reason='nsfw') for
 -- media it could not actually score, and the anon hold-release gate counts OPEN
 -- worker flags: every already-held anon report therefore stays held forever with
@@ -10,7 +10,7 @@
 -- clears the flags already stranded in prod so the drain sweep can release them.
 --
 -- WHY THIS IS SAFE: no NSFW scorer is vendored in this deployment, so a
--- worker-raised 'nsfw' flag is never a real content verdict — it is only ever the
+-- worker-raised 'nsfw' flag is never a real content verdict; it is only ever the
 -- artifact of the unscored branch this release removes. The genuine fail-closed
 -- control for un-inspectable media is the media STATUS (held/rejected), which is
 -- untouched here; resolving these flags does not publish anything the status gate
diff --git a/services/api/drizzle/0069_posts_repost_unique_softdelete.sql b/services/api/drizzle/0069_posts_repost_unique_softdelete.sql
index d3d5fbd3..572c0aaa 100644
--- a/services/api/drizzle/0069_posts_repost_unique_softdelete.sql
+++ b/services/api/drizzle/0069_posts_repost_unique_softdelete.sql
@@ -1,24 +1,24 @@
 -- =============================================================================
 -- 0069_posts_repost_unique_softdelete.sql
 -- -----------------------------------------------------------------------------
--- FINDING F003 / F148 / F054 (one design) — unrepost becomes a SOFT delete
+-- FINDING F003 / F148 / F054 (one design): unrepost becomes a SOFT delete
 -- (tombstone), not a hard row delete. The original repost-toggle unique index
 -- `posts_repost_unique_idx (author_id, repost_of_id) WHERE kind='repost'` (0051)
 -- counts tombstoned reposts, so once a user un-reposts (deleted_at set) they can
--- NEVER repost the same target again — the partial unique still matches the dead
+-- NEVER repost the same target again: the partial unique still matches the dead
 -- row. Rebuild it to ignore tombstones: at most one LIVE repost per (author,
 -- target). repost() then revives a tombstone via
 -- `ON CONFLICT ... DO UPDATE SET deleted_at = NULL` naming this predicate.
 --
 -- WHY THE REBUILD CANNOT FAIL: the old index already guaranteed at most one
 -- repost row per (author_id, repost_of_id); the new predicate is strictly more
--- permissive (a subset — live rows only), so no duplicate can exist to violate it.
+-- permissive (a subset: live rows only), so no duplicate can exist to violate it.
 --
 -- CANONICAL DDL: hand-authored source of truth. Mirror: schema/posts.ts
 -- (posts_repost_unique_idx WHERE clause gains `AND deleted_at IS NULL`).
 --
 -- Conventions: one transaction per file (src/db/migrate.ts); non-CONCURRENTLY
--- index build accepted for this pre-launch release (posts row count trivial —
+-- index build accepted for this pre-launch release (posts row count trivial,
 -- recorded in the delivery report). Forward-only, no down.
 --
 -- Ordering rules: requires 0051_social_posts.sql.
diff --git a/services/api/drizzle/0070_posts_reply_to_fk_idx.sql b/services/api/drizzle/0070_posts_reply_to_fk_idx.sql
index 017703b0..642b8f6a 100644
--- a/services/api/drizzle/0070_posts_reply_to_fk_idx.sql
+++ b/services/api/drizzle/0070_posts_reply_to_fk_idx.sql
@@ -3,7 +3,7 @@
 -- -----------------------------------------------------------------------------
 -- FINDING F151: posts.reply_to_id is a self-FK (posts→posts) but the only index
 -- covering it was `posts_reply_to_idx (reply_to_id, created_at) WHERE deleted_at
--- IS NULL` (0051) — a PARTIAL index that excludes tombstoned rows. Postgres uses
+-- IS NULL` (0051), a PARTIAL index that excludes tombstoned rows. Postgres uses
 -- an index to enforce/scan an ON DELETE FK action only when it covers ALL rows,
 -- so the delete-time lookup for reply_to_id children (and the F148 RESTRICT swap
 -- in 0072) has no usable index and does a sequential scan of posts. Add a plain
diff --git a/services/api/drizzle/0073_posts_media_fk_validate.sql b/services/api/drizzle/0073_posts_media_fk_validate.sql
index e21a2cc3..58233d9f 100644
--- a/services/api/drizzle/0073_posts_media_fk_validate.sql
+++ b/services/api/drizzle/0073_posts_media_fk_validate.sql
@@ -6,7 +6,7 @@
 -- them here, in a SEPARATE file, so a slow VALIDATE can't strand 0072 mid-apply
 -- (each file is its own transaction + bookkeeping row). Trivial on pre-launch
 -- volumes. Guarded so re-running (or running before 0072 on a partial history) is
--- a no-op rather than an error — VALIDATE has no IF EXISTS of its own.
+-- a no-op rather than an error; VALIDATE has no IF EXISTS of its own.
 --
 -- CANONICAL DDL: hand-authored source of truth. No shape change → no mirror edit
 -- beyond 0072's onDelete hints.
diff --git a/services/api/drizzle/0075_media_chat_created_at.sql b/services/api/drizzle/0075_media_chat_created_at.sql
index 752ae9a2..b6121aa7 100644
--- a/services/api/drizzle/0075_media_chat_created_at.sql
+++ b/services/api/drizzle/0075_media_chat_created_at.sql
@@ -3,7 +3,7 @@
 -- -----------------------------------------------------------------------------
 -- FINDING F072: media_assets.chat_message_id carries the id of a chat/dm message
 -- an attachment belongs to, but those message tables are RANGE-partitioned with a
--- COMPOSITE PK (id, created_at) — the id alone is not a key, so an authorization
+-- COMPOSITE PK (id, created_at): the id alone is not a key, so an authorization
 -- probe that resolves an attachment to its message cannot prune to a partition and
 -- has no unique target. Add the partition key alongside the id so a NEW attachment
 -- records (chat_message_id, chat_message_created_at) and the probe can match the
diff --git a/services/api/drizzle/0076_media_single_claim_check.sql b/services/api/drizzle/0076_media_single_claim_check.sql
index 9dd87001..2d2dd0a0 100644
--- a/services/api/drizzle/0076_media_single_claim_check.sql
+++ b/services/api/drizzle/0076_media_single_claim_check.sql
@@ -1,13 +1,13 @@
 -- =============================================================================
 -- 0076_media_single_claim_check.sql
 -- -----------------------------------------------------------------------------
--- FINDING F017 / F049: a media asset is meant to belong to at most ONE subject —
+-- FINDING F017 / F049: a media asset is meant to belong to at most ONE subject:
 -- a report (report_id), a chat/dm message (chat_message_id), or a social post
 -- (post_id). Nothing at the DB level stopped an asset from being claimed by two
 -- lanes at once, which would let one upload leak across authorization domains.
 -- Add a CHECK that at most one of the three binding columns is non-null.
 --
--- NOTE — NOT A TOTAL INVARIANT: the avatar/profile-media lane stamps NONE of these
+-- NOTE, NOT A TOTAL INVARIANT: the avatar/profile-media lane stamps NONE of these
 -- columns (an avatar is referenced from users.avatar_media_id / chat_groups.
 -- avatar_media_id, not by a back-pointer here), so num_nonnulls = 0 is legitimate
 -- and the CHECK only forbids a DOUBLE binding, not "unbound". It is a
@@ -18,7 +18,7 @@
 -- a long lock; 0077 validates separately. On pre-launch volumes both are trivial.
 --
 -- CANONICAL DDL: hand-authored source of truth. A CHECK constraint is not modeled
--- in the Drizzle mirror (schema/media.ts) — documented there as a NOTE for parity.
+-- in the Drizzle mirror (schema/media.ts), documented there as a NOTE for parity.
 --
 -- Conventions: guarded ADD CONSTRAINT so a repeat apply is a no-op; one
 -- transaction per file (src/db/migrate.ts). Forward-only, no down.
diff --git a/services/api/drizzle/0077_media_single_claim_validate.sql b/services/api/drizzle/0077_media_single_claim_validate.sql
index 9a74bd89..b50d3484 100644
--- a/services/api/drizzle/0077_media_single_claim_validate.sql
+++ b/services/api/drizzle/0077_media_single_claim_validate.sql
@@ -4,7 +4,7 @@
 -- FINDING F017 / F049 (validation half). 0076 added media_single_claim_chk as
 -- NOT VALID; validate it here in a SEPARATE file so a slow validation scan cannot
 -- strand 0076 mid-apply. Trivial on pre-launch volumes. Guarded so a re-run (or a
--- run before 0076 on a partial history) is a no-op — VALIDATE has no IF EXISTS.
+-- run before 0076 on a partial history) is a no-op; VALIDATE has no IF EXISTS.
 --
 -- CANONICAL DDL: hand-authored source of truth. No shape change → no mirror edit.
 --
diff --git a/services/api/drizzle/0078_idempotency_owner_unique.sql b/services/api/drizzle/0078_idempotency_owner_unique.sql
index 69bc2610..ded927cd 100644
--- a/services/api/drizzle/0078_idempotency_owner_unique.sql
+++ b/services/api/drizzle/0078_idempotency_owner_unique.sql
@@ -4,13 +4,13 @@
 -- FINDING F028: idempotency_keys is keyed by `key` ALONE (the PK, 0001_core.sql),
 -- so a client-chosen idempotency key is global across scope AND identity. Two
 -- consequences: (1) the same key reused in a different scope collides; (2) one
--- actor can pre-insert a victim's key and squat it — a DoS on report create. The
+-- actor can pre-insert a victim's key and squat it: a DoS on report create. The
 -- fix scopes uniqueness to (key, scope, owner). Add the composite UNIQUE index
 -- FIRST (this file); 0079 drops the old key-only PK afterward so new code can
 -- target the composite while the widening lands non-destructively.
 --
 -- COALESCE(user_or_anon, '') IS LOAD-BEARING: user_or_anon is nullable, and in
--- Postgres a plain multi-column UNIQUE treats NULLs as distinct — two anonymous
+-- Postgres a plain multi-column UNIQUE treats NULLs as distinct: two anonymous
 -- rows with the SAME (key, scope) and NULL owner would BOTH be admitted,
 -- reopening the squat. Folding NULL to '' makes the identity dimension total so
 -- unowned rows collide correctly.
diff --git a/services/api/drizzle/0080_chat_group_bans.sql b/services/api/drizzle/0080_chat_group_bans.sql
index ee225e7c..62505ce2 100644
--- a/services/api/drizzle/0080_chat_group_bans.sql
+++ b/services/api/drizzle/0080_chat_group_bans.sql
@@ -3,16 +3,16 @@
 -- -----------------------------------------------------------------------------
 -- FINDING F044: a group owner/admin can remove a member, but nothing records a
 -- BAN, so a public group's removed member can immediately re-join (joinGroup) and
--- an invite-only group's removed member can be re-added — there is no persistent
+-- an invite-only group's removed member can be re-added; there is no persistent
 -- "not allowed back" state. Add chat_group_bans: one row per (group, banned user).
 -- Chat gates joinGroup on the presence of a ban row. Unban = an owner/admin
 -- re-invite via the existing addGroupMembers path, which CLEARS the ban row (so
--- addMembers is intentionally NOT gated on the ban — see the F044 brief); there is
+-- addMembers is intentionally NOT gated on the ban; see the F044 brief); there is
 -- no separate unban endpoint.
 --
 -- PK(group_id, user_id) makes a ban idempotent. Both FKs cascade on the parent's
 -- delete (group deletion / hard user delete in test teardown); banned_by is a bare
--- uuid (no FK) mirroring the audit-actor stance elsewhere — the actor trace must
+-- uuid (no FK) mirroring the audit-actor stance elsewhere: the actor trace must
 -- outlive an actor row and never block a delete.
 --
 -- CANONICAL DDL: hand-authored source of truth. Mirror: NEW file
diff --git a/services/api/drizzle/0082_mail_messages_message_id_uk.sql b/services/api/drizzle/0082_mail_messages_message_id_uk.sql
index 7e05ca41..d119c7b0 100644
--- a/services/api/drizzle/0082_mail_messages_message_id_uk.sql
+++ b/services/api/drizzle/0082_mail_messages_message_id_uk.sql
@@ -9,7 +9,7 @@
 -- Runs AFTER 0081's dedupe so the build cannot fail on pre-existing duplicates.
 --
 -- Note: the existing NON-unique `mail_messages_message_id_idx (message_id) WHERE
--- message_id IS NOT NULL` (schema/mail.ts) stays — it is a redundant prefix of
+-- message_id IS NOT NULL` (schema/mail.ts) stays; it is a redundant prefix of
 -- this unique index but dropping it is a needless lock; noted for a future pass.
 --
 -- CANONICAL DDL: hand-authored source of truth. Mirror: schema/mail.ts.
diff --git a/services/api/drizzle/0084_notifications_feed_idx.sql b/services/api/drizzle/0084_notifications_feed_idx.sql
index ffc38ed9..4d7cc651 100644
--- a/services/api/drizzle/0084_notifications_feed_idx.sql
+++ b/services/api/drizzle/0084_notifications_feed_idx.sql
@@ -12,7 +12,7 @@
 --
 -- PREDICATE PINNING: the literal list below MUST equal
 -- FEED_HIDDEN_NOTIFICATION_TYPES (src/services/notification-helpers.ts). notif
--- pins the pair with an enums test (WIRING REQUEST to integration — see the
+-- pins the pair with an enums test (WIRING REQUEST to integration; see the
 -- manifest). If a hidden type is added/removed, this index must be re-issued.
 --
 -- CANONICAL DDL: hand-authored source of truth. Mirror: schema/notifications.ts
diff --git a/services/api/drizzle/0086_notification_prefs_tz.sql b/services/api/drizzle/0086_notification_prefs_tz.sql
index 7a9396d1..e76e856f 100644
--- a/services/api/drizzle/0086_notification_prefs_tz.sql
+++ b/services/api/drizzle/0086_notification_prefs_tz.sql
@@ -4,7 +4,7 @@
 -- FINDING F086: quiet-hours suppression compares the current time against
 -- notification_prefs.quiet_start/quiet_end, but those are `time` (no zone) values
 -- and there is no per-user timezone, so suppression was evaluated in the server's
--- zone — silencing (or failing to silence) users at the wrong wall-clock hour. Add
+-- zone, silencing (or failing to silence) users at the wrong wall-clock hour. Add
 -- a nullable `tz` (IANA name). notif's code disables suppression entirely when tz
 -- IS NULL (never suppress at a guessed hour) and evaluates quiet hours in the
 -- user's zone when set.
diff --git a/services/api/drizzle/0090_chat_groups_avatar_media_idx.sql b/services/api/drizzle/0090_chat_groups_avatar_media_idx.sql
index c4b4848a..32a98a9c 100644
--- a/services/api/drizzle/0090_chat_groups_avatar_media_idx.sql
+++ b/services/api/drizzle/0090_chat_groups_avatar_media_idx.sql
@@ -5,7 +5,7 @@
 -- media orphan-reaper (media-worker findOrphans) checks whether an asset is still
 -- referenced as a group avatar. With no index on avatar_media_id that check does a
 -- sequential scan of chat_groups per candidate asset. Add a partial index over the
--- non-null avatar references. (mw fixes the stale findOrphans comment — its half.)
+-- non-null avatar references. (mw fixes the stale findOrphans comment, its half.)
 --
 -- CANONICAL DDL: hand-authored source of truth. Mirror: schema/chat-groups.ts.
 --
diff --git a/services/api/drizzle/0092_anon_tokens_claim_code_null.sql b/services/api/drizzle/0092_anon_tokens_claim_code_null.sql
index aa288fa3..dba31329 100644
--- a/services/api/drizzle/0092_anon_tokens_claim_code_null.sql
+++ b/services/api/drizzle/0092_anon_tokens_claim_code_null.sql
@@ -1,7 +1,7 @@
 -- =============================================================================
 -- 0092_anon_tokens_claim_code_null.sql
 -- -----------------------------------------------------------------------------
--- FINDING F150 (part 2): anon_tokens.claim_code is a DEAD plaintext column — the
+-- FINDING F150 (part 2): anon_tokens.claim_code is a DEAD plaintext column; the
 -- per-report claim secret moved to reports.claim_code (0005) and now to its hash
 -- (0091); nothing reads anon_tokens.claim_code anymore. NULL it out so no stale
 -- plaintext secret sits in the token table. The column DROP is deferred to the
diff --git a/services/api/drizzle/0093_follows_people_pagination_idx.sql b/services/api/drizzle/0093_follows_people_pagination_idx.sql
index aba29f97..53e7a755 100644
--- a/services/api/drizzle/0093_follows_people_pagination_idx.sql
+++ b/services/api/drizzle/0093_follows_people_pagination_idx.sql
@@ -4,10 +4,10 @@
 -- FINDING F158: the "following" list (a user's outbound follows) pages by
 -- follower_id newest-first with the house keyset cursor. follows_people has
 -- PK(follower_id, followee_id) and an index on followee_id ("who follows me"), but
--- NOTHING keyed for (follower_id, created_at DESC) — verified against the schema:
--- the pagination support is genuinely MISSING — so the list sorts at scan time.
+-- NOTHING keyed for (follower_id, created_at DESC), verified against the schema:
+-- the pagination support is genuinely MISSING, so the list sorts at scan time.
 -- Add the keyset index; social sorts by display name WITHIN a fetched page (no
--- users(display_name) index — deliberately out of scope).
+-- users(display_name) index, deliberately out of scope).
 --
 -- CANONICAL DDL: hand-authored source of truth. Mirror: schema/follows.ts.
 --
diff --git a/services/api/drizzle/0094_notifications_created_idx.sql b/services/api/drizzle/0094_notifications_created_idx.sql
index ea55a083..a146e613 100644
--- a/services/api/drizzle/0094_notifications_created_idx.sql
+++ b/services/api/drizzle/0094_notifications_created_idx.sql
@@ -6,7 +6,7 @@
 --     SELECT id FROM notifications WHERE created_at < $1 LIMIT $2)
 -- Every index on the table is user_id-LEADING (notifications_user_created_idx and
 -- notifications_user_unread_idx from 0001_core.sql, notifications_feed_idx from
--- 0084), so none of them can serve a bare created_at range — the nightly sweep
+-- 0084), so none of them can serve a bare created_at range; the nightly sweep
 -- seq-scans the largest write-heavy table in the schema, once per page, up to
 -- maxPages times. Index created_at on its own, exactly as
 -- idempotency_keys_created_idx (0037) does for that table's identical lane.
@@ -18,7 +18,7 @@
 -- CANONICAL DDL: hand-authored source of truth. Mirror: schema/notifications.ts.
 --
 -- Conventions: additive CREATE INDEX IF NOT EXISTS; one transaction per file;
--- non-CONCURRENTLY build accepted (pre-launch, trivial rows) — on a live-traffic
+-- non-CONCURRENTLY build accepted (pre-launch, trivial rows); on a live-traffic
 -- box this one would be built out-of-band with CONCURRENTLY first, leaving this
 -- file an idempotent no-op. Forward-only, no down.
 --
diff --git a/services/api/drizzle/0095_media_stuck_sweep_rotation.sql b/services/api/drizzle/0095_media_stuck_sweep_rotation.sql
index 34638e8f..2b2ba9f5 100644
--- a/services/api/drizzle/0095_media_stuck_sweep_rotation.sql
+++ b/services/api/drizzle/0095_media_stuck_sweep_rotation.sql
@@ -19,7 +19,7 @@
 --
 -- The sweep also narrows to `finalized_at IS NOT NULL` (0087's marker, wired by
 -- this change set): a row is created 'validating' at PRESIGN time, before any
--- bytes exist, so an unfinalized row is an upload intent — the ORPHAN sweep's
+-- bytes exist, so an unfinalized row is an upload intent; the ORPHAN sweep's
 -- job, never the stuck sweep's. Rows finalized BEFORE 0087 was wired carry a
 -- NULL finalized_at, which would put them out of the sweep's scope forever --
 -- and findOrphans skips any BOUND row, so a bound pre-0087 asset stuck at
@@ -39,7 +39,7 @@
 -- The finalize watermark is also load-bearing for CLAIMS: every media bind
 -- predicate (report create, anon held-report create, post attach, chat/DM
 -- attachment) accepts a 'validating' row only when finalized_at IS NOT NULL, so
--- a bearer of an upload id cannot bind a never-finalized intent — a row with no
+-- a bearer of an upload id cannot bind a never-finalized intent: a row with no
 -- bytes, no job, and (bound) no orphan-sweep coverage. 'ready' rows are accepted
 -- unconditionally because a pre-0087 'ready' row can legitimately carry a NULL
 -- finalized_at; 'ready' is post-pipeline by definition. Together with the
@@ -52,7 +52,7 @@
 -- checked -> highest priority). The one backfill here is on finalized_at, and
 -- it only ever writes rows where it is NULL -- nothing is overwritten.
 --
--- The partial index serves the sweep's exact claim query — the ORDER BY is
+-- The partial index serves the sweep's exact claim query; the ORDER BY is
 -- `stuck_checked_at ASC NULLS FIRST, finalized_at` over a tiny slice of a large,
 -- write-heavy table, so without it every run sorts all validating rows; carrying
 -- finalized_at in the index also serves the `finalized_at < cutoff` filter from
@@ -62,7 +62,7 @@
 --
 -- Conventions: additive ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS;
 -- one transaction per file (src/db/migrate.ts); non-CONCURRENTLY build accepted
--- (pre-launch, trivial rows) — on a live-traffic box this index would be built
+-- (pre-launch, trivial rows); on a live-traffic box this index would be built
 -- out-of-band with CONCURRENTLY first, leaving that statement an idempotent
 -- no-op. Forward-only, no down.
 --
diff --git a/services/api/drizzle/0098_sweep_predicate_indexes.sql b/services/api/drizzle/0098_sweep_predicate_indexes.sql
index 67448ae6..c16480e2 100644
--- a/services/api/drizzle/0098_sweep_predicate_indexes.sql
+++ b/services/api/drizzle/0098_sweep_predicate_indexes.sql
@@ -24,7 +24,7 @@
 -- docs/out-of-band-indexes.md carries the same command plus the verification
 -- steps; re-running this migration afterwards simply stops warning.
 --
--- The index predicate is the STABLE part of the orphan predicate — the two
+-- The index predicate is the STABLE part of the orphan predicate; the two
 -- avatar NOT EXISTS probes and the `created_at < cutoff` bound are evaluated on
 -- the (now much smaller) candidate set; created_at is the index key so the age
 -- bound is served from the index tuple.
diff --git a/services/api/drizzle/0100_mail_messages_affiliation_effects.sql b/services/api/drizzle/0100_mail_messages_affiliation_effects.sql
index f60c8ec0..a97f1a88 100644
--- a/services/api/drizzle/0100_mail_messages_affiliation_effects.sql
+++ b/services/api/drizzle/0100_mail_messages_affiliation_effects.sql
@@ -2,7 +2,7 @@
 --
 -- H5 + M(inbound side effects): the facts an inbound thread message has to carry.
 --
--- `unaffiliated` — the message reached the thread by echoing a thread token or an outbound Message-ID
+-- `unaffiliated`: the message reached the thread by echoing a thread token or an outbound Message-ID
 -- (both disclosed to every recipient of a forwarded packet) but its From domain does NOT align with any
 -- address civfix actually mailed on that thread. Such a message is still stored (audit trail) but must
 -- never drive an official-city-reply effect, a bounce, or recipient resolution.
@@ -13,7 +13,7 @@
 --
 --   effects_claimed_at  a runner holds the message. RECLAIMABLE: the sweep re-drives any claim older than
 --                       its lease, because a process death between claim and completion (a deploy
---                       restart, OOM, the drain watchdog) would otherwise strand the row forever — the
+--                       restart, OOM, the drain watchdog) would otherwise strand the row forever; the
 --                       exact failure the fix is for, just narrower.
 --   effects_applied_at  set ONLY on completion. `IS NULL` is the "still owed" set the partial index
 --                       serves; the lease comparison stays in the query because now() is not IMMUTABLE.
@@ -33,7 +33,7 @@ ALTER TABLE mail_messages ADD COLUMN IF NOT EXISTS effects_stage integer NOT NUL
 -- NULL" with no created_at floor, so without this every historical inbound reply would match on the first
 -- inbound.sweep after deploy and be re-driven at stage 0: a second "The city responded" timeline row, a
 -- chat system message, a push, and thread status forced back to 'replied' over a closed or bounced
--- thread — 100 per run until drained, including senders the affiliation gate never applied to. Their
+-- thread: 100 per run until drained, including senders the affiliation gate never applied to. Their
 -- effects already ran under the old fire-and-forget path, so they are marked applied at their own
 -- created_at with the pipeline recorded complete. Idempotent (only touches rows still NULL).
 UPDATE mail_messages
diff --git a/services/api/drizzle/0101_inbound_emails_archived_at.sql b/services/api/drizzle/0101_inbound_emails_archived_at.sql
index 140c67e3..ec748ec7 100644
--- a/services/api/drizzle/0101_inbound_emails_archived_at.sql
+++ b/services/api/drizzle/0101_inbound_emails_archived_at.sql
@@ -7,7 +7,7 @@
 -- previously recorded only the `status` string, so "when was this archived" was unrecoverable.
 --
 -- Backfill: existing archived rows get `received_at` as their archive time (the only timestamp the row
--- carries). That is conservative — it can only make an already-archived row eligible sooner, never later,
+-- carries). That is conservative: it can only make an already-archived row eligible sooner, never later,
 -- and pre-launch the table is empty.
 --
 -- inbound_emails is a small operator-plane table (catch-all inbox), not a hot table, so a plain
diff --git a/services/api/drizzle/0102_users_last_activity.sql b/services/api/drizzle/0102_users_last_activity.sql
index 36441d56..fffc0b7c 100644
--- a/services/api/drizzle/0102_users_last_activity.sql
+++ b/services/api/drizzle/0102_users_last_activity.sql
@@ -7,13 +7,13 @@
 -- the full sort. Cost was O(total users) per request, inside a 15s
 -- statement_timeout, at 30 req/min per identity.
 --
--- The fix materialises the point that LATERAL used to derive — the user's most
--- recent locatable public act (report filed, event organized/completed) — onto
+-- The fix materialises the point that LATERAL used to derive (the user's most
+-- recent locatable public act: report filed, event organized/completed) onto
 -- the users row, so the candidate set can be bounded BEFORE any per-row work by
 -- a KNN index scan (see social-repository.drizzle.ts suggestFollows).
 --
 -- HOT TABLE: `users`. Both columns are NULLABLE with NO DEFAULT, so this is a
--- catalog-only ADD COLUMN — no table rewrite, no full-table lock beyond the
+-- catalog-only ADD COLUMN: no table rewrite, no full-table lock beyond the
 -- brief ACCESS EXCLUSIVE for the catalog update.
 --
 -- INDEXES ARE NOT CREATED HERE. The migration runner wraps every file in one
diff --git a/services/api/drizzle/0160_donation_refunds_failed_after.sql b/services/api/drizzle/0160_donation_refunds_failed_after.sql
index 7a566d13..cddca48e 100644
--- a/services/api/drizzle/0160_donation_refunds_failed_after.sql
+++ b/services/api/drizzle/0160_donation_refunds_failed_after.sql
@@ -11,7 +11,7 @@
 -- alerted on rather than silently reversed: the money is recovered by hand.
 --
 -- 'failed_after' is deliberately OUTSIDE donation_refunds_app_fee_pending_idx's
--- ('pending','failed') predicate — it is terminal and must never be retried.
+-- ('pending','failed') predicate; it is terminal and must never be retried.
 --
 -- Rewrites one CHECK constraint on a table that is empty on every environment
 -- at this point in the change set; the drop/add pair takes an ACCESS EXCLUSIVE
diff --git a/services/api/drizzle/0167_cleanup_slot_windows.sql b/services/api/drizzle/0167_cleanup_slot_windows.sql
index 49287b84..a3a70603 100644
--- a/services/api/drizzle/0167_cleanup_slot_windows.sql
+++ b/services/api/drizzle/0167_cleanup_slot_windows.sql
@@ -20,7 +20,7 @@
 --
 --   * cleanup_slots_window_chk. The two columns it constrains are added by this very
 --     file, so every pre-existing row is NULL/NULL, which satisfies the CHECK
---     vacuously — the validation scan cannot find a violating row. The old image has
+--     vacuously: the validation scan cannot find a violating row. The old image has
 --     no code path that names starts_at or ends_at, so every row it goes on writing is
 --     NULL/NULL too and can never violate it. The scan takes ACCESS EXCLUSIVE, but
 --     cleanup_slots is small and is not on the hot-table list in
diff --git a/services/api/drizzle/0173_reports_status_check.sql b/services/api/drizzle/0173_reports_status_check.sql
index 10db4ff9..babc552f 100644
--- a/services/api/drizzle/0173_reports_status_check.sql
+++ b/services/api/drizzle/0173_reports_status_check.sql
@@ -4,7 +4,7 @@
 -- The report lifecycle is enforced in the service layer (the shared contract's
 -- ADMIN_REPORT_STATUS_TRANSITIONS / canTransitionReportStatus), but nothing at
 -- the DB level stopped a typo or a future writer from parking a report on a
--- status no reader understands — the admin list buckets, the citizen map filter
+-- status no reader understands: the admin list buckets, the citizen map filter
 -- and the timeline icon mapper all switch on this column and silently drop a
 -- value they do not know. Pin the VALUE SET here; the transition graph stays in
 -- code, where the actor and the reason live.
diff --git a/services/api/drizzle/0176_posts_geom.sql b/services/api/drizzle/0176_posts_geom.sql
index abd66cae..f8d0643a 100644
--- a/services/api/drizzle/0176_posts_geom.sql
+++ b/services/api/drizzle/0176_posts_geom.sql
@@ -2,8 +2,8 @@
 -- 0176_posts_geom.sql
 -- -----------------------------------------------------------------------------
 -- ISSUE #100 (ranked home feed): the ranker scores a "nearby" term, but `posts`
--- carried no geometry at all. A post's only location was indirect — through
--- posts.report_id -> reports.geom or posts.event_id -> cleanups.geom — so a
+-- carried no geometry at all. A post's only location was indirect (through
+-- posts.report_id -> reports.geom or posts.event_id -> cleanups.geom), so a
 -- proximity pool would need two joins and could not use a KNN order at all.
 --
 -- This denormalises that point onto the post row so the nearby candidate pool
@@ -14,7 +14,7 @@
 -- docs/out-of-band-indexes.md (users, reports, chat_messages, dm_messages,
 -- media_assets, notifications, sessions), so the partial GIST is built inline
 -- here rather than out of band. If `posts` has grown large by the time this
--- deploys, build it with CREATE INDEX CONCURRENTLY first — the IF NOT EXISTS
+-- deploys, build it with CREATE INDEX CONCURRENTLY first; the IF NOT EXISTS
 -- guard below then turns this statement into a no-op.
 --
 -- NO BACKFILL HERE: one UPDATE over the whole table inside this file's single
@@ -28,7 +28,7 @@
 -- PRIVACY: no new class of data. The value is a copy of a coordinate this
 -- platform already publishes at full precision on the linked report or event
 -- DTO. It is never populated from a client-supplied coordinate, carries no
--- EXIF, and is never returned to any client — it only orders the feed. Any
+-- EXIF, and is never returned to any client; it only orders the feed. Any
 -- future location-coarsening decision must cover this column too
 -- (docs/location-coarsening-assessment.md).
 --
diff --git a/services/api/eslint.config.js b/services/api/eslint.config.js
index 3f54be35..0254da4f 100644
--- a/services/api/eslint.config.js
+++ b/services/api/eslint.config.js
@@ -1,6 +1,3 @@
 import { config } from "@civfix/config/eslint"
 
-export default config({
-  // Tests use vitest globals via explicit imports, so nothing extra is needed here yet.
-  // Service-specific overrides can be appended as additional flat-config objects.
-})
+export default config({})
diff --git a/services/api/scripts/prepare-boundaries.ts b/services/api/scripts/prepare-boundaries.ts
index 98a7a060..120f3eb6 100644
--- a/services/api/scripts/prepare-boundaries.ts
+++ b/services/api/scripts/prepare-boundaries.ts
@@ -1,8 +1,7 @@
 /**
- * Boundary-prep convert tool (ops / air-gapped fallback): the thin imperative wrapper around the PURE
- * boundaryManifest (src/db/boundaries/manifest.ts). For each layer job it shells `ogr2ogr` to convert one
- * already-downloaded Census/USGS source into a reprojected (EPSG:4326) GeoJSON file, then prints the
- * matching `pnpm db:ingest` line.
+ * Boundary-prep convert tool (ops / air-gapped fallback) around the PURE boundaryManifest
+ * (src/db/boundaries/manifest.ts): it shells `ogr2ogr` to convert each already-downloaded Census/USGS
+ * source into a reprojected (EPSG:4326) GeoJSON file, then prints the matching `pnpm db:ingest` line.
  *
  *   pnpm db:prepare-boundaries             # default vintage, outDir data/boundaries
  *   pnpm db:prepare-boundaries 2025 out    # explicit vintage + outDir
@@ -12,9 +11,9 @@
  * only the standalone CONVERT step, kept for the air-gapped path: convert here, copy the GeoJSON to the
  * box, then `node dist/db/ingest-jurisdictions.js   [geoid-prefix]` per layer there.
  *
- * Scope + constraints: REQUIRES GDAL/ogr2ogr; run on a box that has it (never inside the API image — this
+ * Scope + constraints: REQUIRES GDAL/ogr2ogr; run on a box that has it (never inside the API image: this
  * is a `scripts/` tsx tool, never a tsup entry). Touches NO database, NO env, NO npm deps beyond node
- * builtins. It does NOT download the sources — it expects each job's source already extracted under
+ * builtins. It does NOT download the sources; it expects each job's source already extracted under
  * `/sources/` at the path the manifest's `sourcePath` names (TIGER `.shp`; PAD-US `.gdb`).
  *
  * Exit codes: 0 = at least one layer converted; 1 = NOTHING converted; 2 = GDAL not found / bad args.
@@ -26,19 +25,16 @@ import { boundaryManifest, type BoundaryJob } from "../src/db/boundaries/manifes
 
 const PREFIX = "prepare-boundaries"
 
-/** The local dataset path ogr2ogr reads for a job: /sources/ (the .shp or .gdb
- *  the source archive extracts to). */
 function localSourceFor(job: BoundaryJob, outDir: string): string {
   return `${outDir}/sources/${job.sourcePath}`
 }
 
-/** Verify GDAL/ogr2ogr is on PATH; exit(2) with an actionable message if not. */
 function assertOgr2ogr(): void {
   try {
     execFileSync("ogr2ogr", ["--version"], { stdio: "ignore" })
   } catch {
     console.error(
-      `${PREFIX}: ogr2ogr (GDAL) not found on PATH — install GDAL (e.g. \`brew install gdal\`) and re-run.`,
+      `${PREFIX}: ogr2ogr (GDAL) not found on PATH; install GDAL (e.g. \`brew install gdal\`) and re-run.`,
     )
     process.exit(2)
   }
@@ -68,8 +64,8 @@ function main(): void {
   for (const job of jobs) {
     const source = localSourceFor(job, outDir)
     const outPath = `${outDir}/${job.outFile}`
-    // The full argv: the manifest's conversion args, then OUTPUT then SOURCE (ogr2ogr's dst-then-src order).
-    // No geoid prefix appears here — prefixing is an ingest-time concern (see the manifest header).
+    // OUTPUT before SOURCE: ogr2ogr's dst-then-src order. No geoid prefix here: prefixing is an
+    // ingest-time concern (see the manifest header).
     const args = [...job.ogr2ogrArgs, outPath, source]
     console.log(`${PREFIX}: [${job.layer}] ogr2ogr ${args.join(" ")}`)
     try {
@@ -87,7 +83,7 @@ function main(): void {
     }
   }
 
-  console.log(`${PREFIX}: done — ${converted} converted, ${failed.length} failed`)
+  console.log(`${PREFIX}: done: ${converted} converted, ${failed.length} failed`)
   if (failed.length > 0) console.error(`${PREFIX}: failed/missing jobs: ${failed.join(", ")}`)
   if (converted === 0) process.exit(1)
 }
diff --git a/services/api/scripts/refresh-boundaries.ts b/services/api/scripts/refresh-boundaries.ts
index fe1ecfc9..3e1a1121 100644
--- a/services/api/scripts/refresh-boundaries.ts
+++ b/services/api/scripts/refresh-boundaries.ts
@@ -175,7 +175,7 @@ function fetchSource(job: BoundaryJob, outDir: string): boolean {
     return true
   } catch (err) {
     if (!isFederal(job)) throw err
-    warn(`PAD-US source fetch failed (${job.sourceUrl}) — federal layer will be skipped`)
+    warn(`PAD-US source fetch failed (${job.sourceUrl}); federal layer will be skipped`)
     return false
   } finally {
     rmSync(zip, { force: true })
@@ -194,7 +194,7 @@ function convert(job: BoundaryJob, outDir: string): string | null {
     execFileSync("ogr2ogr", [...job.ogr2ogrArgs, outPath, src], { stdio: "inherit" })
   } catch (err) {
     if (isFederal(job)) {
-      warn(`PAD-US convert failed — federal layer will be skipped`)
+      warn(`PAD-US convert failed; federal layer will be skipped`)
       return null
     }
     throw err
@@ -270,7 +270,7 @@ async function main(): Promise {
       execFileSync("ogr2ogr", ["--version"], { stdio: "ignore" })
     } catch {
       console.error(
-        `${PREFIX}: ogr2ogr (GDAL) not found on PATH — install it (e.g. \`brew install gdal\`) and re-run.`,
+        `${PREFIX}: ogr2ogr (GDAL) not found on PATH; install it (e.g. \`brew install gdal\`) and re-run.`,
       )
       process.exit(2)
     }
@@ -301,7 +301,7 @@ async function main(): Promise {
       const total = Object.values(rowCounts).reduce((a, b) => a + b, 0)
       if (total === 0)
         warn(
-          "no jurisdictions in the DB — run a full load first (this backfill will resolve nothing)",
+          "no jurisdictions in the DB; run a full load first (this backfill will resolve nothing)",
         )
     } else {
       log(`vintage ${year}: ${boundaryManifest(year).length} layer job(s); work dir ${outDir}`)
@@ -315,7 +315,7 @@ async function main(): Promise {
         if (out) converted.push({ job, path: out })
       }
       if (!converted.some((c) => !isFederal(c.job))) {
-        throw new Error("no census layers converted — refusing to load a partial-coverage dataset")
+        throw new Error("no census layers converted; refusing to load a partial-coverage dataset")
       }
 
       rowCounts = {}
@@ -337,11 +337,11 @@ async function main(): Promise {
               )
           rowCounts[job.layer] = (rowCounts[job.layer] ?? 0) + upserted
           log(`[${job.layer}] upserted ${upserted} (skipped ${skipped} of ${features})`)
-          if (upserted === 0) warn(`[${job.layer}] upserted 0 rows — check the source/conversion`)
+          if (upserted === 0) warn(`[${job.layer}] upserted 0 rows; check the source/conversion`)
           if (isFederal(job)) federalLoaded = true
         } catch (err) {
           if (!isFederal(job)) throw err
-          warn(`[federal] ingest failed — skipping federal layer (${(err as Error).message})`)
+          warn(`[federal] ingest failed; skipping federal layer (${(err as Error).message})`)
         }
       }
 
@@ -362,7 +362,7 @@ async function main(): Promise {
 
     const tag = federalLoaded ? vintageTag(year) : `${vintageTag(year)}-nofed`
     await stampVintage(handle.sql, tag, year, rowCounts)
-    log(`done — vintage ${tag}; row counts: ${JSON.stringify(rowCounts)}`)
+    log(`done: vintage ${tag}; row counts: ${JSON.stringify(rowCounts)}`)
     completed = true
   } finally {
     if (handle) await handle.close()
@@ -370,7 +370,7 @@ async function main(): Promise {
     if (!keep && !backfillOnly && completed) {
       rmSync(outDir, { recursive: true, force: true })
     } else if (!keep && !backfillOnly && !completed) {
-      warn(`run did not complete — keeping the work dir for retry/inspection: ${outDir}`)
+      warn(`run did not complete; keeping the work dir for retry/inspection: ${outDir}`)
     }
   }
 }
diff --git a/services/api/scripts/render-sample-certificate.ts b/services/api/scripts/render-sample-certificate.ts
index ed32ae55..8de43505 100644
--- a/services/api/scripts/render-sample-certificate.ts
+++ b/services/api/scripts/render-sample-certificate.ts
@@ -15,7 +15,7 @@
  *   sample-en-1200.pdf             the 1000-row cap + the truncation banner under the totals
  *
  * This is a `scripts/` tsx tool: it is never bundled into the API image and touches no DB, no storage and
- * no network — the renderer is pure, so a sample needs nothing but the vendored fonts.
+ * no network: the renderer is pure, so a sample needs nothing but the vendored fonts.
  */
 
 import { mkdirSync, writeFileSync } from "node:fs"
diff --git a/services/api/scripts/revoke-certificate.ts b/services/api/scripts/revoke-certificate.ts
index 16790ae1..2b85139e 100644
--- a/services/api/scripts/revoke-certificate.ts
+++ b/services/api/scripts/revoke-certificate.ts
@@ -8,7 +8,7 @@
  *
  * WHY THIS EXISTS. The product's only revoke is HOLDER-gated: `POST /service-hours/certificates/:code/
  * revoke` runs `requireAuth`, the service hardcodes the reason `"holder"`, and the repository scopes its
- * UPDATE by `WHERE user_id = `. An operator cannot reach it — they are not the holder — and
+ * UPDATE by `WHERE user_id = `. An operator cannot reach it (they are not the holder), and
  * even if they had the holder sign in, `verify()` would then publicly report `revokedReason: "holder"`,
  * i.e. that the VOLUNTEER withdrew their own record rather than that civfix corrected the ledger. This
  * script is the seam that makes the documented remedy real: same repository call, an operator reason, and
@@ -16,22 +16,21 @@
  *
  * THE MOTIVATING CASE (`drizzle/0065_void_report_volunteer_hours.sql`): filing a report stopped being
  * volunteer service, so every historical `source='report'` credit was voided. A transcript issued before
- * that itemised those rows and OVERSTATES service. It cannot be corrected in place — the row is an
+ * that itemised those rows and OVERSTATES service. It cannot be corrected in place: the row is an
  * immutable snapshot, `verify()` reports it verbatim, and editing `snapshot`/`total_hours` would break
  * `document_sha256` against the stored PDF. Revoke, notify the holder, let them re-issue: after the void
  * their ledger fingerprint differs, so the `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL`
- * idempotency index does not block the new document. The detection query is in the migration banner and in
- * docs/security/2026-07-24-full-backend-security-review.md §1b.
+ * idempotency index does not block the new document. The detection query is in the migration banner.
  *
  * DELIBERATELY NOT AN HTTP ROUTE. An admin endpoint that revokes another person's signed document is a
  * standing capability on a public-verification surface; this is a rare, deliberate, per-code action, so it
- * lives where `refresh-boundaries.ts` lives — a `scripts/` tsx tool, never bundled into the API image.
+ * lives where `refresh-boundaries.ts` lives: a `scripts/` tsx tool, never bundled into the API image.
  *
  * ENV. `DATABASE_URL` only (open your own SSH tunnel to prod Postgres and export it). The R2 delete is
  * skipped with a warning unless R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET are
  * also set. Skipping it is SAFE, never an exposure: `findLiveByFingerprint` filters `revoked_at IS NULL`
  * and is the only path that re-presigns an object, so a revoked certificate's PDF is unreachable through
- * the API either way — any already-issued URL simply expires. Deleting is still preferred, because the
+ * the API either way; any already-issued URL simply expires. Deleting is still preferred, because the
  * object is otherwise an orphan nothing will ever collect.
  *
  * IDEMPOTENT. The repository's `revoked_reason = COALESCE(revoked_reason, …)` means re-running never
@@ -53,7 +52,7 @@ const warn = (m: string): void => console.warn(`${PREFIX}: ⚠ ${m}`)
  * on its own (`holder` = the volunteer withdrew it; `account_closed` = erasure tombstoned the holder).
  */
 const OPERATOR_REASONS = {
-  /** The ledger the document was built from was itself wrong — the 0065 case. */
+  /** The ledger the document was built from was itself wrong (the 0065 case). */
   ledger_corrected: "the underlying volunteer-hours ledger was corrected after issue",
   /** The document was issued from data obtained by fraud or abuse. */
   issued_in_error: "the document should never have been issued",
@@ -155,21 +154,21 @@ async function main(): Promise {
     )
 
     if (args.dryRun) {
-      log(`dry run — nothing changed`)
+      log(`dry run: nothing changed`)
       return
     }
 
     const row = await repo.revoke(before.userId, args.code, args.reason, new Date())
     if (row === null) {
       // Unreachable: findByCode just resolved this code, and revoke scopes by that row's own user_id.
-      console.error(`${PREFIX}: revoke matched no row — did the certificate disappear mid-run?`)
+      console.error(`${PREFIX}: revoke matched no row; did the certificate disappear mid-run?`)
       process.exit(1)
     }
 
     if (before.revokedAt !== null) {
       warn(
         `already revoked; COALESCE preserved the original timestamp and reason ` +
-          `(${row.revokedReason ?? "no reason"}) — "${args.reason}" was NOT written`,
+          `(${row.revokedReason ?? "no reason"}); "${args.reason}" was NOT written`,
       )
     } else {
       log(
@@ -185,7 +184,7 @@ async function main(): Promise {
     await deleteObject(row.r2Key)
 
     log(
-      `done. Notify the holder — after the ledger correction they can re-issue a corrected transcript.`,
+      `done. Notify the holder: after the ledger correction they can re-issue a corrected transcript.`,
     )
   } finally {
     await handle.close()
@@ -203,7 +202,7 @@ async function deleteObject(key: string): Promise {
   const bucket = process.env.R2_BUCKET
   if (!accountId || !accessKeyId || !secretAccessKey || !bucket) {
     warn(
-      `R2_* not set — leaving ${key} in place. The certificate is revoked and no API path re-presigns ` +
+      `R2_* not set; leaving ${key} in place. The certificate is revoked and no API path re-presigns ` +
         `a revoked row, so this is an orphaned object, not an exposure. Re-run with R2 credentials (or ` +
         `delete the key by hand) to reclaim it.`,
     )
diff --git a/services/api/src/abuse/anon-token.ts b/services/api/src/abuse/anon-token.ts
index f7a4cdde..7d95f1bc 100644
--- a/services/api/src/abuse/anon-token.ts
+++ b/services/api/src/abuse/anon-token.ts
@@ -2,41 +2,24 @@
  * Anonymous reporting token: a signed, short-lived credential that lets a logged-out reporter submit a
  * bounded number of reports and later claim them into an account.
  *
- * Shape on the wire: `"."` where
- *   - tokenId is 256 bits of CSPRNG entropy (base64url), the PRIMARY KEY of an anon_tokens row, and
- *   - hmac is HMAC-SHA256(ANON_TOKEN_SIGNING_KEY, tokenId), base64url.
- * The HMAC makes the token UNFORGEABLE: a client cannot mint a tokenId that references a row without
- * the server's signing key. The server still loads the row to enforce per-token state (report_count,
- * expiry, flagged), so the token is a stateless authenticator over server-side state - it is never a
- * bearer of authority by itself.
+ * Wire form `"."`: tokenId is 256 bits of CSPRNG entropy and the anon_tokens primary key,
+ * hmac is HMAC-SHA256(ANON_TOKEN_SIGNING_KEY, tokenId). The HMAC makes the token unforgeable, but it only
+ * authenticates server-side state: the row still decides expiry, report_count and flagged, so the token
+ * never carries authority by itself.
  *
- * LIFETIME: 24h (ANON_TOKEN_TTL_SECONDS), stamped on the row's expires_at at issue time. An expired or
- * missing row makes the token invalid even if the HMAC checks out.
- *
- * PER-TOKEN CAP: a token may back at most ANON_TOKEN_REPORT_CAP reports in its lifetime. The cap is
- * enforced against the row's report_count, which the anon-service bumps inside the same transaction
- * that creates the report (so a crashed submit cannot leak quota).
- *
- * ISSUANCE: on the first anon submit with no valid token presented, the service calls `issueAnonToken`
- * to create a row + return the signed token; the client stores it (cookie for web, body for mobile -
- * the transport is documented on the anon route) and sends it back as `anonToken` next time.
- *
- * The signing/verification is PURE (sign/verify/parse take the key as an argument); the row lifecycle
- * sits behind a small AnonTokenStore seam with an in-memory impl, so every behavior (issue, verify,
- * cap, expiry, claim-code) is unit-testable with no database.
+ * The per-token cap is enforced against report_count, which anon-service bumps in the same transaction
+ * that creates the report, so a crashed submit cannot leak quota.
  */
 
 import { createHmac } from "node:crypto"
 import { AppError } from "@civfix/shared"
 import { generateToken, constantTimeStringEqual } from "../auth/crypto.js"
 
-/** Anonymous token lifetime: 24 hours, in seconds. */
 export const ANON_TOKEN_TTL_SECONDS = 24 * 60 * 60
 
-/** Max reports a single anon token may back over its lifetime. */
 export const ANON_TOKEN_REPORT_CAP = 5
 
-/** Separator between the token id and its HMAC in the wire form. base64url never contains ".". */
+/** base64url never contains ".", so the separator cannot collide with the id or the HMAC. */
 const TOKEN_SEP = "."
 
 function hmac(tokenId: string, signingKey: string): string {
@@ -48,9 +31,7 @@ export function signAnonToken(tokenId: string, signingKey: string): string {
 }
 
 /**
- * Verify a presented wire token's signature and return its token id, or null when malformed / the HMAC
- * does not match. PURE. Does NOT consult the store (no expiry/cap check here) - this only proves the
- * token was minted by us. The caller loads the row to enforce state.
+ * Only proves the token was minted by us. Expiry and the cap live on the row, which the caller loads.
  */
 export function verifyAnonTokenSignature(token: string, signingKey: string): string | null {
   const idx = token.indexOf(TOKEN_SEP)
@@ -61,7 +42,6 @@ export function verifyAnonTokenSignature(token: string, signingKey: string): str
   return constantTimeStringEqual(sig, expected) ? tokenId : null
 }
 
-/** The anon_tokens row fields the abuse stack reads/writes. */
 export interface AnonTokenRecord {
   id: string
   createdAt: Date
@@ -71,11 +51,6 @@ export interface AnonTokenRecord {
   claimCode: string | null
 }
 
-/**
- * Persistence seam for anon_tokens. The production impl is Drizzle/Postgres; the offline tests pass an
- * in-memory implementation. Keeping the row lifecycle behind this interface is what makes the token
- * logic unit-testable with no DB.
- */
 export interface AnonTokenStore {
   insert(row: AnonTokenRecord): Promise
   findById(id: string): Promise
@@ -84,24 +59,16 @@ export interface AnonTokenStore {
 export interface AnonTokenDeps {
   store: AnonTokenStore
   signingKey: string
-  /** Injectable id factory (defaults to a 256-bit base64url token). */
   newId?: () => string
-  /** Injectable clock (defaults to Date.now). */
   now?: () => Date
 }
 
 export interface IssuedAnonToken {
-  /** The signed wire token to hand back to the client. */
   token: string
-  /** The persisted row (its id is the report's anon_session_id). */
+  /** Its id becomes the report's anon_session_id. */
   record: AnonTokenRecord
 }
 
-/**
- * Issue a brand-new anon token: mint a random id, persist an anon_tokens row (24h expiry, report_count
- * 0, not flagged, no claim code yet), and return the signed wire token + the row. Called on the first
- * anon submit when no valid token is presented.
- */
 export async function issueAnonToken(deps: AnonTokenDeps): Promise {
   const newId = deps.newId ?? (() => generateToken())
   const now = deps.now ?? (() => new Date())
@@ -119,10 +86,8 @@ export async function issueAnonToken(deps: AnonTokenDeps): Promise
.jurisdiction_geoid` from the row's own `geom`, in keyset-cursor batches - * over the id. Returns `resolved` (rows that got a non-NULL geoid) and `stayedNull` (points outside all - * loaded coverage, left NULL on purpose). `ids` narrows the pass to those rows, for a caller that must not - * touch rows it did not null itself. + * `stayedNull` counts points outside all loaded coverage, left NULL on purpose. `ids` narrows the pass + * for a caller that must not touch rows it did not null itself. * - * The candidate polygon is picked with the SHARED ordering constant the write-time resolver uses - * (JURISDICTION_RESOLVE_ORDER_BY via `sql.unsafe` — a trusted, code-defined string, never user input), so a - * backfilled row lands on exactly the jurisdiction a fresh insert would have chosen. + * The polygon is picked with the same ordering constant the write-time resolver uses (a trusted, + * code-defined string passed through `sql.unsafe`), so a backfilled row lands on exactly the jurisdiction + * a fresh insert would have chosen. * - * SHAPE: a CTE + join, NOT `UPDATE
t ... FROM LATERAL (... t.geom ...)`: Postgres forbids a LATERAL - * FROM-item from referencing the UPDATE target table (42P10). Inside the CTE's plain SELECT the correlation - * IS allowed. Rows outside every polygon resolve to a NULL geoid, are filtered by `m.geoid IS NOT NULL` -> - * stay NULL -> absent from RETURNING. The IN(...) / IS NULL guards keep the statement idempotent. + * SHAPE: a CTE + join, not `UPDATE ... FROM LATERAL (... t.geom ...)`, because Postgres forbids a LATERAL + * FROM-item from referencing the UPDATE target (42P10); inside the CTE's plain SELECT the correlation is + * allowed. */ export async function resolveGeomJurisdictions( sql: Queryable, @@ -54,12 +39,10 @@ export async function resolveGeomJurisdictions( ): Promise<{ resolved: number; stayedNull: number }> { let resolved = 0 let stayedNull = 0 - // The keyset cursor: the last id already paged past. NULL on the first iteration (no lower bound). let cursor: string | null = null for (;;) { - // Strict lower bound for this page, lifted into an explicitly-typed fragment (the codebase convention; - // the `: SqlFragment` annotation breaks the `sql` self-reference that would otherwise infer `any`). + // The `: SqlFragment` annotation breaks the `sql` self-reference that would otherwise infer `any`. const cursorFilter: SqlFragment = cursor === null ? sql`` : sql`AND id > ${cursor}` const idFilter: SqlFragment = opts.ids === undefined ? sql`` : sql`AND id = ANY(${opts.ids as string[]}::uuid[])` @@ -100,7 +83,7 @@ export async function resolveGeomJurisdictions( resolved += updated.length stayedNull += batch.length - updated.length - // Advance past the whole batch — including rows that stayed NULL — so they are never revisited. + // Advance past the whole batch, including rows that stayed NULL, so they are never revisited. cursor = batch[batch.length - 1]!.id console.log( @@ -113,29 +96,21 @@ export async function resolveGeomJurisdictions( return { resolved, stayedNull } } -/** The columns every reference-code loop selects; `Row` adds whatever else its allocator needs. */ export interface ReferenceCodeRow { id: string created_at: Date // The keyset bound. A millisecond Date bound sits below its own row, so a row that keeps failing at // the tail would be re-selected forever. cursor_at: string | null - /** jurisdictions.code joined through jurisdiction_geoid; NULL when unresolved or the row has no code. */ jur_code: number | null } /** - * Stamp `
.reference_code` for every row still NULL, oldest-first (created_at ASC, id ASC for a - * stable tiebreak), in keyset-cursor batches. For each row the JURCODE comes from the joined - * jurisdictions.code (UNKNOWN_JURCODE/0 when unresolved), `spec.allocate` mints the code from the SHARED - * reference_counters allocator — the same one the live create paths use, so a backfill running concurrently - * with live traffic can never collide — and the row is stamped. Returns how many rows were stamped. - * - * Each row runs in its OWN transaction: allocate (FIRST, per the reference-code lock-order contract) + - * stamp commit together, so a mid-batch failure cannot leak a consumed counter value against an un-stamped - * row beyond that single row. A failed row is counted and logged, never fatal. + * `spec.allocate` must use the shared reference_counters allocator the live create paths use, so a + * backfill running alongside live traffic can never collide. * - * `spec.extraColumn` is the one extra column the allocator needs (reports: `type`); NULL selects none. + * Each row runs in its own transaction (allocate first, per the lock-order contract, then stamp), so a + * failure can waste at most one counter value. A failed row is counted and logged, never fatal. */ export async function stampReferenceCodes( sql: Sql, @@ -149,7 +124,6 @@ export async function stampReferenceCodes( ): Promise<{ stamped: number; failed: number }> { let stamped = 0 let failed = 0 - // Keyset cursor over (created_at, id). NULL on the first page (no lower bound). let cursor: { at: string | null; id: string } | null = null const extraColumns: SqlFragment = spec.extraColumn === null ? sql`` : sql`, t.${sql(spec.extraColumn)}` diff --git a/services/api/src/db/backfill-population-core.ts b/services/api/src/db/backfill-population-core.ts index 79f2b29f..23cdc8e4 100644 --- a/services/api/src/db/backfill-population-core.ts +++ b/services/api/src/db/backfill-population-core.ts @@ -16,7 +16,7 @@ const defaultFetchJson: CensusJsonFetch = async (url) => { const contentType = res.headers.get("content-type") ?? "" if (res.url.includes("missing_key") || (!contentType.includes("json") && res.redirected)) { throw new Error( - "Census API requires an API key — set CENSUS_API_KEY (free, instant: https://api.census.gov/data/key_signup.html)", + "Census API requires an API key: set CENSUS_API_KEY (free, instant: https://api.census.gov/data/key_signup.html)", ) } if (!res.ok) throw new Error(`Census API ${res.status} ${res.statusText}`) @@ -46,11 +46,9 @@ function acsUrl( } /** - * The Census geography columns a response may carry, in the order they CONCATENATE into a GEOID - * (state "06" + county "037" = "06037"; state "06" + place "44000" = "0644000"). Only these are used, and - * always in this order — never header order — so an extra column (a `NAME` get-variable, a Census column - * reshuffle) can never end up inside a geoid. Add a level here to support it; an unlisted level is ignored, - * which is why every caller must fetch only the levels above. + * The order these columns concatenate into a GEOID (state "06" + county "037" = "06037"). They are always + * read in this order, never header order, so an extra column (a `NAME` variable, a Census reshuffle) can + * never end up inside a geoid. An unlisted level is ignored, so callers must fetch only these levels. */ const ACS_GEO_COLUMNS = ["state", "county", "place"] as const @@ -135,7 +133,7 @@ export async function backfillPopulation( const fetched = collected.length if (fetched === 0) { log( - "fetched 0 ACS rows — every Census call failed. The Census API requires CENSUS_API_KEY " + + "fetched 0 ACS rows: every Census call failed. The Census API requires CENSUS_API_KEY " + "(free, instant: https://api.census.gov/data/key_signup.html); set it and re-run.", ) return { fetched: 0, updated: 0, states: states.length } diff --git a/services/api/src/db/backfill-population.ts b/services/api/src/db/backfill-population.ts index 6fe64154..9bd2a6dc 100644 --- a/services/api/src/db/backfill-population.ts +++ b/services/api/src/db/backfill-population.ts @@ -4,11 +4,9 @@ * pnpm db:backfill-population # latest default ACS vintage * pnpm db:backfill-population 2023 # explicit ACS5 vintage year * - * Runs against the env DATABASE_URL (open your own tunnel + export it, or use - * `pnpm db:boundaries:refresh --backfill-only` which auto-tunnels to prod and includes this step). The - * upsert LOGIC lives in the guard-free ./backfill-population-core.js (imported here + by refresh-boundaries). - * REQUIRES CENSUS_API_KEY — the Census API now rejects unkeyed requests (redirects to missing_key.html). - * Get a free, instant key at https://api.census.gov/data/key_signup.html and export it before running. + * Runs against the env DATABASE_URL (open your own tunnel and export it, or use + * `pnpm db:boundaries:refresh --backfill-only`, which includes this step). Requires CENSUS_API_KEY: the + * Census API rejects unkeyed requests. A free key: https://api.census.gov/data/key_signup.html */ import { runDbCli, runIfMain } from "./cli.js" diff --git a/services/api/src/db/backfill-post-geom.ts b/services/api/src/db/backfill-post-geom.ts index 96d04e4c..d2846d6f 100644 --- a/services/api/src/db/backfill-post-geom.ts +++ b/services/api/src/db/backfill-post-geom.ts @@ -6,7 +6,7 @@ export { backfillPostGeom } from "./backfill-post-geom-core.js" async function main(): Promise { await runDbCli(async (_db, sql) => { const { scanned, filled } = await backfillPostGeom(sql) - console.log(`backfill-post-geom: done — scanned=${scanned} filled=${filled}`) + console.log(`backfill-post-geom: done: scanned=${scanned} filled=${filled}`) }) } diff --git a/services/api/src/db/backfill-reference-codes-core.ts b/services/api/src/db/backfill-reference-codes-core.ts index fda73aa7..f8608d63 100644 --- a/services/api/src/db/backfill-reference-codes-core.ts +++ b/services/api/src/db/backfill-reference-codes-core.ts @@ -1,17 +1,9 @@ /** - * Reference-code backfill CORE: the pure, side-effect-free entry points that stamp historical - * reports + cleanups with reference codes (issue #56). No `main()`, no CLI guard, so this module is safe - * to import from anywhere (the CLI shell backfill-reference-codes.ts imports it). See - * ingest-jurisdictions-core.ts for the full tsup-bundling rationale behind the guard-free split. + * Guard-free (no runIfMain) so it is safe to import anywhere; see ingest-jurisdictions-core.ts. * - * RACE-FREE BY DESIGN: every code is minted through the SAME reference_counters allocator - * (allocateNextSeq) that the live create paths use, so a backfill running AFTER deploy — concurrently - * with live traffic minting fresh codes — can never collide. Idempotent: each loop only touches rows - * whose reference_code IS NULL, and a KEYSET CURSOR guarantees termination even for rows that stay NULL. - * The loops themselves live in ./backfill-keyset.ts (shared with the reports jurisdiction backfill). - * - * H3: run this as a SEPARATE post-deploy script, NEVER inside the migration transaction (a long table - * scan/rewrite would block boot). + * Every code comes from the reference_counters allocator the live create paths use, so running this + * alongside live traffic can never collide. Run it as a separate post-deploy script, never inside the + * migration transaction: a long table scan there would block boot. */ import type { ReportType } from "@civfix/shared" @@ -25,16 +17,8 @@ import { allocateReportReferenceCode, allocateEventReferenceCode } from "./refer const BATCH_SIZE = 500 -/** Log prefix shared by every step of this backfill (matches the CLI shell's messages). */ const LABEL = "reference-codes" -/** - * Backfill `reports.reference_code` for every report still NULL, oldest-first (created_at ASC, id ASC for - * a stable tiebreak), in keyset-cursor batches. The TYPECODE comes from `reports.type` (via the shared map - * inside allocateReportReferenceCode, M6) and the JURCODE from the report's jurisdiction - * (jurisdictions.code; UNKNOWN_JURCODE/0 when unresolved or the joined code is NULL). Returns how many rows - * were stamped. - */ export async function backfillReportReferenceCodes( sql: Sql, ): Promise<{ stamped: number; failed: number }> { @@ -47,12 +31,6 @@ export async function backfillReportReferenceCodes( }) } -/** - * Resolve `cleanups.jurisdiction_geoid` for every cleanup still NULL, using the SAME point-in-polygon SQL - * + ordering constant as the write-time resolver (resolveForPoint / JURISDICTION_RESOLVE_ORDER_BY), so the - * EVENT JURCODE matches what a fresh create would assign. Keyset-batched over cleanups.id, idempotent - * (only touches NULL rows). Returns how many got a non-NULL geoid. Geometry flows ONLY through the raw tag. - */ export async function backfillCleanupJurisdictions( sql: Queryable, opts: { ids?: readonly string[] } = {}, @@ -64,12 +42,7 @@ export async function backfillCleanupJurisdictions( }) } -/** - * Backfill `cleanups.reference_code` for every cleanup still NULL, oldest-first, in keyset-cursor batches. - * JURCODE comes from the cleanup's jurisdiction (jurisdictions.code; UNKNOWN_JURCODE/0 when unresolved), - * the code is allocated from the shared EVENT counter, and the row is stamped. Run AFTER - * backfillCleanupJurisdictions so the JURCODE is resolved. Returns how many stamped. - */ +/** Run after backfillCleanupJurisdictions so each event's JURCODE is resolved. */ export async function backfillCleanupReferenceCodes( sql: Sql, ): Promise<{ stamped: number; failed: number }> { @@ -82,10 +55,6 @@ export async function backfillCleanupReferenceCodes( }) } -/** - * Run the full reference-code backfill in the correct order: stamp report codes, resolve cleanup - * jurisdictions, then stamp event codes. All steps are idempotent + safe to re-run. - */ export async function backfillReferenceCodes(sql: Sql): Promise<{ reports: { stamped: number; failed: number } cleanupJurisdictions: { resolved: number; stayedNull: number } diff --git a/services/api/src/db/backfill-reference-codes.ts b/services/api/src/db/backfill-reference-codes.ts index 6c1e71d8..2d89dc67 100644 --- a/services/api/src/db/backfill-reference-codes.ts +++ b/services/api/src/db/backfill-reference-codes.ts @@ -1,13 +1,9 @@ /** - * Reference-code backfill CLI (issue #56): stamp historical reports + cleanups with reference codes, and - * resolve cleanup jurisdictions, AFTER a deploy is healthy (H3 — never in the migration tx). + * Stamps historical reports and cleanups with reference codes after a deploy is healthy, never inside the + * migration transaction. * * pnpm db:backfill:reference-codes (dev: tsx) * node dist/db/backfill-reference-codes.js (prod) - * - * The keyset loops live in the guard-free ./backfill-reference-codes-core.js (imported by both this - * shell and any runtime caller; see that file for the race-free-by-shared-counter design, idempotency, - * and the tsup-bundling rationale that requires the split). This file is ONLY the CLI shell. */ import { runDbCli, runIfMain } from "./cli.js" @@ -19,7 +15,7 @@ async function main(): Promise { await runDbCli(async (_db, sql) => { const { reports, cleanupJurisdictions, cleanups } = await backfillReferenceCodes(sql) console.log( - `reference-codes: done — reports stamped=${reports.stamped} failed=${reports.failed}; ` + + `reference-codes: done: reports stamped=${reports.stamped} failed=${reports.failed}; ` + `cleanup jurisdictions resolved=${cleanupJurisdictions.resolved} ` + `still-null=${cleanupJurisdictions.stayedNull}; ` + `cleanups stamped=${cleanups.stamped} failed=${cleanups.failed}`, diff --git a/services/api/src/db/backfill-signup-seats.ts b/services/api/src/db/backfill-signup-seats.ts index dbd7a352..b49f860e 100644 --- a/services/api/src/db/backfill-signup-seats.ts +++ b/services/api/src/db/backfill-signup-seats.ts @@ -1,24 +1,17 @@ /** - * Signup-seat backfill (DECISIONS §44). A sign-up on an event with NO ticket types now writes a free - * `cleanup_registrations` row plus one `cleanup_registration_seats` row, so the host roster, the - * check-in scanner, the counters and the attendee's own ticket all work on slot-based events. Rows - * that predate that change have a `cleanup_members` row and nothing else — this CLI mints the missing - * registration + seat for them. + * A sign-up on an event with no ticket types writes a free registration plus one seat (DECISIONS §44); + * members who joined before that have neither, and this CLI mints them. * - * It is a Node one-shot and NOT a SQL migration because `ticket_token_hash` is - * sha256(base32(hmac(TICKET_TOKEN_SECRET, seat_id))) — the HMAC is not computable in Postgres without - * putting the ticket secret in the database. + * A Node one-shot rather than a SQL migration because `ticket_token_hash` is an HMAC over the ticket + * secret, which is not computable in Postgres without putting the secret in the database. * * DATABASE_URL=postgres://... pnpm db:backfill:signup-seats # rehearse (rollback), report * DATABASE_URL=postgres://... pnpm db:backfill:signup-seats -- --yes # commit * DATABASE_URL=postgres://... pnpm db:backfill:signup-seats -- --yes --batch 200 * - * Scope: every `cleanup_members` row of an event that is not cancelled and has not ended - * (`ends_at > now()`), that has zero ticket types, where the member holds no active registration — - * organizers included, because the live `claimSlot` path mints a seat for an organizer who claims - * their own shift and 0169 gave every existing member a slot claim. Safe to re-run: the insert - * carries the same `ON CONFLICT (cleanup_id, user_id) WHERE status = 'registered'` arbiter the - * runtime path uses, and the candidate query already excludes anyone who has a registration. + * Organizers are included, because the live claimSlot path mints a seat for an organizer who claims + * their own shift and 0169 gave every existing member a slot claim. Safe to re-run: the insert carries + * the same ON CONFLICT arbiter the runtime path uses. */ import { randomUUID } from "node:crypto" @@ -238,7 +231,7 @@ export async function main(): Promise { ...(batchSize !== undefined ? { batchSize } : {}), }) console.log( - `backfill-signup-seats: done — scanned=${result.scanned} seats=${result.created} batches=${result.batches}` + + `backfill-signup-seats: done: scanned=${result.scanned} seats=${result.created} batches=${result.batches}` + (commit ? "" : " (rolled back; re-run with --yes to commit)"), ) }) diff --git a/services/api/src/db/backfill-user-activity.ts b/services/api/src/db/backfill-user-activity.ts index ea4f9443..9757e97e 100644 --- a/services/api/src/db/backfill-user-activity.ts +++ b/services/api/src/db/backfill-user-activity.ts @@ -6,7 +6,7 @@ export { backfillUserActivity } from "./backfill-user-activity-core.js" async function main(): Promise { await runDbCli(async (_db, sql) => { const { scanned, filled } = await backfillUserActivity(sql) - console.log(`backfill-user-activity: done — scanned=${scanned} filled=${filled}`) + console.log(`backfill-user-activity: done: scanned=${scanned} filled=${filled}`) }) } diff --git a/services/api/src/db/boundaries/manifest.ts b/services/api/src/db/boundaries/manifest.ts index 5ac5fb51..c358ae3c 100644 --- a/services/api/src/db/boundaries/manifest.ts +++ b/services/api/src/db/boundaries/manifest.ts @@ -1,134 +1,77 @@ /** - * Path-A boundary-prep manifest: the PURE, DB-free enumeration of the GIS conversion jobs an operator - * runs ONCE (Phase 1 of documents/20-jurisdiction-mapping.md) to turn the authoritative, public-domain - * US boundary sources into the GeoJSON artifacts the existing `db:ingest` CLI loads. This is what ends - * the "everything is Unmapped" problem: the resolver/table/ingest/geocoder are all built and correct; - * the only thing missing is real coverage in the `jurisdictions` table, and these jobs produce it. + * The GIS conversion jobs that turn the public-domain US boundary sources into the GeoJSON the `db:ingest` + * CLI loads. Pure (no IO, no DB, no GDAL) so it is unit-testable; scripts/prepare-boundaries.ts is the only + * piece that shells out to ogr2ogr. * - * One job per layer (a national file for state/county/AIANNH/federal, and one file PER STATE for - * places). Each job records: - * - the canonical Census/USGS download URL for the source archive, - * - the civfix `layer` the file maps to (a member of JURISDICTION_LAYER_VALUES — drift-guarded), - * - the exact `ogr2ogr` arguments (format + reprojection + any source filter), and - * - the output GeoJSON filename + the geoid prefix the INGEST step must apply (metadata only). + * Two load-bearing invariants: * - * Two invariants this module deliberately enforces, both load-bearing: + * 1. Every job reprojects NAD83 (EPSG:4269, TIGER's native CRS) to EPSG:4326, which the geom column and + * the ST_GeomFromGeoJSON ingest path both assume. The offset is sub-meter, but the SRID must be right. * - * 1. EVERY job reprojects NAD83 (EPSG:4269, the TIGER native CRS) -> WGS84 (EPSG:4326). The - * `jurisdictions.geom` column and the `ST_GeomFromGeoJSON` ingest path both assume 4326; the - * offset is sub-meter but the SRID MUST be correct, so `-t_srs EPSG:4326` is on every job. + * 2. Conversion keeps the raw Census/USGS ids. The AIANNH-/PADUS- prefix that makes the non-FIPS layers + * globally unique (and keeps the geocoder's FIPS-prefix shortcut valid for place/county/state only) + * is applied exactly once, at ingest. It never appears in `ogr2ogrArgs`, which is what makes a + * double prefix impossible. * - * 2. The conversion keeps the RAW Census/USGS ids (place/county/state carry the bare FIPS-hierarchical - * GEOID; AIANNH/PAD-US keep their native ids). The geoid PREFIX (AIANNH-/PADUS-) that makes the - * non-FIPS layers globally unique — and keeps the geocoder's FIPS-prefix shortcut valid (it must - * only trust place/county/state geoids, see geocoder.tiger.ts) — is applied EXACTLY ONCE, later, - * by the ingest CLI (src/db/ingest-jurisdictions.ts, positional geoid-prefix arg). This manifest - * therefore NEVER puts a prefix string inside `ogr2ogrArgs`; it records the intended prefix only as - * the `ingestGeoidPrefix` METADATA field so the runner/runbook can print the right `db:ingest` - * invocation. Keeping prefixing out of the conversion is what makes a double-prefix impossible. + * Places are filtered to MTFCC G4110 (incorporated places with governmental authority). Census Designated + * Places (G4210) are excluded because a point on unincorporated land must fall through to the county, the + * correct routing authority there. * - * Why places are filtered to MTFCC `G4110`: that is the "incorporated place" class that carries - * governmental authority. `G4210` (Census Designated Place — an unincorporated statistical area) is - * EXCLUDED, because a point in unincorporated land must fall through place -> county, and the COUNTY is - * the correct routing authority there (Design A; no county-subdivision tier — see the design doc §5). - * - * Why PAD-US is filtered to `Mang_Type='FED'`: AIANNH owns the `tribal` layer (cleaner, non-overlapping, - * GEOID-keyed), so PAD-US is scoped to FEDERAL manager only to avoid double-counting tribal polygons. - * - * This module is PURE: it imports nothing from the DB client or env, performs no IO and no network - * probe (the `'latest'` vintage resolves to DEFAULT_TIGER_VINTAGE in-process), so it is fully - * unit-testable with no database and no GDAL. The thin runner (scripts/prepare-boundaries.ts) is the - * only piece that shells `ogr2ogr`. + * PAD-US is scoped to Mang_Type='FED' because AIANNH owns the tribal layer; including PAD-US tribal + * polygons would double-count them. */ import type { JURISDICTION_LAYER_VALUES } from "../schema/types.js" -/** The civfix jurisdiction layers a boundary job can target. Subset of JURISDICTION_LAYER_VALUES. */ type BoundaryLayer = (typeof JURISDICTION_LAYER_VALUES)[number] -/** - * One boundary-prep job: a single source archive converted (with reprojection + any source filter) into - * one GeoJSON file, plus the geoid prefix the ingest step should apply when loading it. - */ export interface BoundaryJob { - /** Census/USGS download URL for the source archive (the operator fetches + unzips this). */ sourceUrl: string - /** civfix `layer` this file maps to (drift-guarded against JURISDICTION_LAYER_VALUES). */ layer: BoundaryLayer /** - * Exact `ogr2ogr` arguments for the conversion: GeoJSON output (GeoJSONSeq for the oversized federal - * layer) reprojected to EPSG:4326, `-makevalid` geometry repair on EVERY job (so no invalid polygon ever - * reaches prod — see the federal job note), plus a source filter for places (`-where` G4110) and federal - * (`-sql` Mang_Type='FED'). NEVER contains a geoid - * prefix string — prefixing happens at ingest time (see file header). The runner appends the output - * path and the (already-downloaded) source path after these args. + * Never contains a geoid prefix (see the header). `-makevalid` is on every job so no invalid polygon + * reaches prod. The runner appends the output and source paths. */ ogr2ogrArgs: string[] - /** Output GeoJSON filename (relative to the runner's outDir), e.g. "places_06.geojson". */ outFile: string /** - * The dataset path (relative to /sources/) that ogr2ogr reads — i.e. what the source archive - * extracts to. For TIGER/AIANNH shapefiles this is the like-named ".shp" (a single-layer dataset); - * for the PAD-US job it is the File Geodatabase directory "PADUSGeodatabase.gdb" (the specific Fee - * feature class is selected by the job's ogr2ogr `-sql`, so no separate layer arg is needed). An - * explicit path — not a basename heuristic — so the GDB and the query-string PAD-US URL load correctly. + * An explicit path rather than a basename heuristic, so the PAD-US geodatabase directory and its + * query-string URL load correctly. */ sourcePath: string - /** - * The geoid prefix the INGEST CLI must apply to this file's features (METADATA ONLY — not used by the - * conversion). null for the FIPS-hierarchical TIGER layers (place/county/state keep the raw GEOID); - * "AIANNH-" for the tribal/AIANNH job; "PADUS-" for the federal/PAD-US job. - */ + /** Metadata only: the conversion never uses it. */ ingestGeoidPrefix: string | null } -/** - * Newest TIGER/Line vintage as of the design doc (2026-06-15). The freshness job (design doc §11) should - * always resolve "latest" rather than pinning a year; in Design A `boundaryManifest('latest')` resolves - * to this constant in-process (no network probe). - */ +/** `boundaryManifest('latest')` resolves to this in-process rather than probing the network. */ export const DEFAULT_TIGER_VINTAGE = 2025 -/** Current PAD-US (USGS GAP) version — versioned separately from TIGER (4.1 = 2024 release). */ +/** Versioned separately from TIGER. Bumping it also requires updating PADUS_GDB_URL. */ export const PADUS_VERSION = "4.1" -/** - * Canonical dataset identity for a (TIGER vintage, PAD-US version) pair, e.g. "tiger2025-padus4.1". The - * local refresh tool (scripts/refresh-boundaries.ts) stamps this into boundary_vintage after a load so an - * operator can see WHAT is loaded (a "-nofed" suffix records a run where PAD-US was skipped). AIANNH - * shares the TIGER vintage, so it does not appear in the tag. - */ +/** AIANNH shares the TIGER vintage, so it does not appear in the tag. */ export function vintageTag(tigerVintage: number, padusVersion: string = PADUS_VERSION): string { return `tiger${tigerVintage}-padus${padusVersion}` } -/** - * USGS PAD-US human download page — kept for the runbook/citation (DOI 10.5066/P96WBCHS). The machine - * fetch uses PADUS_GDB_URL below, not this page. - */ +/** For the runbook and citation (DOI 10.5066/P96WBCHS); the machine fetch uses PADUS_GDB_URL. */ export const PADUS_DOWNLOAD_URL = "https://www.usgs.gov/programs/gap-analysis-project/science/pad-us-data-download" /** - * VERIFIED direct-download URL for the PAD-US 4.1 national File Geodatabase (~1.52 GB zip), used by the - * CI workflow's federal job. This is a ScienceBase asset whose item id (652d4fc5…) is SPECIFIC to the - * 4.1 release and is NOT derivable from the version number — so bumping PADUS_VERSION ALSO requires - * updating this URL (find the new "Full Inventory Database" item on ScienceBase). The only working fetch - * pattern is catalog/file/get/?name= on www.sciencebase.gov; the `manager/` - * download hosts serve an HTML shell, not the zip. NOTE: this endpoint ignores HTTP Range, so the full - * 1.52 GB is pulled each run (no resumable download). + * The ScienceBase item id is specific to the 4.1 release and not derivable from the version, so bumping + * PADUS_VERSION means finding the new "Full Inventory Database" item. Only the catalog/file/get pattern + * serves the zip (the `manager/` hosts serve an HTML shell), and it ignores HTTP Range, so the full + * ~1.5 GB is pulled every run. */ export const PADUS_GDB_URL = "https://www.sciencebase.gov/catalog/file/get/652d4fc5d34e44db0e2ee45e?name=PADUS4_1Geodatabase.zip" -/** PAD-US version with the dot replaced ("4.1" -> "4_1"), used to derive the GDB folder + Fee layer - * names, which both embed the version (PADUS4_1Geodatabase.gdb / PADUS4_1Fee). */ const PADUS_VERSION_NODOT = PADUS_VERSION.replace(/\./g, "_") /** - * All 50 US state FIPS codes (2-digit, zero-padded), used to enumerate the per-state TIGER PLACE jobs. - * Territories + DC (11 DC, 60 AS, 66 GU, 69 MP, 72 PR, 78 VI) are DEFERRED to Design B — places there - * are not part of the first Design-A ship. Order matches the geocoder's STATE_FIPS_TO_USPS key order. + * Territories and DC (11, 60, 66, 69, 72, 78) are deliberately not covered yet. Order matches the + * geocoder's STATE_FIPS_TO_USPS key order. */ export const STATE_FIPS: readonly string[] = [ "01", @@ -183,25 +126,15 @@ export const STATE_FIPS: readonly string[] = [ "56", ] as const -/** Census TIGER directory root for a given vintage, e.g. ".../TIGER2025". */ function tigerRoot(vintage: number): string { return `https://www2.census.gov/geo/tiger/TIGER${vintage}` } -/** - * Build the Path-A boundary-prep manifest for a TIGER vintage. `'latest'` resolves to - * DEFAULT_TIGER_VINTAGE (no network probe — Design A pins the in-process default; the freshness job - * owns "actually latest"). Returns one job per national layer (state/county/AIANNH/federal) plus one - * PLACE job per state in STATE_FIPS (50). PURE: no IO, no DB, deterministic for a given vintage. - * - * @param vintage TIGER vintage year, or 'latest' (-> DEFAULT_TIGER_VINTAGE). - */ export function boundaryManifest( vintage: number | "latest" = DEFAULT_TIGER_VINTAGE, ): BoundaryJob[] { const year = vintage === "latest" ? DEFAULT_TIGER_VINTAGE : vintage - // Reject a non-integer / out-of-range vintage here rather than letting it 404 at fetch time with a - // confusing URL like .../TIGERNaN. The Census TIGER program began in 2007. + // Fail here rather than 404 later on a URL like .../TIGERNaN. TIGER began in 2007. if (!Number.isInteger(year) || year < 2007 || year > 2100) { throw new Error(`boundaryManifest: invalid TIGER vintage ${String(vintage)}`) } @@ -209,7 +142,6 @@ export function boundaryManifest( const jobs: BoundaryJob[] = [] - // (a) States (national). GEOID = 2-char STATEFP; raw geoid is kept (FIPS-hierarchical), no prefix. jobs.push({ sourceUrl: `${root}/STATE/tl_${year}_us_state.zip`, layer: "state", @@ -219,7 +151,6 @@ export function boundaryManifest( ingestGeoidPrefix: null, }) - // (b) Counties (national). GEOID = 5-char (state+county); raw geoid is kept, no prefix. jobs.push({ sourceUrl: `${root}/COUNTY/tl_${year}_us_county.zip`, layer: "county", @@ -229,9 +160,6 @@ export function boundaryManifest( ingestGeoidPrefix: null, }) - // (c) Places (per-state, 50 jobs). Filter to MTFCC G4110 (incorporated places with governmental - // authority); G4210 (CDP) is excluded so unincorporated land falls through place -> county. - // GEOID = 7-char (state+place); raw geoid is kept, no prefix. for (const ss of STATE_FIPS) { jobs.push({ sourceUrl: `${root}/PLACE/tl_${year}_${ss}_place.zip`, @@ -251,10 +179,7 @@ export function boundaryManifest( }) } - // (d) AIANNH (national tribal). TIGER AIANNH carries GEOID + NAME as real attributes (id field is - // AIANNHCE, equal to GEOID), so the ingest CLI reads them directly. The "AIANNH-" prefix that - // prevents a 5-char AIANNH geoid from colliding with a 5-char county PK is applied by the INGEST - // step, recorded here as metadata only. + // The AIANNH- prefix keeps a 5-char AIANNH geoid from colliding with a 5-char county PK. jobs.push({ sourceUrl: `${root}/AIANNH/tl_${year}_us_aiannh.zip`, layer: "tribal", @@ -264,26 +189,19 @@ export function boundaryManifest( ingestGeoidPrefix: "AIANNH-", }) - // (e) PAD-US federal (national File Geodatabase). Reads the Fee feature class and scopes to FEDERAL - // manager (AIANNH owns tribal). Uses ogr2ogr's DEFAULT (OGR) SQL dialect — which reliably carries - // the geometry through an attribute projection — to (1) select the version-named Fee layer, - // (2) filter Mang_Type='FED', and (3) ALIAS the GDB's OBJECTID -> GEOID and Unit_Nm -> NAME so they - // land as GeoJSON *properties* the ingest CLI reads. `-nlt PROMOTE_TO_MULTI` normalizes Polygon -> - // MultiPolygon. (We deliberately avoid the SQLITE dialect here: with an explicit column projection - // it can drop the geometry unless the geometry column is named, which silently yields 0 ingestable - // features — the prepare-boundaries polygon-count guard would catch that, but the OGR dialect avoids - // it outright.) The "PADUS-" prefix (applied at ingest, metadata here) makes the non-FIPS ids - // globally unique AND makes the geocoder fall back to the authoritative containing-state query. - // NOTE: PAD-US OBJECTIDs are not stable across PAD-US versions, so a version bump reshuffles these - // geoids (old PADUS- rows linger, advisory-stale, since the load never deletes). - // OUTPUT FORMAT: GeoJSONSeq (`.geojsonl`, one Feature per line), NOT a single FeatureCollection. The - // federal export is >512 MB, which exceeds Node's max string length — so the refresh tool STREAMS it - // (ingestGeoJsonSeqFile) instead of readFileSync, which would throw ERR_STRING_TOO_LONG. The small - // TIGER layers stay on plain GeoJSON. ogr2ogr `-t_srs`/`-sql`/`-nlt` are format-independent. - // VALIDITY: `-makevalid` repairs PAD-US's self-intersecting / ring-invalid polygons DURING the local - // ogr2ogr conversion, so prod only ever ingests valid geometry. This MUST happen here, not as a - // prod-side ST_MakeValid — ST_MakeValid on the worst PAD-US multipolygons is prohibitively slow and - // can take down a Postgres backend. Invalid geom would make the resolver's ST_Contains unreliable. + // The default OGR SQL dialect, not SQLITE: with an explicit column projection SQLITE can drop the + // geometry unless it is named, silently yielding zero ingestable features. The aliases land OBJECTID and + // Unit_Nm as the GEOID/NAME properties the ingest CLI reads. + // + // PAD-US OBJECTIDs are not stable across versions, so a version bump reshuffles these geoids; old + // PADUS- rows linger because the load never deletes. + // + // GeoJSONSeq, not a FeatureCollection: the federal export exceeds Node's max string length, so the + // refresh tool streams it instead of readFileSync (ERR_STRING_TOO_LONG). + // + // `-makevalid` must repair PAD-US's invalid polygons here, during the local conversion: ST_MakeValid on + // the worst multipolygons is slow enough to take down a Postgres backend, and invalid geometry would + // make the resolver's ST_Contains unreliable. jobs.push({ sourceUrl: PADUS_GDB_URL, layer: "federal", diff --git a/services/api/src/db/client.ts b/services/api/src/db/client.ts index 9d36e195..88eec8ab 100644 --- a/services/api/src/db/client.ts +++ b/services/api/src/db/client.ts @@ -13,20 +13,14 @@ export interface DbHandle { close(): Promise } -/** - * The postgres.js `ssl` option shapes we produce. `false` = plaintext (dev/testcontainers only). - */ +/** `false` = plaintext, for dev and testcontainers only. */ export type DbSslOption = false | "require" | "verify-full" | { rejectUnauthorized: true } /** - * Derive the postgres.js `ssl` option from the connection string's `sslmode` (M15). - * - * postgres.js defaults `ssl` to **false**: without an explicit option a URL that merely *looks* secure - * still ships credentials and rows in cleartext. It does read `sslmode` itself, but silently — so we - * resolve it here instead, pass the result explicitly to BOTH postgres() clients, and unit-test the - * mapping. `prefer`/`allow`/`disable`/absent stay plaintext on purpose: local dev and the testcontainers - * integration suite talk to a loopback container with no TLS. Production can never reach that branch — - * loadEnv refuses to boot unless DATABASE_URL carries a TLS sslmode. + * postgres.js defaults `ssl` to false, so a URL that merely looks secure would still ship credentials in + * cleartext; it reads `sslmode` itself only silently. Resolving it here makes the mapping explicit and + * testable. `prefer`/`allow`/`disable`/absent stay plaintext on purpose for loopback dev and test + * containers; production never reaches that branch because loadEnv refuses a non-TLS sslmode. */ export function sslOptionForUrl(databaseUrl: string): DbSslOption { let mode: string | null = null @@ -39,8 +33,8 @@ export function sslOptionForUrl(databaseUrl: string): DbSslOption { switch (mode?.trim().toLowerCase()) { case "verify-full": return "verify-full" - // verify-ca = TLS with a verified CA chain but no hostname check; postgres.js has no named mode for - // it, so express it as the equivalent tls option. + // verify-ca (verified chain, no hostname check) has no named postgres.js mode, so it is expressed + // as the equivalent tls option. case "verify-ca": return { rejectUnauthorized: true } case "require": @@ -56,7 +50,6 @@ export function makeDb( max?: number statementTimeoutMs?: number idleInTxTimeoutMs?: number - /** Override the sslmode-derived TLS setting (tests / callers with out-of-band TLS config). */ ssl?: DbSslOption } = {}, ): DbHandle { diff --git a/services/api/src/db/demo-join-event.ts b/services/api/src/db/demo-join-event.ts index 1c3e16a5..8630d095 100644 --- a/services/api/src/db/demo-join-event.ts +++ b/services/api/src/db/demo-join-event.ts @@ -1,16 +1,10 @@ /** - * demo-join-event: joins a subset of the seeded demo users (seed-demo-la.ts) to ONE existing event, - * exactly the way the live join path does — a cleanup_members row per user (role 'member') plus, on an - * event with no ticket types, the free registration + seat the roster and check-in read, refusing - * closed (done/cancelled) events and banned users, and never exceeding the event's RSVP capacity - * (counting existing members + non-cancelled guests, the same sum goingCount uses). If the event has - * signup slots with open capacity, some joiners also claim one (one slot per person, capacity - * respected against existing claims), matching cleanup_slot_claims semantics. - * + * Joins seeded demo users to one existing event the way the live join path does, including its refusals + * (closed events, banned users, RSVP capacity) and the free registration + seat on unticketed events. * Only accounts on the reserved demo domain are ever touched, so this cannot RSVP a real user. * - * SAFETY: same stance as seed-demo-la — the default run is a REHEARSAL (everything runs in one - * transaction, prints what it would do, then rolls back). Pass --yes to commit. + * SAFETY: the default run is a rehearsal (one transaction, printed, then rolled back). Pass --yes to + * commit. * * The seat token hash is keyed by TICKET_TOKEN_SECRET, resolved exactly as the API resolves it (the * development fallback included), or the demo seats will not scan at check-in; the api container @@ -29,8 +23,6 @@ import { DEMO_EMAIL_DOMAIN } from "./seed-demo-domain.js" import { demoTicketTokenHasher, mintDemoSignupSeats } from "./demo-signup-seats.js" import { deriveCleanupStatus, eventWindowOf } from "../services/cleanup-rules.js" -// --- deterministic PRNG (mulberry32), same generator seed-demo-la uses --------------------------- - let rand = (): number => Math.random() function mulberry32(seed: number): () => number { @@ -57,10 +49,7 @@ function shuffle(arr: T[]): T[] { return arr } -/** - * A plausible joined_at: spread over the window from `start` to now, biased toward the recent end - * (RSVPs cluster after a promo post), with daytime/evening minutes. - */ +/** Biased toward the recent end, because RSVPs cluster after a promo post. */ function joinTimestamp(start: Date, now: Date): Date { const span = Math.max(now.getTime() - start.getTime(), 60_000) const t = new Date(now.getTime() - Math.pow(rand(), 2) * span) @@ -97,7 +86,6 @@ async function loadEvent(tx: TransactionSql, ref: string): Promise` SELECT id, title, status, scheduled_at, ends_at, created_at, capacity, organizer_user_id @@ -154,7 +142,6 @@ export async function main(): Promise { `event: ${event.title} (${derived}, scheduled ${event.scheduled_at.toISOString()})`, ) - // Demo users not already members, not banned, not the organizer; skip soft-deleted. const candidates = await tx<{ id: string; handle: string; created_at: Date }[]>` SELECT u.id, u.handle, u.created_at FROM users u @@ -170,7 +157,7 @@ export async function main(): Promise { ) } - // Respect the RSVP capacity the way goingCount measures it: members + non-cancelled guests. + // The same sum goingCount measures: members plus non-cancelled guests. let room = Number.POSITIVE_INFINITY if (event.capacity !== null) { const [going] = await tx<{ n: number }[]>` @@ -210,7 +197,6 @@ export async function main(): Promise { hashFor, }) - // Some joiners claim an open signup slot, respecting per-slot capacity + one-claim-per-person. const slots = await tx< { id: string; title: string; capacity: number | null; claims: number }[] >` @@ -245,7 +231,6 @@ export async function main(): Promise { ) console.log(` ${handles}`) - // Verify: capacity + slot invariants still hold after the writes. const [overCap] = await tx<{ n: number }[]>` SELECT count(*)::int AS n FROM cleanup_slots s WHERE s.cleanup_id = ${event.id} AND s.capacity IS NOT NULL diff --git a/services/api/src/db/demo-signup-seats.ts b/services/api/src/db/demo-signup-seats.ts index 9fdda16f..c9bc0eaa 100644 --- a/services/api/src/db/demo-signup-seats.ts +++ b/services/api/src/db/demo-signup-seats.ts @@ -1,8 +1,7 @@ /** - * The free registration + seat minting both demo CLIs share (seed-demo-la.ts, demo-join-event.ts). - * Guard-free module (no run-as-main CLI guard) for the same reason as seed-demo-domain.ts: tsup - * (splitting: false) inlines imports into each bundled entry, so a runIfMain guard in an imported - * CLI fires inside the importing bundle and runs the wrong main. + * Guard-free module (no runIfMain), shared by both demo CLIs: tsup (splitting: false) inlines imports + * into each bundled entry, so a guard in an imported CLI would fire inside the importing bundle and run + * the wrong main. */ import { randomUUID } from "node:crypto" @@ -35,7 +34,7 @@ export function demoTicketTokenHasher( return (seatId) => signer.hashFor(seatId) } -/** Mint the live join path's free registration + seat for each member; a no-op on ticketed events. */ +/** A no-op on ticketed events: only the free join path mints a registration. */ export async function mintDemoSignupSeats( tx: Queryable, args: { diff --git a/services/api/src/db/ingest-jurisdictions.ts b/services/api/src/db/ingest-jurisdictions.ts index 29390f54..a466b925 100644 --- a/services/api/src/db/ingest-jurisdictions.ts +++ b/services/api/src/db/ingest-jurisdictions.ts @@ -1,14 +1,7 @@ /** - * Ingest CLI: load REAL jurisdiction boundaries from an external GeoJSON FeatureCollection into the - * `jurisdictions` table. - * * pnpm db:ingest [layer] [geoid-prefix] * - * `layer` (default "federal") is the fallback layer for features whose properties carry none. The - * geoid-prefix rule (AIANNH-/PADUS- for the non-FIPS layers) and the ingest LOGIC live in the guard-free - * ./ingest-jurisdictions-core.js (imported by both this shell and scripts/refresh-boundaries.ts; see that - * file's header for the prefix rule, the contact-preserving upsert, the public-domain sources, and the - * tsup-bundling rationale that requires the split). This file is ONLY the CLI shell (args + run). + * `layer` (default "federal") is the fallback for features whose properties carry none. */ import { readFile } from "node:fs/promises" @@ -20,8 +13,7 @@ import { type IngestRow, } from "./ingest-jurisdictions-core.js" -// Re-export the core API from the historical path so existing importers (e.g. the unit test importing -// `normalizeFeatures` from this module) keep resolving against this module. +// Re-exported from the historical path so existing importers keep resolving. export { normalizeFeatures, upsertJurisdiction, @@ -47,7 +39,7 @@ async function main(): Promise { process.exit(2) } - // Read the file up front so a missing/unreadable path is a clear error, not confused with a DB failure. + // Read up front so a missing path is a clear error rather than something that looks like a DB failure. let text: string try { text = await readFile(file, "utf8") diff --git a/services/api/src/db/migrate-files.ts b/services/api/src/db/migrate-files.ts index 4d676d15..3cc1e241 100644 --- a/services/api/src/db/migrate-files.ts +++ b/services/api/src/db/migrate-files.ts @@ -1,21 +1,6 @@ /** - * Pure helpers for the migration runner: discovering and ordering the hand-authored .sql files. - * - * Kept separate from migrate.ts (which has the side-effecting DB application) so the ordering logic - * can be unit-tested with an injected directory listing and no filesystem or database. - */ - -/** - * Given a directory listing, return only the migration .sql files in the order they must be applied: - * lexical (ASCII) ascending by filename. The numeric prefixes (0000_, 0001_, ...) make lexical order - * equal to intended order; we sort explicitly so callers never depend on readdir() ordering, which is - * platform/filesystem dependent. - * - * Non-.sql entries are ignored. Comparison is a plain `<`/`>` UTF-16 code-unit compare (NOT - * localeCompare) so it is deterministic across machines/locales. - * - * @param entries raw directory entries (file names, not paths). - * @returns the .sql file names, sorted ascending. + * Sorts explicitly because readdir() order is filesystem-dependent, and with a code-unit compare rather + * than localeCompare so the order is identical on every machine and locale. */ export function orderMigrationFiles(entries: readonly string[]): string[] { return entries diff --git a/services/api/src/db/migrate.ts b/services/api/src/db/migrate.ts index 3994c5a0..c0e07ee1 100644 --- a/services/api/src/db/migrate.ts +++ b/services/api/src/db/migrate.ts @@ -1,22 +1,10 @@ /** - * Migration runner for the hand-authored, canonical civfix DDL. + * drizzle-kit's generated-migration journal is not used: the hand SQL is the source of truth because + * drizzle-kit cannot express PostGIS geometry, GiST indexes or declarative partitioning. * - * Applies every .sql file in services/api/drizzle in lexical order, each inside its OWN transaction, - * and records applied files in a bookkeeping table `_civfix_migrations(name text primary key, - * applied_at timestamptz)`. A file that is already recorded is skipped, so re-running is safe. - * - * Design notes: - * - We do NOT use drizzle-kit's generated-migration journal: the hand SQL is the source of truth - * (PostGIS geometry, GiST indexes, declarative partitioning cannot be expressed by drizzle-kit). - * - Each file runs via `sql.unsafe(text)` in simple-query mode (no bind params), which lets a file - * contain multiple statements. Wrapping each file in `sql.begin()` makes a failed file roll back - * atomically; the bookkeeping insert is in the same transaction so a file is only ever marked - * applied if it fully succeeded. - * - Most statements are themselves idempotent (CREATE ... IF NOT EXISTS), so even a half-recorded - * state recovers cleanly. - * - Requires DATABASE_URL and a reachable Postgres. Exits non-zero on any error. Because it needs a - * live database, it is exercised in CI (Docker) and by the Testcontainers harness, not by the - * offline unit suite. The pure file-ordering logic lives in migrate-files.ts and IS unit-tested. + * Each file runs through `unsafe(text)` in simple-query mode (no bind params) so it may hold several + * statements, and its bookkeeping row commits in the same transaction, so a file is only ever marked + * applied if it fully succeeded. */ import { readdir, readFile } from "node:fs/promises" @@ -26,17 +14,14 @@ import type { Sql } from "./client.js" import { runDbCli, runIfMain } from "./cli.js" import { orderMigrationFiles } from "./migrate-files.js" -/** Absolute path to services/api/drizzle, resolved relative to this module (cwd-independent). */ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "drizzle") /** - * Fixed advisory-lock key so two instances/CI jobs booting concurrently serialize the apply loop instead - * of racing the same file (a non-IF-NOT-EXISTS statement applied twice would abort the deploy, and deploys - * are NOT health-gated). Arbitrary constant, unique to this runner. + * Serializes concurrent runners: a non-idempotent statement applied twice would abort the deploy. + * Arbitrary constant, unique to this runner. */ const MIGRATE_ADVISORY_LOCK_KEY = 4747120626 -/** Ensure the bookkeeping table exists. Idempotent. */ async function ensureBookkeeping(sql: Sql): Promise { await sql.unsafe( `CREATE TABLE IF NOT EXISTS _civfix_migrations ( @@ -46,26 +31,19 @@ async function ensureBookkeeping(sql: Sql): Promise { ) } -/** Fetch the set of already-applied migration file names. */ async function appliedSet(sql: Sql): Promise> { const rows = await sql<{ name: string }[]>`SELECT name FROM _civfix_migrations` return new Set(rows.map((r) => r.name)) } /** - * Apply all pending migrations from `dir` using `sql`. Returns the list of files that were applied - * (in order). Exported for reuse by the Testcontainers harness so tests apply the EXACT same SQL the - * runner does. - * - * Concurrency: a reserved (connection-pinned) session-level `pg_advisory_lock` serializes the whole - * read-applied -> apply-pending sequence, so two instances booting at once don't both try to apply the - * same file. The lock + every per-file transaction run on the SAME reserved connection. + * Exported so the Testcontainers harness applies exactly the SQL the runner does. The session-level + * advisory lock and every per-file transaction run on one reserved connection, so the whole + * read-applied -> apply-pending sequence is serialized. * - * GOTCHA: each file is one `tx.unsafe(text)` in a transaction, so a large-table `CREATE INDEX` - * (non-CONCURRENTLY — CONCURRENTLY can't run in a tx) takes a SHARE lock that blocks writes on - * reports/chat_messages during the deploy. For a big table, add the index out-of-band with - * `CREATE INDEX CONCURRENTLY` BEFORE the migration and make the migration's `CREATE INDEX ... IF NOT - * EXISTS` a no-op. + * GOTCHA: CREATE INDEX CONCURRENTLY cannot run in a transaction, so a plain CREATE INDEX here takes a + * SHARE lock that blocks writes on a big table during the deploy. Build such an index out-of-band with + * CONCURRENTLY first and make the migration's CREATE INDEX IF NOT EXISTS a no-op. */ export async function applyMigrations(sql: Sql, dir: string = MIGRATIONS_DIR): Promise { const reserved = await sql.reserve() @@ -85,9 +63,8 @@ export async function applyMigrations(sql: Sql, dir: string = MIGRATIONS_DIR): P for (const name of ordered) { if (already.has(name)) continue const text = await readFile(join(dir, name), "utf8") - // Transaction-per-file: the DDL and its bookkeeping row commit together or not at all. A reserved - // connection has no `.begin()` in postgres 3.4 (only the pool sql does), so drive BEGIN/COMMIT - // explicitly on the pinned connection. + // A reserved connection has no `.begin()` in postgres 3.4 (only the pool does), so BEGIN/COMMIT + // are driven explicitly on the pinned connection. await reserved.unsafe("begin") try { await reserved.unsafe(text) diff --git a/services/api/src/db/reference-code.ts b/services/api/src/db/reference-code.ts index 84ef978f..4805f402 100644 --- a/services/api/src/db/reference-code.ts +++ b/services/api/src/db/reference-code.ts @@ -1,42 +1,28 @@ /** - * Reference-code allocation (issue #56). The single, DRY home for minting the human-readable, IMMUTABLE - * reference codes stamped on reports + cleanups — shared by BOTH the live create paths (report / - * anon / cleanup repositories) AND the post-deploy backfill scripts, so a code minted by one can never - * collide with the other. + * The one allocator for the immutable reference codes on reports and cleanups, shared by the live create + * paths and the backfill scripts so a code minted by one can never collide with the other. * - * Shape (D1): * - reports: `{TYPECODE}-{JURCODE}-{NNNNNN}` e.g. "DU-42-000001" * - events : `EVENT-{JURCODE}-{NNNNNN}` e.g. "EVENT-42-000001" - * where TYPECODE is the shared REPORT_TYPE_CODE entry (M6 — imported, never re-hardcoded), JURCODE is - * jurisdictions.code (0 = unknown bucket, D5), and NNNNNN is a 6-digit zero-padded per-scope counter. * - * Allocation (D4): allocateNextSeq runs ONE atomic upsert against reference_counters. The counter is - * per scope_key ("{typecode}:{jurcode}" for reports, "EVENT:{jurcode}" for events), so concurrent - * creates in different scopes never contend, and same-scope creates serialize on the row lock. + * The counter is per scope, so creates in different scopes never contend and same-scope creates + * serialize on the counter row lock. * - * LOCK-ORDER CONTRACT (D4): callers MUST invoke allocateNextSeq / allocateReferenceCode as the FIRST - * statement inside their create transaction. Taking the counter-row lock before any report/cleanup row - * lock gives every create path a consistent lock-acquisition order, so two concurrent creates can never - * deadlock (no ABBA). The later agent wiring the live create paths is bound by this contract. - * - * Pure + dependency-free: the formatting/scope-key helpers are pure; the allocator takes the project's - * `Queryable` tag (postgres.js Sql | TransactionSql) so it runs standalone OR inside a `sql.begin` tx. + * LOCK-ORDER CONTRACT: callers MUST allocate as the FIRST statement of their create transaction. Taking + * the counter-row lock before any report or cleanup row lock gives every create path the same + * acquisition order, so two concurrent creates can never deadlock ABBA. */ import { REPORT_TYPE_CODE, type ReportType } from "@civfix/shared" import type { Queryable } from "./client.js" -/** JURCODE used when a report/event has no resolved jurisdiction (the "unknown" bucket, D5). */ export const UNKNOWN_JURCODE = 0 -/** Fixed prefix for the EVENT (cleanup) reference-code family + counter scope. */ export const EVENT_PREFIX = "EVENT" /** - * Resolve a jurisdiction GEOID to its compact integer JURCODE (jurisdictions.code) for the reference-code - * JURCODE segment. Returns UNKNOWN_JURCODE (0) when the geoid is null OR the row has no code on file (D5), - * so a code is always mintable and an unmapped/unknown-code report lands in the shared "0" bucket. Run - * pre-tx by the create paths (the geoid is already resolved before the create transaction). + * Falls back to UNKNOWN_JURCODE rather than failing, so a code is always mintable. It is a plain SELECT + * that takes no row lock, so it cannot disturb the allocator's lock order wherever it is called. */ export async function resolveJurisdictionCode( sql: Queryable, @@ -50,44 +36,28 @@ export async function resolveJurisdictionCode( return code === null || code === undefined ? UNKNOWN_JURCODE : Number(code) } -/** reference_counters scope_key for a report: `"{typecode}:{jurcode}"`. */ export function reportScopeKey(typeCode: string, jurCode: number): string { return `${typeCode}:${jurCode}` } -/** reference_counters scope_key for an event (cleanup): `"EVENT:{jurcode}"`. */ export function eventScopeKey(jurCode: number): string { return `${EVENT_PREFIX}:${jurCode}` } -/** Zero-pad a sequence value to the 6-digit NNNNNN segment of a reference code. */ function pad6(seq: number): string { return String(seq).padStart(6, "0") } -/** - * Compose a reference code from its parts: `{PREFIX}-{JURCODE}-{NNNNNN}`. `prefix` is a report TYPECODE - * (e.g. "DU") or the EVENT_PREFIX ("EVENT"); the seq is zero-padded to 6 digits. - */ export function formatReferenceCode(prefix: string, jurCode: number, seq: number): string { return `${prefix}-${jurCode}-${pad6(seq)}` } -/** - * Resolve a report TYPECODE from a report `type` via the shared REPORT_TYPE_CODE map (M6). Falls back to - * the "other" code if an unrecognized type ever reaches here, so a code is always mintable. - */ +/** Falls back to the "other" code for an unrecognized type, so a code is always mintable. */ export function typeCodeFor(type: ReportType): string { return REPORT_TYPE_CODE[type] ?? REPORT_TYPE_CODE.other } -/** - * Atomically allocate (and return) the NEXT value for `scopeKey` from reference_counters. The first - * allocation for a scope inserts next_val = 1; every subsequent one increments and returns. Returns the - * bigint as a JS number (counts stay far below 2^53). - * - * D4 lock order: call this FIRST in the create transaction — see the module header. - */ +/** Call this FIRST in the create transaction (see the lock-order contract above). */ export async function allocateNextSeq(sql: Queryable, scopeKey: string): Promise { const rows = await sql<{ next_val: number }[]>` INSERT INTO reference_counters (scope_key, next_val) @@ -99,13 +69,7 @@ export async function allocateNextSeq(sql: Queryable, scopeKey: string): Promise return Number(rows[0]!.next_val) } -/** - * Allocate the next reference code for a REPORT: derives the TYPECODE from the report `type` and the - * scope from (typecode, jurCode), bumps that scope's counter, and formats the code. `jurCode` is - * jurisdictions.code, or UNKNOWN_JURCODE (0) when the report has no resolved jurisdiction (D5). - * - * D4 lock order: call this FIRST in the create transaction. - */ +/** Call this FIRST in the create transaction (see the lock-order contract above). */ export async function allocateReportReferenceCode( sql: Queryable, type: ReportType, @@ -116,13 +80,7 @@ export async function allocateReportReferenceCode( return formatReferenceCode(typeCode, jurCode, seq) } -/** - * Allocate the next reference code for an EVENT (cleanup): bumps the `EVENT:{jurcode}` scope counter and - * formats `EVENT-{jurcode}-{NNNNNN}`. `jurCode` is jurisdictions.code, or UNKNOWN_JURCODE (0) when the - * event has no resolved jurisdiction. - * - * D4 lock order: call this FIRST in the create transaction. - */ +/** Call this FIRST in the create transaction (see the lock-order contract above). */ export async function allocateEventReferenceCode( sql: Queryable, jurCode: number = UNKNOWN_JURCODE, diff --git a/services/api/src/db/schema/anon.ts b/services/api/src/db/schema/anon.ts index c8db907f..2a0ffe5b 100644 --- a/services/api/src/db/schema/anon.ts +++ b/services/api/src/db/schema/anon.ts @@ -1,13 +1,7 @@ /** - * anon_tokens: server records for anonymous reporting sessions. `id` is the token id (string). - * `report_count` and `flagged` support per-token abuse throttling. `expires_at` bounds the token - * lifetime and is indexed for cleanup sweeps. - * - * DEPRECATED COLUMN: `claim_code` is dead. The claim secret moved to the report row (0005) and now - * rests only as its SHA-256 on reports.claim_code_hash (0091); 0092 NULLed every value here and no - * code path reads it. The DROP is deferred to the release that also drops reports.claim_code. - * - * NOTE: per-IP and per-H3 hourly rate-limit counters do NOT live here; they live in Redis. + * `claim_code` is dead: the claim secret rests only as its SHA-256 on reports.claim_code_hash, 0092 + * NULLed every value here and no code path reads it. The DROP is deferred to the release that also drops + * reports.claim_code. */ import { boolean, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core" diff --git a/services/api/src/db/schema/audit.ts b/services/api/src/db/schema/audit.ts index e23fd912..8c565916 100644 --- a/services/api/src/db/schema/audit.ts +++ b/services/api/src/db/schema/audit.ts @@ -1,10 +1,3 @@ -/** - * audit_log: append-only record of privileged actions (status changes, role grants, moderation, - * outreach edits, ...). `actor_id` null for system actions. `target` is a free-form reference string - * (e.g. "report:"). `meta` is jsonb with action-specific detail. Indexed by (actor_id, - * created_at) for "what did this actor do" and by (action) for "all events of this type". - */ - import { sql } from "drizzle-orm" import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" @@ -24,12 +17,11 @@ export const auditLog = pgTable( (t) => [ index("audit_log_actor_created_idx").on(t.actorId, t.createdAt), index("audit_log_action_idx").on(t.action), - // Performance indexes (drizzle/0013_perf_indexes.sql) — each backs a specific access pattern. // Activity-feed audit branch: global newest-first scan. index("audit_log_created_idx").on(t.createdAt.desc()), // Admin audit list keyset (created_at DESC, id DESC). index("audit_log_created_id_idx").on(t.createdAt.desc(), t.id.desc()), - // discovery notes / contact-suggestion reads filter (action, target) then order by created_at ASC. + // Discovery notes and contact-suggestion reads filter (action, target), then order by created_at. index("audit_log_action_target_created_idx").on(t.action, t.target, t.createdAt), ], ) diff --git a/services/api/src/db/schema/boundary_vintage.ts b/services/api/src/db/schema/boundary_vintage.ts index 6ca275fa..ffa57014 100644 --- a/services/api/src/db/schema/boundary_vintage.ts +++ b/services/api/src/db/schema/boundary_vintage.ts @@ -1,17 +1,12 @@ /** - * boundary_vintage: single-row audit record of the active jurisdiction-boundary dataset, stamped by the - * local refresh tool (scripts/refresh-boundaries.ts) after each successful nationwide load. Canonical DDL: - * drizzle/0028_boundary_vintage.sql. - * - * Mirrors the hand-authored DDL for typed queries + drizzle-kit diff inspection only (the refresh tool - * writes this row through raw `sql` like the other db/* scripts). The singleton CHECK (id) and the jsonb - * default live in the SQL (drizzle-kit cannot model the CHECK), so they are NOT re-declared here. + * Single-row record of the active boundary dataset, stamped by scripts/refresh-boundaries.ts after each + * nationwide load. The singleton CHECK (id) lives only in 0028_boundary_vintage.sql because drizzle-kit + * cannot model it. */ import { boolean, integer, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core" export const boundaryVintage = pgTable("boundary_vintage", { - // Singleton primary key (always true). The CHECK (id) enforcing exactly one row lives in the DDL. id: boolean("id").primaryKey().default(true), vintageTag: text("vintage_tag").notNull(), tigerVintage: integer("tiger_vintage").notNull(), diff --git a/services/api/src/db/schema/chat-polls.ts b/services/api/src/db/schema/chat-polls.ts index b094e072..02f29b2a 100644 --- a/services/api/src/db/schema/chat-polls.ts +++ b/services/api/src/db/schema/chat-polls.ts @@ -1,17 +1,8 @@ /** - * chat_polls + chat_poll_options + chat_poll_votes: P6 polls. A poll IS a chat message with - * kind='poll'; this trio holds the poll body, keyed on the message's id. chat_polls is 1:1 with the - * poll message, chat_poll_options is the ordered choice list (composite PK poll_id+idx), and - * chat_poll_votes records one row per (poll, option, voter). + * A poll is a chat message with kind='poll'; these tables hold its body, keyed on the message id. * - * messageId is a BARE uuid, NOT a Drizzle .references() to chat_messages — chat_messages is - * PARTITIONED and an FK pointing AT the partitioned parent is impossible in postgres (same stance as - * chat_message_reactions / chat pins). Message ids are globally unique so keying on the bare id is - * safe. createdBy is a real users FK with no cascade (accounts soft-delete everywhere — same stance - * as chat_groups.ownerId, schema/chat-groups.ts); vote rows, by contrast, DO cascade on user delete. - * - * CANONICAL DDL: drizzle/0048_chat_polls.sql. These mirrors exist for typed queries / diff - * inspection; the poll repo + routes land in the following P6 tasks. + * messageId is a bare uuid, not a .references() to chat_messages: Postgres cannot point an FK at a + * partitioned parent. Message ids are globally unique, so keying on the bare id is safe. */ import { @@ -28,15 +19,13 @@ import { import { users } from "./users.js" export const chatPolls = pgTable("chat_polls", { - // Bare message id (the poll message's id). NOT an FK — see file header (partitioned parent). messageId: uuid("message_id").primaryKey(), question: text("question").notNull(), allowMultiple: boolean("allow_multiple").notNull().default(false), anonymous: boolean("anonymous").notNull().default(true), - // NULL while the poll is open; set when the poll is closed to further voting. closedAt: timestamp("closed_at", { withTimezone: true }), - // Non-cascading users FK, same stance as chat_groups.ownerId: accounts soft-delete everywhere, so - // this can never block a deletion path; a tombstoned author renders as "Deleted User". + // Non-cascading: accounts soft-delete, so this never blocks a deletion path; a tombstoned author + // renders as "Deleted User". Vote rows, by contrast, cascade. createdBy: uuid("created_by") .notNull() .references(() => users.id), @@ -67,8 +56,7 @@ export const chatPollVotes = pgTable( }, (t) => [ primaryKey({ columns: [t.pollId, t.optionIdx, t.userId] }), - // Composite FK to the option row: a vote can only point at an option that exists on the poll; - // deleting the option (or poll) cascades away the votes. Canonical in 0048_chat_polls.sql. + // A vote can only point at an option that exists on its own poll. foreignKey({ columns: [t.pollId, t.optionIdx], foreignColumns: [chatPollOptions.pollId, chatPollOptions.idx], diff --git a/services/api/src/db/schema/chat.ts b/services/api/src/db/schema/chat.ts index 28f0450e..73dfae5d 100644 --- a/services/api/src/db/schema/chat.ts +++ b/services/api/src/db/schema/chat.ts @@ -16,11 +16,9 @@ export const chatMessages = pgTable( .default(sql`gen_random_uuid()`), cleanupId: uuid("cleanup_id").references(() => cleanups.id), reportId: uuid("report_id").references(() => reports.id), - // Third scope (P4): a standalone group chat. Exactly ONE of (cleanup_id, report_id, group_id) - // is set — chat_messages_scope_chk, see drizzle/0047_chat_groups.sql. + // Exactly one of cleanup_id, report_id, group_id is set (chat_messages_scope_chk, 0047). groupId: uuid("group_id").references(() => chatGroups.id), - // Nullable: a SYSTEM message (report status/timeline event posted into report chat) has no - // author. See drizzle/0040_chat_system_messages.sql. + // Nullable: a system message has no author. senderId: uuid("sender_id").references(() => users.id), body: text("body"), kind: text("kind").$type().notNull().default("text"), @@ -28,13 +26,12 @@ export const chatMessages = pgTable( createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), editedAt: timestamp("edited_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }), - // Quoted-reply target. Nullable; no FK (partitioned table, see drizzle/0045_chat_reply_to.sql). + // No FK: Postgres cannot reference a partitioned table by id alone. replyToId: uuid("reply_to_id"), - // Pin state (P3): pinned_at NULL = not pinned; doubles as the pin-list sort key. pinned_by has - // no FK (partitioned table, same stance as reply_to_id — see drizzle/0046_chat_pins.sql). + // pinned_at doubles as the pin-list sort key. pinned_by has no FK, for the same reason as + // reply_to_id. pinnedAt: timestamp("pinned_at", { withTimezone: true }), pinnedBy: uuid("pinned_by"), - // Structured payload for kind:"system" rows. Nullable; NULL on every non-system row. systemStatus: text("system_status"), systemKind: text("system_kind"), systemBody: text("system_body"), @@ -45,7 +42,6 @@ export const chatMessages = pgTable( index("chat_messages_report_created_idx").on(t.reportId, t.createdAt.desc()), index("chat_messages_group_created_idx").on(t.groupId, t.createdAt.desc()), index("chat_messages_sender_created_idx").on(t.senderId, t.createdAt.desc(), t.id.desc()), - // Partial pin-list indexes (0046): only pinned rows are indexed. index("chat_messages_cleanup_pinned_idx") .on(t.cleanupId, t.pinnedAt.desc()) .where(sql`pinned_at IS NOT NULL`), diff --git a/services/api/src/db/schema/cleanup_bans.ts b/services/api/src/db/schema/cleanup_bans.ts index 2b11e9d0..437bfa3e 100644 --- a/services/api/src/db/schema/cleanup_bans.ts +++ b/services/api/src/db/schema/cleanup_bans.ts @@ -1,15 +1,9 @@ /** - * cleanup_bans: one row per (event, removed user) — the enforcement record behind attendee removal. - * - * SECURITY (audit 2026-07-24, M17): removing an attendee used to be a bare DELETE of the - * cleanup_members row while joining was an unconditional self-service INSERT, so a removed user - * re-joined instantly and in a loop. The ban row is written in the SAME transaction as the membership - * delete and is checked by joinCleanupTx before the membership insert. It deliberately lives OUTSIDE - * cleanup_members because it has to survive that row's deletion. - * - * Composite PK(cleanup_id, user_id) — one ban per person per event; deleting the cleanup cascades, the - * user FKs do not (bans survive user soft-delete, same convention as cleanup_members). Canonical DDL: - * drizzle/0052_cleanup_bans.sql. + * The enforcement record behind attendee removal. SECURITY: joining is a self-service insert, so without + * a ban a removed user could re-join instantly. The ban is written in the same transaction as the + * membership delete and checked by joinCleanupTx before the membership insert; it lives outside + * cleanup_members because it must survive that row's deletion. The user FKs do not cascade, so bans + * survive user soft-delete. */ import { index, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" @@ -25,10 +19,9 @@ export const cleanupBans = pgTable( userId: uuid("user_id") .notNull() .references(() => users.id), - // The host who removed them. Nullable so a future operator-initiated removal can leave it unset - // rather than fabricate an actor. + // Nullable so an operator-initiated removal can leave it unset rather than fabricate an actor. bannedByUserId: uuid("banned_by_user_id").references(() => users.id), - // Free-text host note; unused by the current remove flow (no reason field on the wire). + // Unused by the current remove flow: the wire has no reason field. reason: text("reason"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/services/api/src/db/schema/cleanup_reports.ts b/services/api/src/db/schema/cleanup_reports.ts index 3533afff..e88e3c08 100644 --- a/services/api/src/db/schema/cleanup_reports.ts +++ b/services/api/src/db/schema/cleanup_reports.ts @@ -1,13 +1,7 @@ /** - * cleanup_reports: the event <-> report junction. One row = one report linked to one cleanup (event), - * and the durable source of truth for that link (linked_by_user_id + linked_at). The event-detail - * "Reports we'll handle" gallery scans by cleanup_id; the report-detail "Cleanup events" gallery (and the - * batched ANY(report_id) hydration on ReportDTO.linkedEvents) scans by report_id. - * - * A report links to a cleanup at most once: UNIQUE(cleanup_id, report_id) makes a re-link a single - * ON CONFLICT DO NOTHING. Deleting either the cleanup or the report cascades the junction row away; - * linked_by_user_id has NO cascade (the link survives a soft-deleted actor). Mirrors the - * cleanup_members composite-uniqueness + reverse-index pattern; canonical DDL: drizzle/0018. + * Event <-> report junction. UNIQUE(cleanup_id, report_id) makes a re-link a single ON CONFLICT DO + * NOTHING. The event gallery scans by cleanup_id and the report's linked-events hydration by report_id, + * hence both indexes. */ import { sql } from "drizzle-orm" @@ -28,7 +22,7 @@ export const cleanupReports = pgTable( reportId: uuid("report_id") .notNull() .references(() => reports.id, { onDelete: "cascade" }), - // The actor who made the link (nullable; no cascade so the link survives a soft-deleted actor). + // No cascade: the link survives a soft-deleted actor. linkedByUserId: uuid("linked_by_user_id").references(() => users.id), linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/services/api/src/db/schema/cleanup_slots.ts b/services/api/src/db/schema/cleanup_slots.ts index 6a7c3ae3..65ee01f0 100644 --- a/services/api/src/db/schema/cleanup_slots.ts +++ b/services/api/src/db/schema/cleanup_slots.ts @@ -1,30 +1,24 @@ /** - * cleanup_slots + cleanup_slot_claims: P9 signup slots. A host defines named roles/shifts on an event - * ("Registration table", "Grill", "8-10am sweep"), each with an optional capacity, and an attendee - * claims exactly ONE of them. + * Signup slots: a host defines named roles or shifts on an event, each with an optional capacity, and an + * attendee claims exactly one of them. * - * Identity is the surrogate uuid, NOT (cleanup_id, sort_order): slots are EDITABLE — a host renames and - * reorders them while claims already exist, and an ordinal key would silently re-point every claim at a - * different role. sort_order is presentational only. + * Identity is the surrogate uuid, not (cleanup_id, sort_order): hosts rename and reorder slots while + * claims exist, and an ordinal key would silently re-point every claim at a different role. * - * cleanup_slot_claims is keyed (cleanup_id, user_id) — the same PK shape as cleanup_members. That - * composite PK IS the "one slot per person per event" product rule, enforced by the schema rather than - * by application code, so a concurrent double-claim is a constraint conflict and not a race. Re-claiming - * is an ON CONFLICT DO UPDATE (a MOVE), never a second row. The composite FK (slot_id, cleanup_id) makes - * claiming a slot that belongs to a DIFFERENT event structurally impossible (same stance as - * chat_poll_votes' composite FK, schema/chat-polls.ts). + * cleanup_slot_claims is keyed (cleanup_id, user_id), so "one slot per person per event" is enforced by + * the schema: a concurrent double-claim is a constraint conflict, not a race, and re-claiming is an + * ON CONFLICT DO UPDATE (a move). The composite FK (slot_id, cleanup_id) makes claiming a slot of a + * different event structurally impossible. * - * Cleanups cascade; the users FK does not (accounts soft-delete everywhere, so a cascade would never - * fire, and a claim is roster data that must survive a tombstone exactly like its cleanup_members row). + * The users FK does not cascade: accounts soft-delete, and a claim is roster data that must survive a + * tombstone like its cleanup_members row. * * LOCK ORDER (binding on every writer): cleanups -> cleanup_members -> cleanup_slots -> - * cleanup_slot_claims. joinCleanupTx already takes FOR SHARE on cleanups first, so a claim transaction - * that starts anywhere else can deadlock ABBA against it. + * cleanup_slot_claims. joinCleanupTx takes FOR SHARE on cleanups first, so a claim transaction that + * starts anywhere else can deadlock ABBA against it. * - * CANONICAL DDL: drizzle/0063_cleanup_slots.sql, extended by drizzle/0167_cleanup_slot_windows.sql. - * The expression unique index cleanup_slots_cleanup_title_window_uidx - * (cleanup_id, lower(title), COALESCE(starts_at, '-infinity'), COALESCE(ends_at, 'infinity')) is - * deliberately NOT mirrored here — functional indexes stay SQL-only in this repo. + * The expression unique index cleanup_slots_cleanup_title_window_uidx (0167_cleanup_slot_windows.sql) is + * SQL-only, like every functional index in this repo. */ import { sql } from "drizzle-orm" @@ -55,8 +49,8 @@ export const cleanupSlots = pgTable( .references(() => cleanups.id, { onDelete: "cascade" }), title: text("title").notNull(), description: text("description"), - // NULL = unlimited. Lowering it below the current claim count does NOT evict anyone: the slot - // simply refuses new claims until it drains. + // NULL = unlimited. Lowering it below the current claim count evicts nobody: the slot just refuses + // new claims until it drains. capacity: integer("capacity"), startsAt: timestamp("starts_at", { withTimezone: true }), endsAt: timestamp("ends_at", { withTimezone: true }), @@ -69,10 +63,9 @@ export const cleanupSlots = pgTable( "cleanup_slots_window_chk", sql`(${t.startsAt} IS NULL) = (${t.endsAt} IS NULL) AND (${t.endsAt} IS NULL OR ${t.endsAt} > ${t.startsAt})`, ), - // The ordered read for one event's slot list (also the batched multi-event load). index("cleanup_slots_cleanup_idx").on(t.cleanupId, t.sortOrder, t.id), - // Redundant-looking, but load-bearing: the FK target for cleanup_slot_claims' composite reference. - // A FK target must be a unique constraint and the PK alone is only (id). + // Redundant-looking but load-bearing: the FK target of cleanup_slot_claims' composite reference must + // be a unique constraint, and the PK alone is only (id). uniqueIndex("cleanup_slots_id_cleanup_uidx").on(t.id, t.cleanupId), ], ) @@ -92,16 +85,12 @@ export const cleanupSlotClaims = pgTable( claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [ - // The one-slot-per-person-per-event rule, in the schema. primaryKey({ columns: [t.cleanupId, t.userId] }), - // "This slot belongs to THIS event" — structural, not an app-layer check that a new call path can - // forget. Canonical in 0063_cleanup_slots.sql. foreignKey({ columns: [t.slotId, t.cleanupId], foreignColumns: [cleanupSlots.id, cleanupSlots.cleanupId], }).onDelete("cascade"), - // "Who is on this slot" (the host's per-slot roster) + the capacity count the claim transaction - // runs under the slot row lock. + // Also serves the capacity count the claim transaction runs under the slot row lock. index("cleanup_slot_claims_slot_idx").on(t.slotId), ], ) diff --git a/services/api/src/db/schema/cleanup_timeline.ts b/services/api/src/db/schema/cleanup_timeline.ts index 627708b5..be662489 100644 --- a/services/api/src/db/schema/cleanup_timeline.ts +++ b/services/api/src/db/schema/cleanup_timeline.ts @@ -1,12 +1,3 @@ -/** - * cleanup_timeline: append-only activity history for a cleanup (event), mirroring report_timeline - * (Phase 2). Each row is one event-domain activity entry (status change, attendee message, flag, ...) - * with an optional human note and the acting user (`actor_id` null for system entries). Deleting the - * cleanup cascades. Indexed by (cleanup_id, created_at) for the ordered timeline render. - * - * CANONICAL DDL: drizzle/0007_admin_phase2.sql. This mirror exists for typed queries / diff inspection. - */ - import { sql } from "drizzle-orm" import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { cleanups } from "./cleanups.js" diff --git a/services/api/src/db/schema/cleanups.ts b/services/api/src/db/schema/cleanups.ts index 6d704e35..5306df24 100644 --- a/services/api/src/db/schema/cleanups.ts +++ b/services/api/src/db/schema/cleanups.ts @@ -45,7 +45,6 @@ export const cleanups = pgTable( status: text("status").$type().notNull(), bring: text("bring").array(), address: text("address"), - /** 0179: where `address` came from - resolved | edited | manual. NULL only for addressless legacy rows. */ addressSource: text("address_source").$type(), capacity: integer("capacity"), bags: integer("bags").notNull().default(0), diff --git a/services/api/src/db/schema/conversation_mutes.ts b/services/api/src/db/schema/conversation_mutes.ts index 3870d3a0..2abe231a 100644 --- a/services/api/src/db/schema/conversation_mutes.ts +++ b/services/api/src/db/schema/conversation_mutes.ts @@ -1,26 +1,15 @@ /** - * conversation_mutes: per-user, per-conversation mute. A user can mute notifications for a single - * room (cleanup group chat, a DM thread, or a report chat) without leaving/muting globally. - * room_kind + room_id together identify the room; room_id is plain `uuid` (NOT a foreign key) - * because it points into whichever table room_kind selects (cleanups.id | dm_threads.id | - * reports.id) -- app-level integrity, same reasoning as the no-FK message_id columns in - * chat_message_reactions / chat_message_mentions / report_message_forwards. + * Per-user, per-conversation mute. room_id is a plain uuid, not a foreign key, because it points into + * whichever table room_kind selects (cleanups | dm_threads | reports | chat_groups); integrity is + * app-level, like the no-FK message_id columns elsewhere. * - * Composite PK(user_id, room_kind, room_id) means a user mutes a given room at most once. - * - * NOTE: @civfix/shared already exports a `RoomKind` type (ws-frame room kind: 'cleanup' | 'dm' | - * 'report' | 'report_discussion') used by src/ws/*. This table's room_kind is a DIFFERENT, - * backend-internal enumeration (mute targets only: no 'report_discussion') so it is named - * `ConversationMuteRoomKind` here to avoid colliding with / being confused for the shared type. - * - * CANONICAL DDL: drizzle/0042_conversation_mutes.sql. This mirror exists for typed queries / diff - * inspection only; nothing reads/writes it yet (mute repo lands in D-E1). + * room_kind is deliberately not the shared `RoomKind` (the ws-frame kind, which includes + * 'report_discussion'): mute targets are a different, backend-internal set, hence the distinct name. */ import { pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" -/** conversation_mutes.room_kind: which table room_id points into (P4 adds 'group' -> chat_groups.id). */ export type ConversationMuteRoomKind = "cleanup" | "dm" | "report" | "group" export const conversationMutes = pgTable( @@ -29,11 +18,7 @@ export const conversationMutes = pgTable( userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - // 'cleanup' | 'dm' | 'report'. Kept as plain text (not a DB enum) since it only selects which - // table room_id logically points into -- see header. roomKind: text("room_kind").$type().notNull(), - // Points into cleanups.id | dm_threads.id | reports.id depending on room_kind. Intentionally - // NOT a FK -- see header. roomId: uuid("room_id").notNull(), mutedAt: timestamp("muted_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/services/api/src/db/schema/discovery.ts b/services/api/src/db/schema/discovery.ts index 7b971cb8..579c634c 100644 --- a/services/api/src/db/schema/discovery.ts +++ b/services/api/src/db/schema/discovery.ts @@ -1,12 +1,7 @@ /** - * jurisdiction_discovery_tasks: work items for onboarding a new government jurisdiction (finding - * contact info / report form). Created when a report lands in an area civfix does not yet route. - * - * `place_geojson` holds the candidate boundary; `sample_report_id` links the report that triggered - * discovery. `status` defaults to 'open'; an operator may be assigned. Indexed by (status, - * population DESC) so the highest-impact open tasks surface first. A PARTIAL UNIQUE on geoid WHERE - * status <> 'done' (created in 0001_core.sql) prevents two concurrent open tasks for the same geoid - * while still allowing historical done rows. + * Onboarding work items, created when a report lands in an area civfix does not yet route. A partial + * UNIQUE(geoid) WHERE status <> 'done' (0001_core.sql, SQL-only) prevents two concurrent open tasks for + * one geoid while keeping historical done rows. */ import { sql } from "drizzle-orm" @@ -31,7 +26,6 @@ export const jurisdictionDiscoveryTasks = pgTable( assignedOperatorId: uuid("assigned_operator_id").references(() => users.id), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), }, - // NOTE: the partial UNIQUE(geoid) WHERE status <> 'done' lives in 0001_core.sql. (t) => [index("jurisdiction_discovery_status_pop_idx").on(t.status, t.population.desc())], ) diff --git a/services/api/src/db/schema/dm_messages.ts b/services/api/src/db/schema/dm_messages.ts index 46d76018..6978f475 100644 --- a/services/api/src/db/schema/dm_messages.ts +++ b/services/api/src/db/schema/dm_messages.ts @@ -24,10 +24,10 @@ export const dmMessages = pgTable( createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), editedAt: timestamp("edited_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }), - // Quoted-reply target. Nullable; no FK (see drizzle/0045_chat_reply_to.sql). + // No FK: Postgres cannot reference a partitioned table by id alone. replyToId: uuid("reply_to_id"), - // Pin state (P3): pinned_at NULL = not pinned; doubles as the pin-list sort key. pinned_by has - // no FK (mirrors chat_messages' shape — see drizzle/0046_chat_pins.sql). + // pinned_at doubles as the pin-list sort key. pinned_by has no FK, for the same reason as + // reply_to_id. pinnedAt: timestamp("pinned_at", { withTimezone: true }), pinnedBy: uuid("pinned_by"), }, @@ -35,7 +35,6 @@ export const dmMessages = pgTable( primaryKey({ columns: [t.id, t.createdAt] }), index("dm_messages_thread_created_idx").on(t.threadId, t.createdAt.desc()), index("dm_messages_sender_created_idx").on(t.senderId, t.createdAt.desc(), t.id.desc()), - // Partial pin-list index (0046): only pinned rows are indexed. index("dm_messages_thread_pinned_idx") .on(t.threadId, t.pinnedAt.desc()) .where(sql`pinned_at IS NOT NULL`), diff --git a/services/api/src/db/schema/dm_read_state.ts b/services/api/src/db/schema/dm_read_state.ts index ead74fca..9da08d7f 100644 --- a/services/api/src/db/schema/dm_read_state.ts +++ b/services/api/src/db/schema/dm_read_state.ts @@ -1,10 +1,6 @@ /** - * dm_read_state: the per-(thread, user) chat read watermark for direct messages, mirroring - * cleanup_members.last_read_at. NULL last_read_at = never read (the unread baseline falls back to the - * thread's created_at). Written by the WS `ack` handler, read by the threads service for unread counts. - * Composite PK(thread_id, user_id) means one watermark per participant; deleting the thread cascades. - * - * Canonical DDL: services/api/drizzle/0009_dm_and_privacy.sql. + * Read watermark per participant. NULL last_read_at = never read; the unread baseline falls back to the + * thread's created_at. */ import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" diff --git a/services/api/src/db/schema/dm_threads.ts b/services/api/src/db/schema/dm_threads.ts index 2c63c379..7ffe73a7 100644 --- a/services/api/src/db/schema/dm_threads.ts +++ b/services/api/src/db/schema/dm_threads.ts @@ -1,13 +1,6 @@ /** - * dm_threads: one row per unordered user PAIR for 1:1 direct messages. - * - * A thread is unique per pair, stored as (user_lo, user_hi) with user_lo < user_hi (enforced by a CHECK - * in the canonical SQL) and a UNIQUE(user_lo, user_hi). Combined with ON CONFLICT DO NOTHING this makes - * openOrCreateThread idempotent regardless of which party initiates. Deleting a thread cascades to its - * dm_messages / dm_read_state (FKs declared there). - * - * The hand-authored SQL is the DDL source of truth: services/api/drizzle/0009_dm_and_privacy.sql. This - * Drizzle definition mirrors it for type-safe queries only. + * One row per unordered user pair, stored ordered (user_lo < user_hi) under a UNIQUE so that, with + * ON CONFLICT DO NOTHING, openOrCreateThread is idempotent regardless of which party initiates. */ import { sql } from "drizzle-orm" @@ -31,8 +24,8 @@ export const dmThreads = pgTable( (t) => [ unique("dm_threads_user_lo_user_hi_key").on(t.userLo, t.userHi), check("dm_threads_lo_lt_hi", sql`${t.userLo} < ${t.userHi}`), - // user_hi has no leftmost-prefix coverage from the UNIQUE(user_lo, user_hi); this standalone index - // lets the inbox `user_lo = X OR user_hi = X` filter BitmapOr both branches. See drizzle/0013. + // The UNIQUE(user_lo, user_hi) gives user_hi no leftmost-prefix coverage; this index lets the inbox + // `user_lo = X OR user_hi = X` filter BitmapOr both branches. index("dm_threads_user_hi_idx").on(t.userHi), ], ) diff --git a/services/api/src/db/schema/geocode_cache.ts b/services/api/src/db/schema/geocode_cache.ts index bb25f19c..52130153 100644 --- a/services/api/src/db/schema/geocode_cache.ts +++ b/services/api/src/db/schema/geocode_cache.ts @@ -2,11 +2,9 @@ import { index, pgTable, text, timestamp } from "drizzle-orm/pg-core" import type { AddressPrecision } from "@civfix/shared" /** - * Mirror of drizzle/0179_address_resolution.sql. Read-through cache of reverse geocodes keyed by the - * shared 5-decimal point key (@civfix/shared `geocodePointKey`). A row with a NULL `address` is a - * NEGATIVE entry: the providers answered nothing, and it expires on a much shorter TTL than a hit so a - * provider outage cannot poison the point. TTL is applied on read (services/geocode-cache.ts), never by - * a cron. + * Read-through reverse-geocode cache keyed by the shared `geocodePointKey`. A NULL `address` is a negative + * entry with a much shorter TTL than a hit, so a provider outage cannot poison the point. TTL is applied + * on read (services/geocode-cache.ts), never by a cron. */ export const geocodeCache = pgTable( "geocode_cache", diff --git a/services/api/src/db/schema/gov_claims.ts b/services/api/src/db/schema/gov_claims.ts index b857e297..26d636ed 100644 --- a/services/api/src/db/schema/gov_claims.ts +++ b/services/api/src/db/schema/gov_claims.ts @@ -1,15 +1,9 @@ /** - * gov_claims: the government provisioning queue (Phase 2). - * - * An applicant claims authority over a jurisdiction; an operator verifies LinkedIn / municipal - * Directory / phone Callback, then approves (which sets the linked user's role to gov_admin and links - * the jurisdiction) or rejects. All transitions are audited via writeAudit. + * Government provisioning queue: approving a claim sets the linked user's role to gov_admin and links the + * jurisdiction, so every transition is audited via writeAudit. * * `checks` jsonb shape: { linkedin|directory|callback: { status:'verified'|'pending', - * evidence?:string, note?:string, at?:timestamp } }. `method` records how the applicant arrived - * (email | cold_outreach). The status CHECK is enforced in 0007_admin_phase2.sql. - * - * CANONICAL DDL: drizzle/0007_admin_phase2.sql. This mirror exists for typed queries / diff inspection. + * evidence?:string, note?:string, at?:timestamp } }. */ import { sql } from "drizzle-orm" diff --git a/services/api/src/db/schema/jurisdictions.ts b/services/api/src/db/schema/jurisdictions.ts index 7b7c4a50..83c424cd 100644 --- a/services/api/src/db/schema/jurisdictions.ts +++ b/services/api/src/db/schema/jurisdictions.ts @@ -1,18 +1,11 @@ /** - * jurisdictions: government boundaries used to route reports to the responsible authority. + * Government boundaries used to route reports to the responsible authority. Which layer wins is decided + * by the layer rank in db/sql/jurisdiction.ts; `priority` only breaks ties between polygons of the same + * layer. The GiST index on geom lives in 0001_core.sql because drizzle-kit cannot emit GiST. * - * Keyed by `geoid` (Census GEOID string). `layer` is one of place|county|state; `priority` orders - * overlapping layers so the most specific authority wins (place < county < state). `geom` is the - * MultiPolygon(4326) covering the area FOR INGESTED ROWS; the GiST index that makes ST_Contains fast is - * created in the hand SQL (0001_core.sql), not here, because drizzle-kit cannot emit GiST. - * - * geom is NULLABLE (0015_jurisdiction_geom_nullable.sql): the write-time Census fallback lazily upserts - * API-sourced rows that carry geoid + name + layer but NO polygon (the Census Geographies API returns an - * identity, not a boundary — see src/adapters/jurisdiction-lookup.census.ts). A NULL-geom row never matches - * ST_Contains (so the GiST index + resolver ranking are unaffected) and is only ever read by its own geoid. - * - * contact_emails / report_form_url / notes / contact_updated_at hold the outreach + routing metadata - * the discovery pipeline fills in. + * geom is nullable (0015): the write-time Census fallback upserts rows with geoid + name + layer but no + * polygon, because the Census Geographies API returns an identity, not a boundary. A NULL-geom row never + * matches ST_Contains and is only ever read by its own geoid. */ import { index, integer, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core" @@ -27,45 +20,32 @@ export const jurisdictions = pgTable( name: text("name").notNull(), layer: text("layer").$type().notNull(), priority: integer("priority").notNull(), - // NULLABLE (DDL source of truth: drizzle/0015_jurisdiction_geom_nullable.sql). Ingested boundary rows - // carry a polygon; lazily API-sourced rows (write-time Census fallback) have geoid+name but geom = NULL. geom: geometry("geom", { subtype: "MultiPolygon", srid: 4326 }), population: integer("population"), contactEmails: text("contact_emails").array(), reportFormUrl: text("report_form_url"), notes: text("notes"), - // Per-jurisdiction custom forwarding email template (report -> jurisdiction), 0050. Both NULLABLE: - // NULL = use the built-in refined default packet (mail-format.ts buildReportPacket). Interpolated with - // the report's {referenceCode}/{category}/… tokens (shared FORWARD_TEMPLATE_VARIABLES); the length - // bounds (subject 300 / body 8000) are enforced app-side by the shared PatchJurisdictionRequest schema. + // NULL uses the built-in default packet (mail-format.ts buildReportPacket). The length bounds are + // enforced app-side by the shared PatchJurisdictionRequest schema, not by the column. forwardSubjectTemplate: text("forward_subject_template"), forwardBodyTemplate: text("forward_body_template"), contactUpdatedAt: timestamp("contact_updated_at", { withTimezone: true }), - // Operator "flag for review" state (0012). Nullable; purely advisory (does not affect resolution). + // Purely advisory: the flag never affects jurisdiction resolution. flaggedAt: timestamp("flagged_at", { withTimezone: true }), flagReason: text("flag_reason"), - // The @handle used to mention this jurisdiction in a report discussion, e.g. "@sf" (0017). - // Nullable; most jurisdictions have no handle. A PARTIAL UNIQUE index on lower(handle) keeps - // handles case-insensitively unique (see NOTE below). handle: text("handle"), - // Compact integer code used as the JURCODE segment of report/event reference codes (0030, D2). - // Allocated from jurisdiction_code_seq (single source of truth): backfilled by geoid ordinal in - // 0030, and via nextval() for lazily-upserted rows. NULLABLE in the mirror; all rows are backfilled. + // The JURCODE segment of reference codes, allocated from jurisdiction_code_seq. Nullable in the + // mirror only: every row is backfilled (0030) or gets nextval() on its lazy upsert. code: integer("code"), }, - // NOTE: the GiST(geom) index lives in 0001_core.sql. Only b-tree indexes are declared here. (t) => [ index("jurisdictions_layer_idx").on(t.layer), index("jurisdictions_population_idx").on(t.population), - // UNIQUE jurisdiction code (0030). Tolerates NULLs; here all rows are backfilled. uniqueIndex("jurisdictions_code_uidx").on(t.code), - // NOTE: a trigram GIN index `jurisdictions_name_trgm ON jurisdictions USING gin (name gin_trgm_ops)` - // backs the admin reports `j.name ILIKE '%q%'` search. It lives in drizzle/0014_search_trgm.sql and - // is intentionally NOT mirrored here (raw-SQL-only search; gin_trgm_ops opclass form). - // NOTE: a partial UNIQUE index `jurisdictions_handle_lower_key ON jurisdictions (lower(handle)) - // WHERE handle IS NOT NULL` enforces case-insensitive handle uniqueness. It lives in - // drizzle/0017_report_discussion.sql and is intentionally NOT mirrored here (the lower() functional - // expression + partial predicate is not worth the brittle Drizzle expression). + // Two indexes are SQL-only because Drizzle cannot express them cleanly: the trigram GIN + // jurisdictions_name_trgm behind the admin name search (0014_search_trgm.sql), and the partial unique + // jurisdictions_handle_lower_key on lower(handle) that keeps handles case-insensitively unique + // (0017_report_discussion.sql). ], ) diff --git a/services/api/src/db/schema/media_reap_tombstones.ts b/services/api/src/db/schema/media_reap_tombstones.ts index 6a813d1a..ec55895a 100644 --- a/services/api/src/db/schema/media_reap_tombstones.ts +++ b/services/api/src/db/schema/media_reap_tombstones.ts @@ -1,14 +1,9 @@ /** - * media_reap_tombstones: R2 keys whose physical delete failed AFTER the orphan sweep had already deleted - * their media_assets row (drizzle/0057_media_reap_tombstones.sql). - * - * The sweep deletes the row before the objects on purpose (a TOCTOU fix — see MediaWorkerRepo.findOrphans - * and jobs/orphan-sweep.ts), which means a failed storage DELETE would otherwise strand the object with - * nothing left in the database to rediscover it. A tombstone is the durable "still owes a delete" note the - * next sweep retries from; `attempts` caps the retries and the row is deliberately KEPT at the cap as the - * operator-visible record of a permanent leak. - * - * media_id is informational only and intentionally has no .references() — the media row it names is gone. + * Storage keys whose physical delete failed after the orphan sweep had already deleted their + * media_assets row. The sweep deletes the row before the objects on purpose (a TOCTOU fix, see + * MediaWorkerRepo.findOrphans), so without a tombstone a failed delete would strand the object with + * nothing left to rediscover it. `attempts` caps the retries, and a row at the cap is deliberately kept + * as the operator-visible record of a permanent leak. */ import { sql } from "drizzle-orm" @@ -17,11 +12,9 @@ import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-c export const mediaReapTombstones = pgTable( "media_reap_tombstones", { - /** The R2 object key still owed a delete. PK: the key IS the identity of the pending work. */ r2Key: text("r2_key").primaryKey(), - /** The reaped media_assets row this key came from. No FK — that row no longer exists. */ + /** No FK: the media row it names no longer exists. */ mediaId: uuid("media_id"), - /** Failed delete attempts so far (1 on the first record). Bounds the retry loop. */ attempts: integer("attempts").notNull().default(1), lastError: text("last_error"), createdAt: timestamp("created_at", { withTimezone: true }) diff --git a/services/api/src/db/schema/message_mentions.ts b/services/api/src/db/schema/message_mentions.ts index 9438ab04..e26da55a 100644 --- a/services/api/src/db/schema/message_mentions.ts +++ b/services/api/src/db/schema/message_mentions.ts @@ -1,32 +1,15 @@ -/** - * User @-mentions in a chat message — cleanup group chat + 1:1 DM + report chat (chat_message_mentions, - * one table for all, message ids being globally-unique uuids). These are keyed by the mentioned USER and - * drive the mention notification (gated by blocks + prefs in the service). - * - * (The former report_message_user_mentions table — user @-mentions in a per-report DISCUSSION message — - * was dropped with the discussion system, 0044_drop_report_discussion.sql.) - * - * chat_message_mentions has NO FK on message_id: chat_messages / dm_messages are RANGE-partitioned with - * composite PK(id, created_at), so there is no single-column key to reference (exactly like - * chat_message_reactions, schema/chat_reactions.ts). mentioned_user_id FKs users. - * - * CANONICAL DDL: drizzle/0023_message_mentions.sql. This mirror exists for typed queries / diff - * inspection only; it is NOT applied to create the database. - */ - import { index, pgTable, primaryKey, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" /** - * chat_message_mentions: one user @-mentioned in one CHAT message — the cleanup group chat (chat_messages) - * OR a 1:1 DM (dm_messages). One table serves both because a message id is a globally-unique uuid across - * both message tables. NO FK on message_id (those tables are partitioned with composite PKs); app-level - * integrity holds. mentioned_user_id DOES FK to users. + * One user @-mentioned in one chat message, across every chat kind: message ids are globally unique + * uuids, so one table serves chat_messages and dm_messages. There is no FK on message_id because those + * tables are range-partitioned with composite PK(id, created_at), leaving no single-column key to + * reference; integrity is app-level. */ export const chatMessageMentions = pgTable( "chat_message_mentions", { - // A chat_messages.id OR a dm_messages.id (uuids, globally unique). Intentionally NOT a FK — see header. messageId: uuid("message_id").notNull(), mentionedUserId: uuid("mentioned_user_id") .notNull() diff --git a/services/api/src/db/schema/oauth.ts b/services/api/src/db/schema/oauth.ts index 84096e32..1cdaf0de 100644 --- a/services/api/src/db/schema/oauth.ts +++ b/services/api/src/db/schema/oauth.ts @@ -1,19 +1,10 @@ -/** - * oauth_identities: external identity-provider links (Apple, Google) for a user. - * - * A user may have multiple identities (one per provider). The (provider, provider_user_id) pair is - * globally unique so the same external account cannot be attached to two civfix users. - */ - import { sql } from "drizzle-orm" import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" -// NOTE: `provider` is intentionally left as plain `text` (NOT $type-narrowed to the shared -// OAuthProvider union). The OAuthIdentityStore seam queries/links by a `string` provider (the -// OAuthService passes its PROVIDER_* string constants), so narrowing the column would force a cast at -// every call site for no real safety gain. The values are still mirrored + drift-tested via -// OAUTH_PROVIDER_VALUES in db/schema/types.ts. +// `provider` stays plain text rather than the OAuthProvider union: the OAuthIdentityStore seam takes a +// string provider, so narrowing would force a cast at every call site for no safety gain. The values +// are still drift-tested via OAUTH_PROVIDER_VALUES in types.ts. export const oauthIdentities = pgTable( "oauth_identities", { diff --git a/services/api/src/db/schema/organization_invites.ts b/services/api/src/db/schema/organization_invites.ts index 945a3e42..3005795e 100644 --- a/services/api/src/db/schema/organization_invites.ts +++ b/services/api/src/db/schema/organization_invites.ts @@ -11,7 +11,6 @@ import type { type OrganizationInviteRole = (typeof ORGANIZATION_INVITE_ROLE_VALUES)[number] type OrganizationInviteStatus = (typeof ORGANIZATION_INVITE_STATUS_VALUES)[number] -/** Mirror of drizzle/0162_org_suspension_and_invites.sql, 0165 and 0182 (the SQL is canonical). */ export const organizationInvites = pgTable( "organization_invites", { diff --git a/services/api/src/db/schema/organizations.ts b/services/api/src/db/schema/organizations.ts index e9b67c4f..a31a7820 100644 --- a/services/api/src/db/schema/organizations.ts +++ b/services/api/src/db/schema/organizations.ts @@ -36,7 +36,7 @@ export const organizations = pgTable( verifiedKind: text("verified_kind").$type(), verifiedAt: timestamp("verified_at", { withTimezone: true }), createdBy: uuid("created_by").references(() => users.id), - // Operator suspension flag (0162): reversible, independent of verified_status and deleted_at. + // Reversible, and independent of verified_status and deleted_at. suspendedAt: timestamp("suspended_at", { withTimezone: true }), suspendedReason: text("suspended_reason"), suspendedBy: uuid("suspended_by").references(() => users.id), diff --git a/services/api/src/db/schema/outreach_state.ts b/services/api/src/db/schema/outreach_state.ts index 97976804..ad776014 100644 --- a/services/api/src/db/schema/outreach_state.ts +++ b/services/api/src/db/schema/outreach_state.ts @@ -1,11 +1,6 @@ /** - * outreach_state: one row per jurisdiction tracking the outreach throttle (Phase 2). - * - * The digest cron + the "Save & route" enqueue consult this row to enforce the <=1 outreach / - * jurisdiction / OUTREACH_THROTTLE_DAYS rule (compare now - last_outreach_at against the window) and a - * manual `suppressed` opt-out. The geoid PK makes the upsert-on-send a single statement. - * - * CANONICAL DDL: drizzle/0007_admin_phase2.sql. This mirror exists for typed queries / diff inspection. + * Enforces at most one outreach per jurisdiction per OUTREACH_THROTTLE_DAYS, plus a manual `suppressed` + * opt-out. The geoid PK makes the upsert-on-send a single statement. */ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core" diff --git a/services/api/src/db/schema/post_likes.ts b/services/api/src/db/schema/post_likes.ts index 04ef47a1..62dcb538 100644 --- a/services/api/src/db/schema/post_likes.ts +++ b/services/api/src/db/schema/post_likes.ts @@ -1,10 +1,6 @@ /** - * post_likes: one row = one user's like on one post. Composite PK(post_id, user_id) de-dupes and - * makes the like toggle a single INSERT ... ON CONFLICT DO NOTHING / DELETE; the denormalized - * posts.like_count is bumped in the same txn. Both columns FK real uuid PKs with ON DELETE CASCADE - * (cleaner than the chat_message_reactions precedent, which cannot FK the partitioned message tables). - * - * CANONICAL DDL: drizzle/0051_social_posts.sql. This mirror is for typed queries / diff only. + * The composite PK makes the like toggle a single INSERT ... ON CONFLICT DO NOTHING or DELETE; the + * denormalized posts.like_count is bumped in the same transaction. */ import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" diff --git a/services/api/src/db/schema/post_mentions.ts b/services/api/src/db/schema/post_mentions.ts index f4c471a1..9c5101ed 100644 --- a/services/api/src/db/schema/post_mentions.ts +++ b/services/api/src/db/schema/post_mentions.ts @@ -1,10 +1,6 @@ /** - * post_mentions: one row = one user @-mentioned in one post. Mirrors chat_message_mentions - * (schema/message_mentions.ts) — keyed by the mentioned USER, drives the post_mention notification - * (gated by blocks + the `mentions` pref in the service). Unlike the chat table this one CAN FK - * post_id (posts is a plain uuid PK, not partitioned). Reuses makeMentionRepo(sql, 'post_mentions'). - * - * CANONICAL DDL: drizzle/0051_social_posts.sql. This mirror is for typed queries / diff only. + * Same shape as chat_message_mentions so makeMentionRepo serves both, except that post_id can carry an + * FK: posts is not partitioned. */ import { index, pgTable, primaryKey, uuid } from "drizzle-orm/pg-core" diff --git a/services/api/src/db/schema/post_saves.ts b/services/api/src/db/schema/post_saves.ts index 68184810..42d7925e 100644 --- a/services/api/src/db/schema/post_saves.ts +++ b/services/api/src/db/schema/post_saves.ts @@ -1,10 +1,4 @@ -/** - * post_saves: one row = one user's private bookmark of one post. Same toggle shape as post_likes; - * the denormalized posts.save_count is bumped in the same txn. Indexed by (user_id, created_at DESC) - * for the newest-saved-first GET /me/saves keyset. - * - * CANONICAL DDL: drizzle/0051_social_posts.sql. This mirror is for typed queries / diff only. - */ +/** Private bookmarks; the denormalized posts.save_count is bumped in the same transaction as the toggle. */ import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" import { posts } from "./posts.js" diff --git a/services/api/src/db/schema/push_tokens.ts b/services/api/src/db/schema/push_tokens.ts index 8b55928d..d0dea140 100644 --- a/services/api/src/db/schema/push_tokens.ts +++ b/services/api/src/db/schema/push_tokens.ts @@ -1,8 +1,4 @@ -/** - * push_tokens: registered device push tokens (APNs/FCM/WebPush). `platform`+`token` is UNIQUE so a - * re-register upserts rather than duplicates. `revoked_at` soft-revokes a token without deleting the - * row (audit trail of devices). - */ +/** `revoked_at` soft-revokes a token so the row stays as the device audit trail. */ import { sql } from "drizzle-orm" import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" diff --git a/services/api/src/db/schema/reference_counters.ts b/services/api/src/db/schema/reference_counters.ts index 32e7ce18..5901e273 100644 --- a/services/api/src/db/schema/reference_counters.ts +++ b/services/api/src/db/schema/reference_counters.ts @@ -1,21 +1,15 @@ /** - * reference_counters: per-scope monotonic counter backing the human-readable reference codes minted on - * reports + cleanups (issue #56). One row per scope_key; `next_val` is the last allocated value. - * - * scope_key shape: "{typecode}:{jurcode}" for reports (e.g. "DU:42") and "EVENT:{jurcode}" for events. - * Allocation is a single atomic upsert (INSERT ... ON CONFLICT (scope_key) DO UPDATE SET next_val = - * next_val + 1 RETURNING next_val), so the live create paths and the post-deploy backfill share one - * counter and can never mint a colliding code. See src/db/reference-code.ts for the allocator + formatter. - * - * CANONICAL DDL: drizzle/0030_reference_codes.sql. This mirror exists for typed queries / diff inspection. + * Per-scope counter behind reference codes; `next_val` is the last allocated value. scope_key is + * "{typecode}:{jurcode}" for reports (e.g. "DU:42") and "EVENT:{jurcode}" for events. Allocation is one + * atomic upsert (see reference-code.ts), so the live create paths and the backfill share one counter and + * can never mint a colliding code. */ import { bigint, pgTable, text } from "drizzle-orm/pg-core" export const referenceCounters = pgTable("reference_counters", { scopeKey: text("scope_key").primaryKey(), - // bigint stored as number: report/event counts per scope stay far below 2^53, so the JS `number` - // mode is exact here (matches how allocateNextSeq returns a JS number). + // Number mode is exact: per-scope counts stay far below 2^53. nextVal: bigint("next_val", { mode: "number" }).notNull(), }) diff --git a/services/api/src/db/schema/report_chat_members.ts b/services/api/src/db/schema/report_chat_members.ts index 2606e58f..62a4e7b0 100644 --- a/services/api/src/db/schema/report_chat_members.ts +++ b/services/api/src/db/schema/report_chat_members.ts @@ -1,14 +1,3 @@ -/** - * report_chat_members: join table of users who have JOINED a report's group chat, with a - * per-report role (owner|member). Mirrors cleanup_members (schema/cleanup_members.ts) but scoped - * to a report instead of a cleanup. Composite PK(report_id, user_id) means a user joins a report's - * chat at most once. Deleting the report cascades; deleting the user cascades (unlike - * cleanup_members, which intentionally leaves the user FK non-cascading). - * - * CANONICAL DDL: drizzle/0041_report_chat_members.sql. This mirror exists for typed queries / diff - * inspection only; nothing reads/writes it yet (membership repo lands in D-C1). - */ - import { index, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" import { reports } from "./reports.js" import { users } from "./users.js" @@ -27,8 +16,7 @@ export const reportChatMembers = pgTable( .references(() => users.id, { onDelete: "cascade" }), role: text("role").$type().notNull().default("member"), joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(), - // Chat read watermark, same convention as cleanup_members.lastReadAt: NULL = never read; - // unread baseline falls back to joined_at. + // NULL = never read; the unread baseline falls back to joined_at. lastReadAt: timestamp("last_read_at", { withTimezone: true }), }, (t) => [ diff --git a/services/api/src/db/schema/report_message_forwards.ts b/services/api/src/db/schema/report_message_forwards.ts index 88f45f11..8f5655d2 100644 --- a/services/api/src/db/schema/report_message_forwards.ts +++ b/services/api/src/db/schema/report_message_forwards.ts @@ -1,20 +1,10 @@ /** - * report_message_forwards: @city forward audit. Records that a report-chat message mentioning - * @city (jurisdiction handle) was forwarded to that jurisdiction's contact -- or, when - * forwarded_at is NULL, that @city was mentioned but there is no city contact to forward to yet. - * geoid identifies the jurisdiction (matches jurisdictions.geoid elsewhere in the schema). + * @city forward audit: a report-chat message mentioning a jurisdiction handle was forwarded to its + * contact, or (forwarded_at NULL) was mentioned while no contact existed to forward to. * - * NO foreign key on message_id: chat_messages is RANGE-partitioned with a composite PK(id, - * created_at), so there is no single-column key to reference -- exactly like - * chat_message_reactions (schema/chat_reactions.ts) / chat_message_mentions - * (schema/message_mentions.ts). App-level integrity holds. geoid is also left unconstrained (no FK - * to jurisdictions) so forwarding can be recorded even for a geoid not yet present in the - * jurisdictions table. - * - * Composite PK(message_id, geoid) means one forward record per (message, jurisdiction) pair. - * - * CANONICAL DDL: drizzle/0043_report_message_forwards.sql. This mirror exists for typed queries / - * diff inspection only; nothing reads/writes it yet (forward audit lands in D-C4). + * No FK on message_id: chat_messages is range-partitioned with composite PK(id, created_at), so there is + * no single-column key to reference. geoid has no FK either, so a forward can be recorded for a geoid the + * jurisdictions table does not hold yet. */ import { pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" @@ -22,10 +12,8 @@ import { pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" export const reportMessageForwards = pgTable( "report_message_forwards", { - // A chat_messages.id (uuid, globally unique). Intentionally NOT a FK -- see header. messageId: uuid("message_id").notNull(), geoid: text("geoid").notNull(), - // NULL = @city mentioned but not yet forwarded (no city contact). forwardedAt: timestamp("forwarded_at", { withTimezone: true }), }, (t) => [primaryKey({ columns: [t.messageId, t.geoid] })], diff --git a/services/api/src/db/schema/reports.ts b/services/api/src/db/schema/reports.ts index 65d98b1f..2d1d061f 100644 --- a/services/api/src/db/schema/reports.ts +++ b/services/api/src/db/schema/reports.ts @@ -35,16 +35,15 @@ export const reports = pgTable( title: text("title"), description: text("description"), addr: text("addr"), - /** 0179: where `addr` came from. NULL for legacy rows and for reports with no address. */ addrSource: text("addr_source").$type(), - /** 0179: the ladder rung a RESOLVED addr reached. Always NULL when addrSource is 'user'. */ + /** Always NULL when addrSource is 'user': only a resolved address has a precision. */ addrPrecision: text("addr_precision").$type(), status: text("status").$type().notNull(), visibility: text("visibility").$type().notNull().default("public"), h3Cell: text("h3_cell").notNull(), - /** DEPRECATED (F150): never written since 0091; new rows carry NULL. DROP deferred one release. */ + /** Dead: never written since 0091. The DROP is deferred one release. */ claimCode: text("claim_code"), - /** SHA-256 hex of the per-report single-use claim code (0091). The only claim secret at rest. */ + /** The only claim secret at rest: a SHA-256 hex, never the code itself. */ claimCodeHash: text("claim_code_hash"), referenceCode: text("reference_code"), verificationVerdict: text("verification_verdict").$type<"approved" | "rejected">(), diff --git a/services/api/src/db/schema/sessions.ts b/services/api/src/db/schema/sessions.ts index 3b6526d3..f2045a15 100644 --- a/services/api/src/db/schema/sessions.ts +++ b/services/api/src/db/schema/sessions.ts @@ -1,26 +1,16 @@ -/** - * sessions: server-side session records keyed by the SHA-256 hex of the 256-bit session token. - * - * The raw token is never stored: `id` is its SHA-256 hex digest, so a database leak does not expose - * live session tokens. `ip` uses the PostGIS-independent pg `inet` type. `last_seen_at` is bumped on - * activity for idle-timeout/analytics. - */ - import { index, inet, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" export const sessions = pgTable( "sessions", { - // SHA-256 hex digest of the raw 256-bit token (64 lowercase hex chars). + // SHA-256 hex of the raw token, never the token itself, so a database leak exposes no live session. id: text("id").primaryKey(), userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - // NOT NULL as of drizzle/0058_sessions_created_at.sql, which backfilled the legacy NULL rows - // from last_seen_at. The absolute 90-day session ceiling (M3) is measured FROM this column, so - // a NULL is unrepresentable by schema rather than merely unexpected. Inserts omit it and let - // the DB default stamp it (see PgSessionStore.insert). + // The absolute 90-day session ceiling is measured from this column, so NULL is unrepresentable by + // schema rather than merely unexpected. Inserts let the DB default stamp it. createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/services/api/src/db/schema/timeline.ts b/services/api/src/db/schema/timeline.ts index 7eb25021..9ea8d317 100644 --- a/services/api/src/db/schema/timeline.ts +++ b/services/api/src/db/schema/timeline.ts @@ -1,10 +1,3 @@ -/** - * report_timeline: append-only status history for a report (submitted -> held -> published -> ...). - * Each row is one transition with an optional human note and the acting user (`actor_id` null for - * system transitions). Deleting the report cascades. Indexed by (report_id, created_at) for the - * ordered timeline render. - */ - import { sql } from "drizzle-orm" import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { reports } from "./reports.js" @@ -24,8 +17,8 @@ export const reportTimeline = pgTable( .references(() => reports.id, { onDelete: "cascade" }), status: text("status").$type().notNull(), note: text("note"), - // Issue #56 (D13): `note` stays the short collapsed preview; `kind` tags the entry type and `body` - // carries the full (untruncated) inbound text. Both NULLABLE (0031); legacy rows have neither. + // `note` stays the short collapsed preview while `body` carries the full inbound text; legacy rows + // have neither kind nor body. kind: text("kind"), body: text("body"), actorId: uuid("actor_id").references(() => users.id), @@ -33,8 +26,7 @@ export const reportTimeline = pgTable( }, (t) => [ index("report_timeline_report_idx").on(t.reportId, t.createdAt), - // Partial index for the jurisdiction-directory last_routed_at subquery - // (MAX(created_at) WHERE status = 'acknowledged'). Added in drizzle/0013_perf_indexes.sql. + // Backs the jurisdiction-directory last_routed_at subquery. index("report_timeline_acknowledged_idx") .on(t.reportId, t.createdAt) .where(sql`status = 'acknowledged'`), diff --git a/services/api/src/db/schema/user_verification.ts b/services/api/src/db/schema/user_verification.ts index 771229aa..a315ca00 100644 --- a/services/api/src/db/schema/user_verification.ts +++ b/services/api/src/db/schema/user_verification.ts @@ -1,33 +1,19 @@ /** - * user_verification: document-verification ("verified neighbor") queue. 1:1 with users via the PK so - * the core `users` row stays lean — mirrors the user_moderation side-table pattern, but models the - * gov_claims application-review lifecycle (pending -> verified|rejected) for a single per-user - * verification rather than the abuse/risk signals. - * - * A row exists once the user applies (ABSENCE of a row = "unverified"). `status='verified'` is the - * only state that lights the verified mark; approval changes NO role (it is a cosmetic trust signal). - * `documents` jsonb: array of { mediaId, status?, note? } referencing media_assets rows tagged - * purpose='verification'. All transitions audited via writeAudit. - * - * CANONICAL DDL: drizzle/0016_user_verification.sql. This mirror exists for typed queries / diff - * inspection. + * Document-verification ("verified neighbor") queue, a 1:1 side table so the core users row stays lean. + * A row exists once the user applies; its absence means "unverified". Approval changes no role: the + * verified mark is a cosmetic trust signal only. */ import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" import type { VERIFICATION_STATUS_VALUES } from "./types.js" -/** - * The STORED status subset. 'unverified' (in the shared VerificationStatus enum) is represented by the - * ABSENCE of a row, so it is never written to this column — the CHECK constraint in the canonical SQL - * allows only pending|verified|rejected. - */ +/** 'unverified' is the absence of a row, so the column's CHECK never admits it. */ export type VerificationStoredStatus = Exclude< (typeof VERIFICATION_STATUS_VALUES)[number], "unverified" > -/** One uploaded verification document: a media_assets id (purpose='verification') + optional per-doc state. */ export interface VerificationDocument { mediaId: string status?: VerificationStoredStatus diff --git a/services/api/src/db/seed-demo-domain.ts b/services/api/src/db/seed-demo-domain.ts index 02cb1382..bc01c4ca 100644 --- a/services/api/src/db/seed-demo-domain.ts +++ b/services/api/src/db/seed-demo-domain.ts @@ -1,8 +1,6 @@ /** - * The reserved email domain that marks seeded demo accounts (see seed-demo-la.ts). Guard-free module - * (no run-as-main CLI guard) so BOTH demo CLIs can import it: tsup (splitting: false) inlines imports - * into each bundled entry, and importing a file that carries a runIfMain guard would fire that guard - * inside the importing bundle at boot (see the tsup.config.ts header for the precedent). + * Guard-free module (no runIfMain) so both demo CLIs can import it: tsup (splitting: false) inlines + * imports into each bundled entry, and an imported runIfMain guard would fire inside the importing bundle. * * A real person can never sign up with this domain, so it is safe to key purges and lookups on it. */ diff --git a/services/api/src/db/seed-demo-la.ts b/services/api/src/db/seed-demo-la.ts index d1fb7079..69ea57ef 100644 --- a/services/api/src/db/seed-demo-la.ts +++ b/services/api/src/db/seed-demo-la.ts @@ -1,30 +1,26 @@ /** - * seed-demo-la: seeds a realistic Los Angeles demo community — 250 users with a follow graph, posts - * (top-level / replies / quotes / reposts), likes, saves, mentions, reports (+ timeline + reference - * codes + H3 cells + jurisdiction resolution), cleanup events (+ members, slots, slot claims, linked - * reports) and volunteer hours (+ per-jurisdiction rollups + audit journal). + * Seeds a realistic Los Angeles demo community: users with a follow graph, posts, reports, cleanup + * events and volunteer hours. * * INVARIANTS (each mirrors the live write path so seeded rows are indistinguishable from organic ones): - * - Reference codes come from allocateReportReferenceCode / allocateEventReferenceCode, i.e. the SAME - * reference_counters atomic upsert the create transactions use, so live creates can never collide. - * - reports.h3_cell uses reportH3Cell (H3 res 10) and reports.jurisdiction_geoid / cleanups. - * jurisdiction_geoid come from resolveJurisdiction (the canonical ORDER BY), same as create paths. - * - Every denormalized counter is exact: posts.like_count/reply_count/repost_count/save_count - * (repost_count counts PURE reposts only — quotes do not bump it, matching createPost), - * users.follower_count/following_count, and user_jurisdiction_hours.total_hours. A SQL - * verification pass recomputes all of them inside the transaction and aborts on any mismatch. - * - Volunteer hours follow the logEventHours shape: an 'event'-source volunteer_hours row per - * credited attendee, the user_jurisdiction_hours rollup upsert, and one volunteer_hours_audit row. + * - Reference codes come from the same reference_counters allocator the create transactions use, so + * live creates can never collide. + * - reports.h3_cell and the jurisdiction_geoid columns come from reportH3Cell and resolveJurisdiction, + * as on the create paths. + * - Every denormalized counter is exact (repost_count counts pure reposts only; quotes do not bump it, + * matching createPost). A SQL verification pass recomputes them all inside the transaction and + * aborts on any mismatch. + * - Volunteer hours follow the logEventHours shape: a ledger row per credited attendee, the rollup + * upsert, and one audit row. * * CLOSED WORLD: all follows / likes / replies / memberships the seeder writes stay inside the seeded * cohort. Real users can still interact with demo content once it is live, so --purge recomputes the * counters of every real user and post a demo account touched, and refuses (naming the rows) when real * replies, reposts or volunteer hours depend on demo content. * - * SAFETY: the default run is a REHEARSAL — the entire seed executes in one transaction, the - * verification queries run, and then everything rolls back. Pass --yes to commit. Seeded accounts use - * the reserved @DEMO_EMAIL_DOMAIN so a real person can never collide with (or inherit) one via the - * OTP flow, and --purge keys off that domain. + * SAFETY: the default run is a rehearsal: the seed and its verification run in one transaction that + * then rolls back. Pass --yes to commit. Seeded accounts use the reserved DEMO_EMAIL_DOMAIN so a real + * person can never collide with (or inherit) one via the OTP flow, and --purge keys off that domain. * * Usage: * DATABASE_URL=postgres://... pnpm db:seed:demo # rehearse (rollback), print report @@ -59,10 +55,7 @@ import { touchUserActivity } from "./sql/user-activity.js" const SEEDED_REPORT_ADDR_SOURCE = "user" const SEEDED_EVENT_ADDRESS_SOURCE = "manual" -// --------------------------------------------------------------------------------------------------- -// Deterministic PRNG (mulberry32) + sampling helpers. Seeded so a rehearsal and the committed run (or -// a re-run after purge) generate the same cohort. -// --------------------------------------------------------------------------------------------------- +// Seeded so a rehearsal, the committed run and a re-run after purge generate the same cohort. function mulberry32(seed: number): () => number { let a = seed >>> 0 @@ -103,7 +96,6 @@ function shuffle(arr: T[]): T[] { } return arr } -/** Sample up to n distinct items by weight, excluding `exclude`. */ function sampleWeighted( items: readonly T[], weightOf: (t: T) => number, @@ -166,7 +158,6 @@ export function laLocalToUtc(dayMs: number, hour: number, minute: number, second return new Date(wallAsUtc - offset * HOUR) } -/** Weighted local hour: mornings light, lunchtime medium, evenings heavy, small overnight tail. */ function localHour(): number { return pickWeighted([ [6, 1], @@ -192,7 +183,6 @@ function localHour(): number { ]) } -/** A timestamp on the given local calendar day with a realistic local time, converted to UTC. */ function atLocalTime(dayMs: number): Date { const hour = localHour() const minute = rint(0, 59) @@ -200,14 +190,12 @@ function atLocalTime(dayMs: number): Date { return laLocalToUtc(dayMs, hour, minute, second) } -/** Random realistic timestamp in [start, end], weekend-boosted. */ function randTimestamp(start: Date, end: Date): Date { const span = Math.max(end.getTime() - start.getTime(), 60_000) for (let attempt = 0; attempt < 6; attempt++) { const t = atLocalTime(start.getTime() + rand() * span) if (t.getTime() < start.getTime() || t.getTime() > end.getTime()) continue const dow = t.getUTCDay() - // Weekend boost: keep weekday picks with p=0.75, always keep weekend picks. if (dow === 0 || dow === 6 || chance(0.75)) return t } return new Date(start.getTime() + rand() * span) @@ -220,10 +208,7 @@ function later(a: Date, b: Date): Date { return a.getTime() >= b.getTime() ? a : b } -// --------------------------------------------------------------------------------------------------- -// Name pools. Predominantly Hispanic (about 70% of the cohort), with the rest reflecting LA's mix -// (Korean, Armenian, Filipino, Black and White American, Chinese, Vietnamese). -// --------------------------------------------------------------------------------------------------- +// About 70% of the cohort is Hispanic, with the rest reflecting LA's mix. const HISPANIC_FIRST_M = [ "Jose", @@ -405,7 +390,7 @@ const HISPANIC_LAST = [ "Renteria", ] as const -/** Accent variants for display names only (handles and emails stay ASCII). */ +/** Display names only: handles and emails stay ASCII. */ const ACCENTED: Record = { Jose: "José", Maria: "María", @@ -488,10 +473,8 @@ const OTHER_POOLS: readonly { }, ] -// --------------------------------------------------------------------------------------------------- -// Geography: neighborhoods (approx centers + jitter radius in degrees), streets, parks. -// Weighted toward the Eastside, Southeast LA and the harbor corridor. -// --------------------------------------------------------------------------------------------------- +// Neighborhood jitter radii are in degrees. Weighted toward the Eastside, Southeast LA and the harbor +// corridor. interface Hood { name: string @@ -770,12 +753,9 @@ function jitterPoint(lat: number, lng: number, r: number): { lat: number; lng: n } } -// --------------------------------------------------------------------------------------------------- -// Content pools. Deliberately casual: lowercase drift, loose punctuation, Spanish and Spanglish mixed -// in, occasional emoji, no long-form writing and no em dashes anywhere. -// --------------------------------------------------------------------------------------------------- +// Deliberately casual: lowercase drift, loose punctuation, Spanish and Spanglish mixed in, occasional +// emoji, no long-form writing and no em dashes anywhere (validate() enforces the last). -/** Neutral bios usable by anyone. */ const BIO_NEUTRAL: readonly string[] = [ "{hood} born and raised", "trying to keep {hood} clean one block at a time", @@ -792,7 +772,6 @@ const BIO_NEUTRAL: readonly string[] = [ "{hood} neighborhood watch", ] -/** Gendered bios. */ const BIO_MALE: readonly string[] = [ "dad of 3. tired of the dumping on our streets", "girl dad in {hood}", @@ -804,7 +783,7 @@ const BIO_FEMALE: readonly string[] = [ "abuela energy. {hood}", ] -/** Spanish or culture-specific bios, only for Hispanic users; vecino/vecina gendered. */ +/** Only for Hispanic users; vecino/vecina gendered. */ const BIO_HISPANIC_NEUTRAL: readonly string[] = [ "orgullosamente de {hood} 🇲🇽", "aqui puro {hood} 💪", @@ -813,7 +792,7 @@ const BIO_HISPANIC_NEUTRAL: readonly string[] = [ const BIO_HISPANIC_M: readonly string[] = ["vecino de {hood}, aqui para ayudar"] const BIO_HISPANIC_F: readonly string[] = ["vecina de {hood}, aqui para ayudar"] -/** Standalone top-level posts (English). {street}/{street2}/{hood} slots are filled per author. */ +/** {street}/{street2}/{hood} slots are filled per author. */ const POST_TEMPLATES_EN: readonly string[] = [ "third couch this month dumped on {street}. who keeps doing this", "the graffiti on the handball courts finally got painted over 🙏", @@ -858,7 +837,7 @@ const POST_TEMPLATES_EN: readonly string[] = [ "if every block had 2 people who cared we could keep this whole neighborhood clean", ] -/** Spanish and Spanglish top-level posts, drawn only by Hispanic authors. */ +/** Drawn only by Hispanic authors. */ const POST_TEMPLATES_ES: readonly string[] = [ "mucha basura en la calle otra vez. ya reporte, a ver si hacen algo", "el alley behind my place is getting bad again, gonna report it manana", @@ -871,7 +850,6 @@ const POST_TEMPLATES_ES: readonly string[] = [ "que bonito se ve {hood} cuando todos ayudamos", ] -/** Bodies for posts that attach a report card (post.report_id set). */ const REPORT_POST_EN: readonly string[] = [ "reported this dump on {street}, yall check it out so the city sees it", "this has been here over a week. finally reported it", @@ -888,7 +866,6 @@ const REPORT_POST_ES: readonly string[] = [ "miren esto. reportado. compartan para que lo vean", ] -/** Bodies for posts that attach an upcoming event card (post.event_id set). */ const EVENT_POST_EN: readonly string[] = [ "hosting a cleanup this weekend, bring gloves if you got em. everyone welcome", "cleanup this saturday 🧹 kids welcome, we got extra grabbers", @@ -903,7 +880,6 @@ const EVENT_POST_ES: readonly string[] = [ "este sabado nos toca limpiar. lleguenle con la familia", ] -/** Recap bodies posted by organizers after a done event (post.event_id set). */ const EVENT_RECAP_EN: readonly string[] = [ "{bags} bags today. arms are dead but the block looks brand new. thank you everyone 🙏", "we got {bags} bags out of the park today. proud of this neighborhood", @@ -916,7 +892,6 @@ const EVENT_RECAP_ES: readonly string[] = [ "terminamos con {bags} bolsas. gracias a mi gente que llego temprano", ] -/** Generic replies (agreement, support, banter). */ const REPLY_GENERIC_EN: readonly string[] = [ "same thing on my street", "reported one like this last week, took 3 weeks but they picked it up", @@ -952,7 +927,6 @@ const REPLY_GENERIC_ES: readonly string[] = [ "orale, buen trabajo", ] -/** Replies to event posts (RSVPs, logistics). */ const REPLY_EVENT_EN: readonly string[] = [ "i'll be there", "count me in", @@ -969,7 +943,6 @@ const REPLY_EVENT_EN: readonly string[] = [ ] const REPLY_EVENT_ES: readonly string[] = ["yo tambien voy", "ahi estare", "llevare bolsas extra"] -/** Replies to report posts. */ const REPLY_REPORT_EN: readonly string[] = [ "just liked it so it gets visibility", "reported the same spot last month, they cleared it but it came back", @@ -982,7 +955,6 @@ const REPLY_REPORT_EN: readonly string[] = [ ] const REPLY_REPORT_ES: readonly string[] = ["eso esta a una cuadra de mi casa", "gracias vecino"] -/** Quote-post bodies. */ const QUOTE_EN: readonly string[] = [ "this right here", "everyone in {hood} needs to see this", @@ -993,7 +965,7 @@ const QUOTE_EN: readonly string[] = [ ] const QUOTE_ES: readonly string[] = ["lo que siempre digo", "mi gente 💪"] -/** Report content per type: [titles, descriptions]. Slots: {street} {street2}. */ +/** [titles, descriptions]; slots: {street} {street2}. */ const REPORT_CONTENT: Record< ReportType, { titles: readonly string[]; descs: readonly string[]; descsEs?: readonly string[] } @@ -1196,10 +1168,6 @@ const TIMELINE_RESOLVE_NOTES: readonly string[] = [ "Cleared by sanitation crew", ] -// --------------------------------------------------------------------------------------------------- -// In-memory model -// --------------------------------------------------------------------------------------------------- - type Tier = "power" | "casual" | "light" | "lurker" interface SeedUser { @@ -1300,10 +1268,6 @@ interface SeedHours { createdAt: Date } -// --------------------------------------------------------------------------------------------------- -// Generation -// --------------------------------------------------------------------------------------------------- - function fill(template: string, user: SeedUser, extra?: Record): string { const streets = user.hood.streets const s1 = pick(streets) @@ -1318,7 +1282,6 @@ function fill(template: string, user: SeedUser, extra?: Record): .replaceAll("{n}", extra?.n ?? "") } -/** Pick a body for `user`: Hispanic authors draw from the Spanish pool some of the time. */ function bilingual(user: SeedUser, en: readonly string[], es: readonly string[]): string { const useEs = user.hispanic && es.length > 0 && chance(user.locale === "es" ? 0.55 : 0.18) return pick(useEs ? es : en) @@ -1341,7 +1304,6 @@ function makeUsers(count: number, start: Date, end: Date): SeedUser[] { last = pick(pool.last) } - // Display name variants; a slice of Hispanic users display accented forms. const dFirst = hispanic && chance(0.35) ? (ACCENTED[first] ?? first) : first const dLast = hispanic && chance(0.2) ? (ACCENTED[last] ?? last) : last const displayName = pickWeighted([ @@ -1357,7 +1319,7 @@ function makeUsers(count: number, start: Date, end: Date): SeedUser[] { ], ]) - // Handle: ASCII, matches HANDLE_REGEX (3-20 of [A-Za-z0-9_]). + // Must match HANDLE_REGEX (3-20 of [A-Za-z0-9_]). const fl = first.toLowerCase().replace(/[^a-z0-9]/g, "") const ll = last.toLowerCase().replace(/[^a-z0-9]/g, "") const candidates = [ @@ -1449,7 +1411,6 @@ function makeFollows(users: SeedUser[], now: Date): SeedFollow[] { edges.set(key(u.id, f.id), { followerId: u.id, followeeId: f.id, createdAt: at }) } } - // Reciprocity: ~30% of edges get a follow-back. for (const e of [...edges.values()]) { if (!chance(0.3)) continue const back = key(e.followeeId, e.followerId) @@ -1478,7 +1439,6 @@ function makeEvents(users: SeedUser[], now: Date): SeedEvent[] { const organizer = organizers[i % organizers.length]! const park = parkPool[i % parkPool.length]! const hood = hoodByName(park.hood) - // 13 past done, 1 cancelled, 6 upcoming. const kind: SeedEvent["status"] = i < 13 ? "done" : i === 13 ? "cancelled" : "upcoming" const scheduledAt = kind === "upcoming" @@ -1525,7 +1485,6 @@ function makeEvents(users: SeedUser[], now: Date): SeedEvent[] { hood, } - // Members: neighbors + followers-of-organizer flavored sample. const memberTarget = kind === "cancelled" ? rint(3, 8) : rint(6, 26) const weight = (c: SeedUser) => (c.hood.name === hood.name ? 4 : 1) * @@ -1579,7 +1538,6 @@ function makeEvents(users: SeedUser[], now: Date): SeedEvent[] { return events } -/** A Saturday-or-Sunday-leaning date at least `minDays` ahead of base (bounded by `latest`). */ function nextSaturdayish(base: Date, minDays: number, latest?: Date): Date { let d = new Date(base.getTime() + minDays * DAY) for (let i = 0; i < 7; i++) { @@ -1729,7 +1687,6 @@ function makePosts( return p } - // 1) Plain top-level posts, volume by tier. for (const u of users) { const n = u.tier === "power" @@ -1750,7 +1707,6 @@ function makePosts( } } - // 2) Event promo + recap posts by organizers (and some cohosts). for (const ev of events) { if (ev.status !== "cancelled") { const promoAt = randTimestamp( @@ -1798,7 +1754,6 @@ function makePosts( } } - // 3) Report share posts by the reporter (~30% of published reports). for (const r of reports) { if (r.status === "submitted" || !chance(0.3)) continue const at = minutesAfter(r.publishedAt ?? r.createdAt, 5, 36 * 60) @@ -1814,7 +1769,6 @@ function makePosts( ) } - // 4) Replies (threaded). Popular posts attract more; repliers lean followers + neighbors. const topLevel = posts.filter((p) => p.depth === 0) for (const p of topLevel) { const base = p.eventId ? 2.2 : p.reportId ? 1.6 : 1 @@ -1837,7 +1791,7 @@ function makePosts( const followerPool = followersOf.get(parent.author.id) ?? [] const replier = parent.depth > 0 && parent.author.id !== p.author.id && chance(0.4) - ? p.author // the root author responding to someone in their thread + ? p.author : followerPool.length > 0 && chance(0.65) ? pick(followerPool) : pickWeighted(users.map((u) => [u, u.tier === "lurker" ? 0.2 : 1] as const)) @@ -1883,7 +1837,6 @@ function makePosts( } } - // 5) Reposts (pure) + quotes on popular top-level posts. const repostKeys = new Set() const popularTargets = topLevel.filter((p) => (followersOf.get(p.author.id)?.length ?? 0) >= 3) for (const target of popularTargets) { @@ -2032,10 +1985,6 @@ function makeHours(events: SeedEvent[], now: Date): SeedHours[] { return rows } -// --------------------------------------------------------------------------------------------------- -// Validation (in-memory, before any DB writes) -// --------------------------------------------------------------------------------------------------- - const HANDLE_RE = /^[a-zA-Z0-9_]{3,20}$/ function validate( @@ -2060,7 +2009,6 @@ function validate( if (!u.email.endsWith(`@${DEMO_EMAIL_DOMAIN}`)) errors.push(`email outside demo domain: ${u.email}`) } - // Closed world + counter consistency. const followerCounts = new Map() const followingCounts = new Map() const edgeKeys = new Set() @@ -2079,7 +2027,6 @@ function validate( if ((followingCounts.get(u.id) ?? 0) !== u.followingCount) errors.push(`followingCount drift for @${u.handle}`) } - // Posts: thread + counter integrity, no em dashes anywhere, repost uniqueness, ordering. const likeAgg = new Map() for (const l of likes) likeAgg.set(l.postId, (likeAgg.get(l.postId) ?? 0) + 1) const saveAgg = new Map() @@ -2088,7 +2035,8 @@ function validate( const repostAgg = new Map() const repostKeys = new Set() for (const p of posts) { - if (p.body && p.body.includes("—")) errors.push(`em dash in post body: ${p.body.slice(0, 40)}`) + if (p.body && p.body.includes("\u2014")) + errors.push(`em dash in post body: ${p.body.slice(0, 40)}`) if (p.kind === "reply") { if (!p.replyTo || !p.threadRoot) errors.push("reply missing parent/root") else { @@ -2112,7 +2060,6 @@ function validate( if ((replyAgg.get(p.id) ?? 0) !== p.replyCount) errors.push("replyCount drift") if ((repostAgg.get(p.id) ?? 0) !== p.repostCount) errors.push("repostCount drift") } - // Events: membership uniqueness, slot claim rules, capacity. for (const ev of events) { const seen = new Set() for (const m of ev.members) { @@ -2137,7 +2084,8 @@ function validate( if (r.timeline[i]!.createdAt.getTime() < r.timeline[i - 1]!.createdAt.getTime()) errors.push("timeline out of order") } - if (r.description.includes("—") || r.title.includes("—")) errors.push("em dash in report") + if (r.description.includes("\u2014") || r.title.includes("\u2014")) + errors.push("em dash in report") } if (errors.length > 0) { throw new Error( @@ -2146,10 +2094,6 @@ function validate( } } -// --------------------------------------------------------------------------------------------------- -// Write phase -// --------------------------------------------------------------------------------------------------- - function chunk(arr: T[], size: number): T[][] { const out: T[][] = [] for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)) @@ -2174,7 +2118,6 @@ export async function writeAll( const { now, users, follows, events, reports, posts, likes, saves, hours } = data const resolver = tx as unknown as Sql - // Users + prefs. for (const rows of chunk(users, 200)) { await tx`INSERT INTO users ${tx( rows.map((u) => ({ @@ -2224,7 +2167,7 @@ export async function writeAll( )}` } - // Events: per-row (geometry + reference code allocation, counter FIRST like createCleanupTx). + // Per row, with the reference-code counter allocated first as in createCleanupTx (lock order). for (const ev of events) { const jur = await resolveJurisdiction(resolver, ev.lng, ev.lat) const jurCode = await resolveJurisdictionCode(tx, jur?.geoid ?? null) @@ -2249,7 +2192,6 @@ export async function writeAll( lat: ev.lat, at: ev.createdAt, }) - // Stamp resolved geoid onto pending hours rows for this event. for (const h of hours) if (h.cleanupId === ev.id) h.jurisdictionGeoid = jur?.geoid ?? null } for (const ev of events) { @@ -2295,7 +2237,7 @@ export async function writeAll( } } - // Reports: per-row (geometry, H3, jurisdiction, reference code with the counter allocation first). + // Per row, with the reference-code counter allocated first (lock order). for (const r of reports) { const jur = await resolveJurisdiction(resolver, r.lng, r.lat) const jurCode = await resolveJurisdictionCode(tx, jur?.geoid ?? null) @@ -2329,7 +2271,6 @@ export async function writeAll( await tx`INSERT INTO report_timeline ${tx(rows)}` } - // Link a few nearby reports to cleanup events (the "reports we'll handle" gallery). const linkRows: { cleanup_id: string report_id: string @@ -2358,7 +2299,7 @@ export async function writeAll( } if (linkRows.length > 0) await tx`INSERT INTO cleanup_reports ${tx(linkRows)}` - // Posts, in dependency waves (parents before replies, targets before reposts/quotes). + // Dependency waves: parents before replies, targets before reposts and quotes. const waves = new Map() for (const p of posts) { const arr = waves.get(p.depth) ?? [] @@ -2418,7 +2359,6 @@ export async function writeAll( )}` } - // Volunteer hours: rows + audit journal + per-jurisdiction rollups (logEventHours shape). for (const rows of chunk(hours, 300)) { await tx`INSERT INTO volunteer_hours ${tx( rows.map((h) => ({ @@ -2464,9 +2404,7 @@ export async function writeAll( } } -// --------------------------------------------------------------------------------------------------- -// SQL verification (inside the same transaction; throws -> rollback) -// --------------------------------------------------------------------------------------------------- +// Runs inside the seed transaction, so a mismatch rolls the whole seed back. async function verify(tx: TransactionSql, now: Date): Promise { const lines: string[] = [] @@ -2552,10 +2490,6 @@ async function verify(tx: TransactionSql, now: Date): Promise { return lines } -// --------------------------------------------------------------------------------------------------- -// Purge -// --------------------------------------------------------------------------------------------------- - const PURGE_BLOCKER_SAMPLE = 20 // Real replies/reposts point at demo posts through ON DELETE RESTRICT, and real volunteer hours point @@ -2697,10 +2631,6 @@ export async function purgeDemo(tx: TransactionSql): Promise= 0 ? process.argv[idx + 1] : undefined @@ -2777,7 +2707,6 @@ export async function main(): Promise { const verification = await handle.sql .begin(async (tx) => { - // Refuse to double-seed: purge first if demo users already exist. const [existing] = await tx<{ n: number }[]>` SELECT count(*)::int AS n FROM users WHERE email LIKE ${"%@" + DEMO_EMAIL_DOMAIN} ` diff --git a/services/api/src/db/seed-fixtures.ts b/services/api/src/db/seed-fixtures.ts index 80e38b24..4f569d2f 100644 --- a/services/api/src/db/seed-fixtures.ts +++ b/services/api/src/db/seed-fixtures.ts @@ -1,39 +1,16 @@ /** - * Seed fixtures for jurisdictions, shared between the seed runner (seed.ts) and the spatial - * integration test so both agree on the exact geometries and the expected resolution of each probe - * point. Keeping them here is the single source of truth for the dev/test jurisdiction set. + * Shared by seed.ts and the spatial integration test so both agree on the geometries and on the expected + * resolution of each probe point. The boxes are rectangles, not true boundaries, and strictly nested so + * place precedence is testable. * - * Geometry strategy: each jurisdiction is a simple axis-aligned bounding box built with - * ST_Multi(ST_MakeEnvelope(xmin, ymin, xmax, ymax, 4326)) -> a valid MultiPolygon(4326). The boxes - * are strictly NESTED so place precedence is testable: - * - * California (state) ---------------------------------------------------------------- - * | Los Angeles County (county) ------------------------------ | - * | | Los Angeles city (place) ------------ | | - * | | | PROBE: inside_city | | | - * | | ----------------------------- | | - * | | PROBE: county_not_city (unincorporated) | | - * | ---------------------------------------------------------- | - * | PROBE: outside_all is OUTSIDE this box -> | - * ----------------------------------------------------------------------------------- | - * - * Coordinates are real-ish LA-area lon/lat but deliberately rectangular, not the true boundaries. - * Envelope bounds: [xmin(lng), ymin(lat), xmax(lng), ymax(lat)]. - * - * REAL geoids, PLACEHOLDER contacts (deliberate): the geoids below are the true Census GEOIDs, so the first - * real TIGER load UPSERTS these very rows and replaces the rectangles with authentic boundaries instead of - * leaving fake duplicates overlapping them (unlike the federal/tribal fixtures, nothing prunes stale - * place/county/state rows). The example.* contact addresses are the flip side of that: they must NOT survive - * as the routing target for California / Los Angeles on a box where the seed ran before the first refresh, - * so upsertJurisdiction (ingest-jurisdictions-core.ts) clears contact_emails when EVERY address is an - * example.* placeholder. Keep any contact added here inside an example.* domain (RFC 2606) so it stays - * self-cleaning. + * REAL geoids, PLACEHOLDER contacts (deliberate): the geoids are the true Census GEOIDs, so the first real + * TIGER load upserts these very rows and replaces the rectangles instead of leaving fake duplicates + * overlapping them (nothing prunes stale place/county/state rows). The example.* contacts must not survive + * as the routing target on a box where the seed ran before the first refresh, so upsertJurisdiction clears + * contact_emails when every address is an example.* placeholder. Keep any contact added here inside an + * example.* domain (RFC 2606) so it stays self-cleaning. */ -/** - * A jurisdiction seed row plus its bounding box (lng/lat). These are the nested place/county/state TEST - * boxes; the real federal + tribal lands are a separate curated set in data/federal-lands.ts. - */ export interface JurisdictionSeed { geoid: string name: string @@ -41,11 +18,10 @@ export interface JurisdictionSeed { priority: number population: number contactEmails: string[] - /** [xmin(lng), ymin(lat), xmax(lng), ymax(lat)] envelope passed to ST_MakeEnvelope. */ + /** [xmin(lng), ymin(lat), xmax(lng), ymax(lat)] */ bbox: [number, number, number, number] } -/** California: outermost box. Contains the county box. */ export const CALIFORNIA: JurisdictionSeed = { geoid: "06", name: "California", @@ -56,7 +32,6 @@ export const CALIFORNIA: JurisdictionSeed = { bbox: [-124.5, 32.5, -114.0, 42.0], } -/** Los Angeles County: middle box. Inside California, contains the city box. */ export const LA_COUNTY: JurisdictionSeed = { geoid: "06037", name: "Los Angeles County", @@ -67,7 +42,6 @@ export const LA_COUNTY: JurisdictionSeed = { bbox: [-119.0, 33.7, -117.6, 34.8], } -/** Los Angeles city: innermost box. Inside the county box. */ export const LA_CITY: JurisdictionSeed = { geoid: "0644000", name: "Los Angeles", @@ -78,14 +52,9 @@ export const LA_CITY: JurisdictionSeed = { bbox: [-118.5, 34.0, -118.2, 34.2], } -/** - * The place/county/state seed set, outer-to-inner (insertion order is irrelevant; ON CONFLICT DO - * NOTHING). The real federal + tribal lands (incl. the Angeles National Forest that overlaps LA County) - * live in data/federal-lands.ts and are seeded alongside these by seedJurisdictions -> seedFederalLands. - */ export const JURISDICTION_SEEDS: readonly JurisdictionSeed[] = [CALIFORNIA, LA_COUNTY, LA_CITY] -/** A probe point and the geoid we expect resolveJurisdiction() to return (null = outside all). */ +/** `expectGeoid` null = outside every box. */ export interface ProbePoint { name: string lng: number @@ -93,11 +62,6 @@ export interface ProbePoint { expectGeoid: string | null } -/** - * Probe points used by the spatial test. inside_city falls inside all three boxes and must resolve to - * the place; county_not_city falls inside the county + state boxes but OUTSIDE the city box and must - * resolve to the county (unincorporated path); outside_all falls outside every box and resolves null. - */ export const PROBE_INSIDE_CITY: ProbePoint = { name: "inside_city", lng: -118.35, diff --git a/services/api/src/db/seed.ts b/services/api/src/db/seed.ts index 1b5e0d06..a23beee7 100644 --- a/services/api/src/db/seed.ts +++ b/services/api/src/db/seed.ts @@ -58,7 +58,7 @@ async function main(): Promise { const forced = process.argv.includes("--force") if (process.env.NODE_ENV === "production" && !forced) { console.log( - "seed: skipping — NODE_ENV=production. The dev jurisdiction fixtures (hand-made octagons + " + + "seed: skipping, NODE_ENV=production. The dev jurisdiction fixtures (hand-made octagons + " + "example.* contacts) are not for production; pass --force to override deliberately.", ) return @@ -69,7 +69,7 @@ async function main(): Promise { ` if ((authoritative?.n ?? 0) > 0 && !forced) { console.log( - `seed: skipping — found ${authoritative?.n} authoritative (PADUS-/AIANNH-) jurisdictions ` + + `seed: skipping, found ${authoritative?.n} authoritative (PADUS-/AIANNH-) jurisdictions ` + `(a real boundary load). The dev seed would inject fixtures over real data; pass --force to override.`, ) return diff --git a/services/api/src/db/sql/jurisdiction.ts b/services/api/src/db/sql/jurisdiction.ts index efe8dee2..2fc1d409 100644 --- a/services/api/src/db/sql/jurisdiction.ts +++ b/services/api/src/db/sql/jurisdiction.ts @@ -1,32 +1,16 @@ /** - * Single source of truth for the jurisdiction-resolution spatial query. + * The one definition of jurisdiction resolution, so the service, the backfills and the spatial test can + * never drift. The most specific containing boundary wins: federal land, then tribal land (land ownership + * supersedes local incorporation, so a point in a national park resolves to the federal owner even inside + * a city polygon), then place, county, state. * - * Given a longitude/latitude, find the most-specific jurisdiction whose boundary CONTAINS the point. - * "Most specific" = federal land, then tribal land (both land-OWNERSHIP overrides), then place, then - * county, then state. A point inside a national park / forest / military base resolves to the federal - * owner even when it also falls within a city/county polygon (ownership supersedes local incorporation); - * an address inside an incorporated city resolves to the city; a point in unincorporated county land - * (inside the county polygon but outside every place polygon) resolves to the county; outside all -> null. - * - * The ordering is `layer rank, priority, geoid` (JURISDICTION_RESOLVE_ORDER_BY below): the rank decides - * BETWEEN layers, so the specificity above holds whatever `priority` contains, and `priority` then - * `geoid` only break ties WITHIN one layer (same-layer overlaps are common — overlapping PAD-US federal - * parcels, a dev fixture polygon over the real boundary — and without a total order Postgres could hand - * the write-time resolver and the backfill different rows for the same point). `priority` is therefore a - * live sort key at every resolution site: it cannot change which layer wins, but curating it DOES change - * which of two same-layer polygons a point routes to. The GiST index on jurisdictions.geom (0001_core.sql) - * makes the ST_Contains scan an index lookup. - * - * This is THE query the jurisdiction-service will use; importing it here keeps a single definition so - * the spatial integration test and the service can never drift. It is written against the raw - * postgres-js tag (`Sql`) because it relies on PostGIS functions (ST_Contains / ST_MakePoint / - * ST_SetSRID) that Drizzle's query builder does not model. + * `priority` cannot change which layer wins, but it does decide between two same-layer polygons, so + * curating it changes routing. */ import type { Sql } from "../client.js" import type { JURISDICTION_LAYER_VALUES } from "../schema/types.js" -/** A resolved jurisdiction: just the routing-relevant columns. */ export interface ResolvedJurisdiction { geoid: string name: string @@ -34,59 +18,29 @@ export interface ResolvedJurisdiction { } /** - * THE single source of truth for the jurisdiction layer ranking — a bare SQL `CASE` expression that - * maps a row's `layer` to an ascending rank (lower rank = more specific authority, wins under - * `ORDER BY ... LIMIT 1`). The ordering is federal(0) -> tribal(1) -> place(2) -> county(3) -> - * state/ELSE(4): the two land-OWNERSHIP overrides (federal, then tribal) supersede local incorporation, - * then the most-specific civil boundary (place, then county, then state) wins. - * - * It reaches every resolution site through JURISDICTION_RESOLVE_ORDER_BY (below), which appends the - * tie-breaks, so they can NEVER drift: - * 1. JURISDICTION_RESOLVE_SQL below — the write-time resolver run on every report/anon insert. - * 2. The backfill loops (src/db/backfill-keyset.ts, driven by backfill-jurisdictions.ts and the - * reference-code backfill), which re-resolve NULL jurisdiction_geoid rows via the same correlated - * `ORDER BY` (embedded with `sql.unsafe`, since this is a trusted, code-defined string — never user - * input). - * - * The byte-for-byte tie between this constant and JURISDICTION_RESOLVE_SQL is asserted by - * jurisdiction-sql.test.ts; the literal value is pinned by jurisdiction-backfill.test.ts. Keep it free - * of leading/trailing whitespace so it can be dropped straight into an `ORDER BY` clause. + * Reaches the write-time resolver and the backfill loops only through JURISDICTION_RESOLVE_ORDER_BY, so + * the ranking can never drift between them. The backfills embed it with `sql.unsafe`, which is safe only + * because it is a code-defined constant. Keep it free of surrounding whitespace so it drops straight into + * an ORDER BY. */ export const JURISDICTION_LAYER_RANK_CASE = "CASE layer WHEN 'federal' THEN 0 WHEN 'tribal' THEN 1 WHEN 'place' THEN 2 WHEN 'county' THEN 3 ELSE 4 END" as const /** - * THE `ORDER BY` list every resolution site uses: the layer rank FIRST, then two total-order tie-breaks. - * - * The rank alone leaves SAME-LAYER overlaps undecided, and those are common: PAD-US emits overlapping - * federal Fee parcels, and a dev-seeded fixture polygon can overlap the real boundary it duplicates. - * Without a stable secondary key Postgres is free to return either row, so the write-time resolver and the - * backfill could stamp DIFFERENT jurisdictions (hence different routing) for the same point. - * - * `priority` comes second so curated per-row ordering is honoured within a layer (it is uniform per layer - * today: federal -2, tribal -1, place 0, county 1, state 2 — see ingest LAYER_RANK / LAYER_PRIORITY), and - * `geoid` (the PK, a total order) breaks the remaining ties so the choice is deterministic forever. + * The rank alone leaves same-layer overlaps undecided, and those are common (overlapping PAD-US federal + * parcels, a dev fixture over the real boundary). Without a total order Postgres may return either row, + * so the resolver and the backfill could route the same point differently; `geoid` makes it deterministic. */ export const JURISDICTION_RESOLVE_ORDER_BY = `${JURISDICTION_LAYER_RANK_CASE}, priority, geoid` as const -/** - * The canonical SQL text, exported for assertions/inspection. Uses positional params $1 (lng) and - * $2 (lat). NOTE the coordinate order: ST_MakePoint takes (x=lng, y=lat). - * - * The `ORDER BY` interpolates JURISDICTION_RESOLVE_ORDER_BY (which embeds JURISDICTION_LAYER_RANK_CASE) - * so the resolver and the backfill CLI share one ordering definition. - */ +/** ST_MakePoint takes (x = lng, y = lat), hence $1 = lng. */ export const JURISDICTION_RESOLVE_SQL = `SELECT geoid, name, layer FROM jurisdictions WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)) ORDER BY ${JURISDICTION_RESOLVE_ORDER_BY} LIMIT 1` as const -/** - * Resolve the jurisdiction containing (lng, lat). Returns the most-specific match, or null if the - * point is outside every known boundary. NOTE the coordinate order: (lng = x, lat = y). - */ export async function resolveJurisdiction( sql: Sql, lng: number, diff --git a/services/api/src/db/worker-db.ts b/services/api/src/db/worker-db.ts index 6e71c2ca..a6920dcb 100644 --- a/services/api/src/db/worker-db.ts +++ b/services/api/src/db/worker-db.ts @@ -1,16 +1,7 @@ /** - * Worker-facing DB barrel. - * - * The media-worker is a SEPARATE pnpm package but must NOT re-declare the Drizzle schema or the - * postgres-js client: there is exactly one source of truth for the civfix data layer and it lives in - * @civfix/api. This module is the curated entry the worker imports via the package export - * "@civfix/api/db" (see services/api/package.json -> exports). It re-exports: - * - * - the full Drizzle schema barrel (media_assets, abuse_flags, chat_messages, ...); - * - makeDb + the Db / Sql / DbHandle types (the lazily-connecting postgres-js client factory). - * - * Pointing the export at this SOURCE file (not dist) means the worker's tsc and vitest both resolve - * the exact same types/runtime the API uses, with no duplication and no build-order coupling. + * The media worker's only entry into the data layer (package export "@civfix/api/db"), so the schema and + * client are never re-declared there. The export points at this source file rather than dist, so the + * worker's tsc and vitest resolve the same types the API uses with no build-order coupling. */ export * as schema from "./schema/index.js" diff --git a/services/api/src/env.ts b/services/api/src/env.ts index 09ab569b..d203b23d 100644 --- a/services/api/src/env.ts +++ b/services/api/src/env.ts @@ -128,7 +128,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { if (isProd) { for (const { flag, consequence } of FAKE_SEAM_FLAGS) { if (fakeFlags[flag]) { - errors.push(`${flag}: must not be true in production — ${consequence}`) + errors.push(`${flag}: must not be true in production; ${consequence}`) } } } @@ -276,7 +276,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { !isNonRoutableDbHost(DATABASE_URL) ) { errors.push( - "DATABASE_URL: production requires TLS — append ?sslmode=require (or verify-ca / verify-full); " + + "DATABASE_URL: production requires TLS; append ?sslmode=require (or verify-ca / verify-full); " + "postgres.js otherwise connects in cleartext", ) } @@ -315,7 +315,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { } if (usesLocalStorage && PUBLIC_API_URL.length === 0) { errors.push( - "PUBLIC_API_URL: required whenever LOCAL_STORAGE_DIR is set — the local-disk driver's presigned " + + "PUBLIC_API_URL: required whenever LOCAL_STORAGE_DIR is set: the local-disk driver's presigned " + "URLs must be absolute and reachable from the browser and the media worker", ) } @@ -323,7 +323,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { if (!fakeFlags.USE_FAKE_GEOCODER && DATABASE_URL.length === 0) { errors.push( - "DATABASE_URL: required whenever USE_FAKE_GEOCODER is false — the real geocoder resolves its " + + "DATABASE_URL: required whenever USE_FAKE_GEOCODER is false: the real geocoder resolves its " + '"City, ST" label from the jurisdictions PostGIS table, so there is nothing to query without a database', ) } @@ -340,7 +340,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { if (!fakeFlags.USE_FAKE_STORAGE && !usesLocalStorage) { if (R2_PUBLIC_BASE.length > 0 && R2_INBOUND_BUCKET.length === 0) { errors.push( - "R2_INBOUND_BUCKET: required [BOOT] whenever R2_PUBLIC_BASE is set — a shared bucket would " + + "R2_INBOUND_BUCKET: required [BOOT] whenever R2_PUBLIC_BASE is set: a shared bucket would " + "publish raw inbound email and its attachments on the public CDN", ) } diff --git a/services/api/src/env/comms-env.ts b/services/api/src/env/comms-env.ts index 89d04b75..20e19b39 100644 --- a/services/api/src/env/comms-env.ts +++ b/services/api/src/env/comms-env.ts @@ -60,7 +60,7 @@ export function loadCommsEnv(source: NodeJS.ProcessEnv, errors: string[]): Comms if (isProd && (source.PUBLIC_API_URL ?? "").trim().length === 0) { errors.push( - "PUBLIC_API_URL: required [BOOT] for host communications — the RFC 8058 List-Unsubscribe " + + "PUBLIC_API_URL: required [BOOT] for host communications: the RFC 8058 List-Unsubscribe " + "header in every broadcast email is an API-origin URL, and a wrong origin makes one-click " + "unsubscribe fail for every recipient", ) diff --git a/services/api/src/env/registration-env.ts b/services/api/src/env/registration-env.ts index c1021194..93371660 100644 --- a/services/api/src/env/registration-env.ts +++ b/services/api/src/env/registration-env.ts @@ -45,7 +45,7 @@ export function loadRegistrationEnv( TICKET_TOKEN_SECRET.length < TICKET_TOKEN_SECRET_MIN_LENGTH ) { errors.push( - `TICKET_TOKEN_SECRET: must be at least ${TICKET_TOKEN_SECRET_MIN_LENGTH} characters — ` + + `TICKET_TOKEN_SECRET: must be at least ${TICKET_TOKEN_SECRET_MIN_LENGTH} characters: ` + "it is the only thing standing between a guessed string and a forged event ticket", ) } diff --git a/services/api/src/i18n/locales.ts b/services/api/src/i18n/locales.ts index ea819c56..5b0094ba 100644 --- a/services/api/src/i18n/locales.ts +++ b/services/api/src/i18n/locales.ts @@ -1,9 +1,4 @@ -/** - * Supported server locales (i18n). `en` is the source catalog + the fallback for every key and for - * any unsupported/absent locale. Kept in lockstep with the client `LocaleEnum` (@civfix/shared) and - * the four client catalogs. The DB column `users.locale` is the source of truth for server-generated - * user-facing copy; this module owns clamping an arbitrary stored/incoming value to a supported code. - */ +/** Kept in lockstep with the client `LocaleEnum` in @civfix/shared and the four client catalogs. */ export const SUPPORTED_LOCALES = ["en", "es", "de", "ko"] as const @@ -11,11 +6,7 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number] export const DEFAULT_LOCALE: Locale = "en" -/** - * Clamp an arbitrary value (a stored `users.locale`, a request field, or an `Accept-Language` base - * tag) to a supported locale. A full BCP-47 tag is reduced to its base language (`es-419` -> `es`); - * anything not in {en,es,de,ko} falls back to `en`. Pure + total (never throws). - */ +/** Total: a BCP-47 tag reduces to its base language (`es-419` -> `es`); anything else becomes `en`. */ export function resolveLocale(value: unknown): Locale { if (typeof value !== "string" || value.trim() === "") return DEFAULT_LOCALE const base = value.trim().toLowerCase().split(/[-_]/)[0] @@ -24,7 +15,6 @@ export function resolveLocale(value: unknown): Locale { : DEFAULT_LOCALE } -/** Type guard: is the value one of the four supported locale codes (exact, no clamping)? */ export function isSupportedLocale(value: unknown): value is Locale { return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value) } diff --git a/services/api/src/i18n/messages/de.ts b/services/api/src/i18n/messages/de.ts index 327f0f2c..f88042f6 100644 --- a/services/api/src/i18n/messages/de.ts +++ b/services/api/src/i18n/messages/de.ts @@ -2,15 +2,12 @@ * German (de) catalog for server-generated, user-facing copy. Translated key-by-key from en.ts. * Preserves all {{interpolation}} placeholders exactly. "civfix", URLs, and @handles are not translated. * Tone: concise, natural app German (informal "du" register appropriate for a community platform). - * - * SCOPE: push/bell notification titles + bodies; account/OTP email subjects + bodies. - * Falls back to English (via renderMessage) for any key not present here. + * Missing keys fall back to English in renderMessage. */ import type { MessageKey } from "./en.js" export const de: Partial> = { - // ---- Push / in-app bell notifications -------------------------------------------------------- "notification.follower.title": "Neuer Follower", "notification.follower.body": "{{name}} folgt dir jetzt.", @@ -73,7 +70,6 @@ export const de: Partial> = { "notification.cleanup_slot.moved.body": 'Die Schicht "{{slot}}" bei {{title}} hat eine neue Zeit. Öffne das Event, um sie zu prüfen.', - // ---- Account / OTP emails -------------------------------------------------------------------- "email.otp.subject": "Dein civfix-Anmeldecode", "email.otp.body_line1": "Dein civfix-Anmeldecode lautet {{code}}.", "email.otp.body_expiry": @@ -85,9 +81,8 @@ export const de: Partial> = { "email.generic.subject": "Eine civfix-Benachrichtigung", "email.generic.body": "Du hast eine neue civfix-Benachrichtigung.", - // ---- Nachweis über ehrenamtliche Arbeit (PDF, P5) ----------------------------------------------- "certificate.doc.title": "Nachweis über ehrenamtliche Arbeit", - "certificate.doc.pdf_title": "civfix-Einsatzstunden — {{name}} — {{code}}", + "certificate.doc.pdf_title": "civfix-Einsatzstunden: {{name}}, {{code}}", "certificate.header.number": "Zertifikatsnr.", "certificate.holder.eyebrow": "Ausgestellt für", "certificate.holder.period": "Einsatzzeitraum", @@ -118,7 +113,6 @@ export const de: Partial> = { "certificate.footer.timezone": "Datumsangaben in Pazifikzeit (America/Los_Angeles).", "certificate.error.no_hours": "Du hast noch keine erfassten Einsatzstunden.", - // ---- Guest event RSVP ------------------------------------------------------------------------- "email.guest_otp.subject": "Dein Code für die Anmeldung zu {{title}}", "email.guest_otp.html_intro": "Dein Code für die Anmeldung zu {{title}} lautet:", "email.guest_otp.body_expiry": diff --git a/services/api/src/i18n/messages/en.ts b/services/api/src/i18n/messages/en.ts index b8db87f9..9d5051a4 100644 --- a/services/api/src/i18n/messages/en.ts +++ b/services/api/src/i18n/messages/en.ts @@ -1,39 +1,30 @@ /** - * English (source) catalog for SERVER-generated, user-facing copy. This is the source of truth; es/de/ko - * are translated from these strings key-by-key and fall back here for any missing key. + * Source catalog for server-generated, user-facing copy; es/de/ko are translated key-by-key and fall back + * here for any missing key. * - * SCOPE (per the i18n design spec §4 + worklist-backend.json): - * - backend-notifications: push/bell titles + bodies (DM, mention, comment/reply, follower). - * - backend-emails: account/OTP email subjects + bodies. - * EXCLUDED: jurisdiction report-packet emails (mail-format.ts) stay English (recipients are officials), - * and validation errors are conveyed by stable error CODES (clients localize by code), not message text. + * Excluded: jurisdiction report-packet emails (mail-format.ts) stay English because the recipients are + * officials, and validation errors travel as stable error CODES that clients localize. * - * Interpolation uses `{{var}}` placeholders (see renderMessage). User-authored content echoed into a - * notification body (a DM/mention preview) is passed as a `{{preview}}` var and truncated by the caller; - * only the surrounding wrapper copy is translated. + * User-authored content echoed into a notification (a DM/mention preview) arrives as a `{{preview}}` var, + * truncated by the caller; only the wrapper copy is translated. */ export type MessageCatalog = Record export const en = { - // ---- Push / in-app bell notifications (backend-notifications) ---------------------------------- - // New follower (social-service onNewFollower). {{name}} = follower @handle/display name. + // {{name}} = follower @handle/display name. "notification.follower.title": "New follower", "notification.follower.body": "{{name}} started following you.", - // Report-discussion comment on your report (discussion.routes notifyOnMessage, top-level comment). "notification.comment.title": "New comment on your report", "notification.comment.body": "Someone commented on your report.", - // Report-discussion reply to your comment. "notification.reply.title": "New reply on your comment", "notification.reply.body": "Someone replied to your comment.", - // You were @-mentioned in a report discussion (discussion.routes notifyMention). "notification.report_mention.title": "You were mentioned", "notification.report_mention.body": "Someone mentioned you in a report discussion.", - // Social-feed post interactions (notification-service onPostLike/Repost/Reply/Quote/Mention). // {{name}} = the actor's display name / @handle. "notification.post.like.title": "New like", "notification.post.like.body": "{{name}} liked your post.", @@ -46,24 +37,20 @@ export const en = { "notification.post.mention.title": "{{name}} mentioned you", "notification.post.mention.body": "{{name}} mentioned you in a post.", - // @-mention in a cleanup/report group chat (chat-bells makeChatMentionNotifier). {{name}} = author handle/name. + // {{name}} = author handle/name. "notification.chat_mention.title": "{{name}} mentioned you", - // Reply to YOUR message in any chat room (chat-bells makeChatReplyNotifier / makeDmBellNotifier, - // P2 2.5 — the bell that pierces conversation mutes). {{name}} = author handle/name. + // The one chat bell that pierces conversation mutes. {{name}} = author handle/name. "notification.chat_reply.title": "{{name}} replied to you", - // Direct message delivered bell (chat-gateway-wiring onDmDelivered). {{name}} = sender handle/name. - // {{preview}} = the caller-truncated message preview (user content); the wrapper is what we localize. + // {{name}} = sender handle/name. "notification.dm.title": "{{name}}", "notification.dm.title_fallback": "New message", - // Report-chat member bell (report-chat-notifier). Title fallback for a SYSTEM / author-less report - // message (e.g. a status/timeline event) that has no sender name to show. + // For a system / author-less report message (a status or timeline event) with no sender name to show. "notification.report_chat.title_fallback": "New message", "notification.group_chat.title_fallback": "New message", - // Body shown when the message has no text preview (a non-text frame: share_pin / rsvp / task_complete). + // For a non-text frame (share_pin / rsvp / task_complete) that has no text preview. "notification.message.no_preview": "Sent you a message", - // Cleanup membership-role bells (WS4, type 'cleanup_role'): the organizer promoted/demoted you, or a - // host removed you from the event. {{title}} = the event title. + // {{title}} = the event title. "notification.cleanup_role.promoted.title": "You're now a co-host", "notification.cleanup_role.promoted.body": "You're now a co-host of {{title}}.", "notification.cleanup_role.demoted.title": "Co-host role removed", @@ -79,26 +66,21 @@ export const en = { "role.coordinator": "coordinator", "role.staff": "staff", - // Event-cancellation bell (L24, type 'cleanup_cancelled'): the host called the event off. Fanned out to - // every OTHER member by cleanup-service.notifyCancellation. The reason is OPTIONAL on the wire, so there - // are two bodies rather than one with an empty tail; {{reason}} is the host's own words (user content, - // slur-gated by assertEventTextClean) and only the wrapper copy is translated. + // The reason is optional on the wire, so there are two bodies rather than one with an empty tail. + // {{reason}} is the host's own words (user content, slur-gated by assertEventTextClean). "notification.cleanup_cancelled.title": "Event cancelled", "notification.cleanup_cancelled.body": "This event has been cancelled by the host.", "notification.cleanup_cancelled.body_reason": "This event has been cancelled by the host. Reason: {{reason}}", - // Service-hours credit bell (P4/B33, type 'hours_logged'): a host logged hours for a completed - // event and you were credited. Fired by volunteer-hours-service.logEventHours ONLY when the credit is - // NEW or has INCREASED — re-logging is how a host fixes a typo, and a downward correction is - // deliberately silent. {{hours}} = the credited amount, {{title}} = the event title. + // Sent only when a credit is new or has increased: re-logging is how a host fixes a typo, and a + // downward correction is deliberately silent. {{hours}} = the credited amount, {{title}} = the event. "notification.hours_logged.title": "Service hours credited", "notification.hours_logged.body": "{{hours}} hours were credited for {{title}}.", - // Event-slot bell (P4/B34, type 'cleanup_slot'): the host edited the event's roles and the one you had - // claimed no longer exists, so you are back in the crew with no role. Fired by cleanup-service's slot - // reconciliation; the actor is excluded. {{slot}} = the removed role's title, {{title}} = the event. - // (There is deliberately NO "slot claimed" bell to the host — see B35.) + // The host edited the event's roles and the one you had claimed no longer exists, so you are back in + // the crew with no role. {{slot}} = the removed role's title, {{title}} = the event. There is + // deliberately no "slot claimed" bell to the host. "notification.cleanup_slot.removed.title": "Your event role changed", "notification.cleanup_slot.removed.body": 'The "{{slot}}" role was removed from {{title}}.', @@ -106,24 +88,20 @@ export const en = { "notification.cleanup_slot.moved.body": 'The "{{slot}}" shift at {{title}} has a new time. Open the event to check it.', - // ---- Service-hours transcript / certificate (P5) ------------------------------------------------ - // Every chrome string the SERVER-RENDERED PDF prints (services/certificate-pdf.ts). This is an - // official document a volunteer hands to a school, an employer or a court, so the register is plain - // and the attestation paragraph states ONLY what the shipped crediting rules actually enforce - // (volunteer-hours-service.ts: organizer/cohost gate, self-credit block). Do - // not soften or embellish it — an untrue attestation on an official document is the worst failure mode - // this feature has. + // Chrome for the server-rendered certificate PDF (services/certificate-pdf.ts). It is an official + // document a volunteer hands to a school, an employer or a court, so the attestation states ONLY what + // the crediting rules in volunteer-hours-service.ts enforce (organizer/cohost gate, self-credit block). + // An untrue attestation is the worst failure mode this feature has: do not soften or embellish it. // - // 0065 RETIRED THE REPORT AUTO-AWARD, so the attestation no longer claims one: `logEventHours` is the - // ONLY writer of credited hours, and a sentence saying the platform auto-awards report-verification - // hours would now be false on the face of the document. Do not reinstate it. - // test/unit/adapters-i18n-messages.test.ts pins that absence in all four locales. + // Migration 0065 retired the report auto-award, so `logEventHours` is the only writer of credited hours + // and the attestation must not claim an auto-award. test/unit/adapters-i18n-messages.test.ts pins that + // absence in all four locales. // - // Dates print in America/Los_Angeles (CERTIFICATE_TIME_ZONE, a code constant, not an env var), which - // is exactly what certificate.footer.timezone states on page 1. + // Dates print in America/Los_Angeles (CERTIFICATE_TIME_ZONE, a code constant), which is what + // certificate.footer.timezone states. "certificate.doc.title": "Record of Volunteer Service", - // PDF /Title in the Info dictionary. {{name}} = holder, {{code}} = the CFX-XXXX-XXXX-XXXX display code. - "certificate.doc.pdf_title": "civfix service hours — {{name}} — {{code}}", + // PDF /Title in the Info dictionary. {{code}} = the CFX-XXXX-XXXX-XXXX display code. + "certificate.doc.pdf_title": "civfix service hours: {{name}}, {{code}}", "certificate.header.number": "Certificate No.", "certificate.holder.eyebrow": "Issued to", "certificate.holder.period": "Period of service", @@ -138,15 +116,15 @@ export const en = { "certificate.table.hours": "Hours", "certificate.table.credited_by": "Credited by", "certificate.table.total": "Total", - // Above MAX_CERTIFICATE_ENTRIES the transcript STILL issues — refusing the most prolific volunteers - // would be the wrong failure. B40b: the printed total is the sum of the PRINTED LINES, so this banner - // says so plainly instead of claiming the total covers rows the document does not itemise. + // Above MAX_CERTIFICATE_ENTRIES the transcript still issues (refusing the most prolific volunteers would + // be the wrong failure). The printed total is the sum of the printed lines, so this says so instead of + // claiming the total covers rows the document does not itemise. "certificate.table.truncated": "Showing the {{shown}} most recent of {{total}} activities. The total above is the sum of the {{shown}} listed.", // Report credits are awarded by the PLATFORM, never by a person: naming someone would be a fabricated // attestation. "certificate.credited_by.automatic": "Automatic (report verified)", - // The report itself is never named or linked — a transcript is handed to strangers and reports can be + // The report itself is never named or linked: a transcript is handed to strangers and reports can be // held, unlisted or sensitive. {{ref}} is the already-public reference code, and may be empty. "certificate.activity.report": "Verified report {{ref}}", "certificate.activity.manual": "Adjustment", @@ -161,28 +139,25 @@ export const en = { "certificate.verify.fingerprint": "Document fingerprint", "certificate.footer.page": "Page {{page}} of {{total}}", "certificate.footer.timezone": "Dates shown in Pacific Time (America/Los_Angeles).", - // 409 body when the ledger is empty: we refuse to mint an empty official-looking document. + // The ledger is empty: we refuse to mint an empty official-looking document. "certificate.error.no_hours": "You have no recorded service hours yet.", - // ---- Account / OTP emails (backend-emails) ----------------------------------------------------- - // Sign-in passcode email. {{code}} = the numeric OTP. "email.otp.subject": "Your civfix sign-in code", "email.otp.body_line1": "Your civfix sign-in code is {{code}}.", "email.otp.body_expiry": "It expires in {{minutes}} minutes. If you did not request it, you can ignore this email.", - // HTML-variant intro (the code itself is rendered in a styled block by the template). + // The code itself is rendered in a styled block by the template. "email.otp.html_intro": "Your civfix sign-in code is:", - // Report status-update transactional email. {{status}} = localized/raw status label. + // {{status}} = localized/raw status label. "email.report_update.subject": "Your civfix report was {{status}}", "email.report_update.body": "Your report has a new status: {{status}}.", - // Generic transactional fallback (unknown template). {{subject}}/{{message}} supplied by the caller. + // Fallback for an unknown template. "email.generic.subject": "A civfix notification", "email.generic.body": "You have a new civfix notification.", - // ---- Guest event RSVP (contract 0.38.0) -------------------------------------------------------- - // Sent to people who are NOT users, so there is no users.locale to key off: the guest service renders + // Guest RSVP messages go to people who are NOT users, so there is no users.locale to key off: the guest service renders // these in "en". They are catalogued anyway so a future guest-locale field needs no code change. "email.guest_otp.subject": "Your code to RSVP for {{title}}", "email.guest_otp.html_intro": "Your code to RSVP for {{title}} is:", @@ -218,5 +193,5 @@ export const en = { "sms.guest_cancelled.body": "{{title}} has been cancelled by the host. Reply STOP to opt out.", } satisfies MessageCatalog -/** The exhaustive set of message keys, derived from the EN source so es/de/ko can be checked complete. */ +/** Derived from the EN source so es/de/ko can be checked complete. */ export type MessageKey = keyof typeof en diff --git a/services/api/src/i18n/messages/es.ts b/services/api/src/i18n/messages/es.ts index d76ea212..773fea92 100644 --- a/services/api/src/i18n/messages/es.ts +++ b/services/api/src/i18n/messages/es.ts @@ -1,15 +1,12 @@ /** * Spanish (es) catalog for server-generated, user-facing copy. Translated key-by-key from en.ts. * Preserves all {{interpolation}} placeholders exactly. "civfix", URLs, and @handles are not translated. - * - * SCOPE: push/bell notification titles + bodies; account/OTP email subjects + bodies. - * Falls back to English (via renderMessage) for any key not present here. + * Missing keys fall back to English in renderMessage. */ import type { MessageKey } from "./en.js" export const es: Partial> = { - // ---- Push / in-app bell notifications -------------------------------------------------------- "notification.follower.title": "Nuevo seguidor", "notification.follower.body": "{{name}} ha empezado a seguirte.", @@ -72,7 +69,6 @@ export const es: Partial> = { "notification.cleanup_slot.moved.body": 'El turno "{{slot}}" de {{title}} tiene un horario nuevo. Abre el evento para verlo.', - // ---- Account / OTP emails -------------------------------------------------------------------- "email.otp.subject": "Tu código de acceso a civfix", "email.otp.body_line1": "Tu código de acceso a civfix es {{code}}.", "email.otp.body_expiry": @@ -84,9 +80,8 @@ export const es: Partial> = { "email.generic.subject": "Una notificación de civfix", "email.generic.body": "Tienes una nueva notificación de civfix.", - // ---- Registro de servicio voluntario (PDF, P5) -------------------------------------------------- "certificate.doc.title": "Registro de servicio voluntario", - "certificate.doc.pdf_title": "horas de servicio civfix — {{name}} — {{code}}", + "certificate.doc.pdf_title": "Horas de servicio en civfix: {{name}}, {{code}}", "certificate.header.number": "Certificado n.º", "certificate.holder.eyebrow": "Emitido a", "certificate.holder.period": "Periodo de servicio", @@ -118,7 +113,6 @@ export const es: Partial> = { "Las fechas se muestran en hora del Pacífico (America/Los_Angeles).", "certificate.error.no_hours": "Todavía no tienes horas de servicio registradas.", - // ---- Guest event RSVP ------------------------------------------------------------------------- "email.guest_otp.subject": "Tu código para confirmar tu asistencia a {{title}}", "email.guest_otp.html_intro": "Tu código para confirmar tu asistencia a {{title}} es:", "email.guest_otp.body_expiry": diff --git a/services/api/src/i18n/messages/ko.ts b/services/api/src/i18n/messages/ko.ts index 74fb04e1..78c11e81 100644 --- a/services/api/src/i18n/messages/ko.ts +++ b/services/api/src/i18n/messages/ko.ts @@ -2,15 +2,12 @@ * Korean (ko) catalog for server-generated, user-facing copy. Translated key-by-key from en.ts. * Preserves all {{interpolation}} placeholders exactly. "civfix", URLs, and @handles are not translated. * Tone: natural, friendly Hangul appropriate for a community app (informal-polite 해요체 register). - * - * SCOPE: push/bell notification titles + bodies; account/OTP email subjects + bodies. - * Falls back to English (via renderMessage) for any key not present here. + * Missing keys fall back to English in renderMessage. */ import type { MessageKey } from "./en.js" export const ko: Partial> = { - // ---- Push / in-app bell notifications -------------------------------------------------------- "notification.follower.title": "새 팔로워", "notification.follower.body": "{{name}}님이 팔로우하기 시작했어요.", @@ -72,7 +69,6 @@ export const ko: Partial> = { "notification.cleanup_slot.moved.body": '{{title}}의 "{{slot}}" 교대 시간이 바뀌었어요. 이벤트를 열어 확인해 주세요.', - // ---- Account / OTP emails -------------------------------------------------------------------- "email.otp.subject": "civfix 로그인 코드", "email.otp.body_line1": "civfix 로그인 코드는 {{code}}입니다.", "email.otp.body_expiry": @@ -84,9 +80,8 @@ export const ko: Partial> = { "email.generic.subject": "civfix 알림", "email.generic.body": "새로운 civfix 알림이 있어요.", - // ---- 자원봉사 활동 증명서 (PDF, P5) -------------------------------------------------------------- "certificate.doc.title": "자원봉사 활동 증명서", - "certificate.doc.pdf_title": "civfix 봉사 시간 — {{name}} — {{code}}", + "certificate.doc.pdf_title": "civfix 봉사 시간: {{name}}, {{code}}", "certificate.header.number": "증명서 번호", "certificate.holder.eyebrow": "발급 대상", "certificate.holder.period": "봉사 기간", @@ -117,7 +112,6 @@ export const ko: Partial> = { "certificate.footer.timezone": "날짜는 태평양 시간(America/Los_Angeles) 기준이에요.", "certificate.error.no_hours": "아직 기록된 봉사 시간이 없어요.", - // ---- Guest event RSVP ------------------------------------------------------------------------- "email.guest_otp.subject": "{{title}} 참가 신청 코드", "email.guest_otp.html_intro": "{{title}} 참가 신청 코드:", "email.guest_otp.body_expiry": diff --git a/services/api/src/i18n/renderMessage.ts b/services/api/src/i18n/renderMessage.ts index 5ff02dfa..3fafd68c 100644 --- a/services/api/src/i18n/renderMessage.ts +++ b/services/api/src/i18n/renderMessage.ts @@ -1,22 +1,9 @@ -/** - * Standalone (NO React, NO i18next runtime) message renderer for server-generated user-facing copy. - * - * A simple typed lookup with `{{var}}` interpolation + English fallback, keyed identically to the client - * catalogs (per the i18n design spec). Resolution per key: the target locale's catalog -> the English - * source -> the literal key (so a never-translated/typo'd key is visible rather than silently empty). - * - * Usage: - * renderMessage(user.locale, "notification.follower.body", { name }) - * renderMessage("en", "email.otp.subject") - */ - import { type Locale, resolveLocale } from "./locales.js" import { en, type MessageCatalog, type MessageKey } from "./messages/en.js" import { es } from "./messages/es.js" import { de } from "./messages/de.js" import { ko } from "./messages/ko.js" -/** All four catalogs, keyed by locale. `en` is the complete source; es/de/ko are (partial) overlays. */ const CATALOGS: Record = { en, es, @@ -28,7 +15,6 @@ export type MessageVars = Record const VAR_RE = /\{\{\s*([\w.]+)\s*\}\}/g -/** Substitute every `{{var}}` with the matching `vars` value; an unmatched placeholder is left intact. */ function interpolate(template: string, vars: MessageVars | undefined): string { if (!vars) return template return template.replace(VAR_RE, (match, name: string) => { @@ -38,9 +24,8 @@ function interpolate(template: string, vars: MessageVars | undefined): string { } /** - * Render `key` in `locale` with `vars`. `locale` is clamped to a supported code (so a raw `users.locale` - * or `Accept-Language` base tag is safe to pass directly). Falls back to the English string, then to the - * literal key, so a send never throws or emits an empty string. + * Falls back to the English string, then to the literal key, so a send never throws or emits an empty + * string and an untranslated or mistyped key is visible. */ export function renderMessage(locale: unknown, key: MessageKey, vars?: MessageVars): string { const resolved: Locale = resolveLocale(locale) diff --git a/services/api/src/plugins/cookie.ts b/services/api/src/plugins/cookie.ts index 4eceafcb..c29238ba 100644 --- a/services/api/src/plugins/cookie.ts +++ b/services/api/src/plugins/cookie.ts @@ -1,10 +1,7 @@ /** - * Cookie parsing/signing via @fastify/cookie. Signed with SESSION_SIGNING_KEY so later auth steps - * can set tamper-evident session cookies without re-registering the plugin. - * - * `parseOptions` are DEFAULTS only: @fastify/cookie applies them when a setter omits a field. The - * session/CSRF cookie setters (auth/transport.ts, auth/csrf.ts) must re-assert httpOnly/secure/sameSite - * on each `setCookie`/`clearCookie` (they do) — these defaults are the safety net, not the guarantee. + * `parseOptions` are only DEFAULTS that @fastify/cookie applies when a setter omits a field. The session + * and CSRF setters (auth/transport.ts, auth/csrf.ts) must still re-assert httpOnly/secure/sameSite on each + * `setCookie`/`clearCookie`; these defaults are the safety net, not the guarantee. */ import fastifyCookie from "@fastify/cookie" diff --git a/services/api/src/plugins/cors.ts b/services/api/src/plugins/cors.ts index 2c2aa819..908f3cfe 100644 --- a/services/api/src/plugins/cors.ts +++ b/services/api/src/plugins/cors.ts @@ -1,23 +1,11 @@ -/** - * CORS plugin configured from the WEB_ORIGINS allowlist. - * - * Behavior: - * - Requests with no Origin header (curl, server-to-server, health checks) are allowed. - * - An Origin present in the allowlist is reflected back. - * - Any other Origin is rejected (the browser then blocks the response). - * - If the allowlist is empty (dev convenience only), all origins are allowed; this is logged. - */ - import fastifyCors from "@fastify/cors" import type { FastifyInstance } from "fastify" import { isProd } from "../env.js" export async function registerCors(app: FastifyInstance, webOrigins: string[]): Promise { const allowlist = new Set(webOrigins) - // SECURITY: reflecting ANY origin while credentials:true is set is a credential-theft hole. The - // empty-allowlist "allow all" branch is a DEV convenience ONLY — it must never be reachable in - // production. Fail closed there (deny all cross-origin) rather than reflect-all-with-credentials. - // Uses the validated env loader (isProd()), the project convention, not a raw process.env read. + // Security: reflecting ANY origin with credentials:true is a credential-theft hole, so the empty-allowlist + // "allow all" dev convenience must be unreachable in production, which fails closed instead. const prod = isProd() const isEmpty = allowlist.size === 0 const allowAll = isEmpty && !prod @@ -32,12 +20,9 @@ export async function registerCors(app: FastifyInstance, webOrigins: string[]): await app.register(fastifyCors, { credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - // Cache preflight for 10 min so a CORS-using SPA does not re-OPTIONS every request. maxAge: 600, - // Expose the anon-token header so a CROSS-ORIGIN client could read a freshly-issued token from the - // response. Same-origin web does not need this (it round-trips via the readable civfix_anon cookie), - // but a cross-origin SPA reading X-Anon-Token must have it whitelisted here (browsers hide all but a - // few simple response headers from JS otherwise). Header name is case-insensitive on the wire. + // Browsers hide all but a few simple response headers from cross-origin JS. Same-origin web reads the + // anon token from the civfix_anon cookie instead, but a cross-origin client needs X-Anon-Token here. exposedHeaders: [ "X-Anon-Token", "X-RateLimit-Limit", diff --git a/services/api/src/plugins/helmet.ts b/services/api/src/plugins/helmet.ts index 85819510..673ef697 100644 --- a/services/api/src/plugins/helmet.ts +++ b/services/api/src/plugins/helmet.ts @@ -1,13 +1,7 @@ /** - * Security headers via @fastify/helmet. - * - * CSP (P2-6 hardening): the API serves ONLY JSON, never HTML, so it never legitimately loads scripts, - * styles, frames, or other subresources. We therefore apply a maximally-restrictive lock-down CSP: - * default-src 'none' plus an explicit frame-ancestors 'none' (clickjacking defense). If a JSON response - * were ever mis-rendered as a document (a content-type confusion attack), this CSP blocks every active - * content vector, and nosniff (helmet default, retained) stops the browser from sniffing JSON as HTML. - * This is strictly additive for a JSON API: there is nothing of value to block, so it cannot break a - * legitimate response. The web app and the edge own their own page CSPs separately. + * The API serves only JSON, so the CSP denies every subresource and framing: if a JSON response were ever + * rendered as a document (content-type confusion), no active content can run, and helmet's default nosniff + * stops the browser sniffing JSON as HTML. The web app and the edge own their own page CSPs. */ import fastifyHelmet from "@fastify/helmet" @@ -16,8 +10,6 @@ import { isProd } from "../env.js" export async function registerHelmet(app: FastifyInstance): Promise { await app.register(fastifyHelmet, { - // Lock-down CSP for a pure-JSON API: deny every subresource and disallow being framed. helmet's - // other defaults (X-Content-Type-Options: nosniff, X-Frame-Options, etc.) remain on. contentSecurityPolicy: { useDefaults: false, directives: { @@ -27,13 +19,10 @@ export async function registerHelmet(app: FastifyInstance): Promise { "form-action": ["'none'"], }, }, - // HSTS in prod only (TLS is always upstream there). L21: set it EXPLICITLY rather than taking - // helmet's ~180-day default — two years, all subdomains, preload-eligible. The API and the web app - // share the civfix.org tree, so `includeSubDomains` is what actually closes the sibling-subdomain - // downgrade path (an attacker who can MITM plain HTTP on any *.civfix.org host), and `preload` gets - // the policy baked into browsers so even a user's FIRST request is protected. maxAge 63072000 (2y) - // + includeSubDomains + preload are the values the hstspreload.org submission requires. - // In dev/test HSTS is off: pinning localhost to HTTPS for months would break local development. + // Explicit rather than helmet's ~180-day default: the API and web app share the civfix.org tree, so + // `includeSubDomains` closes the sibling-subdomain downgrade path, and `preload` protects even a + // user's FIRST request. Two years + includeSubDomains + preload is what hstspreload.org requires. + // Off outside prod: pinning localhost to HTTPS for months would break local development. ...(isProd() ? { strictTransportSecurity: { diff --git a/services/api/src/plugins/request-id.ts b/services/api/src/plugins/request-id.ts index f7c5bc7f..985d9668 100644 --- a/services/api/src/plugins/request-id.ts +++ b/services/api/src/plugins/request-id.ts @@ -1,26 +1,10 @@ -/** - * Request-id support. Fastify already assigns `request.id`; we configure the generator at server - * construction time (see server.ts `genReqId`) and this module provides: - * - a small, dependency-free id generator - * - an onSend hook that echoes the id back in the `x-request-id` response header - * - * Keeping the generator here means the error mapper and logs share one definition of request ids. - */ - import { randomUUID } from "node:crypto" import type { FastifyInstance, FastifyRequest } from "fastify" -/** - * Safe charset for an inbound request id we are willing to echo: alphanumerics plus a few separators. - * This prevents reflecting hostile bytes (CRLF / control chars / log-injection payloads) from a - * client-supplied x-request-id back into logs and the x-request-id response header. - */ +/** Keeps hostile bytes (CRLF, control chars, log-injection payloads) out of logs and the echoed header. */ const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,200}$/ -/** - * Generate a request id. Honors an inbound `x-request-id` (for edge/reverse-proxy trace stitching) ONLY - * when it matches the safe charset; anything else (including hostile or oversized values) mints a UUID. - */ +/** An inbound `x-request-id` is honored for edge trace stitching only when it matches the safe charset. */ export function genReqId(req: { headers: Record }): string { const header = req.headers["x-request-id"] if (typeof header === "string" && SAFE_REQUEST_ID.test(header)) { @@ -29,7 +13,6 @@ export function genReqId(req: { headers: Record }): string { return randomUUID() } -/** Register the onSend hook that reflects the request id back to the caller. */ export async function registerRequestId(app: FastifyInstance): Promise { app.addHook("onSend", async (request: FastifyRequest, reply, payload) => { reply.header("x-request-id", request.id) diff --git a/services/api/src/plugins/trust-proxy.ts b/services/api/src/plugins/trust-proxy.ts index 2420bfa7..7f1a64a9 100644 --- a/services/api/src/plugins/trust-proxy.ts +++ b/services/api/src/plugins/trust-proxy.ts @@ -1,17 +1,8 @@ /** - * Trusted-proxy resolution for Fastify's `trustProxy` option. - * - * The pure parser now lives in env/parsers.ts (it is an env parser, not a Fastify plugin); this module - * re-exports it so existing importers/tests keep resolving. - * - * SECURITY: Fastify derives `request.ip` (and `request.protocol`/`request.hostname`) from X-Forwarded-* - * headers ONLY for hops it is told to trust. With `trustProxy: true` it trusts ANY upstream and reads the - * LEFTMOST X-Forwarded-For entry as the client — which a client can spoof. That single misconfiguration - * unwinds every per-IP control (the global limiter, the anon per-IP submit cap, the OTP per-IP cap) and - * poisons the audit trail (sessions.ip / abuse logs). The default trusts ONLY the internal loopback + - * RFC1918 + unique-local ranges, so a forged X-Forwarded-For from a real internet client is never honored - * while the value Caddy sets for the true client IS. Pairs with infra/caddy/Caddyfile, which SETS (not - * appends) X-Forwarded-For and strips any inbound XFF so the client cannot pre-seed even the trusted hop. + * Security: with `trustProxy: true` Fastify trusts ANY upstream and takes the client-spoofable LEFTMOST + * X-Forwarded-For entry as `request.ip`, which unwinds every per-IP control and poisons the audit trail. + * The default trusts only loopback + RFC1918 + unique-local ranges. Pairs with the Caddyfile in + * civfix-infra, which SETS (not appends) X-Forwarded-For so a client cannot pre-seed even the trusted hop. */ export { diff --git a/services/api/src/routes/admin/_audit-read.ts b/services/api/src/routes/admin/_audit-read.ts index c702edb1..5bf388ef 100644 --- a/services/api/src/routes/admin/_audit-read.ts +++ b/services/api/src/routes/admin/_audit-read.ts @@ -1,30 +1,22 @@ /** - * L4: audit trail for SENSITIVE admin READS. + * Audit trail for sensitive admin READS. * - * The admin console audits every WRITE (writeAudit inside the effect's transaction) but historically - * audited no reads at all. That is a defensible default for aggregate/list/analytics surfaces — they are - * high-volume, low-sensitivity, and auditing them would bury the log (this decision is documented in - * audit.routes.ts). It is NOT defensible for the per-SUBJECT reads, which dump one identified person's - * private material to one operator: + * Aggregate, list and analytics reads are not audited: they are high-volume and low-sensitivity, and + * auditing them would bury the log. Per-subject reads that dump one identified person's private material + * to one operator are audited through this helper, among them: * - * - GET /admin/users/:id/messages — the full TEXT of that user's private DMs, group chats and report - * chats, INCLUDING messages the user soft-deleted. This is the single most sensitive read in the - * product and, before this, it left no trace whatsoever. - * - GET /admin/users/:id — the user's identity + moderation dossier. - * - GET /admin/inbox/:id — one citizen↔city email with its body and attachments. - * - GET /admin/mail/:id — one full correspondence thread. + * - GET /admin/users/:id/messages: the full text of that user's DMs, group chats and report chats, + * including messages the user soft-deleted. The most sensitive read in the product. + * - GET /admin/users/:id: the user's identity and moderation dossier. + * - GET /admin/inbox/:id: one citizen-to-city email with its body and attachments. + * - GET /admin/mail/:id: one full correspondence thread. * - * These four now write an audit row naming the operator and the subject, so "who looked at whose messages" - * is answerable after the fact. + * Best-effort, deliberately: a read need not be atomic with its audit, and failing a read on an + * audit-table hiccup would take the console down. Failures are logged at warn. Write paths keep the + * stricter contract (audit in the same transaction; failure fails the request). * - * BEST-EFFORT, deliberately: unlike a write, a read is not required to be atomic with its audit, and - * failing a read because the audit insert failed would take the console down on an audit-table hiccup. - * A failure is logged at warn. (The write paths keep the stricter contract: audit-in-transaction, failure - * fails the request.) - * - * OFFLINE-SAFE: `container.getDb()` throws when no DATABASE_URL is configured (the offline HTTP test - * harnesses), and that throw is caught here like any other failure — so a read route that audits still - * runs in those harnesses. + * Offline-safe: `container.getDb()` throws without a DATABASE_URL (the offline HTTP test harnesses), and + * that throw is caught like any other failure. */ import type { FastifyRequest } from "fastify" @@ -36,21 +28,18 @@ import { } from "../../services/admin/audit.js" export interface ReadAuditInput { - /** Stable dotted action, e.g. "user.messages_viewed". */ action: AdminAuditAction - /** Subject reference, conventionally ":". */ + /** Conventionally ":". */ target: string meta?: Record | null } /** - * Optional injected read-audit sink (tests), mirroring AdminAuthOverrides.auditSink. - * - * Without it these four audits are UNOBSERVABLE from an HTTP test: the offline harness has no DB, so - * `container.getDb()` throws and the failure is swallowed by design (see the file header) — deleting an - * `auditRead` call breaks nothing. With a sink installed the per-route action/target/actor is assertable. + * Injected read-audit sink (tests). Without it these audits are unobservable from an HTTP test: the + * offline harness has no DB, so the failure is swallowed by design and deleting an `auditRead` call would + * break nothing. * - * Read off `request.server`, so it covers every read-audited route at once and is inherited through the + * Read off `request.server`, so it covers every read-audited route and is inherited through the * encapsulated admin scope's prototype chain (a test may install it after buildServer). */ export interface AdminReadAuditOverrides { @@ -59,15 +48,11 @@ export interface AdminReadAuditOverrides { declare module "fastify" { interface FastifyInstance { - /** Injected read-audit sink (tests). See AdminReadAuditOverrides. */ adminReadAuditOverrides?: AdminReadAuditOverrides } } -/** - * Record that `request`'s operator READ the given subject. Never throws: a failure is logged and the read - * proceeds. Pass the actor explicitly (the routes already resolved it via requireOperator). - */ +/** Never throws: a failure is logged and the read proceeds. */ export async function auditRead( request: FastifyRequest, container: Container, @@ -81,9 +66,8 @@ export async function auditRead( meta: input.meta ?? null, } try { - // Both branches stay inside the try so the "never throws" contract holds either way; a sink is expected - // to collect the row for a later assertion rather than assert inline. `server` is optional-chained - // because a bare request stub (the helper's own unit test) has none. + // Both branches stay inside the try so the never-throws contract holds either way. `server` is + // optional-chained because a bare request stub (the helper's own unit test) has none. const sink = request.server?.adminReadAuditOverrides?.sink if (sink) await sink(row) else await writeAudit(container.getDb().sql, row) diff --git a/services/api/src/routes/admin/_route-utils.ts b/services/api/src/routes/admin/_route-utils.ts index 1384294d..7d75b4a9 100644 --- a/services/api/src/routes/admin/_route-utils.ts +++ b/services/api/src/routes/admin/_route-utils.ts @@ -15,9 +15,8 @@ import { export { parse } -// Validates one path segment as a UUID (the columns these feed are `uuid`) so a malformed id returns -// AppError.validation (HTTP 422) instead of reaching the SQL layer, where a non-uuid would throw -// `invalid input syntax for type uuid` -> a hidden 500 + a GlitchTip capture. +// The columns these feed are `uuid`: a malformed id must be a 422 here rather than reach SQL, where +// `invalid input syntax for type uuid` becomes a hidden 500 and a GlitchTip capture. function idSegment(value: unknown, field: string): string { const raw = typeof value === "string" ? value : "" const parsed = IdSchema.safeParse(raw) @@ -27,17 +26,11 @@ function idSegment(value: unknown, field: string): string { return parsed.data } -/** Validate the `:id` path segment as a UUID. See idSegment for why this runs before the SQL layer. */ export function idParam(request: FastifyRequest): { id: string } { return { id: idSegment((request.params as { id?: unknown }).id, "id") } } -/** - * Validate a two-segment `:id`/`:` path (e.g. `/events/:id/reports/:reportId`, - * `/users/:id/messages/:messageId`). Both segments get the same UUID check idParam applies, so a malformed - * child id is a 422 rather than a SQL-layer 500. Path params carry only the segments the URL declares, so - * no unknown-key rejection is needed here. - */ +// Path params carry only the segments the URL declares, so no unknown-key rejection is needed. export function twoIdParams( request: FastifyRequest, secondKey: K, @@ -51,11 +44,8 @@ export function twoIdParams( } /** - * Parse a mutation body against its shared schema with the PATH id merged in. - * - * The typed client fills the id into the body as well, but the URL path is authoritative: the merged id - * (validated by idParam) overwrites whatever the body claimed, so a body id can never address a different - * row than the one the path names. Returns both so a handler can pass the id positionally. + * The typed client also fills the id into the body, but the URL path is authoritative: the path id + * overwrites whatever the body claimed, so a body id can never address a different row than the path names. */ export function parseBodyWithId( schema: S, @@ -65,19 +55,14 @@ export function parseBodyWithId( return { id, body: parse(schema, { ...(request.body as object), id }) } } -/** Send the shared `{ ok: true }` mutation response (AdminOkResponse), the reply of ~24 admin mutations. */ export function sendOk(reply: FastifyReply): void { const payload: AdminOkResponse = { ok: true } reply.status(200).send(payload) } /** - * Build the "injected overrides, else the container" service factory every admin router needs. - * - * `key` names the FastifyInstance override slot each router declares (e.g. "homeOverrides"). The slot is - * re-read on EVERY call rather than captured, so a test that installs it AFTER buildServer still wins (the - * value is reached through the encapsulated admin scope's prototype chain). `fromOverrides` builds the - * service from the injected in-memory deps; `fromContainer` builds it from the container's Drizzle repos. + * The override slot is re-read on every call rather than captured, so a test that installs it after + * buildServer still wins (it is reached through the encapsulated admin scope's prototype chain). */ export function overridableService( app: FastifyInstance, @@ -94,9 +79,8 @@ export function overridableService( } /** - * The container-backed announcer for operator message removals and restores. The message is re-read - * through the room's around-history window, the one read path that returns a tombstoned target, so the - * frame carries exactly what a member's own delete would broadcast. + * The message is re-read through the room's around-history window, the one read path that returns a + * tombstoned target, so the frame carries exactly what a member's own delete would broadcast. */ export function makeContainerMessageUpdateAnnouncer( container: Container, @@ -126,30 +110,25 @@ export function makeContainerMessageUpdateAnnouncer( }) } -/** - * Spread an override bundle's optional clock into a service's deps: `{ ...spreadNow(overrides) }`. Absent - * means "the service keeps its own default clock", which is not the same as passing `now: undefined`. - */ +// Absent means "the service keeps its own default clock", which is not the same as `now: undefined`. export function spreadNow(overrides: { now?: () => Date }): { now?: () => Date } { return overrides.now !== undefined ? { now: overrides.now } : {} } /** - * L7: server-side scheme allowlist for a stored, re-served URL (`formUrl` on jurisdictions and on discovery + * Server-side scheme allowlist for a stored, re-served URL (`formUrl` on jurisdictions and discovery * contacts). * - * The wire schemas use Zod's `.url()`, which only asserts that `new URL()` PARSES the string — and - * `javascript:alert(1)`, `data:text/html,...` and `vbscript:...` all parse. Those values are persisted and - * later rendered as an href in both the admin console and the public jurisdiction directory, so a stored - * `javascript:` URI is a stored XSS in two UIs (and in the console that is a full authz bypass, since the - * CSRF cookie is JS-readable). + * The wire schemas use Zod's `.url()`, which only asserts that `new URL()` parses the string, and + * `javascript:alert(1)`, `data:text/html,...` and `vbscript:...` all parse. These values are rendered as an + * href in the admin console and the public jurisdiction directory, so a stored `javascript:` URI is a + * stored XSS in two UIs (in the console a full authz bypass, since the CSRF cookie is JS-readable). * - * The wire schema lives in @civfix/shared and cannot be edited from this repo, so the constraint is enforced - * at the persist boundary instead. FOLLOW-UP: tighten `PatchJurisdictionRequestSchema` / - * `SaveContactsRequestSchema` / `SaveDraftRequestSchema` in @civfix/shared to a scheme-checked URL so the - * contract itself rejects it and other consumers inherit the fix. + * The wire schemas live in @civfix/shared, so the constraint is enforced at the persist boundary until + * `PatchJurisdictionRequestSchema` / `SaveContactsRequestSchema` / `SaveDraftRequestSchema` carry a + * scheme-checked URL themselves. * - * Returns the value unchanged (null/absent passes through — clearing the field is legitimate). + * Null or blank passes through as null: clearing the field is legitimate. */ export function httpUrlField(value: string | null | undefined, field: string): string | null { if (value === null || value === undefined || value.trim() === "") return null @@ -166,8 +145,8 @@ export function httpUrlField(value: string | null | undefined, field: string): s return trimmed } -// A GEOID is free-form text (not a uuid), so this checks presence only. It is safe ONLY because every -// consumer binds it as a bound SQL parameter — a GEOID must NEVER reach sql.unsafe()/sql.raw(). +// A GEOID is free-form text (not a uuid), so this checks presence only. That is safe only because every +// consumer binds it as a SQL parameter: a GEOID must never reach sql.unsafe()/sql.raw(). export function geoidParam(request: FastifyRequest): string { const params = request.params as { geoid?: unknown } const geoid = typeof params.geoid === "string" ? params.geoid : "" diff --git a/services/api/src/routes/admin/activity.routes.ts b/services/api/src/routes/admin/activity.routes.ts index 6b27b97c..c23b936e 100644 --- a/services/api/src/routes/admin/activity.routes.ts +++ b/services/api/src/routes/admin/activity.routes.ts @@ -1,16 +1,9 @@ /** - * Admin activity-feed route (Phase 2). + * GET /admin/activity: a union of audit_log entries and recent domain events (new reports, cleanups, mail + * bounces). No read audit (aggregate view). * - * GET /admin/activity the recent activity feed (ActivityListResponse): a union of audit_log entries + - * recent domain events (new reports, cleanups, mail bounces). - * - * Read-only operator view (no audit written for a read). The requireOperator guard is applied by - * routes/admin/index.ts. The query is validated against the shared ActivityListQuerySchema via parse(). - * The service is built lazily from the container (Drizzle repo) or a test override (in-memory repo). - * - * The whole query (q / filter / sort / cursor / limit) is forwarded verbatim: `filter` and `sort` are - * free-form strings on the wire, and the service NORMALIZES them (parseActivityFilter / parseActivitySort) - * so an unrecognized value degrades to the default instead of 422ing or reaching SQL. + * `filter` and `sort` are free-form strings on the wire and are forwarded verbatim: the service normalizes + * them so an unrecognized value degrades to the default instead of 422ing or reaching SQL. */ import { ActivityListQuerySchema, type ActivityListResponse } from "@civfix/shared" @@ -24,10 +17,7 @@ import { import { makeDrizzleActivityRepository } from "../../services/admin/activity-repository.drizzle.js" import { route } from "../../versioning/route.js" -/** - * Optional injected activity-service dependencies (tests). When present the route builds the service from - * these (an in-memory repo) instead of the container, so the whole HTTP flow runs offline. - */ +/** Injected activity-service deps (tests), so the whole HTTP flow runs offline. */ export interface ActivityRouteOverrides { repo: ActivityRepository now?: () => Date @@ -35,7 +25,6 @@ export interface ActivityRouteOverrides { declare module "fastify" { interface FastifyInstance { - /** Injected activity-route overrides (tests). See ActivityRouteOverrides. */ activityOverrides?: ActivityRouteOverrides } } @@ -44,7 +33,6 @@ export async function registerAdminActivityRoutes( app: FastifyInstance, container: Container, ): Promise { - /** Build the activity service from injected overrides (tests) or the container (production). */ const service = overridableService( app, "activityOverrides", diff --git a/services/api/src/routes/admin/audit.routes.ts b/services/api/src/routes/admin/audit.routes.ts index 0069c6b6..a92c656b 100644 --- a/services/api/src/routes/admin/audit.routes.ts +++ b/services/api/src/routes/admin/audit.routes.ts @@ -1,6 +1,4 @@ -// GET /admin/audit — read-only operator view of audit_log (no audit written for a read). The -// requireOperator guard is applied by routes/admin/index.ts (this whole router runs inside the guarded -// child context). +// Reads of audit_log are not themselves audited. import { AuditListQuerySchema, type AuditListResponse } from "@civfix/shared" import type { FastifyInstance } from "fastify" @@ -10,7 +8,7 @@ import { makeAuditService, type AuditRepository } from "../../services/admin/aud import { makeDrizzleAuditRepository } from "../../services/admin/audit-repository.drizzle.js" import { route } from "../../versioning/route.js" -/** Injected audit-service deps (tests): an in-memory repo the route builds the service from offline. */ +/** Injected audit-service deps (tests), so the HTTP flow runs offline. */ export interface AuditRouteOverrides { repo: AuditRepository } diff --git a/services/api/src/routes/admin/discovery.routes.ts b/services/api/src/routes/admin/discovery.routes.ts index d3b3d454..a4f9bcf2 100644 --- a/services/api/src/routes/admin/discovery.routes.ts +++ b/services/api/src/routes/admin/discovery.routes.ts @@ -1,11 +1,5 @@ -/** - * Admin discovery (jurisdiction onboarding queue) routes: list / detail + the note / flag / draft - * mutations. The acting operator's userId is recorded on every audit write. - * - * NOTES STORAGE: operator notes are persisted as audit_log rows (action discovery.note_added) and read - * back from there - the discovery task has no notes column and the foundation schema is frozen. See - * discovery-service.ts / discovery-repository.drizzle.ts. - */ +// The discovery task has no notes column, so operator notes are stored and read back as audit_log rows +// (action discovery.note_added); see discovery-repository.drizzle.ts. import { AddNoteRequestSchema, @@ -35,10 +29,7 @@ import { } from "../../services/admin/discovery-service.js" import { makeDrizzleDiscoveryRepository } from "../../services/admin/discovery-repository.drizzle.js" -/** - * Optional injected discovery-service dependencies (tests). When present the routes build the service - * from these (an in-memory repo) instead of the container, so the whole HTTP flow runs offline. - */ +/** Test-only: an in-memory repo so the whole HTTP flow runs offline. */ export interface DiscoveryRouteOverrides { repo: DiscoveryRepository now?: () => Date @@ -46,7 +37,6 @@ export interface DiscoveryRouteOverrides { declare module "fastify" { interface FastifyInstance { - /** Injected discovery-route overrides (tests). See DiscoveryRouteOverrides. */ discoveryOverrides?: DiscoveryRouteOverrides } } @@ -57,7 +47,6 @@ export async function registerAdminDiscoveryRoutes( ): Promise { const csrfProtect = container.csrf.protect - /** Build the discovery service from injected overrides (tests) or the container (production). */ const service = overridableService( app, "discoveryOverrides", @@ -84,8 +73,7 @@ export async function registerAdminDiscoveryRoutes( const actorId = requireOperator(request) const { id, body } = parseBodyWithId(AddNoteRequestSchema, request) const who = await operatorLabel(app, actorId) - // addNote persists the note AS the audit_log discovery.note_added row (the note store), so the write - // is atomic + audited in one place; no separate writeAudit here. + // The note IS its audit_log row, so there is no separate writeAudit here. await service().addNote(id, { text: body.text, actorId, who }) sendOk(reply) }) @@ -103,7 +91,7 @@ export async function registerAdminDiscoveryRoutes( await service().saveDraft(id, { contacts: (body.contacts ?? {}) as Partial>, defaultEmails: body.defaultEmails ?? [], - // L7: reject javascript:/data: URIs the shared `.url()` schema lets through (see httpUrlField). + // The shared `.url()` schema lets javascript:/data: URIs through. formUrl: httpUrlField(body.formUrl, "formUrl"), actorId, }) @@ -111,11 +99,7 @@ export async function registerAdminDiscoveryRoutes( }) } -/** - * Resolve a human "who" label for an operator note from the operator's user record: @handle (the canonical - * identifier), else display name, else email, else a short id. Falls back to "operator" when the user - * record cannot be read. - */ +/** The @handle leads because it is the operator's canonical identifier. */ async function operatorLabel(app: FastifyInstance, actorId: string): Promise { try { const user = await app.authServices.users.findById(actorId) diff --git a/services/api/src/routes/admin/events.routes.ts b/services/api/src/routes/admin/events.routes.ts index 2f1a8980..8a889cbc 100644 --- a/services/api/src/routes/admin/events.routes.ts +++ b/services/api/src/routes/admin/events.routes.ts @@ -1,12 +1,6 @@ /** - * Admin events (cleanups) routes (Phase 2): list / detail / set-status / outcome / flag / cancel / message - * / link-reports / unlink-report. - * - * Every body/query is validated against the shared Zod schema via parse(). The requireOperator guard is - * applied by routes/admin/index.ts; mutations additionally carry csrfProtect. The acting operator's userId - * is resolved once per handler via requireOperator(request) and recorded on every audit write (the audit - * is written inside the service's repo transaction, atomic with the effect). The service is built lazily - * from the container (Drizzle event repo) or from a per-instance test override (in-memory repo). + * Every audit is written inside the service's repo transaction with the operator id resolved here, so the + * audit row and the effect commit or roll back together. */ import { @@ -55,7 +49,7 @@ declare module "fastify" { } } -/** The admin event service, from the test overrides when present, else from the container. Shared with the org routes (adminListOrgEvents). */ +/** Shared with the org routes (adminListOrgEvents). */ export function makeContainerAdminEventService( app: FastifyInstance, container: Container, @@ -106,7 +100,7 @@ export async function registerAdminEventsRoutes( sendOk(reply) }) - // Log bags collected — the only write path for cleanups.bags. + // The only write path for cleanups.bags. route(app, "setEventOutcome", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireOperator(request) const { id, body } = parseBodyWithId(SetEventOutcomeRequestSchema, request) @@ -135,8 +129,6 @@ export async function registerAdminEventsRoutes( sendOk(reply) }) - // The audit (event.reports_linked) is written inside the service's repo transaction (atomic with the - // junction + timeline rows), using the operator userId resolved here. route(app, "linkEventReports", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireOperator(request) const { id, body } = parseBodyWithId(LinkEventReportsRequestSchema, request) diff --git a/services/api/src/routes/admin/inbox.routes.ts b/services/api/src/routes/admin/inbox.routes.ts index 9e35202a..b4571d77 100644 --- a/services/api/src/routes/admin/inbox.routes.ts +++ b/services/api/src/routes/admin/inbox.routes.ts @@ -1,16 +1,6 @@ -/** - * Admin inbox routes (catch-all inbound mail). - * - * GET /admin/inbox the inbox list (filter status / recipient local-part / search) (InboxListResponse). - * GET /admin/inbox/:id one inbound email + body + attachment links (GetInboxMessageResponse). - * POST /admin/inbox/:id/status set the triage status (mark read / archive) (SetInboxStatusRequest). [csrf] - * - * The detail route presigns each attachment's R2 key into a time-limited GET URL (the DTO carries the - * key field; on the wire it is a fetchable URL the admin reader links to). The requireOperator guard is - * applied by routes/admin/index.ts (this router runs inside the guarded child context); the mutation - * additionally carries csrfProtect. The repo is built lazily from the container (Drizzle inbound repo) - * or from a per-instance test override. - */ +// The requireOperator guard is applied by routes/admin/index.ts: this router runs inside the guarded +// child context. On the wire an attachment's `key` field carries a time-limited presigned GET URL, never +// the raw R2 key. import { InboxListQuerySchema, @@ -33,13 +23,10 @@ import { type InboundRepository, } from "../../services/admin/inbound-repository.drizzle.js" -/** Hard cap on attachments presigned per inbound email (defends a crafted mail with thousands of parts). */ +/** Defends against a crafted mail with thousands of parts. */ const MAX_INBOX_ATTACHMENTS = 50 -/** - * Optional injected inbox dependencies (tests). When present the routes use the in-memory inbound repo + - * a fake Storage instead of the container, so the HTTP flow runs offline with no DB and no R2. - */ +/** Test-only: an in-memory repo and a fake Storage so the HTTP flow runs offline with no DB and no R2. */ export interface AdminInboxRouteOverrides { repo: InboundRepository storage: Storage @@ -47,7 +34,6 @@ export interface AdminInboxRouteOverrides { declare module "fastify" { interface FastifyInstance { - /** Injected admin-inbox route overrides (tests). See AdminInboxRouteOverrides. */ adminInboxOverrides?: AdminInboxRouteOverrides } } @@ -65,16 +51,14 @@ export async function registerAdminInboxRoutes( return app.adminInboxOverrides?.storage ?? container.inboundStorage } - // GET /admin/inbox route(app, "listInbox", async (request, reply) => { const query = parse(InboxListQuerySchema, request.query) const payload: InboxListResponse = await repo().list(query) reply.status(200).send(payload) }) - // GET /admin/inbox/:id (presign attachment keys -> time-limited GET URLs) - // L4: a per-subject read — one citizen<->city email with its full body and its attachments presigned - // into fetchable URLs. Audited (best-effort) so the disclosure is attributable. + // A per-subject read (one citizen<->city email with its full body and fetchable attachments), audited + // best-effort so the disclosure is attributable. route(app, "getInboxMessage", async (request, reply) => { const { id } = idParam(request) const dto = await repo().get(id) @@ -84,10 +68,9 @@ export async function registerAdminInboxRoutes( target: `inbound_email:${id}`, meta: { attachments: dto.attachments.length }, }) - // Over the cap the extra parts are ELIDED from the payload with no wire signal (the DTO has no - // truncation flag), so the omission is recorded here and in the audit meta above — otherwise an - // operator reading the message cannot know evidence was left out. FOLLOW-UP: a truncation flag on - // InboundEmailDTO in @civfix/shared would surface it in the console itself. + // Over the cap the extra parts are dropped with no wire signal (the DTO has no truncation flag), so + // the omission is recorded here and in the audit meta above; otherwise nobody could tell evidence was + // left out. if (dto.attachments.length > MAX_INBOX_ATTACHMENTS) { request.log.warn( { inboundEmailId: id, attachments: dto.attachments.length, cap: MAX_INBOX_ATTACHMENTS }, @@ -104,9 +87,7 @@ export async function registerAdminInboxRoutes( reply.status(200).send(payload) }) - // POST /admin/inbox/:id/status [csrf] - // L6: the acting operator is threaded into the repo, which writes the inbox.status_changed audit row in - // the SAME transaction as the UPDATE (mirroring mail.routes.ts and every other admin mutation). + // The repo writes the inbox.status_changed audit row in the same transaction as the UPDATE. route(app, "setInboxStatus", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireOperator(request) const { id, body } = parseBodyWithId(SetInboxStatusRequestSchema, request) diff --git a/services/api/src/routes/admin/jurisdictions.routes.ts b/services/api/src/routes/admin/jurisdictions.routes.ts index 748fd056..fb4cad21 100644 --- a/services/api/src/routes/admin/jurisdictions.routes.ts +++ b/services/api/src/routes/admin/jurisdictions.routes.ts @@ -1,9 +1,6 @@ -/** - * Admin jurisdictions (routing contacts + directory) routes: "Save & route", the directory list, and the - * no-route PATCH. The operator userId is threaded INTO the service so the audit (discovery.contacts_saved - * on save, jurisdiction.patched on patch) is written inside the repo transaction (H4: did + recorded is - * atomic with the effect); the route never writes a separate audit. - */ +// The operator id is threaded into the service so the audit row (discovery.contacts_saved, +// jurisdiction.patched) commits in the same repo transaction as the change; the route never writes a +// separate audit. import { JurisdictionListQuerySchema, @@ -32,10 +29,7 @@ import { } from "../../services/admin/jurisdiction-contacts-service.js" import { makeDrizzleJurisdictionContactsRepository } from "../../services/admin/jurisdiction-contacts-repository.drizzle.js" -/** - * Optional injected contacts-service dependencies (tests). When present the routes build the service - * from these (an in-memory repo + a fake jobs enqueuer) so the whole HTTP flow runs offline. - */ +/** Test-only: an in-memory repo and a fake jobs enqueuer so the whole HTTP flow runs offline. */ export interface JurisdictionRouteOverrides { repo: JurisdictionContactsRepository jobs?: OutreachEnqueuer @@ -45,7 +39,6 @@ export interface JurisdictionRouteOverrides { declare module "fastify" { interface FastifyInstance { - /** Injected jurisdiction-route overrides (tests). See JurisdictionRouteOverrides. */ jurisdictionOverrides?: JurisdictionRouteOverrides } } @@ -56,7 +49,6 @@ export async function registerAdminJurisdictionsRoutes( ): Promise { const csrfProtect = container.csrf.protect - /** Build the contacts service from injected overrides (tests) or the container (production). */ const service = overridableService( app, "jurisdictionOverrides", @@ -90,7 +82,7 @@ export async function registerAdminJurisdictionsRoutes( { contacts: (body.contacts ?? {}) as Partial>, defaultEmails: body.defaultEmails ?? [], - // L7: reject javascript:/data: URIs the shared `.url()` schema lets through (see httpUrlField). + // The shared `.url()` schema lets javascript:/data: URIs through. formUrl: httpUrlField(body.formUrl, "formUrl"), ...(body.forwardSubjectTemplate !== undefined ? { forwardSubjectTemplate: body.forwardSubjectTemplate } @@ -127,7 +119,7 @@ export async function registerAdminJurisdictionsRoutes( ? { contacts: body.contacts as Partial> } : {}), ...(body.defaultEmails !== undefined ? { defaultEmails: body.defaultEmails } : {}), - // L7: same scheme allowlist on the PATCH path (an absent field still means "leave unchanged"). + // Same scheme allowlist as save; an absent field still means "leave unchanged". ...(body.formUrl !== undefined ? { formUrl: httpUrlField(body.formUrl, "formUrl") } : {}), ...(body.notes !== undefined ? { notes: body.notes } : {}), ...(body.flagged !== undefined ? { flagged: body.flagged } : {}), diff --git a/services/api/src/routes/admin/orgs.routes.ts b/services/api/src/routes/admin/orgs.routes.ts index b8a3f787..3807a4c3 100644 --- a/services/api/src/routes/admin/orgs.routes.ts +++ b/services/api/src/routes/admin/orgs.routes.ts @@ -64,9 +64,9 @@ export async function registerAdminOrgRoutes( reply.status(200).send(payload) }) - // ---- Org management (0.41.0, DECISIONS §32). Every mutation's operator audit is written INSIDE the - // repository transaction (the decide precedent below), with the mandatory `reason` in its meta, so the - // audit row and the state change commit or roll back together. The routes add no second writeAudit. + // Every org-management mutation writes its operator audit, with the mandatory `reason` in its meta, inside + // the repository transaction, so the audit row and the state change commit or roll back together. The + // routes add no second writeAudit. route(app, "adminListOrgs", async (request, reply) => { const operatorId = requireOperator(request) @@ -207,12 +207,9 @@ export async function registerAdminOrgRoutes( reply.status(200).send(payload) }) - // Operator audit: unlike the sibling admin mutations (broadcasts/pages), which call writeAudit in the - // route after the effect, this decision is audited INSIDE the repository transaction - // (organization-repository.drizzle.ts decideVerificationTx -> writeHostAudit "org.verification_verified" / - // "org.verification_rejected" with actorId=operator, target=organization:, meta={kind, reason}), so - // the audit row and the state change commit or roll back together. A route-level writeAudit here would - // double-write the same row, so the route deliberately relies on the repo's entry. + // Unlike broadcasts/pages, which call writeAudit in the route after the effect, this decision is audited + // inside the repository transaction (decideVerificationTx), so the audit row and the state change commit + // or roll back together. A route-level writeAudit here would double-write the row. route(app, "adminDecideOrgVerification", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireOperator(request) const { id, body } = parseBodyWithId(DecideOrgVerificationRequestSchema, request) diff --git a/services/api/src/routes/auth.routes.ts b/services/api/src/routes/auth.routes.ts index 376bba69..0ed181a0 100644 --- a/services/api/src/routes/auth.routes.ts +++ b/services/api/src/routes/auth.routes.ts @@ -93,7 +93,7 @@ async function requireIssuedNonce( } opts.log.warn( { control: "oauth-nonce" }, - "native sign-in accepted with no nonce (OAUTH_REQUIRE_NONCE is off) — ID-token replay is not bounded for this client", + "native sign-in accepted with no nonce (OAUTH_REQUIRE_NONCE is off); ID-token replay is not bounded for this client", ) return undefined } diff --git a/services/api/src/routes/chat-groups.routes.ts b/services/api/src/routes/chat-groups.routes.ts index c5387776..7a35d185 100644 --- a/services/api/src/routes/chat-groups.routes.ts +++ b/services/api/src/routes/chat-groups.routes.ts @@ -1,32 +1,6 @@ -/** - * P4 Task 4.3: the /groups HTTP surface — ALL NINE group endpoints from the shared registry. - * - * POST /groups [auth][csrf][10/hour] create group/channel -> ChatGroupDTO (201) - * GET /groups/:id [auth] fetch one -> ChatGroupDTO - * PATCH /groups/:id [auth][csrf] owner/admin update -> ChatGroupDTO - * GET /groups/:id/members [auth] keyset member list -> ListGroupMembersResponse - * POST /groups/:id/members [auth][csrf][30/min] owner/admin add -> refreshed first page - * DELETE /groups/:id/members/:userId [auth][csrf] remove / leave -> {ok:true} - * PUT /groups/:id/members/:userId/role [auth][csrf] owner sets role -> GroupMemberDTO - * GET /groups/:id/messages [auth] room history + pins -> ChatHistoryResponse - * DELETE /groups/:id/messages/:messageId [auth][csrf] sender/moderator del -> tombstone ChatMessageDTO - * - * The management gate ladder lives in chat-group-service.ts (see its role matrix); the two message - * routes ride the unified chat rails: history via chat-repository's group_id scope (pins on the - * INITIAL page only, like the cleanup/report/dm history routes), delete via softDeleteGroup with the - * chat-powers resolver's group lane (owner/admin delete-others, sender self-delete) and the standard - * {type:"message_update"} tombstone broadcast. - * - * Readability: members always; NON-members may read a PUBLIC group's surface (get / members / - * history — the "viewable pre-join" contract on ChatGroupDTO.myRole); private groups 403 with - * fields.code not_a_member. WS join/send for group rooms lands in Task 4.4 — until then the gateway - * fails group frames closed (see ws/frame-handler authorizeRoom). - * - * Repo wiring mirrors messages.routes: injected chatOverrides fakes win (chatOverrides.groups / - * chatRepo / conversationMutes), else lazily-built Drizzle repos over the container's sql tag — - * lazy so the offline route-coverage boot never touches getDb(). Invitee validation (existence + - * blocked-pair) is one bulk query inside the group repo (invitableIdsOf), so no blocks repo here. - */ +// NON-members may read a PUBLIC group's surface (get, members, history: the "viewable pre-join" +// contract on ChatGroupDTO.myRole); a private group answers 403 not_a_member. Repos are built lazily +// so the offline route-coverage boot never touches getDb(). import { AddGroupMembersRequestSchema, @@ -86,13 +60,12 @@ const GROUP_HISTORY_MAX = 50 export const CREATE_GROUP_RATE_LIMIT = perIdentity({ max: 10, timeWindow: "1 hour" }) /** Bulk invites: bounded so a hijacked session can't blast invite sweeps; ample for normal use. */ export const ADD_GROUP_MEMBERS_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) -/** Self-serve join (P5): 20/min bounds scripted join sweeps across public rooms; ample for a real user. */ +/** 20/min bounds scripted join sweeps across public rooms; ample for a real user. */ export const JOIN_GROUP_RATE_LIMIT = perIdentity({ max: 20, timeWindow: "1 minute" }) /** - * L11: the remaining state-changing group routes carried no route limit at all (only the global - * 300/min/IP). Each of these is a moderation action a human performs a handful of times per session, - * so 30/min is generous while bounding a hijacked session's ability to churn a room's name/roster/roles - * or sweep its history. Same order of magnitude as ADD_GROUP_MEMBERS_RATE_LIMIT. + * Each of these is a moderation action a human performs a handful of times per session, so 30/min is + * generous while bounding a hijacked session's ability to churn a room's name, roster or roles or + * sweep its history. */ export const GROUP_MODERATION_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) @@ -120,8 +93,8 @@ export async function registerChatGroupRoutes( makePrivateMediaPresigner(container.storage), )) - // Mute lookup for ChatGroupDTO.muted. When chatOverrides is present WITHOUT a mutes fake we must - // not touch getDb() (offline harness) — fail open to muted:false, mirroring listThreads' stance. + // With chatOverrides present but no mutes fake, getDb() must not be touched (offline harness), so + // ChatGroupDTO.muted fails open to false, as listThreads does. let mutesRepo: ConversationMutesRepository | undefined const getMutes = (): ConversationMutesRepository | undefined => overrides @@ -129,9 +102,8 @@ export async function registerChatGroupRoutes( : (mutesRepo ??= makeConversationMutesRepository(container.getDb().sql)) /** - * Nudge a user's open inbox to refetch (the same best-effort signal joinReportChat sends): a room the - * user just joined — or was added to — otherwise only surfaces on the client's next manual refresh. - * Carries no room id, so it is safe to fire at anyone whose membership may have changed. + * A room the user just joined or was added to otherwise only surfaces on the client's next manual + * refresh. Carries no room id, so it is safe to fire at anyone whose membership may have changed. */ const nudgeThreads = (userId: string): void => { void Promise.resolve(container.userChannel?.publishToUser(userId, { topic: "threads" })).catch( @@ -186,7 +158,6 @@ export async function registerChatGroupRoutes( { preHandler: csrfProtect, config: { rateLimit: JOIN_GROUP_RATE_LIMIT } }, async (request, reply) => { const userId = requireAuth(request) - // Same params shape as getChatGroup ({ id }); the body is empty on the wire. const { id } = parse(GetChatGroupRequestSchema, request.params) const dto = await svc().joinGroup(userId, id) nudgeThreads(userId) @@ -210,11 +181,9 @@ export async function registerChatGroupRoutes( const { id } = parse(GroupIdParamsSchema, request.params) const body = parse(AddGroupMembersRequestSchema, { ...(request.body as object), id }) const { page, added } = await svc().addMembers(userId, body) - // Nudge exactly the invitees the service seated. `added` (not the returned page) is the source of - // truth: an invitee the block filter (M12) dropped is absent from it and must learn nothing, while a - // real invitee is absent from PAGE ONE in any group at/over GROUP_MEMBERS_DEFAULT_LIMIT members - // (fresh members sort last), which is why deriving the nudge set from the page silently stopped - // nudging anyone in exactly the big rooms where it matters most. + // `added`, not the returned page, decides who is nudged: an invitee the block filter dropped must + // learn nothing, and in a group at or over GROUP_MEMBERS_DEFAULT_LIMIT members a fresh member sorts + // past page one, so a page-derived set skips exactly the big rooms. for (const memberId of added) nudgeThreads(memberId) reply.status(200).send(page) }, @@ -252,11 +221,8 @@ export async function registerChatGroupRoutes( const userId = requireAuth(request) const { id } = parse(GroupIdParamsSchema, request.params) const q = parse(GroupHistoryRequestSchema, { ...(request.query as object), id }) - // 404 unknown / 403 private non-member; a PUBLIC group's history is readable pre-join. await svc().requireReadable(userId, id) const limit = Math.min(Math.max(q.limit ?? GROUP_HISTORY_DEFAULT, 1), GROUP_HISTORY_MAX) - // Pins ride ONLY the initial page (no before, no around) — same contract as the other rooms, owned - // by chatHistoryPayload. const payload: ChatHistoryResponse = await chatHistoryPayload( { history: (before, pageLimit, around) => @@ -278,9 +244,8 @@ export async function registerChatGroupRoutes( async (request, reply) => { const userId = requireAuth(request) const { id, messageId } = parse(GroupMessageParamsSchema, request.params) - // Readability pre-gate doubles as the room-existence check (404 unknown / 403 private non-member). - // A public group's NON-member (role null) still reaches the ladder, where the group lane's - // owner/admin-only delete-others power decides. + // A public group's non-member (role null) still reaches the powers ladder, where the owner/admin + // delete-others power decides. const role = await svc().requireReadable(userId, id) const chatRepo = getChatRepo() const tombstone = await deleteMessageWithPowers({ @@ -293,8 +258,8 @@ export async function registerChatGroupRoutes( findMessageMeta: (mid) => chatRepo.findMessageMeta(mid), resolveChatPowers, chat: container.chatService, - // Group rooms shipped after P0: they never emitted the legacy {type:"message"} frame, and one - // here would re-insert the deleted bubble on clients that upsert by id. + // Group rooms never emitted the legacy {type:"message"} frame, and one here would re-insert the + // deleted bubble on clients that upsert by id. legacyBroadcast: false, }) reply.status(200).send(tombstone) diff --git a/services/api/src/routes/chat-notify-copy.ts b/services/api/src/routes/chat-notify-copy.ts index c7b47aea..1b9d6bf4 100644 --- a/services/api/src/routes/chat-notify-copy.ts +++ b/services/api/src/routes/chat-notify-copy.ts @@ -1,22 +1,12 @@ -/** - * Pure copy builders for the chat/dm bell notifications (no I/O). Shared by the gateway wiring so the dm - * delivered-bell and the @-mention bell render consistent author/preview text. - */ - import type { ChatMessageDTO } from "@civfix/shared" -/** Max chars of a text dm preview surfaced in a bell body (server-side truncation). */ const PREVIEW_MAX = 80 -/** - * Author name for a message notification: the sender's DISPLAY NAME if present, else their @handle, else a - * generic fallback (the dm bell uses "New message"; the mention bell uses "Someone"). Display-name-first so - * a "message notification" names the person, not their @handle - matching the messaging header / thread title. - */ +/** Display name first so a message notification names the person, matching the thread title. */ export function authorDisplay(message: ChatMessageDTO, fallback: string): string { const from = message.from - // DM/mention notifications are only ever built from an authored message (a sender-less SYSTEM message - // has no dm/mention bell) - a missing author here just falls back like a blank name would. + // A sender-less SYSTEM message never raises a dm or mention bell, so a missing author just falls back + // like a blank name would. if (!from) return fallback if (from.name.trim() !== "") return from.name if (from.handle) return `@${from.handle}` @@ -24,12 +14,9 @@ export function authorDisplay(message: ChatMessageDTO, fallback: string): string } /** - * The server-truncated preview of a text message (~80 chars, ellipsized), or `null` for a non-text - * message (share_pin / task_complete / rsvp_change / a body-less frame) so a structured payload never - * leaks as the preview. The caller substitutes a localized "Sent you a message" wrapper when this is null. - * - * The preview is USER CONTENT: it is passed to the i18n renderer as an interpolation var (already - * truncated here), so only the surrounding notification copy is translated — never the user's text. + * `null` for a non-text message so a structured payload never leaks as the preview; the caller then + * substitutes localized copy. The preview is user content: it reaches the i18n renderer only as an + * interpolation var, so the user's text is never translated. */ export function textPreview(message: ChatMessageDTO): string | null { const body = message.body @@ -40,7 +27,5 @@ export function textPreview(message: ChatMessageDTO): string | null { return null } -/** The sender's display name for a DM bell title: @handle / display name / a generic fallback marker. */ export const dmAuthorName = (message: ChatMessageDTO): string => authorDisplay(message, "") -/** The mention author's display name: @handle / display name / a generic fallback marker. */ export const mentionAuthorName = (message: ChatMessageDTO): string => authorDisplay(message, "") diff --git a/services/api/src/routes/chat-powers-wiring.ts b/services/api/src/routes/chat-powers-wiring.ts index d4d5d5fa..3f01f140 100644 --- a/services/api/src/routes/chat-powers-wiring.ts +++ b/services/api/src/routes/chat-powers-wiring.ts @@ -1,26 +1,13 @@ /** - * P3: route-level wiring for the chat-powers resolver (src/services/chat-room-roles.ts) — the single - * source of truth for "who may pin" and "who may delete others' messages". Shared by the pin route - * (messages.routes.ts) and the delete routes (chat.routes.ts / report-chat.routes.ts). + * The offline harnesses (chatOverrides without a chatPowers resolver) have no database: lanes with no + * override seam fail closed (null / false) and nothing here may touch container.getDb(). Production + * lookups are built lazily so mounting the routes never opens a connection. * - * Three tiers, mirroring the repo-wiring convention of those route files: - * 1. An injected `chatOverrides.chatPowers` resolver wins outright (integration tests wire the real - * resolver over the pg-backed lookups this way). - * 2. Otherwise, when chatOverrides is present (the offline no-DB harnesses), build the resolver over - * the seams the overrides DO carry — dmRepo participant + reportChat roleOf — and FAIL CLOSED - * (null / false) for the DB-backed lanes that have no seam (cleanup role, global role). Nothing - * here may touch container.getDb(): the offline harness has no database. - * 3. Production: lazily-built Drizzle lookups over the container's sql tag (lazy so mounting the - * routes never opens a connection — same pattern as the repos in messages.routes.ts). + * Report rooms: operator powers apply WITHOUT a membership row, so callers must consult this resolver + * rather than pre-gating on membership. * - * NOTE (report rooms): operator powers apply WITHOUT a membership row, so callers must consult this - * resolver rather than pre-gating on membership. - * - * ONE resolver per Fastify instance: four route files (chat, chat-groups, report-chat, messages) each - * called this at mount and each got its own resolver over its own duplicate cleanup/report/group repo - * handles. They are all registered on the same instance (routes/index.ts), so the memo below hands them - * the same resolver — and, more to the point, makes "who may pin / delete others" exactly one live object - * instead of four that could be wired differently. + * One resolver per Fastify instance, so "who may pin / delete others" is one live object rather than + * one per route file that could be wired differently. */ import type { FastifyInstance } from "fastify" @@ -45,10 +32,8 @@ import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" type GlobalRole = (typeof ROLE_VALUES)[number] /** - * L10: the dm lane's block gate — "is the caller blocked either way with this thread's other - * participant?". Resolves the peer off the thread row, then asks the blocks repo; a thread the caller - * is not in resolves to no peer, which the resolver already handles via isDmParticipant (answering - * false here keeps this helper's contract to blocks alone). + * A thread the caller is not in resolves to no peer and answers false: isDmParticipant already gates + * that case, and this helper's contract stays about blocks alone. */ export function makeIsDmBlocked( dm: DmRepository, @@ -81,7 +66,7 @@ export async function chatAuthorityRoleOf( return row.role } -/** Per-instance memo (see the module banner). Keyed on the app so two harnesses never share a resolver. */ +/** Keyed on the app so two harnesses never share a resolver. */ const resolvers = new WeakMap() export function wireChatPowers(app: FastifyInstance, container: Container): ResolveChatPowers { @@ -97,15 +82,13 @@ function buildChatPowers(app: FastifyInstance, container: Container): ResolveCha if (overrides?.chatPowers) return overrides.chatPowers if (overrides) { - // Offline override harness without an injected resolver: honor the seams that exist, fail closed - // for the rest. Never touches the container's DB handle. const offlineDm = overrides.dmRepo const offlineBlocks = overrides.blocksRepo return makeChatPowersResolver({ isDmParticipant: (threadId, userId) => offlineDm ? offlineDm.isParticipant(threadId, userId) : Promise.resolve(false), - // L10: only wired when the harness carries BOTH seams; otherwise the lane keeps its pre-L10 - // behavior (no block data offline => never blocked), like the other fail-closed-to-null lookups. + // Only wired when the harness carries both seams; without block data offline the lane reads as + // never blocked. ...(offlineDm && offlineBlocks ? { isDmBlocked: makeIsDmBlocked(offlineDm, offlineBlocks) } : {}), @@ -126,8 +109,8 @@ function buildChatPowers(app: FastifyInstance, container: Container): ResolveCha let isDmBlocked: ReturnType | undefined return makeChatPowersResolver({ isDmParticipant: (threadId, userId) => container.getDmRepo().isParticipant(threadId, userId), - // L10: DM pin power respects blocks, like every other DM surface. Built on first use (the lazy - // convention of this file) so mounting never resolves the container's repos. + // DM pin power respects blocks, like every other DM surface. Built on first use so mounting never + // resolves the container's repos. isDmBlocked: (threadId, userId) => (isDmBlocked ??= makeIsDmBlocked(container.getDmRepo(), container.getBlocksRepo()))( threadId, diff --git a/services/api/src/routes/cleanups.routes.ts b/services/api/src/routes/cleanups.routes.ts index 1c0e2096..1b171c3f 100644 --- a/services/api/src/routes/cleanups.routes.ts +++ b/services/api/src/routes/cleanups.routes.ts @@ -406,7 +406,7 @@ export async function registerCleanupRoutes( const payload: GetEventIcsResponse = { ics: buildIcs({ uid: `cleanup-${event.id}@civfix.org`, - title: mine !== undefined ? `${event.title} — ${mine.title}` : event.title, + title: mine !== undefined ? `${event.title} (${mine.title})` : event.title, startsAt: mine !== undefined ? mine.startsAt : event.scheduledAt, ...(event.description !== undefined && event.description !== null ? { description: event.description } diff --git a/services/api/src/routes/forms.routes.ts b/services/api/src/routes/forms.routes.ts index f04621e7..5c268dd8 100644 --- a/services/api/src/routes/forms.routes.ts +++ b/services/api/src/routes/forms.routes.ts @@ -108,7 +108,7 @@ export async function registerHomeTurfRoutes( function requireCounters(): CounterStore { const store = counters() if (store === null) { - app.log.error("home-turf form: no counter store (REDIS_URL unset) — refusing to send") + app.log.error("home-turf form: no counter store (REDIS_URL unset), refusing to send") throw exposeMessage( AppError.internal("This form is temporarily unavailable. Please try again later."), ) @@ -118,7 +118,7 @@ export async function registerHomeTurfRoutes( if (container.env.HOME_TURF_NOTIFY_TO === "") { app.log.warn( - "home-turf form: HOME_TURF_NOTIFY_TO is unset — POST /forms/home-turf is disabled and will accept no submissions", + "home-turf form: HOME_TURF_NOTIFY_TO is unset; POST /forms/home-turf is disabled and will accept no submissions", ) } @@ -233,7 +233,7 @@ export function buildConfirmationEmail( const { text, html } = renderEmailBody({ preheader: subject, blocks: [ - paragraph("Thanks — we received your Home Turf sign-up."), + paragraph("Thanks! We received your Home Turf sign-up."), paragraph( "The civfix event coordination team will call you soon to find a date that works for your season.", ), diff --git a/services/api/src/routes/health.routes.ts b/services/api/src/routes/health.routes.ts index dfdf9277..b3fdd705 100644 --- a/services/api/src/routes/health.routes.ts +++ b/services/api/src/routes/health.routes.ts @@ -1,26 +1,12 @@ /** - * Liveness and readiness routes. + * Both probes send `Cache-Control: no-store` because the deploy gate polls them through Cloudflare. + * /healthz answers 503 plus the draining header once SIGTERM starts the drain (lifecycle.ts), which is how + * blue/green pulls a retiring api color out of the pool BEFORE its listener closes. * - * GET /healthz liveness: pure, no dependencies, always `Cache-Control: no-store` so no edge or - * client ever serves a stale verdict. 200 while the process is serving; 503 plus the - * x-civfix-draining header once SIGTERM has started the shutdown drain (lifecycle.ts), - * which is how the blue/green load balancer pulls a retiring api color out of the pool - * BEFORE its listener closes. Used by the load balancer / Caddy and by unit tests - * (works with no DB/Redis). - * GET /readyz readiness, also `Cache-Control: no-store` (the deploy gate polls it through - * Cloudflare): pings DB and Redis when they are wired (real seams). In all-fakes mode - * there is nothing to check, so each check reports "skipped" and the overall status - * is 200. If a real handle exists but its ping fails, returns 503. - * - * L19 hardening. Both probes are unauthenticated, so both are attack surface: - * - /readyz is no longer exempt from rate limiting (see plugins/rate-limit.ts) AND its result is memoized - * for READY_CACHE_MS. It does real I/O — `select 1` against a 10-connection pool plus a Redis PING — - * so an uncapped, uncached probe was a cheap amplifier: one HTTP request per DB round-trip. The cache - * keeps the probe honest for an orchestrator polling every few seconds while flattening a flood into - * at most one backend check per window. - * - /healthz no longer discloses the build version. Liveness needs `{ok:true}`; publishing the exact - * running version to anonymous callers only helps someone match us against a CVE list. The version is - * still available to operators via the authenticated admin system-health surface. + * Both are unauthenticated attack surface. /readyz does real I/O (`select 1` on a 10-connection pool plus a + * Redis PING), so it is rate limited and memoized for READY_CACHE_MS: an uncached probe was a cheap + * amplifier, one HTTP request per DB round-trip. /healthz does not disclose the build version, which would + * only help someone match us against a CVE list; operators see it on the admin system-health surface. */ import type { FastifyInstance } from "fastify" @@ -41,10 +27,9 @@ interface ReadyBody { const READY_CACHE_MS = 5_000 /** - * Drain marker. It rides in a RESPONSE HEADER so the public 503 body stays a bare {ok:false} - no - * deploy state mixed into the JSON every client parses, and cross-origin browser JS cannot read it - * (no Access-Control-Expose-Headers). It is NOT a secret either way: any `curl -I` sees it. The - * deploy workflows read it to tell a by-design retiring-color 503 from a real outage. + * A header, not a body field, so the public 503 body stays a bare {ok:false} and cross-origin browser JS + * cannot read it (no Access-Control-Expose-Headers). It is not a secret: any `curl -I` sees it. The deploy + * workflows read it to tell a by-design retiring-color 503 from a real outage. */ export const DRAINING_HEADER = "x-civfix-draining" diff --git a/services/api/src/routes/map.routes.ts b/services/api/src/routes/map.routes.ts index b13d5cb7..00314897 100644 --- a/services/api/src/routes/map.routes.ts +++ b/services/api/src/routes/map.routes.ts @@ -142,10 +142,10 @@ export async function registerMapRoutes(app: FastifyInstance, container: Contain /** * The street-level preview the creation flows call once a pin settles. `reverseLabel` above stays - * exactly as it is for already-deployed clients — it answers only TIGER's "City, ST", which is why - * pin previews were vague; this one walks the whole ladder and says which rung it reached, so the UI - * can prefill a confirmed address (street/intersection/landmark) or ask the host to type one - * (locality/null) without ever guessing. `cityStateLabel` is populated either way as the hint line. + * as it is for already-deployed clients and answers only TIGER's "City, ST"; this one walks the whole + * ladder and says which rung it reached, so the UI can prefill a confirmed address + * (street/intersection/landmark) or ask the host to type one (locality/null) without guessing. + * `cityStateLabel` is populated either way as the hint line. * * Same rate-limit bucket as the other geocoder surfaces, and the read-through cache means a dragged * pin and the create that follows it share one provider call. diff --git a/services/api/src/routes/media.routes.ts b/services/api/src/routes/media.routes.ts index d5f5e00e..9f21123b 100644 --- a/services/api/src/routes/media.routes.ts +++ b/services/api/src/routes/media.routes.ts @@ -1,50 +1,37 @@ /** - * Media route plugin. - * - * POST /media/upload createUpload: cheap pre-checks + presign a direct-to-R2 PUT. - * POST /media/:uploadId/finalize finalize: confirm the object + enqueue the "media.checks" job. - * GET /media/:id getMedia: render a MediaDTO with a presigned URL (ready + AUTHORIZED). - * - * AUTHORIZATION (security review H9). getMedia used to authorize NOTHING: any caller holding a media id - * got a fresh presigned URL, which leaked private DM/group-chat attachments, media on held/unlisted - * reports, and attachments on deleted posts. It now resolves the asset's binding and authorizes the - * viewer against that subject's existing visibility rules — see services/media-authorization.ts. Every - * deny is a 404, never a 403, so the endpoint is not an existence oracle. + * getMedia authorizes the viewer against the bound subject's own visibility rules + * (services/media-authorization.ts); without that, any holder of a media id got a fresh presigned URL to + * private DM/group-chat attachments, media on held/unlisted reports and attachments on deleted posts. + * Every deny is a 404, never a 403, so the endpoint is not an existence oracle. * * The write paths stay anon-reachable by necessity: an anonymous reporter uploads photos BEFORE the - * report (and therefore before any anon session) exists, so there is no identity to require yet. They - * are instead bounded by (a) the per-IP request cap below and (b) a cumulative per-caller BYTE quota - * (M10, services/media-byte-quota.ts). - * - * ============================================================================================ - * TODO (L1, CSRF on createMediaUpload + finalizeMedia) — DELIBERATELY NOT ENFORCED YET. + * report (and therefore any anon session) exists. They are bounded instead by the per-IP request cap + * below and a cumulative per-caller byte quota (services/media-byte-quota.ts). * - * These are the only two cookie-authenticated state-changing routes with no `csrfProtect` - * preHandler, and adding one was tried and REVERTED. It cannot land on the backend alone: + * CSRF is deliberately not enforced on createMediaUpload and finalizeMedia, the only two + * cookie-authenticated state-changing routes without `csrfProtect`. Adding it was tried and reverted + * because it cannot land on the backend alone: * - * - csrfProtect enforces on any request with a session cookie and no bearer token — i.e. every + * - csrfProtect enforces on any request with a session cookie and no bearer token, i.e. every * signed-in civfix-web request (mobile is bearer and short-circuits). * - the shared typed client only sends `X-CSRF-Token` when the ENDPOINT DEFINITION says so * (@civfix/shared client: `if (endpoint.csrf && opts.getCsrfToken)`), and the published * contract declares `createMediaUpload csrf: false` / `finalizeMedia csrf: false`. * - * So enforcing here returns 403 "CSRF token missing or invalid." to every signed-in web user on - * POST /v1/media/upload, breaking report photos, avatar upload and composer attachments. The - * residual risk is LOW (a cross-origin POST is already blocked by the CORS preflight, and the - * response — a presigned PUT URL — is not readable cross-origin), which does not justify a - * guaranteed production break. + * Enforcing here would 403 every signed-in web user on POST /v1/media/upload, breaking report photos, + * avatar upload and composer attachments. The residual risk is low (a cross-origin POST is already + * blocked by the CORS preflight, and the response, a presigned PUT URL, is not readable cross-origin), + * which does not justify a guaranteed production break. * - * Conditional enforcement ("enforce only when the client sent a header") is NOT an acceptable - * middle ground: an attacker simply omits the header, so it protects nobody while looking like it - * does. + * Conditional enforcement ("enforce only when the client sent a header") is not a middle ground: an + * attacker simply omits the header, so it protects nobody while looking like it does. * - * TO RE-ADD: ship `csrf: true` for both endpoints in @civfix/shared FIRST, then in the SAME - * release bump this service to that version and add `preHandler: csrfProtect` to both `route(...)` - * calls below. Order matters — backend-first breaks web, shared-first is a no-op until this lands. - * ============================================================================================ + * Enabling it takes `csrf: true` for both endpoints in @civfix/shared FIRST, then, in the same release, + * this service adopting that version and adding `preHandler: csrfProtect` to both `route(...)` calls + * below. Backend-first breaks web; shared-first is a no-op until the backend follows. * - * Pre-report-commit ownership remains CAPABILITY-BASED (the unguessable uploadId/media id is the proof) - * and is bounded in time by media-authorization.ts UNBOUND_GRACE_MS. The DB handle is reached lazily via + * Pre-report-commit ownership is CAPABILITY-BASED (the unguessable uploadId/media id is the proof) and is + * bounded in time by media-authorization.ts UNBOUND_GRACE_MS. The DB handle is reached lazily via * container.getDb() inside handlers, so mounting this plugin never opens a connection. */ @@ -79,14 +66,11 @@ import { route } from "../versioning/route.js" declare module "fastify" { interface FastifyInstance { - // Optional injected media repository (tests) so the create/finalize/getMedia HTTP flow runs offline - // (no Docker) with an in-memory repo. Unset in production, where the routes reach the DB lazily. - mediaRepo?: MediaRepository - // Optional injected view authorizer (tests). Unset in production, where the DB-backed authorizer is - // built lazily below. When BOTH this and mediaRepo are unset the service falls back to its + // Tests inject these so the HTTP flow runs offline; production leaves them unset and reaches the DB + // and Redis lazily. With neither mediaRepo nor mediaAuthorizer set, the service falls back to its // fail-closed unbound-only authorizer. + mediaRepo?: MediaRepository mediaAuthorizer?: MediaViewAuthorizer - // Optional injected byte meter (tests). Unset in production, where Redis backs the quota. mediaByteMeter?: ByteMeter } } @@ -97,7 +81,7 @@ const MediaIdParamsSchema = z.object({ id: IdSchema }).strict() // Per-IP cap on the anon-ok write paths above the global 300/min: createMediaUpload mints presigned R2 // PUTs and finalizeMedia enqueues the untrusted-byte media.checks pipeline, so a tight per-route limit // bounds an unauthenticated client minting hundreds of presigns / pipeline jobs per minute. This bounds -// FREQUENCY only — the cumulative byte quota below bounds VOLUME (M10). +// frequency only; the cumulative byte quota bounds volume. export const MEDIA_WRITE_RATE_LIMIT = perHost({ max: 30, timeWindow: "1 minute" }) export async function registerMediaRoutes( @@ -114,20 +98,18 @@ export async function registerMediaRoutes( } function redisFallbackMeter(): ByteMeter | undefined { - // No Redis configured (no-infra boot) -> no byte quota; the per-route + global rate limits still - // apply. Production always has REDIS_URL, so the quota is always active there. - // - // container.getByteMeter() rather than a route-local RedisByteMeter: the container's wrapper resolves - // ONE RedisByteMeter per process on first add (and drops it in close(), so a post-close reuse cannot - // charge into a quit connection). Constructing it opens nothing, which is what mount time requires. + // Without Redis (no-infra boot) there is no byte quota, only the rate limits; production always has + // REDIS_URL. container.getByteMeter() rather than a route-local RedisByteMeter: the container's + // wrapper resolves ONE RedisByteMeter per process on first add (and drops it in close(), so a + // post-close reuse cannot charge into a quit connection). Constructing it opens nothing, which is + // what mount time requires. if (!container.env.REDIS_URL) return undefined return container.getByteMeter() } function service(): MediaIntakeService { const repo: MediaRepository = app.mediaRepo ?? makeDrizzleMediaRepository(container.getDb().db) - // An injected authorizer wins (tests). Otherwise: the DB-backed one when there is no injected repo - // (i.e. production), and the service's fail-closed default when an in-memory repo is in play. + // An in-memory repo gets the service's fail-closed default authorizer, never the DB-backed one. const authorizer = app.mediaAuthorizer ?? (app.mediaRepo ? undefined : makeDrizzleMediaViewAuthorizer(container.getDb().sql)) @@ -142,7 +124,7 @@ export async function registerMediaRoutes( }) } - // NO `preHandler: csrfProtect` HERE — see the CSRF block in the module header before adding one; it + // No `preHandler: csrfProtect` here: read the CSRF block in the module header before adding one. It // 403s every signed-in web caller until @civfix/shared declares `createMediaUpload csrf: true`. route( app, @@ -166,8 +148,8 @@ export async function registerMediaRoutes( { config: { rateLimit: MEDIA_WRITE_RATE_LIMIT } }, async (request, reply) => { const { uploadId } = parse(UploadIdParamsSchema, request.params) - // The body is empty/ignored; the uploadId in the path IS the request. Still validated via the shared - // schema so the contract (FinalizeMediaRequest { uploadId }) stays the single source. + // The uploadId in the path IS the request; it still goes through the shared schema so the contract + // stays the single source. const input = parse(FinalizeMediaRequestSchema, { uploadId }) const payload: FinalizeMediaResponse = await service().finalize(input, ownerOf(request)) reply.status(200).send(payload) diff --git a/services/api/src/routes/query-encoding.ts b/services/api/src/routes/query-encoding.ts index 41092cc9..1d18079d 100644 --- a/services/api/src/routes/query-encoding.ts +++ b/services/api/src/routes/query-encoding.ts @@ -1,36 +1,19 @@ /** - * Query-string decoders that mirror EXACTLY what the shared typed client (@civfix/shared client.ts - * buildQuery) serializes, so the backend parses what the web + mobile clients actually send. - * - * The shared client encodes a query record like this (see shared/src/client/client.ts): - * - an ARRAY value -> repeated params: categories=trash&categories=hazard - * - an OBJECT value -> a single JSON param: bbox=%7B%22west%22%3A...%7D (JSON.stringify'd) - * - a scalar value -> a single param: zoom=12 / when=upcoming / cursor=abc - * - * Fastify parses the query string with `qs`, so a repeated key arrives as `string | string[]` and a - * JSON-object param arrives as the JSON string. These helpers decode each form back into the nested - * shape the shared request schemas (ListReportsInBBoxRequest, ListCleanupsRequest, ...) expect, with - * safe error handling: malformed JSON becomes an AppError.validation (422) rather than a 500. - * - * Keeping the wire contract authoritative: we do NOT modify shared. We decode here, then re-validate - * the assembled object against the shared schema at the call site so the contract stays the single - * source of truth. + * Mirrors what the shared typed client's buildQuery serializes: an array becomes repeated params + * (`categories=trash&categories=hazard`), an object a single JSON.stringify'd param (`bbox=...`), a scalar a + * single param. Fastify's `qs` hands a repeated key over as `string | string[]` and a JSON param as its + * string, so these decode each form back into what the shared request schemas expect; malformed input is a + * 422, never a 500. */ import { z } from "zod" import { BBoxSchema, LatLngSchema, ReportCategorySchema, ReportTypeSchema } from "@civfix/shared" -// Upper bound on a JSON-encoded query param BEFORE we parse it. A bbox/near object is tiny (well under -// this); the cap stops an unauthenticated GET from forcing a megabytes-of-valid-JSON parse on the event -// loop (DoS) before validation ever runs. Over-cap is a 422, identical to malformed JSON. +// Checked BEFORE parsing: a bbox/near object is tiny, and the cap stops an unauthenticated GET from forcing a +// megabytes-of-valid-JSON parse on the event loop before validation runs. const MAX_JSON_PARAM_LEN = 4096 -/** - * A query param that is a JSON-encoded object, validated against `inner` after parsing. The client - * sends `?bbox=`; here we JSON.parse the string then pipe through the shared - * object schema. An over-length value, bad JSON, or a value that does not match `inner` yields a Zod - * issue (-> 422 at the route's parse()), never a thrown SyntaxError. - */ +/** Bad input becomes a Zod issue (422 at the route's parse()), never a thrown SyntaxError. */ function jsonParam(inner: S) { return z .string() @@ -49,11 +32,7 @@ function jsonParam(inner: S) { .pipe(inner) } -/** - * A query param sent as repeated keys (string[]) OR a single CSV string, each token validated against - * `element`. An unknown token is a 422 (not a silent drop); the result is a non-empty array. Omit the key - * entirely for "no filter". (Used for `categories` and `types`.) - */ +/** An unknown token is a 422, not a silent drop; omit the key entirely for "no filter". */ function csvOrRepeated(element: S) { return z .union([z.string(), z.array(z.string())]) @@ -65,14 +44,9 @@ function csvOrRepeated(element: S) { } /** - * bbox as the client sends it: a single JSON-encoded BBox object param. - * - * ORDERING VALIDATION (P2): the shared BBoxSchema range-checks each bound independently but does NOT - * require west < east or south < north (and it is the frozen contract, so we cannot add the refine there). - * An inverted/degenerate bbox (west >= east or south >= north) would otherwise build an empty - * ST_MakeEnvelope and SILENTLY return no rows. We reject it here with a clear 422 so a transposed bbox - * from a client bug is a loud error, not a mysterious empty map. (A strict `<` also rejects a - * zero-area bbox, which can never contain a point and is therefore never a legitimate viewport query.) + * The shared BBoxSchema checks each bound independently but not their ordering. An inverted bbox would + * build an empty ST_MakeEnvelope and SILENTLY return no rows, so a transposed bbox from a client bug is a + * loud 422 here instead. The strict `<` also rejects a zero-area bbox, which can never contain a point. */ export const BBoxQueryParam = jsonParam(BBoxSchema).refine( (b) => b.west < b.east && b.south < b.north, @@ -80,30 +54,23 @@ export const BBoxQueryParam = jsonParam(BBoxSchema).refine( ) /** - * M14: hard ceiling on the requested viewport AREA in square degrees. - * - * Defense-in-depth behind each read's own row cap: even fully clustered, a world-spanning bbox still makes - * the DB scan up to that cap, and the response is uncacheable in practice because the attacker jitters the - * bounds. The whole globe is 360x180 = 64,800 deg^2; this cap admits a hemispheric/continental view (which - * is a real, if rare, client state) and rejects only the pathological world-scan. + * Square degrees. Defense in depth behind each read's own row cap: a world-spanning bbox still makes the DB + * scan up to that cap, and jittered bounds defeat caching. The globe is 64,800 deg^2; this admits a + * continental view (a real, if rare, client state) and rejects only the pathological world scan. */ export const MAX_MAP_BBOX_AREA_DEG2 = 40_000 /** - * bbox + the M14 area cap: the shape EVERY anon, bbox-driven map read must use (reports, cleanup pins, - * cleanup list). Lives here, next to the ordering refine, so the protection cannot be applied to one - * viewport read and forgotten on the next one — a 422 with a clear message, not a silent degradation. + * Every anonymous, bbox-driven map read must use this shape; it lives next to the ordering refine so the + * protection cannot be applied to one viewport read and forgotten on the next. */ export const CappedBBoxQueryParam = BBoxQueryParam.refine( (b) => (b.east - b.west) * (b.north - b.south) <= MAX_MAP_BBOX_AREA_DEG2, { message: "bbox is too large; zoom in and request a smaller viewport" }, ) -/** near as the client sends it: a single JSON-encoded LatLng object param. */ export const LatLngQueryParam = jsonParam(LatLngSchema) -/** categories: repeated params or a single CSV, each token a ReportCategory. `categories: CategoriesQueryParam.optional()`. */ export const CategoriesQueryParam = csvOrRepeated(ReportCategorySchema) -/** types (0021): the fine-grained-type analogue of categories, each token a ReportType. */ export const TypesQueryParam = csvOrRepeated(ReportTypeSchema) diff --git a/services/api/src/routes/service-hours-certificates.routes.ts b/services/api/src/routes/service-hours-certificates.routes.ts index bb5d0206..5aded156 100644 --- a/services/api/src/routes/service-hours-certificates.routes.ts +++ b/services/api/src/routes/service-hours-certificates.routes.ts @@ -1,11 +1,7 @@ /** - * Service-hours transcript routes (P5 / DP §9.3): three holder endpoints plus ONE public verification - * read. - * - * Shape copied from `volunteer-hours.routes.ts`: an overrides interface + `declare module "fastify"`, a - * LAZY `service()` closure, then the `route()` calls. The laziness is load-bearing — `container.getDb()` - * / `getRedis()` at MOUNT time has produced 500s in this repo before, and the offline route-coverage - * harness boots this router with no database at all. + * The `service()` closure is lazy on purpose: calling `container.getDb()` / `getRedis()` at mount time + * has produced 500s here before, and the offline route-coverage harness boots this router with no + * database at all. */ import { @@ -36,13 +32,13 @@ import { parse } from "./_validate.js" export interface CertificateOverrides { repo: CertificateRepository - /** Only `entriesForCertificate` is used; a test may pass the full in-memory volunteer-hours twin. */ + /** A test may pass the full in-memory volunteer-hours twin. */ hours?: Pick /** * Injected so a test can wrap the storage adapter and ASSERT the presign arguments. `FakeStorage` * ignores its third parameter and always returns `memory://`, so asserting on the returned string - * proves nothing about `forceSigned` — and getting `forceSigned` wrong publishes every transcript - * permanently (H9). + * proves nothing about `forceSigned`, and getting `forceSigned` wrong publishes every transcript + * permanently. */ storage?: CertificateStorage } @@ -53,17 +49,15 @@ declare module "fastify" { } } -/** The path param, before the shared schema's normalizing transform runs over it. */ +/** Validates the raw path param before the shared schema's normalizing transform runs. */ const CodeParamsSchema = z.object({ code: z.string().min(1).max(32) }).strict() /** - * C3 rate limits. - * * ISSUE renders a PDF synchronously and mints a durable public artifact from personal data; 6/hour caps * the worst case (1000 rows, ~500 ms) at ~3 s of event-loop blocking per user per hour. * * VERIFY is 60/min, RAISED from the design's 20/min: the limit is keyed per IP and a school office behind - * one NAT address would 429 after twenty checks. Enumeration is not the threat model — the code space is + * one NAT address would 429 after twenty checks. Enumeration is not the threat model: the code space is * 2^60. */ export const CERTIFICATE_ISSUE_RATE_LIMIT = perIdentity({ @@ -128,9 +122,9 @@ export async function registerServiceHoursCertificateRoutes( async (request, reply) => { const userId = requireAuth(request) const { code } = parse(CodeParamsSchema, request.params) - // `:code` is followed by a static segment, so the typed client cannot extract it — the contract - // schema carries it and the path param is folded back in here (the documented logEventHours merge - // pattern), which also runs the shared normalizing transform over a hand-typed dashed code. + // `:code` is followed by a static segment, so the typed client cannot extract it: the contract + // schema carries it and the path param is folded back in here, which also runs the shared + // normalizing transform over a hand-typed dashed code. const body = parse(RevokeCertificateRequestSchema, { ...((request.body as object | undefined) ?? {}), code, @@ -141,12 +135,12 @@ export async function registerServiceHoursCertificateRoutes( ) /** - * PUBLIC. No auth, no CSRF, and no `showVolunteerHours` gate (C2/DB B32c): the profile privacy flag - * governs the profile projection, while a certificate is a document the holder deliberately handed to a - * verifier. Revocation is the control here. + * PUBLIC. No auth, no CSRF, and no `showVolunteerHours` gate: the profile privacy flag governs the + * profile projection, while a certificate is a document the holder deliberately handed to a verifier. + * Revocation is the control here. * - * The response is never cacheable by a shared cache in a way that matters — it is keyed by a secret - * capability — but it is also never personalized by session, so no `Vary` juggling is required. + * The response is keyed by a secret capability and never personalized by session, so no `Vary` + * juggling is required. */ route( app, diff --git a/services/api/src/services/address-resolver.ts b/services/api/src/services/address-resolver.ts index a9743479..c2a52241 100644 --- a/services/api/src/services/address-resolver.ts +++ b/services/api/src/services/address-resolver.ts @@ -1,10 +1,6 @@ /** - * The one place a coordinate becomes an address. - * - * Before this existed there were two answers to "what is at this point" and they disagreed: reports got - * the street-level chain collapsed to a bare string, events got nothing at all, and the only - * client-callable endpoint returned TIGER's "City, ST". This composes all of it once, honestly, and - * hands every caller the same shape. + * The one place a coordinate becomes an address, so reports, events and the preview endpoint can never + * disagree about what is at a point. * * THE LADDER, top rung first: * @@ -49,7 +45,6 @@ export interface ResolvedAddress { cityStateLabel: string } -/** Coords -> the resolved address. The dep every service and route takes. */ export type AddressResolver = (lat: number, lng: number) => Promise export interface AddressResolverDeps { @@ -114,7 +109,6 @@ export function makeAddressResolver(deps: AddressResolverDeps): AddressResolver } } -/** What a report row records about its address: the text, where it came from, and how exact it is. */ export interface ReportAddressWrite { addr: string | null addrSource: ReportAddressSource | null @@ -122,8 +116,8 @@ export interface ReportAddressWrite { } /** - * Decide a report's address provenance at creation. Shared by the signed-in and anonymous paths so the - * two can never drift - they already write the same column and are read by the same DTO. + * Shared by the signed-in and anonymous create paths so the two can never drift: they write the same + * column and are read by the same DTO. * * The reporter's own text always wins and is marked 'user' with NO precision: a provider rung would be a * claim about text no provider produced. Otherwise the server's resolve is stored as 'resolved' with the diff --git a/services/api/src/services/admin/activity-repository.drizzle.ts b/services/api/src/services/admin/activity-repository.drizzle.ts index d8b06cf6..fed8339a 100644 --- a/services/api/src/services/admin/activity-repository.drizzle.ts +++ b/services/api/src/services/admin/activity-repository.drizzle.ts @@ -1,36 +1,23 @@ /** - * Postgres-backed ActivityRepository (Phase 2): the merged recent-activity feed (#6, enumeration 4.9). + * One UNION ALL over audit_log, reports, cleanups and mail_events. * - * ONE union query across the four sources, capped at `limit`: - * - audit_log (operator / gov actions) -> source 'audit' (action + actor name + target); the L4 - * read audits are excluded here (see AUDIT_READ_ACTIONS) - * - reports (a citizen dropped a pin) -> source 'report' (category subject + jurisdiction place + - * reporter name); `visibility = 'public' AND deleted_at IS NULL` only. NOT the - * publicReportFilter status set: an operator feed reports the pin DROP, so a `submitted` - * or `held` report is exactly what the operator needs to see, and the queue it feeds is - * operator-only. The two terms that ARE applied are the owner's opt-out and the delete. - * - cleanups (a cleanup was planned) -> source 'cleanup' (title subject + organizer name) - * - mail_events (delivery events) -> source 'mail_event' (type + thread org/jurisdiction) + * Reports use `visibility = 'public' AND deleted_at IS NULL` only, not the publicReportFilter status set: + * the operator feed reports the pin drop, so a `submitted` or `held` report is exactly what the operator + * needs to see. The two terms applied are the owner's opt-out and the delete. * - * Each branch projects the SAME column set so the UNION ALL aligns; the outer query applies the ORDER BY - * and the page cut. To bound the per-source scan each branch carries the SAME search predicate, keyset - * predicate, ORDER BY and `limit + 1` cut as the outer query — a branch can therefore contribute at most - * `limit + 1` rows, which is exactly what the outer has-more probe needs (a per-branch `limit` would make - * the probe row unreachable whenever a single branch dominates the window, i.e. nextCursor would always be - * null). + * Each branch carries the same search predicate, keyset predicate, ORDER BY and `limit + 1` cut as the + * outer query, which bounds the per-source scan. A per-branch `limit` would make the outer has-more probe + * row unreachable whenever one branch dominates the window, so nextCursor would always be null. * - * KEYSET: (ts, id) with `id` compared AS TEXT in both the branch predicate and the outer ORDER BY. Every - * source's pk is a uuid, so text order and uuid order coincide, and one text tuple gives the four sources a - * single TOTAL order. The id term only breaks ties between rows with an exactly equal ts; the cursor carries - * the ts at microsecond precision (every branch projects `cursor_at`), because a millisecond anchor would - * re-include the previous page's last row on `oldest` and skip same-millisecond rows on `newest`. + * Keyset: (ts, id) with `id` compared as text in both the branch predicate and the outer ORDER BY. Every + * source's pk is a uuid, so text and uuid order coincide and one text tuple gives the four sources a + * single total order. The cursor carries ts at microsecond precision (`cursor_at`) because a millisecond + * anchor would re-include the previous page's last row on `oldest` and skip same-millisecond rows on + * `newest`. * - * FILTERING: `filter` is an ActivityKind, which is a SERVICE-side classification. Rather than re-typing the - * action prefixes in SQL, the branch set comes from `sourcesForKind` and the audit predicate is BUILT from - * `AUDIT_ACTION_RULES` (activity-service.ts) — one vocabulary, so a chip can never filter out a row it also - * labels. The catch-all kind (AUDIT_FALLBACK_KIND) is defined by exclusion and negates every rule. - * - * The service does the kind/hue/what classification; this repo only NORMALIZES and pages the rows. + * `filter` is a service-side classification, so the branch set comes from `sourcesForKind` and the audit + * predicate is built from `AUDIT_ACTION_RULES` rather than re-typing prefixes in SQL: a chip can never + * filter out a row it also labels. */ import type { Sql } from "../../db/client.js" @@ -57,7 +44,6 @@ import { type ListActivityArgs, } from "./activity-service.js" -/** A unioned activity row as selected back (snake_case; the common projection across all sources). */ interface ActivityRowSelect { source: ActivitySource id: string @@ -83,13 +69,9 @@ function toRecord(r: ActivityRowSelect): ActivitySourceRecord { } } -/** Construct the production ActivityRepository over the raw postgres-js tag (`container.getDb().sql`). */ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { - /** - * The audit branch's kind predicate. For a kind that OWNS rules, match any of them; for the catch-all - * kind, match NONE of them (that is what "everything else" means, and it must stay derived from the one - * rule table or the chip drifts from the label). - */ + // The catch-all kind matches none of the rules; it must stay derived from the one rule table or the + // chip drifts from the label. function auditKindFilter(args: ListActivityArgs): SqlFragment { if (args.filter === "all") return sql`` const own = auditRulesForKind(args.filter) @@ -100,7 +82,6 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { return sql`AND false` } - /** `(action LIKE 'p.%' OR action = 'x' OR ...)` over a rule list. */ function anyRule(rules: readonly AuditActionRule[]): SqlFragment { // The prefixes contain `_` (gov_claim.), a LIKE wildcard, so they are escaped to match literally, as // the service classifier's startsWith does. @@ -114,7 +95,6 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { return sql`(${branches.slice(1).reduce((acc, b) => sql`${acc} OR ${b}`, first)})` } - /** The mail branch's kind predicate (bounced/failed vs everything else on the same type column). */ function mailKindFilter(args: ListActivityArgs): SqlFragment { const types = [...MAIL_BOUNCE_EVENT_TYPES] if (args.filter === "outreach_bounce") return sql`AND e.type = ANY(${types}::text[])` @@ -127,14 +107,13 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { args: ListActivityArgs, ): Promise<{ records: ActivitySourceRecord[]; nextCursor: string | null }> { const limit = clampLimit(args.limit) - // requireUuid is FALSE: the keyset compares `id::text`, never `${id}::uuid`, so a non-uuid id in a - // hand-made cursor cannot raise a 22P02 — it simply anchors past every real row. + // requireUuid is false: the keyset compares `id::text`, never `${id}::uuid`, so a non-uuid id in a + // hand-made cursor cannot raise a 22P02; it simply anchors past every real row. const anchor = decodeCursor(args.cursor) const desc = args.sort === "newest" const sources = args.filter === "all" ? null : new Set(sourcesForKind(args.filter)) const wants = (source: ActivitySource): boolean => sources === null || sources.has(source) - /** The per-branch keyset + ORDER BY tail, parameterized on the branch's own ts/id expressions. */ const pageWindow = (tsCol: SqlFragment, idText: SqlFragment): SqlFragment => { const keyset = anchor === null @@ -162,9 +141,9 @@ export function makeDrizzleActivityRepository(sql: Sql): ActivityRepository { a.action AS action, NULL::text AS event_type, a.target AS subject FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id - -- audit_log carries the L4 read audits too (one row per sensitive PAGE VIEW). Those are - -- navigation, not activity: unfiltered they fill this branch's whole per-source window and evict - -- every real action from the feed. They remain in the audit-log view. + -- Read audits (one row per sensitive page view) are navigation, not activity: unfiltered they + -- fill this branch's whole per-source window and evict every real action from the feed. They + -- remain in the audit-log view. WHERE a.action <> ALL(${[...AUDIT_READ_ACTIONS]}) ${auditKindFilter(args)} ${search([sql`a.action`, sql`u.display_name`, sql`a.target`])} diff --git a/services/api/src/services/admin/activity-repository.memory.ts b/services/api/src/services/admin/activity-repository.memory.ts index 6aec40d7..6071e5d5 100644 --- a/services/api/src/services/admin/activity-repository.memory.ts +++ b/services/api/src/services/admin/activity-repository.memory.ts @@ -1,14 +1,10 @@ /** - * In-memory ActivityRepository for the offline activity-service unit tests (no DB, no Docker). + * Mirrors the Drizzle impl's observable behavior. Cursors share its format but not its precision: the + * Drizzle cursor carries microseconds a JS Date cannot hold. * - * Faithful to the Drizzle impl's observable behavior: it merges all seeded source records, drops the L4 - * read audits the SQL branch excludes, applies the same search / kind facet / direction, and keyset-pages - * on (ts, id) through the shared pageInMemoryById helper so the cursors share the Drizzle impl's format - * (the Drizzle cursor carries microseconds a JS Date cannot hold, so the strings differ in precision). seedRecord appends a normalized source record; the public `records` array is inspectable. - * - * The kind facet is evaluated by CLASSIFYING each record (classifyActivity) rather than by re-deriving the - * action prefixes — the fake is allowed the round trip the SQL branch cannot afford, and it is the sharper - * test of the two: it fails if the repo's SQL predicate and the service's classifier ever disagree. + * The kind facet classifies each record (classifyActivity) rather than re-deriving the action prefixes. + * The fake can afford the round trip the SQL cannot, and it makes tests fail if the repo's SQL predicate + * and the service's classifier ever disagree. */ import { AUDIT_READ_ACTIONS } from "./audit.js" @@ -20,10 +16,8 @@ import { type ListActivityArgs, } from "./activity-service.js" -/** The excluded read-audit actions as a set (the SQL branch's `action <> ALL(...)`, in memory). */ const READ_ACTIONS: ReadonlySet = new Set(AUDIT_READ_ACTIONS) -/** The columns the SQL branches search, flattened into the text a record contributes to `q`. */ function searchText(record: ActivitySourceRecord): string { return [ record.who, @@ -37,10 +31,8 @@ function searchText(record: ActivitySourceRecord): string { } export class InMemoryActivityRepository implements ActivityRepository { - /** All seeded source records (insertion order; list() sorts a copy). */ readonly records: ActivitySourceRecord[] = [] - /** Append a normalized source record. */ seedRecord(record: ActivitySourceRecord): ActivitySourceRecord { this.records.push(record) return record @@ -62,7 +54,7 @@ export class InMemoryActivityRepository implements ActivityRepository { const dir = args.sort === "newest" ? -1 : 1 const sorted = [...feedable].sort((a, b) => { const byTs = (a.ts.getTime() - b.ts.getTime()) * dir - // The same (ts, id) TOTAL order the SQL keyset uses, so a page boundary lands identically. + // The same (ts, id) total order the SQL keyset uses, so a page boundary lands identically. return byTs !== 0 ? byTs : a.id < b.id ? dir : a.id > b.id ? -dir : 0 }) return pageInMemoryById(sorted, args.cursor, args.limit, (r) => ({ createdAt: r.ts, id: r.id })) diff --git a/services/api/src/services/admin/activity-service.ts b/services/api/src/services/admin/activity-service.ts index ee2349ba..f02e4a0e 100644 --- a/services/api/src/services/admin/activity-service.ts +++ b/services/api/src/services/admin/activity-service.ts @@ -1,12 +1,8 @@ /** - * Admin activity-feed service (Phase 2): the merged recent-activity feed's CLASSIFICATION + query - * normalization. The repository (activity-repository.drizzle.ts) unions the four sources and pages them; - * this module turns a normalized source row into the wire DTO. - * - * The kind vocabulary is declared ONCE here (AUDIT_ACTION_RULES / KIND_SOURCES / MAIL_BOUNCE_EVENT_TYPES) - * and read by BOTH `classifyAuditAction` below and the repository's `filter=` SQL predicate. A second - * copy of those prefixes inside a WHERE clause is exactly how a facet starts disagreeing with the chip it - * filters — the feed would show a row under one kind and hide it under that kind's own filter. + * The kind vocabulary is declared once here (AUDIT_ACTION_RULES / KIND_SOURCES / MAIL_BOUNCE_EVENT_TYPES) + * and read by both `classifyAuditAction` and the repository's `filter=` SQL predicate. A second copy + * of those prefixes in a WHERE clause is how a facet starts disagreeing with the chip it filters: the feed + * would show a row under one kind and hide it under that kind's own filter. */ import { relativeAgo, ActivityKindSchema } from "@civfix/shared" @@ -33,13 +29,10 @@ export interface ActivitySourceRecord { export const ACTIVITY_DEFAULT_LIMIT = 25 -/** The facet: "all" or one ActivityKind (the chip the operator clicked). */ export type ActivityFilter = "all" | ActivityKind -/** Feed direction. "newest" is the default; "oldest" walks the same keyset the other way. */ export type ActivitySort = "newest" | "oldest" -/** Normalized list arguments the repository consumes (search + facet + direction + page window). */ export interface ListActivityArgs { q: string | null filter: ActivityFilter @@ -49,15 +42,14 @@ export interface ListActivityArgs { } export interface ActivityRepository { - /** Page the merged feed applying the search + kind facet + direction. */ list( args: ListActivityArgs, ): Promise<{ records: ActivitySourceRecord[]; nextCursor: string | null }> } /** - * `filter` / `sort` are free-form `z.ZodString` on the wire (ActivityListQuerySchema), so an unrecognized - * value must degrade to the default rather than 422 or reach SQL — the server owns this vocabulary. + * `filter` / `sort` are free-form strings on the wire (ActivityListQuerySchema), so an unrecognized value + * degrades to the default rather than 422 or reach SQL: the server owns this vocabulary. */ export function parseActivityFilter(filter: string | undefined): ActivityFilter { if (filter === undefined || filter === "" || filter === "all") return "all" @@ -80,32 +72,26 @@ const KIND_HUE: Record = { cleanup_plan: "#facc15", } -/** One audit-action match: a dotted-namespace `prefix`, or one `exact` action. */ export type AuditActionRule = | { kind: ActivityKind; prefix: string; exact?: undefined } | { kind: ActivityKind; exact: string; prefix?: undefined } -/** - * The audit-action -> ActivityKind rules in EVALUATION ORDER (first match wins). The repository builds its - * `filter=` predicate for the audit branch from this same table, so the chip and the classifier - * cannot disagree. Order matters: `report.routed` must be tested before the `report.` family falls through - * to the catch-all. - */ +// First match wins, so order matters: `report.routed` must be tested before the `report.` family falls +// through to the catch-all. export const AUDIT_ACTION_RULES: readonly AuditActionRule[] = [ { kind: "gov_onboard", prefix: "gov_claim." }, { kind: "discovery_done", prefix: "discovery." }, { kind: "outreach_open", prefix: "outreach." }, { kind: "outreach_open", prefix: "mail." }, - // Forwarding a report to its jurisdiction IS outreach, not moderation — the `report.` prefix would + // Forwarding a report to its jurisdiction is outreach, not moderation: the `report.` prefix would // otherwise paint it the amber mod-action hue alongside flags and removals. { kind: "outreach_open", exact: "report.routed" }, { kind: "outreach_open", prefix: "inbox." }, ] -/** The kind an audit action matching NO rule falls back to (so a future action still renders). */ +/** Unmatched actions fall back here so a future action still renders. */ export const AUDIT_FALLBACK_KIND: ActivityKind = "mod_action" -/** The mail_events.type values that mean the outreach did not land (the rest are outreach_open). */ export const MAIL_BOUNCE_EVENT_TYPES = ["bounced", "failed"] as const export function isMailBounceEventType(type: string): boolean { @@ -113,10 +99,9 @@ export function isMailBounceEventType(type: string): boolean { } /** - * Which union branches can produce each kind — the repository prunes branches with this, so a filtered - * page never scans a source that cannot contribute a row. Maintained BESIDE classifyActivity below; the - * failure mode of a wrong entry is an empty page, never a misfiled row (the branch's own predicate still - * decides), which is why this is a lookup and not a second classifier. + * The repository prunes union branches with this, so a filtered page never scans a source that cannot + * contribute a row. A wrong entry yields an empty page, never a misfiled row (the branch's own predicate + * still decides), which is why this is a lookup and not a second classifier. */ const KIND_SOURCES: Record = { pin: ["report"], @@ -126,19 +111,18 @@ const KIND_SOURCES: Record = { gov_onboard: ["audit"], discovery_done: ["audit"], mod_action: ["audit"], - // NO source produces `claim` today: a gov claim arrives as a `gov_claim.*` audit row -> gov_onboard. The + // No source produces `claim` today: a gov claim arrives as a `gov_claim.*` audit row (gov_onboard). The // value stays in the contract enum, so filtering on it yields an empty page rather than an error. claim: [], } -/** The union branches that can produce `kind`. */ export function sourcesForKind(kind: ActivityKind): readonly ActivitySource[] { return KIND_SOURCES[kind] } /** - * The rules whose kind is `kind`. EMPTY for AUDIT_FALLBACK_KIND — that kind is defined by EXCLUSION, so a - * caller building SQL must negate every rule in AUDIT_ACTION_RULES instead of matching these. + * Empty for AUDIT_FALLBACK_KIND: that kind is defined by exclusion, so a caller building SQL must negate + * every rule in AUDIT_ACTION_RULES instead of matching these. */ export function auditRulesForKind(kind: ActivityKind): readonly AuditActionRule[] { return AUDIT_ACTION_RULES.filter((rule) => rule.kind === kind) @@ -156,10 +140,8 @@ export function classifyAuditAction(action: string): ActivityKind { } /** - * Human label per audit action. Covers every literal in the AdminAuditAction catalogue (audit.ts) — an - * unlabelled action renders as its raw dotted string in the operator's feed, which is how `report.routed` - * and the user.verified family used to surface. An unknown//future action still falls back to the raw - * string rather than failing. + * Keep this covering every AdminAuditAction (audit.ts): an unlabelled action renders as its raw dotted + * string in the operator's feed. An unknown action still falls back to that rather than failing. */ export function describeAuditAction(action: string): string { const map: Record = { @@ -172,7 +154,7 @@ export function describeAuditAction(action: string): string { "discovery.draft_saved": "Saved a routing draft", "discovery.note_added": "Added a discovery note", "discovery.flagged": "Flagged a jurisdiction", - // Written by the PUBLIC suggest-contact endpoint, not an operator (the feed's `who` is the row's actor). + // Written by the public suggest-contact endpoint, not an operator (the feed's `who` is the row's actor). "discovery.contact_suggested": "Suggested a routing contact", "jurisdiction.patched": "Updated a jurisdiction", "report.status_changed": "Changed a report status", @@ -219,8 +201,8 @@ export function describeAuditAction(action: string): string { "mail.forward_template_set": "Updated the default forwarding template", "outreach.digest_sent": "Sent an outreach digest", "inbox.status_changed": "Updated an inbox message", - // The L4 read audits are filtered out of the feed at the repo (AUDIT_READ_ACTIONS), so these labels - // only matter if a row reaches the classifier another way (a fake, or a future feed that includes them). + // Read audits are filtered out of the feed at the repo (AUDIT_READ_ACTIONS), so these labels only + // matter if a row reaches the classifier another way (a fake, or a future feed that includes them). "user.detail_viewed": "Viewed an account", "user.messages_viewed": "Viewed an account's messages", "inbox.message_viewed": "Viewed an inbox message", @@ -241,8 +223,8 @@ export function classifyActivity(record: ActivitySourceRecord, ref: Date): Activ } if (record.source === "mail_event") { const type = record.eventType ?? "" - // The KIND comes from MAIL_BOUNCE_EVENT_TYPES (the same list the repo's outreach_bounce filter uses); - // only the label distinguishes the two members. + // The kind comes from the same list the repo's outreach_bounce filter uses; only the label + // distinguishes the two members. if (isMailBounceEventType(type)) { const what = type === "failed" ? "Outreach failed" : "Outreach bounced" return item("outreach_bounce", record.who || "Mail", what, record.where, ts) diff --git a/services/api/src/services/admin/admin-event-helpers.ts b/services/api/src/services/admin/admin-event-helpers.ts index 4bd0e7ed..0bb05371 100644 --- a/services/api/src/services/admin/admin-event-helpers.ts +++ b/services/api/src/services/admin/admin-event-helpers.ts @@ -1,11 +1,7 @@ -// Pure helpers for the admin events domain. These hold NO DB/IO and are imported by BOTH the service and -// the repos — keeping them here (not in admin-event-service.ts) breaks the repo->service back-edge the -// memory repo would otherwise create by importing flaggedFromTimeline from the service. +// Kept out of admin-event-service.ts so the repos can import these without a repo->service back-edge. import type { EventStatus, EventTimelineItem } from "@civfix/shared" -// The design facet (all|upcoming|in_progress|completed|flagged) reconciles to an event status to match -// and/or the flagged-only marker. export function resolveEventFilter(filter: string | undefined): { status: EventStatus | null flaggedOnly: boolean @@ -39,8 +35,7 @@ export function eventStatusNote(status: EventStatus): string { } } -// flagged = the LAST flag/unflag entry in the ordered timeline is a 'flag'. The repo computes the same in -// SQL for the list; this is the in-memory mirror. +// The in-memory twin of flaggedEventExpr (admin-event-sql.ts); the two must agree. export function flaggedFromTimeline(kinds: readonly string[]): boolean { let flagged = false for (const kind of kinds) { @@ -50,8 +45,8 @@ export function flaggedFromTimeline(kinds: readonly string[]): boolean { return flagged } -// A fallback label for a note-less cleanup_timeline row, derived from its own kind. Used so a row with a -// null note never surfaces a blanket "Status set to Upcoming" (which would mislead on a join/done/flag). +// A note-less timeline row is labelled from its own kind so it never surfaces a blanket "Status set to +// Upcoming", which would mislead on a join/done/flag row. export function timelineDefaultNote(kind: string): string { switch (kind) { case "create": @@ -84,8 +79,6 @@ export function eventOutcomeNote(bags: number): string { return bags === 1 ? "Outcome logged: 1 bag" : `Outcome logged: ${bags} bags` } -// Map a stored cleanup_timeline kind to the design's event-timeline icon kind. The stored 'flag'/'unflag' -// map to 'warn'. export function eventTimelineKind(stored: string): EventTimelineItem["kind"] { switch (stored) { case "create": diff --git a/services/api/src/services/admin/admin-event-repository.memory.ts b/services/api/src/services/admin/admin-event-repository.memory.ts index 7cb5ecf3..820c51d7 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/src/services/admin/admin-event-repository.memory.ts @@ -1,21 +1,8 @@ /** - * In-memory AdminEventRepository (Phase 2): the offline binding of the admin events persistence seam. - * - * Mirrors the Drizzle impl's OBSERVABLE contract so the admin event service can be unit-tested with NO - * database (no Docker): - * - listEvents applies the search (title/address/organizer name+handle, exact-uuid id — see - * matchesQuery) + the status + flagged-only facet and pages newest-id-keyset; flagged is derived from - * the seeded timeline kinds; - * - getEvent/listTimeline/listMessages read the seeded cleanup + its extras; - * - toggleFlag / cancel mutate the cleanup + append a cleanup_timeline row; - * - * STATUS (0.46.0): like the Drizzle repo, this fake DERIVES the status from scheduled_at/ends_at against - * the wall clock and reads the stored value only for 'cancelled' (cleanup-rules.deriveCleanupStatus, the - * TS twin of cleanupStatusExpr). seedEvent takes an EventStatus (or a raw legacy `storedStatus`) and, - * unless the caller pins `scheduledAt`/`endsAt`, seeds a window that derives back to it. - * - postMessage appends a chat message + a 'message' timeline row + returns the seeded members; - * - notifyMember records a notification row (inspectable for the message-attendees test). - * Seed/inspect helpers (seedEvent, seedMember, the public maps) let tests arrange + assert state. + * Mirrors the Drizzle repo's observable behavior so the admin event service is unit-tested with no + * database. Like the Drizzle repo it derives status from scheduled_at/ends_at against the clock and reads + * the stored value only for 'cancelled'; seedEvent seeds a window that derives back to the requested + * status unless the test pins `scheduledAt`/`endsAt`. */ import { randomUUID } from "node:crypto" @@ -60,7 +47,6 @@ function defaultStartOffsetMs(status: string): number { return 7 * 86_400_000 } -/** A recorded member notification (the message-attendees fan-out), inspectable by tests. */ export interface RecordedMemberNotification { cleanupId: string userId: string @@ -69,26 +55,23 @@ export interface RecordedMemberNotification { link: string | null } -/** A recorded audit row (mirrors the Drizzle impl's in-tx writeAudit), inspectable by tests. */ +/** Mirrors the Drizzle repo's in-transaction writeAudit. */ export interface RecordedEventAudit { action: string target: string meta: Record } -/** A seeded cleanup plus its detail extras held in one place. */ export interface SeededEvent { record: AdminEventRecord /** The raw stored cleanups.status; only 'cancelled' is consulted, exactly as the SQL does. */ storedStatus: string - /** cleanups.ends_at — the derivation's right edge. */ + /** cleanups.ends_at, the derivation's right edge. */ endsAt: Date - /** cleanups.organization_id (the org link), for the admin org events list. */ organizationId: string | null members: EventMemberRef[] } -/** A seeded report (the subset the link gallery + the visibility filter need). */ export interface SeededAdminReport { id: string category: ReportCategory @@ -102,21 +85,16 @@ export interface SeededAdminReport { deleted: boolean } -/** An in-memory AdminEventRepository faithful to the Drizzle impl's observable behavior. */ export class InMemoryAdminEventRepository implements AdminEventRepository { - /** Seeded cleanups keyed by id (insertion order preserved for stable paging). */ + /** Insertion order gives stable paging. */ readonly events = new Map() - /** Timeline rows keyed by cleanup id, oldest first. */ + /** Oldest first. */ readonly timeline = new Map() - /** Chat messages keyed by cleanup id, oldest first. */ + /** Oldest first. */ readonly messages = new Map() - /** Recorded member notifications (the message-attendees side effect). */ readonly notifications: RecordedMemberNotification[] = [] - /** Recorded audit rows. */ readonly audits: RecordedEventAudit[] = [] - /** Seeded reports keyed by id (for the link gallery + visibility filter). */ readonly reports = new Map() - /** cleanup_reports junction rows ({cleanupId, reportId, linkedAt}). */ readonly links: { cleanupId: string; reportId: string; linkedAt: Date }[] = [] /** Deterministic clock for appended rows; each row advances by one millisecond. */ @@ -129,14 +107,13 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { } /** - * Seed a cleanup. Defaults fill the optional fields so a test only sets what it asserts on. Pass - * `status` (an EventStatus, mapped to the stored Phase-1 value) for the common case, or `storedStatus` - * to seed a RAW Phase-1 value directly (e.g. a legacy 'active'/'done' row, for the H1 filter test). + * Defaults fill the optional fields so a test only sets what it asserts on. `storedStatus` seeds a raw + * legacy value (e.g. 'active'/'done') instead of an EventStatus. */ seedEvent(input: { id?: string status?: EventStatus - /** Raw stored cleanups.status override (Phase-1 enum). Wins over `status` when provided. */ + /** Wins over `status` when provided. */ storedStatus?: string eventKind?: EventKind title?: string @@ -190,7 +167,7 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { return seeded } - /** Seed a report so the link gallery + visibility filter resolve. Defaults to visible (published+public). */ + /** Defaults to a visible (published, public) report. */ seedReport(over: Partial & { id?: string } = {}): SeededAdminReport { const report: SeededAdminReport = { id: over.id ?? randomUUID(), @@ -208,16 +185,14 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { return report } - /** Seed a cleanup_reports link directly. */ seedLink(cleanupId: string, reportId: string): void { this.links.push({ cleanupId, reportId, linkedAt: this.nextDate() }) } /** - * True when a report is publicly visible - mirrors publicReportFilter(sql), which the Drizzle repo uses - * for both the gallery read and the link-insert validator. Reads the status set from - * report-visibility.ts rather than re-typing it: a hardcoded `status === "published"` here would make - * the fake UNDER-select relative to prod (a resolved report would vanish from an event's gallery). + * Mirrors publicReportFilter(sql), which the Drizzle repo uses for both the gallery read and the link + * insert. Reads the status set from report-visibility.ts: a hardcoded `status === "published"` would make + * the fake under-select relative to prod (a resolved report would vanish from an event's gallery). */ private reportVisible(r: SeededAdminReport | undefined): r is SeededAdminReport { return ( @@ -394,7 +369,7 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { who: "operator", createdAt: this.nextDate(), }) - // L4: fan out a notification per member in the same call (mirrors the Drizzle set-based in-tx insert). + // Mirrors the Drizzle repo's set-based in-transaction fan-out. for (const member of seeded.members) { this.notifications.push({ cleanupId: id, @@ -497,16 +472,9 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { } /** - * The search-needle filter shared by listEvents + countByBucket. - * - * MIRRORS searchEventsFragment (admin-event-sql.ts) COLUMN FOR COLUMN: - * `c.title ILIKE %q% OR c.address ILIKE %q% OR u.display_name ILIKE %q% OR u.handle ILIKE %q%` - * `OR c.id = $q::uuid` — the id branch only when q is a uuid. - * The id was previously matched as a SUBSTRING, which SQL never does (an exact uuid equality), so an - * id-prefix search passed offline and returned nothing in production. `place` and `address` are both - * checked because the SQL projection reads ONE column into both (`c.address AS place, c.address`) while - * the fake lets a test seed them separately — so whichever a test set is the address SQL would search. - * The organizer HANDLE (`u.handle::text`) was missing entirely. + * Mirrors searchEventsFragment (admin-event-sql.ts) column for column, including the exact-uuid id match: + * a substring match here would pass offline and return nothing in production. `place` and `address` are + * both checked because SQL reads one column into both while the fake lets a test seed them separately. */ function matchesQuery(record: AdminEventRecord, q: string): boolean { const needle = q.toLowerCase() @@ -520,7 +488,6 @@ function matchesQuery(record: AdminEventRecord, q: string): boolean { ) } -/** Default seeded organizer (a claimed account with a verified email + oauth). */ function defaultOrganizer(): AdminOrganizerRecord { return { id: randomUUID(), diff --git a/services/api/src/services/admin/admin-event-service.ts b/services/api/src/services/admin/admin-event-service.ts index d6f10a03..433baca7 100644 --- a/services/api/src/services/admin/admin-event-service.ts +++ b/services/api/src/services/admin/admin-event-service.ts @@ -1,15 +1,8 @@ /** - * Admin events service (Phase 2): the civfix cleanups domain. Backs the events list, the detail, and the - * operator actions (set status / log outcome / flag / cancel / post update / link reports), each audited - * inside the repo transaction with the operator userId the route resolves. + * Every operator action is audited inside the repo transaction with the operator id the route resolves. * - * STATUS RECONCILIATION (H1): cleanups.status is the Phase-1 enum (upcoming|active|done|cancelled, no - * CHECK); every read maps stored -> EventStatus and every write maps EventStatus -> stored via - * event-status.ts, so a filter and a write never disagree and no invalid value is leaked or written. - * - * FLAG MODEL: abuse_flags has no `cleanup` subject_type (frozen Phase-1 enum), so an event's "flagged" - * boolean is tracked via cleanup_timeline rows (kind 'flag'/'unflag'); flagged = the most recent - * flag/unflag entry is a 'flag'. + * abuse_flags has no `cleanup` subject_type, so an event's flagged state lives in cleanup_timeline rows + * (kind 'flag'/'unflag'): the most recent of them decides. */ import { AppError } from "@civfix/shared" @@ -36,8 +29,7 @@ import { timelineDefaultNote, } from "./admin-event-helpers.js" -// The pure helpers historically lived here; re-export them so existing importers (tests, the memory repo) -// keep their import path. +// Re-exported so importers of the helpers' old home (tests, the memory repo) keep their import path. export { resolveEventFilter, eventStatusNote, @@ -45,14 +37,9 @@ export { eventTimelineKind, } from "./admin-event-helpers.js" -/** - * The organizer of a cleanup, as the repo resolves it. Field-identical to a report's reporter, so both read - * the shared admin-person shape (admin-person.ts, which also owns the SQL columns and both projections); - * the name is kept because it is what the events domain calls this person. - */ +/** Field-identical to a report's reporter; the alias keeps the events domain's name for this person. */ export type AdminOrganizerRecord = AdminPersonRecord -// A cleanup_timeline row as the repo reads it back. `kind` is the stored free-text kind. export interface AdminEventTimelineRecord { kind: string note: string | null @@ -69,9 +56,8 @@ export interface AdminEventMessageRecord { export interface AdminEventRecord { id: string status: EventStatus - // cleanup vs other_volunteer (0018); only 'cleanup' events may link reports / show the gallery. + // Only 'cleanup' events may link reports or show the linked-report gallery. eventKind: EventKind - // Derived from cleanup_timeline (net flag/unflag toggles). flagged: boolean title: string place: string @@ -86,7 +72,7 @@ export interface AdminEventRecord { scheduledAt: Date } -// `status` is the event status to match (null = any). +// A null `status` matches any status. export interface ListEventsArgs { q: string | null status: EventStatus | null @@ -104,8 +90,8 @@ export interface EventMemberRef { } export interface AdminEventRepository { - // Per-facet totals for the filter chips, over the SEARCHED (q) set — accurate + stable across the facet - // instead of capped to the first keyset page. + // Totals over the whole searched set rather than the first keyset page, so the filter chips stay + // accurate as the operator switches facets. countByBucket(args: { q: string | null }): Promise listEvents( args: ListEventsArgs, @@ -113,21 +99,21 @@ export interface AdminEventRepository { getEvent(id: string): Promise listTimeline(id: string): Promise listMessages(id: string): Promise - // The ONLY write path for cleanups.bags. Returns false when the cleanup is absent. + // The only write path for cleanups.bags. Returns false when the cleanup is absent. setBags(id: string, input: { bags: number; actorId: string | null }): Promise - // Toggle flagged via cleanup_timeline. Returns the resulting state, or null when the cleanup is absent. + // Returns the resulting flagged state, or null when the cleanup is absent. toggleFlag( id: string, input: { reason: string | null; actorId: string | null }, ): Promise cancel(id: string, input: { note: string; actorId: string | null }): Promise - // Insert a chat row + a 'message' timeline row + a set-based per-member notification fan-out, ALL in one + // The chat row, the 'message' timeline row and the per-member notification fan-out commit in one // transaction. Returns the number of members notified, or null when the cleanup does not exist. postMessage( id: string, input: { body: string; actorId: string }, ): Promise<{ notified: number } | null> - // The linked-report gallery: only published+public reports, geom decoded + ready-media thumb key. + // Only publicly visible reports appear in the gallery. loadLinkedReports(id: string): Promise // Returns the newly-linked ids, or null when the event is missing. Skips non-visible (held/hidden) ids. linkReports( @@ -141,10 +127,8 @@ export interface AdminEventRepository { export interface AdminEventServiceDeps { repo: AdminEventRepository - // Presign a linked report's thumb object key into a client-usable URL. Defaults to an identity - // pass-through (raw key) when omitted, so offline tests see a thumb without a storage SDK. + // Defaults to returning the raw key, so offline tests see a thumb without a storage SDK. presignThumb?: (thumbKey: string) => Promise - // Injectable clock so the relative-age labels are deterministic in tests. now?: () => Date } @@ -198,8 +182,6 @@ export function makeAdminEventService(deps: AdminEventServiceDeps): AdminEventSe function toTimelineDTO(record: AdminEventTimelineRecord, ref: Date): EventTimelineItem { return { who: record.who, - // A note-less row gets a label derived from its own kind, NOT a blanket "Status set to Upcoming" - // (which would mislead on a join/done/flag row). what: record.note ?? timelineDefaultNote(record.kind), when: toRelAbs(record.createdAt, ref).rel, kind: eventTimelineKind(record.kind), @@ -221,9 +203,8 @@ export function makeAdminEventService(deps: AdminEventServiceDeps): AdminEventSe cursor: query.cursor ?? null, limit: query.limit ?? 25, } - // Counts span the searched set but ignore the facet, so the chips stay accurate as the operator - // switches them — and because they describe the whole set rather than the page, they are computed on - // PAGE 1 ONLY (the shared admin-list policy; the console reads them off the first page). + // The counts describe the whole searched set rather than the page, so they are computed on page 1 + // only (the shared admin-list policy; the console reads them off the first page). const [{ records, nextCursor }, counts] = await Promise.all([ deps.repo.listEvents(args), args.cursor === null @@ -263,7 +244,6 @@ export function makeAdminEventService(deps: AdminEventServiceDeps): AdminEventSe const [timeline, messages, linkedViews] = await Promise.all([ deps.repo.listTimeline(id), deps.repo.listMessages(id), - // Only a 'cleanup' event has a linked-report gallery (cleanup-only linking). record.eventKind === "cleanup" ? deps.repo.loadLinkedReports(id) : Promise.resolve([]), ]) const linkedReports: LinkedReportRef[] = await mapWithLimit( @@ -338,7 +318,7 @@ export function makeAdminEventService(deps: AdminEventServiceDeps): AdminEventSe reportIds: string[], actorId: string | null, ): Promise<{ linked: string[] }> { - // CLEANUP-ONLY LINKING (decision 3): reject linking on a non-cleanup event before any write. + // Rejected before any write, so a non-cleanup event never gains a link. const record = await deps.repo.getEvent(id) if (!record) throw AppError.notFound("Event not found") if (record.eventKind !== "cleanup") { diff --git a/services/api/src/services/admin/admin-event-sql.ts b/services/api/src/services/admin/admin-event-sql.ts index ac682505..5a0e3b89 100644 --- a/services/api/src/services/admin/admin-event-sql.ts +++ b/services/api/src/services/admin/admin-event-sql.ts @@ -1,5 +1,4 @@ -// Composable SQL fragments + the row->record projector for the Drizzle admin-event repo. Extracted so the -// repo file holds query orchestration only and the flagged/search expressions have ONE definition each. +// Kept apart from the repo so the flagged and search expressions have one definition each. import type { Queryable } from "../../db/client.js" import { isUuid } from "../../db/cursor-helpers.js" @@ -11,9 +10,7 @@ import type { EventKind } from "@civfix/shared" import { adminEventStatusExpr } from "../cleanup-sql.js" import { keysetInstant } from "./pagination.js" -// The "is flagged" boolean: the most recent cleanup_timeline flag/unflag row is a 'flag'. The ONE -// definition — eventSelect + countByBucket both build their flagged column/filter from this so they can't -// drift. +// eventSelect and countByBucket both build their flagged column/filter from this so they cannot drift. export function flaggedEventExpr(sql: Queryable): SqlFragment { return sql`COALESCE(( SELECT ct.kind = 'flag' @@ -24,8 +21,7 @@ export function flaggedEventExpr(sql: Queryable): SqlFragment { ), false)` } -// The event-search predicate (title / address / organizer name+handle, + exact id on a uuid q), shared by -// listEvents + countByBucket. Assumes the query selects `cleanups c` LEFT JOIN `users u`. Empty when null. +// Assumes the query selects `cleanups c` LEFT JOIN `users u`. export function searchEventsFragment(sql: Queryable, q: string | null): SqlFragment { if (q === null) return sql`` // ilikeAnyOf escapes the LIKE metacharacters so %/_ in q match literally (wildcard injection/trigram DoS). @@ -37,10 +33,8 @@ export function searchEventsFragment(sql: Queryable, q: string | null): SqlFragm )}` } -// A cleanups list/detail row as selected back (geom decoded, organizer joined, aggregates computed). export interface EventRowSelect { id: string - // Raw stored cleanups.status (Phase-1 enum); mapped to EventStatus in toRecord. status: string event_kind: EventKind flagged: boolean @@ -77,8 +71,6 @@ export function toRecord(r: EventRowSelect): AdminEventRecord { ) return { id: r.id, - // Map the stored Phase-1 cleanups.status -> the Phase-2 EventStatus DTO (H1); defensive so a legacy - // active/done (or a previously-mis-stored Phase-2 value) never leaks an invalid EventStatus. status: toEventStatus(r.status), eventKind: r.event_kind, flagged: r.flagged, @@ -96,9 +88,7 @@ export function toRecord(r: EventRowSelect): AdminEventRecord { } } -// The shared cleanup SELECT (geom decoded, organizer joined, attendees + flagged computed). `extraWhere`/ -// `orderLimit` narrow it. `place`/`address`: cleanups carry a free-text `address`; the place label falls -// back to that address (cleanups are not jurisdiction-scoped in Phase 1). +// Cleanups carry only a free-text address and no jurisdiction, so the place label is that address. export function eventSelect( sql: Queryable, extraWhere: SqlFragment, diff --git a/services/api/src/services/admin/admin-format.ts b/services/api/src/services/admin/admin-format.ts index 92db7a8b..33b5531a 100644 --- a/services/api/src/services/admin/admin-format.ts +++ b/services/api/src/services/admin/admin-format.ts @@ -1,11 +1,8 @@ -// Shared pure formatting helpers for the admin reports / events / users projections (clock injected, no -// IO) so the relative+absolute timestamp pair renders identically across every admin surface. - import { relativeAgo } from "@civfix/shared" import type { RelAbsTime } from "@civfix/shared" -// Fixed to en-US + UTC so the absolute label is deterministic across machines + in tests (the design -// treats it as an unambiguous display string, not a locale-aware one). +// Fixed to en-US + UTC so the absolute label is deterministic across machines and in tests: the design +// treats it as an unambiguous display string, not a locale-aware one. const ABS_FORMAT = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", year: "numeric", @@ -15,10 +12,7 @@ const ABS_FORMAT = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }) -/** - * Format one Date into the design's absolute label, or "-" when null. MODULE-PRIVATE: `toRelAbs` is the - * only supported surface, so no caller can render an `abs` without its paired `rel`. - */ +// Module-private so no caller can render an `abs` without its paired `rel`. function absoluteLabel(date: Date | null): string { if (date === null) return "-" const t = date.getTime() @@ -26,10 +20,6 @@ function absoluteLabel(date: Date | null): string { return ABS_FORMAT.format(date) } -/** - * Build the { rel, abs } timestamp pair the design renders. `rel` is the compact relative-ago label - * (clock-injected via `now`); `abs` is the absolute label. A null date yields { rel: "-", abs: "-" }. - */ export function toRelAbs(date: Date | null, now: Date): RelAbsTime { if (date === null) return { rel: "-", abs: "-" } return { rel: relativeAgo(date, now), abs: absoluteLabel(date) } diff --git a/services/api/src/services/admin/admin-person.ts b/services/api/src/services/admin/admin-person.ts index bc9eb336..e751f5ed 100644 --- a/services/api/src/services/admin/admin-person.ts +++ b/services/api/src/services/admin/admin-person.ts @@ -1,27 +1,18 @@ /** - * The joined-PERSON projection shared by the admin reports and events surfaces: a report's reporter and an - * event's organizer are the same thing (a `users` row reached through a LEFT JOIN) rendered into the same - * four-field DTO, and they were built by two independent copies of the same three steps. + * The joined-person projection shared by the admin reports and events surfaces (a report's reporter, an + * event's organizer). One SQL definition matters most: a silent divergence would make one surface report + * `hasOauth: false` for every account, the provenance signal an operator reads before acting on a report. * - * The copy that actually mattered is the SQL one: six column expressions including a correlated - * `EXISTS (SELECT 1 FROM oauth_identities …)` probe, duplicated verbatim apart from the alias prefix. A - * divergence there is invisible per file — one surface would just quietly report `hasOauth: false` for - * every account, which is the account-provenance signal an operator reads before acting on a report. - * - * The two surfaces keep their own MISSING-person fallbacks, because the domains genuinely differ: a report - * may be anonymous (no user row at all -> `id: null`, "Anonymous"), while a cleanup always has an organizer - * and a null there is data corruption, not a supported state (-> `id: ""`, "Unknown"). Those strings are on - * the wire, so they stay caller-supplied rather than being unified into one default here. + * Each surface keeps its own missing-person fallback because the domains differ: a report may be + * anonymous (`id: null`, "Anonymous"), while a cleanup always has an organizer and a null there is data + * corruption (`id: ""`, "Unknown"). Those strings are on the wire, so they stay caller-supplied. */ import type { Queryable } from "../../db/client.js" import { toRelAbs } from "./admin-format.js" import type { SqlFragment } from "./sql-fragments.js" -/** - * A person joined onto an admin row (a report's reporter, an event's organizer), as the repo resolves it. - * Null at the call site means "no joined user" (an anonymous report), never "a user with empty fields". - */ +// Null at the call site means "no joined user" (an anonymous report), never "a user with empty fields". export interface AdminPersonRecord { id: string name: string @@ -32,9 +23,8 @@ export interface AdminPersonRecord { } /** - * The six values personSelect emits, read back off the row. ALL nullable: the join is a LEFT JOIN, so a - * row with no person yields null for every one of them (`id === null` is the authoritative "no person" - * test — a real `users.id` is never null). + * All nullable because the join is a LEFT JOIN. `id === null` is the authoritative "no person" test, since + * a real `users.id` is never null. */ export interface AdminPersonColumns { id: string | null @@ -46,10 +36,7 @@ export interface AdminPersonColumns { } /** - * The person columns for one row select: `.AS _` over a LEFT-JOINed `users` - * alias, in the order AdminPersonColumns reads them. `alias` and `prefix` are interpolated as postgres.js - * IDENTIFIERS (`sql(name)`), never as raw text. - * + * `alias` and `prefix` are interpolated as postgres.js identifiers (`sql(name)`), never as raw text. * `has_oauth` is a correlated EXISTS rather than a join so it cannot fan the row out when an account holds * several oauth identities. */ @@ -65,11 +52,8 @@ export function personSelect(sql: Queryable, alias: string, prefix: string): Sql ` } -/** - * Project the selected columns into the record, or null when no person joined. `fallbackName` covers a user - * row whose `display_name` is null (the account exists, so this is NOT the anonymous case) — each surface - * passes its own noun. - */ +// `fallbackName` covers a user row whose `display_name` is null: the account exists, so this is not the +// anonymous case. export function toPersonRecord( cols: AdminPersonColumns, fallbackName: string, @@ -86,12 +70,9 @@ export function toPersonRecord( } /** - * Project the record into the wire shape both surfaces render: `{ id, name, handle, joined }`, where - * `joined` is the ABSOLUTE half of the timestamp pair (the design shows a join date, not "3 months ago"). - * - * `missing` supplies the no-person values. It is generic on `id` alone so both contract shapes are met - * without a cast: the report DTO's `reporter.id` is nullable and passes `null`, the event DTO's - * `organizer.id` is a plain string and passes `""`. + * `joined` is the absolute half of the timestamp pair: the design shows a join date, not "3 months ago". + * Generic on the missing `id` alone so both contract shapes are met without a cast: the report DTO's + * `reporter.id` is nullable, the event DTO's `organizer.id` is a plain string. */ export function toPersonDTO( record: AdminPersonRecord | null, diff --git a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts index 8624e9bb..9b161f9c 100644 --- a/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-chat-repository.drizzle.ts @@ -16,7 +16,6 @@ export interface AdminReportChatRepository { ): Promise } -/** The chat room a chat or DM message lives in, or null when the id matches no message. */ export async function findMessageRoom( sql: Queryable, messageId: string, diff --git a/services/api/src/services/admin/admin-report-chat-service.ts b/services/api/src/services/admin/admin-report-chat-service.ts index fab445ec..26ac5109 100644 --- a/services/api/src/services/admin/admin-report-chat-service.ts +++ b/services/api/src/services/admin/admin-report-chat-service.ts @@ -15,10 +15,7 @@ import { CIVFIX_OFFICIAL_USER_ID } from "../../auth/official-account.js" export const ADMIN_REPORT_CHAT_HISTORY_DEFAULT = 30 export const ADMIN_REPORT_CHAT_HISTORY_MAX = 50 -/** - * Tells connected clients that an operator changed a chat or DM message (removed it, or restored it on an - * appeal), the way a member's own delete does. Called only after the change committed; never throws. - */ +/** Called only after the operator's change committed; never throws. */ export type MessageUpdateAnnouncer = (messageId: string) => Promise export interface MessageUpdateAnnouncerDeps { diff --git a/services/api/src/services/admin/admin-report-repository.drizzle.ts b/services/api/src/services/admin/admin-report-repository.drizzle.ts index c2f94583..205c75e4 100644 --- a/services/api/src/services/admin/admin-report-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-repository.drizzle.ts @@ -175,7 +175,7 @@ function reportSelect( r.reference_code, r.verification_verdict, r.verified_at, - -- The reporter's earned report-verified flag (D7); null for an anonymous report (no user_moderation + -- The reporter's earned report-verified flag; null for an anonymous report (no user_moderation -- row joins), false when the reporter has a user row but no moderation row yet. CASE WHEN u.id IS NULL THEN NULL ELSE COALESCE(um.report_verified, false) END AS reporter_report_verified, ${personSelect(sql, "u", "reporter")} diff --git a/services/api/src/services/admin/admin-report-service.ts b/services/api/src/services/admin/admin-report-service.ts index 9a03f783..8d417183 100644 --- a/services/api/src/services/admin/admin-report-service.ts +++ b/services/api/src/services/admin/admin-report-service.ts @@ -65,7 +65,7 @@ const ROUTABLE_FROM_STATUSES: readonly AdminReportStatus[] = ["submitted", "held export const ALREADY_ROUTED_CONFLICT = "This report has already been sent to its jurisdiction" export const SEND_IN_FLIGHT_CONFLICT = - "A send to this jurisdiction is still in progress. Check back shortly — the outcome will appear on the outreach trail." + "A send to this jurisdiction is still in progress. Check back shortly to see the outcome on the outreach trail." export function isAlreadyRoutedConflict(err: unknown): boolean { return ( @@ -261,7 +261,7 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor const current = await deps.repo.getReport(id) if (current?.status === input.status) return throw AppError.conflict( - "This report moved on while you were looking at it — reload and try again", + "This report moved on while you were looking at it. Reload and try again.", ) } await notifyReporterOfStatus(deps, id, record.reporter?.id ?? null, input.status) @@ -400,7 +400,7 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor const toAddr = routing?.contact ?? null if (toAddr === null || toAddr === "") { throw AppError.notRoutable( - "No routing contact for this report's jurisdiction — set one in Jurisdictions first", + "This report's jurisdiction has no routing contact. Set one in Jurisdictions first.", ) } diff --git a/services/api/src/services/admin/admin-report-status.ts b/services/api/src/services/admin/admin-report-status.ts index 4f732340..8d8605c4 100644 --- a/services/api/src/services/admin/admin-report-status.ts +++ b/services/api/src/services/admin/admin-report-status.ts @@ -2,18 +2,15 @@ import { ADMIN_REPORT_STATUS_LABELS, ReportTimelineItemSchema } from "@civfix/sh import type { AdminReportStatus, ReportTimelineItem } from "@civfix/shared" /** - * The canonical civfix-status -> design-bucket reconciliation (decisions 8 / enumeration 4.2). The design - * surface has only three live buckets (Submitted | In progress | Completed) plus the orthogonal Removed, - * but the civfix lifecycle has seven statuses: submitted -> held -> published -> acknowledged -> - * in_progress -> resolved (+ rejected). An authed pin is created `published` (live, visible, AWAITING city - * action), so published+held belong in the SUBMITTED bucket, NOT Completed. Only `resolved` is Completed. + * The design has three live buckets for the seven civfix statuses. An authed pin is created `published` + * (live, visible, awaiting city action), so published and held belong in Submitted, not Completed. * - * Single source of truth for the list-filter status SET AND the repo's countByBucket FILTER predicates - * (admin-report-repository imports it). The admin frontend keeps a matching map (src/lib/report-status.ts) - * so the pill labels and the filter never disagree. + * Single source of truth for the list-filter status set and the repo's countByBucket predicates. The admin + * frontend keeps a matching map (src/lib/report-status.ts) so the pill labels and the filter never + * disagree. * - * There is deliberately NO `removed` entry: removal soft-deletes the report, so `rejected` rows are filtered - * out of every list and count by `deleted_at IS NULL` — the bucket would never be selectable. + * There is deliberately no `removed` entry: removal soft-deletes the report, so `rejected` rows are + * filtered out of every list and count by `deleted_at IS NULL` and the bucket would never be selectable. */ export const STATUS_BUCKETS: Record< "submitted" | "in_progress" | "completed", @@ -24,11 +21,6 @@ export const STATUS_BUCKETS: Record< completed: ["resolved"], } -/** - * Map the list `filter` facet to a repo query shape. The design facet (all|submitted|in_progress| - * completed|flagged|needs_verification) reconciles to a SET of civfix statuses to match (via - * STATUS_BUCKETS) and/or the flagged-only / needs-verification-only markers. `all` matches everything. - */ export function resolveListFilter(filter: string | undefined): { statuses: AdminReportStatus[] | null flaggedOnly: boolean @@ -72,22 +64,16 @@ export function statusChangeNote(status: AdminReportStatus): string { } /** - * The note a jurisdiction-reply timeline row carries, written by the inbound side-effects (§2.7) and read - * back by the admin timeline mapper when the row has no recorded `kind` (report_timeline.kind, added by - * 0031) — a pre-0031 row, or one from a writer that leaves the column NULL, cannot be identified as a - * reply from its status alone. It lives in this leaf module so the note is ONE constant shared by the - * writer (inbound-thread-correlation) and the reader without either importing the other. + * The admin timeline mapper matches on this note when a jurisdiction-reply row has no recorded `kind`: a + * pre-0031 row, or one from a writer that leaves the column NULL, cannot be identified as a reply from its + * status alone. It lives in this leaf module so the writer (inbound-thread-correlation) and the reader + * share one constant without importing each other. */ export const JURISDICTION_REPLY_NOTE = "The city responded to this report" -/** The contract's timeline kinds, for narrowing a stored (plain-text) `report_timeline.kind` on read. */ const TIMELINE_KINDS: ReadonlySet = new Set(ReportTimelineItemSchema.shape.kind.options) -/** - * Narrow a stored `report_timeline.kind` to the contract union, or null when it is absent (a pre-0031 row, - * or any of the writers that leave the column NULL) or not a value the DTO can carry. Keeps an unexpected - * column value from escaping into a strict response. - */ +// Keeps an unexpected column value (the column is plain text) from escaping into a strict response. export function toTimelineKind(kind: string | null | undefined): ReportTimelineItem["kind"] | null { if (kind === null || kind === undefined) return null return TIMELINE_KINDS.has(kind) ? (kind as ReportTimelineItem["kind"]) : null diff --git a/services/api/src/services/admin/admin-user-repository.memory.ts b/services/api/src/services/admin/admin-user-repository.memory.ts index 6159b707..82c6e8c7 100644 --- a/services/api/src/services/admin/admin-user-repository.memory.ts +++ b/services/api/src/services/admin/admin-user-repository.memory.ts @@ -1,18 +1,3 @@ -/** - * In-memory AdminUserRepository (Phase 2): the offline binding of the admin users persistence seam. - * - * Mirrors the Drizzle impl's OBSERVABLE contract so the admin user service can be unit-tested with NO - * database (no Docker): - * - listUsers applies the search (name/handle/city) + the status + flagged-only facet and pages - * newest-id-keyset; - * - getUser reads the seeded user (+ moderation + counts); - * - listUserReports/Events/Messages page the seeded sub-activity rows; - * - toggleFlag flips user_moderation.flagged (+ records an audit); setStatus sets account_status (+ - * audit); recordRoleAudit records the role-change audit. - * Seed/inspect helpers (seedUser, seedReport/Event/Message, the public maps + audits) let tests arrange - * + assert state directly. The session-revoke + setRole seams are injected into the SERVICE, not here. - */ - import { randomUUID } from "node:crypto" import { pageInMemoryById } from "./pagination.js" import type { @@ -26,26 +11,20 @@ import type { } from "./admin-user-service.js" import type { AdminUserCounts, Role, UserStatus } from "@civfix/shared" -/** A recorded audit row (mirrors the Drizzle impl's writeAudit), inspectable by tests. */ export interface RecordedUserAudit { action: string target: string meta: Record } -/** An in-memory AdminUserRepository faithful to the Drizzle impl's observable behavior. */ export class InMemoryAdminUserRepository implements AdminUserRepository { - /** Seeded users keyed by id (insertion order preserved for stable paging). */ readonly users = new Map() - /** Seeded sub-activity rows keyed by user id. */ readonly reports = new Map() readonly events = new Map() readonly messages = new Map() readonly organizations = new Map() - /** Recorded audit rows. */ readonly audits: RecordedUserAudit[] = [] - /** Seed a user. Defaults fill the optional fields so a test only sets what it asserts on. */ seedUser(input: { id?: string name?: string @@ -82,7 +61,7 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { accountStatus: input.accountStatus ?? "active", reports: input.reports ?? 0, cleanups: input.cleanups ?? 0, - messages: 0, // recomputed from the seeded messages map in getUser (the detail's tab badge). + messages: 0, // recomputed from the seeded messages in getUser removals: input.removals ?? 0, strikes: input.strikes ?? 0, risk: input.risk ?? "low", @@ -96,25 +75,18 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { return record } - /** Seed a row in a user's Reports tab. */ seedReport(userId: string, row: UserReportRecord): void { const list = this.reports.get(userId) ?? [] list.push(row) this.reports.set(userId, list) } - /** Seed a row in a user's Events tab. */ seedEvent(userId: string, row: UserEventRecord): void { const list = this.events.get(userId) ?? [] list.push(row) this.events.set(userId, list) } - /** - * Seed a row in a user's Messages tab. `deletedAt` defaults to null (a live message) and `source` - * defaults to "chat" (the Messages tab unions cleanup chat, standalone group chat, DM, and report - * discussion, so tests may seed any source; the union shape is the same single keyed list here). - */ seedMessage( userId: string, row: Omit & { @@ -151,7 +123,7 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { if (args.flaggedOnly) rows = rows.filter((r) => r.flagged) if (args.deletedOnly) rows = rows.filter((r) => r.deletedAt !== null) - // Newest-first by joinedAt, id desc tiebreak (a null join sorts oldest). + // A null join sorts oldest. rows.sort((a, b) => { const at = a.joinedAt?.getTime() ?? 0 const bt = b.joinedAt?.getTime() ?? 0 @@ -166,15 +138,14 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { } async userExists(id: string): Promise { - // Existence only, matching getUser's reach: a soft-deleted account still EXISTS for the console (the - // list/detail render it with deletedAt set), so its sub-activity tabs must keep resolving. + // A soft-deleted account still exists for the console (list and detail render it with deletedAt set), + // so its sub-activity tabs must keep resolving. return this.users.has(id) } async getUser(id: string): Promise { const r = this.users.get(id) - // Recompute the messages count from the seeded sub-activity (the detail's Messages tab badge), mirroring - // the Drizzle COUNT(chat_messages) so a test that seeds messages sees the badge count. + // Mirrors the Drizzle COUNT(chat_messages) so a test that seeds messages sees the badge count. return r ? { ...r, messages: this.messages.get(id)?.length ?? 0 } : null } @@ -272,7 +243,6 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { if (!r) return false r.accountStatus = input.status this.audits.push({ - // A ban gets the dedicated user.banned action; other transitions are user.status_changed. action: input.status === "banned" ? "user.banned" : "user.status_changed", target: `user:${id}`, meta: { status: input.status, reason: input.reason }, @@ -280,7 +250,7 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { return true } - /** L5 mirror: the role write and its audit are one indivisible step (see the Drizzle impl's tx). */ + /** The role write and its audit are one indivisible step, as in the Drizzle impl's transaction. */ async applyRole(id: string, input: { role: Role; actorId: string | null }): Promise { const r = this.users.get(id) if (!r) return false @@ -298,7 +268,6 @@ export class InMemoryAdminUserRepository implements AdminUserRepository { return Promise.resolve([...(this.organizations.get(id) ?? [])]) } - /** Seed the organization memberships the Users detail page lists. */ seedUserOrganizations(userId: string, orgs: AdminUserOrganizationRecord[]): void { this.organizations.set(userId, orgs) } diff --git a/services/api/src/services/admin/analytics-repository.memory.ts b/services/api/src/services/admin/analytics-repository.memory.ts index 9b11a4db..c1e209d7 100644 --- a/services/api/src/services/admin/analytics-repository.memory.ts +++ b/services/api/src/services/admin/analytics-repository.memory.ts @@ -1,11 +1,7 @@ /** - * In-memory AnalyticsRepository for the offline analytics-service unit tests (no DB, no Docker). - * - * Unlike the row-store memory repos elsewhere, analytics aggregation is the DATABASE's job (medians, - * grouped counts, period windows), so reproducing it in JS would just re-implement the SQL. Instead this - * repo lets a test SET the canned aggregate each method returns, so the test exercises the service's pure - * SHAPING (pct, category fill, deltas, funnel, week/month/cohort alignment) against known inputs. The - * Docker-gated integration test exercises the real SQL aggregates. + * Aggregation is the database's job, so reproducing it in JS would re-implement the SQL. Instead a test + * sets the canned aggregate each method returns and exercises the service's shaping against known inputs; + * the integration test covers the real SQL aggregates. */ import type { @@ -23,7 +19,7 @@ import type { WeekBucket, } from "./analytics-types.js" -/** Default empty KPI aggregates (all zero) so a test only sets what it asserts. */ +/** All zero, so a test only sets what it asserts. */ function zeroKpis(): KpiAggregates { return { pins: { current: 0, previous: 0 }, diff --git a/services/api/src/services/admin/analytics-service.ts b/services/api/src/services/admin/analytics-service.ts index 99d7f743..63c68871 100644 --- a/services/api/src/services/admin/analytics-service.ts +++ b/services/api/src/services/admin/analytics-service.ts @@ -1,12 +1,8 @@ /** - * Admin analytics service (Phase 2): the 11 read-only aggregate endpoints (#56-#66). The repo returns RAW - * aggregates (counts, grouped rows, SQL-computed medians); this service does the pure shaping (pct, - * category fill, KPI deltas, funnel/coverage, week/month/cohort labels) the wire DTOs need. Shaping lives - * in analytics-shaping.ts (unit-tested); aggregate shapes + the repo seam in analytics-types.ts. + * The repo returns raw aggregates; the shaping the wire DTOs need lives in analytics-shaping.ts. * - * Reconciliation: by-category + resolution-by-category use the 6 real report categories (the design's - * "cleanup" is the Events domain, not a report category). "Avg. route time" is a Phase 3 (VRP) metric, - * reported as 0 until routing is deployed. + * The by-category charts use the report categories only: the design's "cleanup" belongs to the Events + * domain, not to the report categories. "Avg. route time" is reported as 0 until routing is deployed. */ import type { diff --git a/services/api/src/services/admin/analytics-shaping.ts b/services/api/src/services/admin/analytics-shaping.ts index 00c8e81f..4091b58c 100644 --- a/services/api/src/services/admin/analytics-shaping.ts +++ b/services/api/src/services/admin/analytics-shaping.ts @@ -88,8 +88,8 @@ export function buildKpis(agg: KpiAggregates): AnalyticsKpi[] { dir: deltaDir(agg.cleanupsPlanned.current, agg.cleanupsPlanned.previous), }, { - // Avg. route time is a Phase 3 (VRP) metric; reported as 0 until routing is deployed. The analytics - // page hides any label matching /route/i in its KPI strip, but the value is part of the contract. + // Reported as 0 until routing is deployed. The analytics page hides any label matching /route/i in + // its KPI strip, but the value is part of the contract. label: "Avg. route time", num: 0, delta: "Phase 3", @@ -156,7 +156,7 @@ export function buildCoverage(counts: CoverageCounts): AnalyticsCoverageResponse /** * Align the repo's sparse weekly buckets to a fixed `weeks`-length trailing window ending at `ref`, - * producing the value array + the design's labels ('', ..., 'last', 'now'). Pure (ref injected). + * producing the value array and the design's labels ('', ..., 'last', 'now'). */ export function buildPinsByWeek( buckets: WeekBucket[], diff --git a/services/api/src/services/admin/analytics-types.ts b/services/api/src/services/admin/analytics-types.ts index ac76c6b7..03984827 100644 --- a/services/api/src/services/admin/analytics-types.ts +++ b/services/api/src/services/admin/analytics-types.ts @@ -14,11 +14,9 @@ import type { ReportCategory, } from "@civfix/shared" -// The canonical report categories in canonical order — the dashboard's fixed bar series, so a category -// with no reports still renders a 0 bar. ONE derivation for the whole admin domain lives in -// category-counts.ts (ADMIN_CATEGORIES, from the contract enum); this alias keeps the analytics shaping -// reading in domain terms. A hand-listed copy here drifted out of sight of REPORT_CATEGORY_VALUES, and a -// category missing from this array is silently missing from every category chart. +// The dashboard's fixed bar series, so a category with no reports still renders a 0 bar. Derived from +// ADMIN_CATEGORIES (from the contract enum): a hand-listed copy drifted from REPORT_CATEGORY_VALUES, and a +// category missing here is silently missing from every category chart. export const ANALYTICS_CATEGORIES: readonly ReportCategory[] = ADMIN_CATEGORIES export const PINS_BY_WEEK_WEEKS = 8 @@ -113,10 +111,6 @@ export interface RetentionRow { activeByPeriod: number[] } -/** - * Persistence seam for analytics. The Drizzle impl runs the raw SQL aggregates; the offline tests pass an - * in-memory impl that returns canned aggregates so the SHAPING is unit-tested without a database. - */ export interface AnalyticsRepository { kpis(): Promise pinsByWeek(weeks: number): Promise diff --git a/services/api/src/services/admin/audit-repository.memory.ts b/services/api/src/services/admin/audit-repository.memory.ts index 7d065aa6..f2f1f29b 100644 --- a/services/api/src/services/admin/audit-repository.memory.ts +++ b/services/api/src/services/admin/audit-repository.memory.ts @@ -1,16 +1,6 @@ -/** - * In-memory AuditRepository for the offline audit-service unit tests (no DB, no Docker). - * - * Faithful to the Drizzle impl's observable behavior: newest-first ordering by (createdAt DESC, id DESC), - * the actor / action / target filters (actor matches actorId OR actorName; action/target are - * case-insensitive substring matches, mirroring the SQL ILIKE), and the shared "|" keyset - * cursor. seedRow appends rows; the public `rows` array is inspectable. - */ - import type { AuditRecord, AuditRepository, ListAuditArgs } from "./audit-service.js" import { decodeCursor, paginate, type CursorAnchor } from "./pagination.js" -/** Optional fields for a seeded audit row (sensible defaults fill the rest). */ export interface SeedAuditInput { id?: string actorId?: string | null @@ -22,12 +12,10 @@ export interface SeedAuditInput { } export class InMemoryAuditRepository implements AuditRepository { - /** All seeded rows (insertion order; the list() method sorts a copy). */ readonly rows: AuditRecord[] = [] private seq = 0 - /** Append an audit row. Unspecified fields get deterministic defaults. */ seedRow(input: SeedAuditInput = {}): AuditRecord { this.seq += 1 const record: AuditRecord = { @@ -60,7 +48,6 @@ export class InMemoryAuditRepository implements AuditRepository { return true }) - // Newest first (createdAt DESC, id DESC), then drop anything not strictly before the cursor anchor. const sorted = [...filtered].sort(compareDesc) const windowed = anchor !== null ? sorted.filter((r) => beforeAnchor(r, anchor)) : sorted @@ -72,14 +59,13 @@ export class InMemoryAuditRepository implements AuditRepository { } } -/** Sort comparator: newest first, id DESC as the tiebreak (matches the SQL ORDER BY). */ function compareDesc(a: AuditRecord, b: AuditRecord): number { const at = b.createdAt.getTime() - a.createdAt.getTime() if (at !== 0) return at return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 } -/** True when `record` sorts strictly after the anchor (i.e. belongs on a later page). */ +// Strictly older than the anchor in the DESC order, i.e. on a later page. function beforeAnchor(record: AuditRecord, anchor: CursorAnchor): boolean { const t = record.createdAt.getTime() const at = anchor.createdAt.getTime() diff --git a/services/api/src/services/admin/audit-service.ts b/services/api/src/services/admin/audit-service.ts index 4d087923..afacde79 100644 --- a/services/api/src/services/admin/audit-service.ts +++ b/services/api/src/services/admin/audit-service.ts @@ -1,21 +1,6 @@ -/** - * Admin audit-log view service (Phase 2): the read side of the audit_log table (#67). - * - * Every admin write records an audit_log row via writeAudit (services/admin/audit.ts). This service is - * the OPERATOR-facing reader: a paginated, filterable view of those rows (filter by actor / action / - * target). It is read-only (no mutations) and infra-free: all access flows through AuditRepository so the - * shaping (the row -> AuditLogEntryDTO projection, the actor-name resolution) is unit-testable with the - * in-memory repo (audit-repository.memory.ts) and the raw-SQL impl (audit-repository.drizzle.ts) is - * Docker-gated. - * - * Pagination reuses the shared keyset cursor (services/admin/pagination.ts): audit rows page newest-first - * by (created_at DESC, id DESC) with the opaque "|" cursor, identical to every other admin list. - */ - import type { AuditListQuery, AuditListResponse, AuditLogEntryDTO } from "@civfix/shared" import { clampLimit } from "./pagination.js" -/** Normalized list arguments the repo consumes (the filters + the page window). */ export interface ListAuditArgs { actor: string | null action: string | null @@ -24,13 +9,10 @@ export interface ListAuditArgs { limit: number } -/** - * An audit_log row projected into the service's record shape. `actorName` is the joined display name of - * the acting user (null for a system action or an unknown actor); the rest mirror the columns. - */ export interface AuditRecord { id: string actorId: string | null + /** Null for a system action or an unknown actor. */ actorName: string | null action: string target: string | null @@ -38,20 +20,12 @@ export interface AuditRecord { createdAt: Date } -/** - * Persistence seam for the audit-log view. The Drizzle impl runs one raw SQL read (LEFT JOIN users for - * the actor name) with a keyset cursor + the optional filters; the offline tests pass an in-memory impl. - */ export interface AuditRepository { - /** Page audit rows newest-first applying the actor / action / target filters with a keyset cursor. */ list(args: ListAuditArgs): Promise<{ records: AuditRecord[]; nextCursor: string | null }> } -/** - * Project an audit record into the strict AuditLogEntryDTO. `target` falls back to "" and `meta` to {} - * for the strict DTO's required fields. `createdAt` is `DEFAULT now()` (nullable in the DDL); a - * hand-inserted NULL row would otherwise throw on `.toISOString()` → 500 on the list, so guard it. - */ +// `created_at` is `DEFAULT now()` but nullable in the DDL: a hand-inserted NULL row would otherwise throw +// on `.toISOString()` and 500 the whole list. export function toAuditEntryDTO(record: AuditRecord): AuditLogEntryDTO { return { id: record.id, @@ -64,7 +38,6 @@ export function toAuditEntryDTO(record: AuditRecord): AuditLogEntryDTO { } } -/** Trim a query filter to null when absent/blank (so the repo only filters on a real value). */ function cleanFilter(value: string | undefined): string | null { if (value === undefined) return null const trimmed = value.trim() diff --git a/services/api/src/services/admin/category-counts.ts b/services/api/src/services/admin/category-counts.ts index 06013557..5b680741 100644 --- a/services/api/src/services/admin/category-counts.ts +++ b/services/api/src/services/admin/category-counts.ts @@ -1,33 +1,20 @@ -/** - * The per-category report-count aggregate shared by every "waiting reports by category" query: the - * outreach digest (outreach-repository.drizzle.ts), the jurisdiction directory + its unmapped bucket - * (jurisdiction-contacts-repository.drizzle.ts). All three used to hand-roll the identical seven - * `COUNT(*) FILTER (WHERE category = '…')` columns plus a matching seven-key parse block, so adding a - * ReportCategory took seven coordinated edits; everything here derives from ADMIN_CATEGORIES instead. - * - * The counts are cast to text in SQL on purpose: postgres.js surfaces int8 without a lossless numeric - * parser, so every count in this codebase crosses the wire as text and is parsed back here. - */ +// The counts are cast to text in SQL on purpose: postgres.js surfaces int8 without a lossless numeric +// parser, so every count in this codebase crosses the wire as text and is parsed back here. import type { Queryable } from "../../db/client.js" import { ReportCategorySchema, type ReportCategory } from "@civfix/shared" import type { SqlFragment } from "./sql-fragments.js" /** - * The canonical ReportCategory list in schema (display) order, derived from the shared zod enum so a new - * category cannot be half-added. Iteration order is load-bearing for the digest's contact-preference - * `array_position` ordering and for the directory's coverage label. + * Derived from the shared zod enum so a new category cannot be half-added. Iteration order is + * load-bearing for the digest's contact-preference `array_position` ordering and the directory's coverage + * label. */ export const ADMIN_CATEGORIES: readonly ReportCategory[] = ReportCategorySchema.options -/** The `cat_` text columns as selected back from SQL. */ export type CategoryCountRow = Partial> -/** - * The aggregate columns: one `COUNT(*) FILTER (…)::text AS cat_` per ReportCategory, filtered on - * `.category`. Belongs in a GROUP BY / LATERAL aggregate over `reports`; the caller owns the - * waiting predicate (the statuses differ between the digest and the directory). - */ +// The caller owns the waiting predicate: the statuses differ between the digest and the directory. export function categoryCountsFragment(sql: Queryable, alias: string): SqlFragment { return joinColumns( sql, @@ -38,10 +25,7 @@ export function categoryCountsFragment(sql: Queryable, alias: string): SqlFragme ) } -/** - * The outer projection over an aggregate produced by categoryCountsFragment: `COALESCE(.cat_x, '0') - * AS cat_x` per category, so a jurisdiction with no matching report still yields a parseable row. - */ +// COALESCE so a jurisdiction with no matching report still yields a parseable row. export function categoryCountsProjection(sql: Queryable, alias: string): SqlFragment { return joinColumns( sql, @@ -52,13 +36,12 @@ export function categoryCountsProjection(sql: Queryable, alias: string): SqlFrag ) } -/** Parse a text count column; absent/malformed reads as 0 (a missing aggregate row means "none"). */ +// A missing aggregate row means "none", so absent or malformed reads as 0. export function parseCount(value: string | null | undefined): number { const n = Number.parseInt(value ?? "0", 10) return Number.isNaN(n) ? 0 : n } -/** Turn the `cat_*` columns into the sparse per-category map the records carry (zeros are omitted). */ export function parseCategoryCounts( row: CategoryCountRow | undefined, ): Partial> { @@ -71,7 +54,6 @@ export function parseCategoryCounts( return counts } -/** Comma-join column fragments (no trailing comma for an empty list). */ function joinColumns(sql: Queryable, parts: SqlFragment[]): SqlFragment { let out = sql`` let first = true diff --git a/services/api/src/services/admin/discovery-jobs.ts b/services/api/src/services/admin/discovery-jobs.ts index c83e5aa0..6f808a09 100644 --- a/services/api/src/services/admin/discovery-jobs.ts +++ b/services/api/src/services/admin/discovery-jobs.ts @@ -1,12 +1,6 @@ -/** - * Registers the `jurisdiction.discovery` pg-boss worker that materializes a discovery TASK row so an - * un-onboarded jurisdiction surfaces in the operator's queue. - * - * The queue is created by PgBossJobs.start() (API_QUEUE_NAMES), which runs BEFORE this work() call, so - * work() never races a missing queue. The handler is idempotent (ON CONFLICT (geoid) WHERE status <> - * 'done') and never throws on a benign miss (unknown geoid / already-onboarded) — those are no-ops, so a - * retry-on-throw is safe. - */ +// PgBossJobs.start() creates the queue (API_QUEUE_NAMES) before this work() call, so work() never races a +// missing queue. The handler is idempotent (ON CONFLICT (geoid) WHERE status <> 'done') and treats an +// unknown or already-onboarded geoid as a no-op, so a retry-on-throw is safe. import type { Container } from "../../di.js" import { @@ -20,13 +14,12 @@ export async function registerDiscoveryJobs(container: Container): Promise await container.jobs.work(JURISDICTION_DISCOVERY_JOB, async (job) => { const data = (job.data ?? {}) as Partial const geoid = typeof data.geoid === "string" ? data.geoid : "" - if (geoid === "") return // Malformed payload: nothing to discover. + if (geoid === "") return const sql = container.getDb().sql - // Raced-onboarding skip: a contact may have been saved between the enqueue and this run, in which case - // the jurisdiction is already routable and there is nothing to discover. Only a contact that has not - // bounced counts: the bounce handler enqueues this job for the geoid whose contact it just marked. + // A contact may have been saved between the enqueue and this run. Only a contact that has not bounced + // counts: the bounce handler enqueues this job for the geoid whose contact it just marked. const contactRows = await sql<{ has_contact: boolean }[]>` SELECT EXISTS ( SELECT 1 FROM jurisdiction_contacts jc @@ -48,8 +41,6 @@ export async function registerDiscoveryJobs(container: Container): Promise ` if (contactRows[0]?.has_contact === true) return - // Materialize the open task (idempotent per geoid). population from the payload overrides the - // jurisdiction's own when present; the repo wires a newest waiting sample report for the detail map. const repo = makeDrizzleDiscoveryRepository(sql) await repo.materializeDiscoveryTask({ geoid, diff --git a/services/api/src/services/admin/discovery-repository.memory.ts b/services/api/src/services/admin/discovery-repository.memory.ts index e02a9da5..d1baab77 100644 --- a/services/api/src/services/admin/discovery-repository.memory.ts +++ b/services/api/src/services/admin/discovery-repository.memory.ts @@ -1,19 +1,5 @@ -/** - * In-memory DiscoveryRepository (Phase 2): the offline binding of the discovery persistence seam. - * - * Mirrors the Drizzle impl's OBSERVABLE contract so the discovery service can be unit-tested with NO - * database (no Docker), the same way InMemoryMailRepository backs the mail tests: - * - listTasks applies the search (place/id) + the attention/clear facet + the pop|reports sort and - * pages newest-id-keyset; - * - getDetail/getTask/listNotes read the seeded task + its contacts + notes; - * - addNote appends a note (the Drizzle impl persists it as an audit_log discovery.note_added row; - * here it is appended to the task's note list with the same observable result); - * - flagTask marks an open task in_progress (+ would open an abuse_flag in the Drizzle impl); - * - saveDraft upserts the per-category + default contacts WITHOUT routing. - * Seed/inspect helpers (seedTask, the public tasks/contacts/notes maps) let tests arrange + assert - * state directly. The waiting-report aggregates (perCategory/total/oldest/newest/sample pins) are seeded - * on the task record rather than recomputed, since the Drizzle impl derives them in SQL. - */ +// Mirrors the Drizzle repository's observable behavior. The waiting-report aggregates are seeded on the +// task record rather than recomputed, since the Drizzle repository derives them in SQL. import { randomUUID } from "node:crypto" import { pageInMemoryById } from "./pagination.js" @@ -30,7 +16,6 @@ import { } from "./discovery-service.js" import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" -/** A seeded discovery task plus its detail extras (contacts/geometry/pins) held in one place. */ export interface SeededDiscoveryTask { task: DiscoveryTaskRecord contacts: DiscoveryContactRecord[] @@ -40,16 +25,13 @@ export interface SeededDiscoveryTask { zoom: number | null } -/** An in-memory DiscoveryRepository faithful to the Drizzle impl's observable behavior. */ export class InMemoryDiscoveryRepository implements DiscoveryRepository { - /** Seeded tasks keyed by task id (insertion order preserved for stable paging). */ + /** Insertion order is preserved for stable paging. */ readonly tasks = new Map() - /** Notes keyed by task id, oldest first. */ readonly notes = new Map() - /** Citizen contact suggestions keyed by GEOID (not task id), oldest first. */ + /** Keyed by GEOID, not task id. */ readonly contactSuggestions = new Map() - /** Deterministic clock for note timestamps; each note advances by one millisecond. */ now = new Date(Date.UTC(2026, 0, 1, 0, 0, 0, 0)) private tick = 0 @@ -58,7 +40,6 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { return new Date(this.now.getTime() + this.tick) } - /** Seed a task. Defaults fill the aggregates so a test only sets what it asserts on. */ seedTask(input: { id?: string geoid: string @@ -110,10 +91,8 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { ): Promise<{ records: DiscoveryTaskRecord[]; nextCursor: string | null }> { let rows = [...this.tasks.values()].map((s) => s.task) - // Search: jurisdiction name OR geoid, case-insensitive — MIRRORS the SQL - // `ilikeAnyOf(sql, [j.name, t.geoid], q)` in discovery-repository.drizzle.ts. It used to search the - // TASK id instead of the geoid, which SQL never looks at: a `q` matching a task id found the row - // offline and nothing in production. + // Mirrors the SQL `ilikeAnyOf(sql, [j.name, t.geoid], q)`: the geoid, never the task id, which SQL + // does not search. if (args.q !== null) { const needle = args.q.toLowerCase() rows = rows.filter( @@ -121,15 +100,13 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { ) } - // Facet: attention = has a waiting report whose category lacks a contact; clear = otherwise. if (args.filter === "attention") { rows = rows.filter((r) => computeContactState(r).missing.length > 0) } else if (args.filter === "clear") { rows = rows.filter((r) => computeContactState(r).missing.length === 0) } - // Sort: pop (population desc) or reports (waiting total desc). Id is the stable tiebreak (desc) so - // the keyset cursor pages deterministically. + // The id tiebreak keeps the keyset cursor deterministic. rows.sort((a, b) => { const primary = args.sort === "reports" ? b.total - a.total : (b.population ?? 0) - (a.population ?? 0) @@ -137,14 +114,12 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 }) - // Keyset over a synthetic (sortValue, id) anchor encoded in the cursor's createdAt slot: the shared - // cursor helper carries a Date, so the sort value rides as epoch-ms and the format stays "|" - // compatible without a bespoke cursor — the same encoding the Drizzle repo's SQL keyset consumes. + // The shared cursor carries a Date, so the sort value rides in its createdAt slot as epoch-ms; the + // Drizzle repository's SQL keyset consumes the same encoding. const sortValue = (r: DiscoveryTaskRecord): number => args.sort === "reports" ? r.total : (r.population ?? 0) - // requireUuid=true: the Drizzle keyset casts the anchor to `${id}::uuid` and therefore DROPS a - // non-uuid anchor (degrading to page one). Without the same rule the offline twin would page from - // cursors production silently discards, which is exactly the drift a fake exists to catch. + // requireUuid: the Drizzle keyset casts the anchor to uuid and drops a non-uuid anchor (page one), so + // the fake must discard the same cursors production does. return pageInMemoryById( rows, args.cursor, @@ -171,7 +146,6 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { return [...(this.notes.get(id) ?? [])] } - /** Seed a citizen contact suggestion for a geoid (tests). Advances the deterministic clock. */ seedSuggestion( geoid: string, input: { email?: string | null; formUrl?: string | null; note?: string | null }, @@ -240,8 +214,7 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { geoid: string population?: number | null }): Promise { - // Mirror the Drizzle ON CONFLICT (geoid) WHERE status <> 'done' DO NOTHING: at most one OPEN task per - // geoid. If one already exists (status <> 'done'), do nothing and report false; else create an open task. + // Mirrors ON CONFLICT (geoid) WHERE status <> 'done' DO NOTHING: at most one open task per geoid. for (const seeded of this.tasks.values()) { if (seeded.task.geoid === input.geoid && seeded.task.status !== "done") return false } @@ -271,11 +244,7 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { } } -/** - * Apply a per-category contact map + default emails to a seeded task's contact list + contactCategories - * (shared by saveDraft here and the contacts service's in-memory save-and-route, so both repos mutate - * the seeded state identically). A null email clears that category. - */ +/** Shared with the in-memory contacts repository so both mutate the seeded state identically. */ export function upsertContacts( seeded: SeededDiscoveryTask, contacts: Partial>, @@ -288,7 +257,6 @@ export function upsertContacts( } seeded.contacts = [...byCategory.entries()].map(([category, email]) => ({ category, email })) - // contactCategories reflects categories with a non-null per-category email. seeded.task.contactCategories = seeded.contacts .filter((c) => c.email !== null && c.email.trim() !== "") .map((c) => c.category) diff --git a/services/api/src/services/admin/discovery-service.ts b/services/api/src/services/admin/discovery-service.ts index b171c458..44892358 100644 --- a/services/api/src/services/admin/discovery-service.ts +++ b/services/api/src/services/admin/discovery-service.ts @@ -1,30 +1,13 @@ -/** - * Admin discovery service (Phase 2): the "Jurisdictions" onboarding queue. - * - * Pins land in places civfix has no routing contact for yet. The operator researches the jurisdiction - * (by GEOID), saves a per-category routing contact, and reports start flowing. This service backs the - * list (population-sorted), the detail (per-category waiting counts + existing contacts + mini-map - * pins), and the note / flag / draft mutations. The "Save & route" action that actually persists - * contacts + routes pending pins lives in jurisdiction-contacts-service.ts (it shares the same repo - * concern of writing jurisdiction_contacts). See enumeration 2.B + endpoints #8-#12. - * - * REPOSITORY SEAM: every read/write goes through DiscoveryRepository (Drizzle impl in - * discovery-repository.drizzle.ts; an in-memory impl in discovery-repository.memory.ts for the offline - * unit tests), mirroring the Phase 1 report-service/report-repository split so the service is testable - * with no database and no Docker. - * - * WAITING-REPORT MODEL: a report counts as "waiting on contact" for a geoid when it is non-deleted and - * still open (status NOT IN ('rejected','resolved')) AND its jurisdiction has no usable routing contact. - * The same notion drives the discovery queue (a task is "needs attention" when it has waiting reports - * but a category is missing a contact). The repo computes the per-category waiting counts; this service - * projects them into the DTO shape + computes the relative-age / SLA labels (pure, clock-injected). - * - * NOTES STORAGE: jurisdiction_discovery_tasks has no notes column and the foundation schema is frozen - * (decisions: do NOT add a column). Operator notes are therefore persisted as audit_log rows with - * action "discovery.note_added" (target = "discovery:", meta.text + meta.who) and read back from - * audit_log into the notes[] list. This keeps notes in the same audited store the activity feed + audit - * view already read, with no schema change. See discovery-repository.drizzle.ts addNote/listNotes. - */ +// "Save & route", which persists contacts and routes pending pins, lives in +// jurisdiction-contacts-service.ts; this service backs the queue list, the detail and the note / flag / +// draft mutations. +// +// A report is "waiting on contact" for a geoid when it is non-deleted, still open (status NOT IN +// ('rejected','resolved')) and its jurisdiction has no usable routing contact. +// +// jurisdiction_discovery_tasks has no notes column, so operator notes are audit_log rows (action +// "discovery.note_added", target "discovery:", meta.text + meta.who). That keeps them in the +// audited store the activity feed and audit view already read; see discovery-repository.drizzle.ts. import { AppError, REPORT_CATEGORY_LABELS, relativeAgo } from "@civfix/shared" import { ADMIN_CATEGORIES } from "./category-counts.js" @@ -43,30 +26,18 @@ import type { } from "@civfix/shared" /** - * The canonical civfix report categories in display order. ONE derivation for the whole admin domain lives - * in category-counts.ts (ADMIN_CATEGORIES, from the contract enum that db/schema/types.ts mirrors under a - * drift test); this alias exists only so the discovery projections below keep reading in domain terms. A - * second hand-copied list is how a new category silently drops out of the projections that iterate it — - * per-category waiting counts, contact state, the dominant-category pin. + * An alias, never a hand-copied list: a second list is how a new category silently drops out of the + * per-category counts, the contact state and the dominant-category pin. */ const DISCOVERY_CATEGORIES = ADMIN_CATEGORIES -/** Discovery SLA: a task breaches when its oldest waiting report is older than this many hours. */ +/** A task breaches when its oldest waiting report is older than this. */ export const DISCOVERY_SLA_HOURS = 24 -/** - * A discovery task row joined with its jurisdiction, plus the derived waiting-report aggregates the - * queue needs. `perCategory` is the count of waiting reports per category for the geoid; `total` is - * their sum. `oldestWaitingAt` / `newestWaitingAt` are the oldest + newest waiting-report timestamps - * (null when there are none). `contactCategories` is the set of categories that have a routing contact - * (a category-specific jurisdiction_contacts row, OR any default/legacy contact when at least one - * default exists), used to compute the routed/missing contact state. - */ export interface DiscoveryTaskRecord { id: string geoid: string place: string - /** The jurisdiction layer/type (place|county|state|federal|tribal); drives the queue row type chip. */ layer: JurisdictionLayer population: number | null status: string @@ -74,37 +45,29 @@ export interface DiscoveryTaskRecord { total: number oldestWaitingAt: Date | null newestWaitingAt: Date | null - /** Categories with a usable routing contact (per-category override OR a present default/legacy). */ contactCategories: ReportCategory[] - /** Whether a default/all-categories or legacy contact is on file (drives the "all routed" fallback). */ hasDefaultContact: boolean } -/** An existing per-category routing contact (one email per category; null email = on file but blank). */ +/** A null email means the contact row is on file but blank. */ export interface DiscoveryContactRecord { category: ReportCategory email: string | null } -/** A waiting-report sample point for the jurisdiction mini-map. */ export interface DiscoverySamplePinRecord { category: ReportCategory lat: number lng: number } -/** A stored operator note (read back from audit_log rows of action discovery.note_added). */ export interface DiscoveryNoteRecord { text: string who: string createdAt: Date } -/** - * A citizen-suggested routing contact for a geoid (public POST /map/jurisdictions/:geoid/suggest-contact, - * stored as an audit_log `discovery.contact_suggested` row). Surfaced in the discovery detail as a - * "Reporter" note so the operator triages it alongside operator notes in the existing UI. - */ +/** Stored as an audit_log `discovery.contact_suggested` row by the public suggest-contact endpoint. */ export interface DiscoveryContactSuggestionRecord { email: string | null formUrl: string | null @@ -112,7 +75,6 @@ export interface DiscoveryContactSuggestionRecord { createdAt: Date } -/** The detail bundle: the task record + its existing contacts + geometry + sample pins. */ export interface DiscoveryDetailRecord { task: DiscoveryTaskRecord contacts: DiscoveryContactRecord[] @@ -122,12 +84,9 @@ export interface DiscoveryDetailRecord { zoom: number | null } -/** Filter facet for the list (mirrors the shared DiscoveryListQuery filter). */ export type DiscoveryFilter = "all" | "attention" | "clear" -/** Sort key for the list (mirrors the shared DiscoveryListQuery sort). */ export type DiscoverySort = "pop" | "reports" -/** Normalized list arguments the repo consumes (search + facet + sort + page window). */ export interface ListDiscoveryArgs { q: string | null filter: DiscoveryFilter @@ -136,50 +95,29 @@ export interface ListDiscoveryArgs { limit: number } -/** - * Persistence seam for the discovery domain. The Drizzle impl runs raw SQL (PostGIS for the sample - * pins); the offline tests pass an in-memory impl. Keeping every read/write here is what makes the - * service unit-testable with no DB. - */ export interface DiscoveryRepository { - /** - * Page the open discovery tasks (each joined with its jurisdiction + waiting aggregates), applying - * the search / facet filter and the sort, newest-id-keyset paged. Returns up to `limit` records plus - * the next cursor (null when exhausted). The repo is responsible for the filter + sort semantics so - * the service stays a pure projector. - */ + /** The repository owns the filter and sort semantics so the service stays a pure projector. */ listTasks( args: ListDiscoveryArgs, ): Promise<{ records: DiscoveryTaskRecord[]; nextCursor: string | null }> - /** Load one task's full detail by task id, or null when the task does not exist. */ getDetail(id: string): Promise - /** Load the notes for a task, oldest first. */ + /** Oldest first. */ listNotes(id: string): Promise - /** - * Load citizen contact suggestions for a geoid (public suggest-contact submissions), oldest first. - * Surfaced as "Reporter" notes in the detail so operators triage them in the existing discovery UI. - */ + /** Oldest first. */ listContactSuggestions(geoid: string): Promise - /** Load the bare task record (no contacts/geometry) by id, or null. Used to resolve geoid for writes. */ getTask(id: string): Promise - /** - * Append an operator note for a task (persisted as an audit_log discovery.note_added row). `who` is - * the operator display label stored in the note. Returns the stored note. - */ addNote( id: string, input: { text: string; actorId: string | null; who: string }, ): Promise /** - * Flag a task for review: open an abuse_flag against the triggering sample report (subject_type - * 'report') when one is on file, and mark the task status 'in_progress'. Returns false when the task - * does not exist (so the route can 404). + * Opens an abuse_flag against the task's sample report when one is on file and marks the task + * in_progress. False when the task does not exist. */ flagTask(id: string, input: { reason: string | null; actorId: string | null }): Promise /** - * Save contact drafts WITHOUT routing: upsert the per-category + default jurisdiction_contacts rows - * and the form URL, but do NOT touch contact_updated_at, route pending pins, or enqueue outreach. - * Returns false when the task does not exist. + * Upserts contacts and the form URL without routing: contact_updated_at, pending pins and outreach are + * left alone. False when the task does not exist. */ saveDraft( id: string, @@ -191,16 +129,12 @@ export interface DiscoveryRepository { }, ): Promise /** - * Materialize (idempotently) an OPEN discovery task for a geoid: the `jurisdiction.discovery` worker - * calls this when a report's jurisdiction has no usable contact, so the operator's Discovery queue shows - * the un-onboarded jurisdiction. Inserts one row per geoid (ON CONFLICT (geoid) WHERE status <> 'done' - * DO NOTHING), wiring the population + a sample report. Returns whether a NEW task row was created (false - * when an open task already existed or the jurisdiction is unknown). + * Idempotent: at most one open task per geoid (ON CONFLICT (geoid) WHERE status <> 'done' DO NOTHING). + * False when an open task already existed or the jurisdiction is unknown. */ materializeDiscoveryTask(input: { geoid: string; population?: number | null }): Promise } -/** Fill a per-category count map so every real category is present (0 when no waiting reports). */ export function fullPerCategoryCounts( partial: Partial>, ): PerCategoryCounts { @@ -211,14 +145,10 @@ export function fullPerCategoryCounts( return out } -/** - * The dominant waiting category (the one with the most waiting reports; ties broken by the canonical - * category order). Falls back to "other" when there are no waiting reports, so the row always has a pin. - */ +/** Ties go to the canonical category order; "other" when nothing waits, so the row always has a pin. */ export function dominantCategory(partial: Partial>): ReportCategory { let best: ReportCategory = "other" - // Start at 0 so a category only becomes dominant when it has at least one waiting report; an all-zero - // map (no waiting reports) keeps the neutral "other" default rather than the first category. + // Starting at 0 keeps an all-zero map on "other" rather than the first category. let bestCount = 0 for (const category of DISCOVERY_CATEGORIES) { const count = partial[category] ?? 0 @@ -231,11 +161,8 @@ export function dominantCategory(partial: Partial } /** - * Compute the routed-vs-missing contact state for a task. A category is "routed" when it has its own - * per-category contact OR (a default/legacy contact exists). EVERY category with waiting reports that is - * not routed is "missing"; categories with no waiting reports and no contact are neither (they do not - * demand attention). This matches the design's "any report-type has reports waiting but no contact" - * attention predicate. + * Categories with no waiting reports and no contact are neither routed nor missing: the attention + * predicate is "some category has reports waiting but no contact". */ export function computeContactState(record: DiscoveryTaskRecord): { routed: ReportCategory[] @@ -248,7 +175,6 @@ export function computeContactState(record: DiscoveryTaskRecord): { const waiting = (record.perCategory[category] ?? 0) > 0 const hasContact = routedSet.has(category) || record.hasDefaultContact if (hasContact) { - // Only surface a routed category when it is relevant (has waiting reports OR an explicit contact). if (waiting || routedSet.has(category)) routed.push(category) } else if (waiting) { missing.push(category) @@ -257,7 +183,6 @@ export function computeContactState(record: DiscoveryTaskRecord): { return { routed, missing } } -/** Whether the oldest waiting report breaches the discovery SLA as of `now`. */ export function isOverSla(oldestWaitingAt: Date | null, now: Date): boolean { if (oldestWaitingAt === null) return false const ageMs = now.getTime() - oldestWaitingAt.getTime() @@ -265,10 +190,8 @@ export function isOverSla(oldestWaitingAt: Date | null, now: Date): boolean { } /** - * Derive the urgency band (low|med|high) shown on a queue row. The design's `priority` is an urgency - * band, NOT the jurisdictions.priority layer ordinal (place !!v && v.trim() !== "") @@ -288,14 +206,13 @@ export function suggestionToNote(s: DiscoveryContactSuggestionRecord): Discovery const head = `Suggested contact: ${contact || "(none provided)"}` return { who: "Reporter", - text: s.note && s.note.trim() !== "" ? `${head} — ${s.note.trim()}` : head, + text: s.note && s.note.trim() !== "" ? `${head} (note: ${s.note.trim()})` : head, createdAt: s.createdAt, } } export interface DiscoveryServiceDeps { repo: DiscoveryRepository - /** Injectable clock (defaults to Date.now) so the SLA + relative-age labels are deterministic. */ now?: () => Date } @@ -318,12 +235,10 @@ export interface DiscoveryService { export function makeDiscoveryService(deps: DiscoveryServiceDeps): DiscoveryService { const now = deps.now ?? (() => new Date()) - /** Project a stored note record into the wire DTO (relative "when" label). */ function toNoteDTO(record: DiscoveryNoteRecord, ref: Date): DiscoveryNote { return { text: record.text, who: record.who, when: relativeAgo(record.createdAt, ref) } } - /** Project a task record (+ its notes) into the list/detail base DTO. */ function toTaskDTO( record: DiscoveryTaskRecord, notes: DiscoveryNoteRecord[], @@ -360,8 +275,8 @@ export function makeDiscoveryService(deps: DiscoveryServiceDeps): DiscoveryServi limit: query.limit ?? 25, } const { records, nextCursor } = await deps.repo.listTasks(args) - // The list rows do not render notes inline (only the detail does), so the list returns an empty - // notes[] per row rather than fanning out a note read per task (the DTO requires the field). + // Only the detail renders notes, so list rows carry an empty notes[] rather than a note read + // per task. const items = records.map((record) => toTaskDTO(record, [], ref)) return { items, nextCursor } }, @@ -371,8 +286,6 @@ export function makeDiscoveryService(deps: DiscoveryServiceDeps): DiscoveryServi const [detail, notes] = await Promise.all([deps.repo.getDetail(id), deps.repo.listNotes(id)]) if (!detail) throw AppError.notFound("Discovery task not found") - // Merge citizen contact suggestions (keyed by geoid) into the note stream as "Reporter" notes so - // operators see them inline in the existing discovery detail UI, interleaved oldest-first. const suggestions = await deps.repo.listContactSuggestions(detail.task.geoid) const merged = [...notes, ...suggestions.map(suggestionToNote)].sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), @@ -387,7 +300,7 @@ export function makeDiscoveryService(deps: DiscoveryServiceDeps): DiscoveryServi category: p.category, lat: p.lat, lng: p.lng, - // Pins are real waiting reports (not operator-drafted markers), so draft is always false. + // Pins are real waiting reports, never operator-drafted markers. draft: false, })) return { diff --git a/services/api/src/services/admin/event-status.ts b/services/api/src/services/admin/event-status.ts index 3d941f04..1147306c 100644 --- a/services/api/src/services/admin/event-status.ts +++ b/services/api/src/services/admin/event-status.ts @@ -1,17 +1,8 @@ /** - * Event status mapping: the one-way bridge from the DERIVED public CleanupStatus (0.46.0 — computed from - * scheduled_at/ends_at by `cleanupStatusExpr`, never read from the stored column except for 'cancelled') - * to the admin EventStatus wire enum. - * - * The map stays TOLERANT of every historical value so a legacy row, or a row an earlier admin build wrote - * a Phase-2 value into, still resolves: - * active|in_progress -> in_progress - * done|completed -> completed - * cancelled -> cancelled - * upcoming|anything else -> upcoming - * - * There is no wire -> stored direction any more: status is a clock reading, and the only admin write is - * Cancel (`adminEventStatusExpr` filters on the same derivation the reads project). + * Status is derived from scheduled_at/ends_at (the stored column is read only for 'cancelled'), so this + * maps one way, to the admin wire enum. It accepts both the public (active/done) and admin + * (in_progress/completed) vocabularies so a legacy row still resolves. There is no wire -> stored + * direction: status is a clock reading, and the only admin write is Cancel. */ import type { EventStatus } from "@civfix/shared" diff --git a/services/api/src/services/admin/gov-claims-repository.drizzle.ts b/services/api/src/services/admin/gov-claims-repository.drizzle.ts index a341e535..1055272c 100644 --- a/services/api/src/services/admin/gov-claims-repository.drizzle.ts +++ b/services/api/src/services/admin/gov-claims-repository.drizzle.ts @@ -1,24 +1,7 @@ -/** - * Postgres-backed GovClaimsRepository (Phase 2): the production binding of the gov-provisioning seam. - * - * Written against the raw postgres-js tag (`Sql`) for the jsonb `checks` merge (jsonb_set / ||) and so - * the approve/reject transitions write the claim row + the audit atomically in one transaction. Reads/ - * writes touch only gov_claims (owned) + audit_log (via writeAudit). The USER provisioning + role grant - * are NOT here - they happen in the service via the UserProvisioner seam (the Phase 1 UserStore) before - * approve() persists the claim transition + the user link, keeping the user-store concern out of this - * raw-SQL repo. - * - * VERIFY (setCheck): merges one check object into the `checks` jsonb under its key, stamping `at` with - * now(). Uses `checks || jsonb_build_object(key, value)` so the other checks are preserved. - * - * APPROVE: gov_claims.status='approved', user_id=, decided_at/by, WHERE status='pending' - * (0 rows when already decided -> null -> the service surfaces a conflict). The approved claim row IS the - * user<->jurisdiction binding (the claim already carries jurisdiction_geoid); there is no separate - * membership table. Audit gov_claim.approved. - * - * REJECT: gov_claims.status='rejected', reject_reason, decided_at/by, WHERE status='pending'. Audit - * gov_claim.rejected. - */ +// Each transition writes the claim row and its audit row in one transaction. User provisioning and the +// role grant stay in the service (UserProvisioner seam), out of this raw-SQL repository. The approved +// claim row IS the user<->jurisdiction binding (it carries jurisdiction_geoid); there is no separate +// membership table. import type { Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" @@ -38,7 +21,6 @@ import { } from "./gov-claims-service.js" import type { GovCheckStatus, GovMethod, GovVerificationCheck } from "@civfix/shared" -/** A gov_claims row (snake_case columns) as read from Postgres. */ interface GovClaimRow { id: string user_id: string | null @@ -54,21 +36,18 @@ interface GovClaimRow { created_at: Date } -/** The valid check keys, used to narrow the parsed jsonb keys to the typed union. */ const CHECK_KEYS: readonly string[] = ["linkedin", "directory", "callback"] -/** The valid check-status values; an unknown/widened status reads back as 'pending' (never coerced). */ const CHECK_STATUSES: readonly GovCheckStatus[] = ["verified", "pending"] -/** Parse the `checks` jsonb into the typed per-check map, dropping unknown keys / malformed entries. */ function parseChecks(raw: unknown): Partial> { const out: Partial> = {} if (!raw || typeof raw !== "object") return out for (const [key, value] of Object.entries(raw as Record)) { if (!CHECK_KEYS.includes(key) || !value || typeof value !== "object") continue const v = value as Record - // Validate against the real union (not "verified else pending") so a future widened status is not - // silently downgraded to verified/pending on read; an unrecognized value defaults to 'pending'. + // Checked against the real union, not "verified else pending", so an unrecognized status can never + // read back as verified. const status: GovCheckStatus = CHECK_STATUSES.includes(v.status as GovCheckStatus) ? (v.status as GovCheckStatus) : "pending" @@ -81,7 +60,6 @@ function parseChecks(raw: unknown): Partial { return sql.begin(async (tx) => { - // Merge one check into the checks jsonb (preserving the others) and stamp `at`. const checkValue = { status: input.status, evidence: input.evidence, note: input.note, at: new Date().toISOString(), } - // WHERE status='pending' so an operator cannot mutate the checks of an already-decided claim (and - // write a spurious gov_claim.verified audit); a non-pending claim returns 0 rows -> 404/no-op. + // A decided claim's checks are immutable, which also keeps a spurious gov_claim.verified audit out. const rows = await tx` UPDATE gov_claims SET checks = COALESCE(checks, '{}'::jsonb) || jsonb_build_object( diff --git a/services/api/src/services/admin/gov-claims-repository.memory.ts b/services/api/src/services/admin/gov-claims-repository.memory.ts index 5495cfd9..93827544 100644 --- a/services/api/src/services/admin/gov-claims-repository.memory.ts +++ b/services/api/src/services/admin/gov-claims-repository.memory.ts @@ -1,19 +1,4 @@ -/** - * In-memory GovClaimsRepository (Phase 2): the offline binding of the gov-provisioning persistence seam. - * - * Mirrors the Drizzle impl's OBSERVABLE contract so the gov-claims service can be unit-tested with NO - * database (no Docker): - * - list pages the claims matching the status facet ("all" applies no status narrowing) + the search - * (name/org), ordered by createdAt in the requested direction with an id tiebreak; - * - getClaim returns the seeded record at any status; - * - setCheck writes one check into the record's checks map (any status); - * - approve links the user + sets status='approved' (only when pending); - * - reject sets status='rejected' + reject_reason (only when pending). - * Seed/inspect helpers (seedClaim, the public claims map) let tests arrange + assert state directly. - * - * Pairs with InMemoryUserProvisioner (below): a tiny find-or-create + setRole fake matching the - * UserProvisioner seam, so an approve test can assert the user was created with role gov_admin. - */ +// Mirrors the Drizzle repository's observable behavior. import { randomUUID } from "node:crypto" import { pageInMemoryById } from "./pagination.js" @@ -27,12 +12,10 @@ import { } from "./gov-claims-service.js" import type { GovCheckStatus, GovVerificationCheck, Role } from "@civfix/shared" -/** An in-memory GovClaimsRepository faithful to the Drizzle impl's observable behavior. */ export class InMemoryGovClaimsRepository implements GovClaimsRepository { - /** Seeded claims keyed by id (insertion order preserved for stable paging). */ + /** Insertion order is preserved for stable paging. */ readonly claims = new Map() - /** Deterministic clock; each seeded claim advances by one millisecond for stable ordering. */ now = new Date(Date.UTC(2026, 0, 1, 0, 0, 0, 0)) private tick = 0 @@ -41,7 +24,6 @@ export class InMemoryGovClaimsRepository implements GovClaimsRepository { return new Date(this.now.getTime() + this.tick) } - /** Seed a claim. Defaults fill the fields so a test only sets what it asserts on. */ seedClaim(input: Partial & { id?: string; name?: string }): GovClaimRecord { const id = input.id ?? randomUUID() const record: GovClaimRecord = { @@ -71,7 +53,6 @@ export class InMemoryGovClaimsRepository implements GovClaimsRepository { rows = rows.filter((r) => r.status === args.filter) } - // Search: name OR org, case-insensitive. if (args.q !== null) { const needle = args.q.toLowerCase() rows = rows.filter( @@ -80,8 +61,8 @@ export class InMemoryGovClaimsRepository implements GovClaimsRepository { ) } - // createdAt in the requested direction; id is the stable tiebreak in the SAME direction the Drizzle - // twin's (created_at, id) keyset uses, so a page boundary lands identically. + // The id tiebreak runs in the same direction as the Drizzle (created_at, id) keyset, so a page + // boundary lands identically. const dir = args.sort === "oldest" ? 1 : -1 rows.sort((a, b) => { const primary = (a.createdAt.getTime() - b.createdAt.getTime()) * dir @@ -110,7 +91,7 @@ export class InMemoryGovClaimsRepository implements GovClaimsRepository { }, ): Promise { const claim = this.claims.get(id) - // Mirror the Drizzle WHERE status='pending' guard: a decided claim's checks are immutable (null/404). + // Mirrors the Drizzle WHERE status='pending' guard: a decided claim's checks are immutable. if (!claim || claim.status !== "pending") return null const check: GovCheckRecord = { status: input.status, @@ -144,17 +125,14 @@ export class InMemoryGovClaimsRepository implements GovClaimsRepository { } } -/** - * In-memory UserProvisioner: a find-or-create-by-email + idempotent setRole fake matching the seam the - * gov approve flow depends on. Mirrors the observable behavior of the Phase 1 UserStore slice (email - * matched case-insensitively, like the CITEXT users.email column). - */ +/** Email is matched case-insensitively, like the CITEXT users.email column. */ export class InMemoryUserProvisioner implements UserProvisioner { readonly users = new Map() - /** Seed an existing user (e.g. to test the find branch of approve). Defaults to a VERIFIED email so the - * common "approve an existing account" test still elevates; pass emailVerified:false to exercise the - * unverified-rejection guard. */ + /** + * Defaults to a verified email so the common "approve an existing account" test still elevates; pass + * emailVerified:false to exercise the unverified-rejection guard. + */ seedUser(input: { id?: string email: string diff --git a/services/api/src/services/admin/gov-claims-service.ts b/services/api/src/services/admin/gov-claims-service.ts index 4ce94b07..e8bbfd6b 100644 --- a/services/api/src/services/admin/gov-claims-service.ts +++ b/services/api/src/services/admin/gov-claims-service.ts @@ -1,29 +1,6 @@ -/** - * Admin gov-provisioning service (Phase 2): the government onboarding queue. - * - * An applicant claims authority over a jurisdiction. The operator verifies up to three checks (LinkedIn - * profile, municipal Directory listing, phone Callback), then either APPROVES (provision the gov user as - * `gov_admin` and link the jurisdiction) or REJECTS (with a reason). The queue is fed by `gov_claims` - * (pending rows). See enumeration 2.H + endpoints #36-#40. - * - * REPOSITORY SEAM: every read/write goes through GovClaimsRepository (Drizzle impl in - * gov-claims-repository.drizzle.ts; an in-memory impl in gov-claims-repository.memory.ts for the offline - * unit tests). The DTO PROJECTIONS (relative age, the verified[]/pending[] partition of the checks map) - * live here and are pure + clock-injected. - * - * VERIFY: writes one check into the `checks` jsonb ({ linkedin|directory|callback: { status, evidence?, - * note?, at } }). The verified[]/pending[] arrays in the DTO are DERIVED from that map (a check is - * "verified" when its status is 'verified'; "pending" when its status is 'pending'; absent checks are - * pending by default), so the row pills always reflect the stored map. - * - * APPROVE: finds-or-creates the gov user by the claim's contact_email (via the UserProvisioner seam, a - * tiny slice of the Phase 1 UserStore), sets that user's role to `gov_admin` (idempotent setRole), links - * it to the claim (gov_claims.user_id) and the jurisdiction (gov_claims.jurisdiction_geoid is the link; - * there is no separate membership table - the approved claim row IS the user<->jurisdiction binding), - * and sets status='approved' + decided_at/by. Audited gov_claim.approved. - * - * REJECT: sets status='rejected' + reject_reason + decided_at/by. Audited gov_claim.rejected. - */ +// The DTO's verified[]/pending[] arrays are derived from the stored `checks` map (an absent check is +// pending), so the row pills always reflect it. The approved claim row IS the user<->jurisdiction binding +// (gov_claims.jurisdiction_geoid); there is no separate membership table. import { AppError, relativeAgo } from "@civfix/shared" import { applyRoleChange, type RevokeAllSessions } from "./role-change.js" @@ -42,23 +19,20 @@ import type { } from "@civfix/shared" import { clampLimit } from "./pagination.js" -/** The three verification checks in display order. */ +/** Display order. */ export const GOV_CHECKS: readonly GovVerificationCheck[] = ["linkedin", "directory", "callback"] -/** The role granted to an approved gov claim's user. */ export const GOV_ADMIN_ROLE: Role = "gov_admin" -/** The console-untouchable role (H3): approving a claim must never re-role an operator account. */ +/** Approving a claim must never re-role an operator account. */ const OPERATOR_ROLE: Role = "operator" -/** The stored per-check state (status + optional evidence + note). */ export interface GovCheckRecord { status: GovCheckStatus evidence: string | null note: string | null } -/** A gov_claims row projected into the service record. `checks` is the parsed jsonb map. */ export interface GovClaimRecord { id: string userId: string | null @@ -74,7 +48,6 @@ export interface GovClaimRecord { createdAt: Date } -/** Filter facet for the list (mirrors the shared GovClaimListQuery filter). "all" applies no status clause. */ export type GovClaimFilter = "all" | GovClaimStatus export type GovClaimSort = "newest" | "oldest" @@ -83,7 +56,6 @@ export function parseGovClaimSort(sort: string | undefined): GovClaimSort { return sort === "oldest" ? "oldest" : "newest" } -/** Normalized list arguments the repo consumes (search + facet + direction + page window). */ export interface ListGovClaimsArgs { q: string | null filter: GovClaimFilter @@ -92,16 +64,11 @@ export interface ListGovClaimsArgs { limit: number } -/** - * The minimal user-provisioning slice the gov approve flow needs (a subset of the Phase 1 UserStore). - * Injected so the service is unit-testable with an in-memory fake. `findByEmail` + `create` are the - * find-or-create; `setRole` is the idempotent role grant. - */ export interface ProvisionedUser { id: string email: string | null role: string - /** Whether the account's owner has proven control of `email` (e.g. via Email-OTP / verified OAuth). */ + /** The owner has proven control of `email` (Email-OTP or verified OAuth). */ emailVerified: boolean } @@ -111,22 +78,10 @@ export interface UserProvisioner { setRole(id: string, role: Role): Promise } -/** - * Persistence seam for the gov-provisioning domain. The Drizzle impl runs raw SQL; the offline tests - * pass an in-memory impl. Action methods return a small result so the service can decide the 404 / audit. - */ export interface GovClaimsRepository { - /** - * Page the claims matching the status facet ("all" = every status) + the search, keyset paged in the - * requested direction. - */ list(args: ListGovClaimsArgs): Promise<{ records: GovClaimRecord[]; nextCursor: string | null }> - /** Load one claim by id (any status), or null when it does not exist. */ getClaim(id: string): Promise - /** - * Write one verification check into the claim's `checks` jsonb. Returns the updated record, or null - * when the claim does not exist. - */ + /** Null when the claim does not exist or is no longer pending. */ setCheck( id: string, input: { @@ -138,26 +93,20 @@ export interface GovClaimsRepository { }, ): Promise /** - * Approve the claim: link the provisioned user (gov_claims.user_id), set status='approved' + - * decided_at/by. The user provisioning + role grant happen in the SERVICE (via UserProvisioner) before - * this call so the repo only persists the claim transition + the user link. Returns the updated - * record, or null when the claim does not exist / is not pending. + * Persists only the claim transition and the user link; provisioning and the role grant belong to the + * service. Null when the claim does not exist or is not pending. */ approve( id: string, input: { userId: string; actorId: string | null; note: string | null }, ): Promise - /** - * Reject the claim: set status='rejected' + reject_reason + decided_at/by. Returns the updated record, - * or null when the claim does not exist / is not pending. - */ + /** Null when the claim does not exist or is not pending. */ reject( id: string, input: { reason: string; actorId: string | null }, ): Promise } -/** Project the stored checks map into the full strict GovChecks DTO (absent checks default pending). */ export function toChecksDTO( checks: Partial>, ): GovChecks { @@ -172,14 +121,12 @@ export function toChecksDTO( return { linkedin: one("linkedin"), directory: one("directory"), callback: one("callback") } } -/** The checks whose status is 'verified', in display order. */ export function verifiedChecks( checks: Partial>, ): GovVerificationCheck[] { return GOV_CHECKS.filter((c) => checks[c]?.status === "verified") } -/** The checks still pending (status 'pending' OR absent), in display order. */ export function pendingChecks( checks: Partial>, ): GovVerificationCheck[] { @@ -190,20 +137,16 @@ export interface GovClaimsServiceDeps { repo: GovClaimsRepository users: UserProvisioner /** - * M4: revoke every live session of the user whose role just changed. REQUIRED — a warm session serves the - * role baked into its Redis projection, and sliding expiry means it can do so indefinitely. See - * services/admin/role-change.ts. + * Required: a warm session serves the role baked into its Redis projection, and sliding expiry lets it + * do so indefinitely. See role-change.ts. */ revokeSessions: RevokeAllSessions - /** Injectable clock (defaults to Date.now) so the relative-age labels are deterministic. */ now?: () => Date } /** - * `actorId` is NON-NULL on verify/approve/reject: every caller is an operator-guarded gov route, where - * `requireOperator(request)` returns a `string` or throws, and each of the three writes an audit row for a - * privilege decision that must be attributable. The REPOSITORY interface above keeps its nullable slot — - * it is the generic persistence seam and `writeAudit` accepts a system actor. + * `actorId` is non-null here because each of these writes an audit row for a privilege decision that must + * be attributable. The repository keeps a nullable slot because `writeAudit` accepts a system actor. */ export interface GovClaimsService { list(query: GovClaimListQuery): Promise @@ -225,13 +168,11 @@ export interface GovClaimsService { export function makeGovClaimsService(deps: GovClaimsServiceDeps): GovClaimsService { const now = deps.now ?? (() => new Date()) - /** Project a claim record into the wire DTO (relative age + derived verified/pending arrays). */ function toDTO(record: GovClaimRecord, ref: Date): GovClaimDTO { return { id: record.id, name: record.name, - // The strict DTO requires non-null title/org/contactEmail (the design always renders them); a - // claim with a missing field degrades to an empty string so the contract still validates. + // The strict DTO requires non-null title/org/contactEmail, so a missing field degrades to "". title: record.title ?? "", org: record.org ?? "", jurisdictionGeoid: record.jurisdictionGeoid, @@ -278,9 +219,8 @@ export function makeGovClaimsService(deps: GovClaimsServiceDeps): GovClaimsServi ): Promise { const updated = await deps.repo.setCheck(id, input) if (!updated) { - // setCheck's `WHERE status='pending'` returns null for BOTH a missing claim and a decided one, so - // re-read to tell them apart (mirrors reject()). An operator racing a colleague's decision was being - // told the claim does not exist. + // setCheck returns null for both a missing claim and a decided one; re-read so an operator racing + // a colleague's decision gets a conflict, not "not found". const claim = await deps.repo.getClaim(id) if (!claim) throw AppError.notFound("Gov claim not found") throw AppError.conflict("Gov claim is not pending") @@ -301,36 +241,32 @@ export function makeGovClaimsService(deps: GovClaimsServiceDeps): GovClaimsServi ) } - // M2: do the privilege grant ONLY AFTER the claim transition durably commits, so a concurrent - // decision (approve returns null -> 409) or a failed transition never leaves a gov_admin elevation - // with no approved claim to justify it (decisions 3.2: the approved claim row IS the binding). - // Find-or-create the user first (no privilege implication - this neither elevates nor is the grant), - // then persist the claim transition + user<->jurisdiction link + audit (repo.approve, one tx). + // The privilege grant happens only after the claim transition commits, so a concurrent decision or + // a failed transition never leaves a gov_admin elevation with no approved claim to justify it. + // Find-or-create first: it carries no privilege. let user = await deps.users.findByEmail(email) if (user) { - // SECURITY (privilege escalation): a claim's contact_email is unverified free text typed into the - // form. Granting gov_admin to a PRE-EXISTING account whose email is not verified would let an - // operator (or a duped/compromised operator) elevate an ARBITRARY victim account by entering its - // address. Only elevate a pre-existing account whose owner has demonstrably controlled the address. + // Security: contact_email is unverified free text. Elevating a pre-existing account whose email + // is not verified would let a duped or compromised operator elevate an arbitrary victim account + // by entering its address. if (!user.emailVerified) { throw AppError.validation( { contactEmail: "unverified" }, "Cannot elevate an existing account whose email is not verified", ) } - // H3 (same rule the users console enforces in setRole): an OPERATOR account is not changeable from - // the console. Approving a claim whose contact_email happens to be an operator's address would - // demote them to gov_admin and revoke every one of their sessions — one operator stripping another, - // through the gov queue instead of the role endpoint. Operator authority is governed by ADMIN_EMAILS - // + Cloudflare Access; if the applicant really is that person, they need a separate address. + // Same rule as the users console's setRole: an operator account is not changeable from the + // console. Otherwise a claim carrying an operator's address would demote them and revoke their + // sessions, one operator stripping another through the gov queue. Operator authority is governed + // by ADMIN_EMAILS + Cloudflare Access. if (user.role === OPERATOR_ROLE) { throw AppError.forbidden( "Operator accounts are managed through ADMIN_EMAILS; they cannot be changed from the console.", ) } } else { - // No account yet: create a placeholder with an UNverified email. It has no sessions and only - // becomes usable when the real owner signs in via Email-OTP, which proves control of the address. + // The placeholder's email is unverified: it has no sessions and only becomes usable when the real + // owner signs in via Email-OTP, which proves control of the address. user = await deps.users.create(email, newAccountDisplayName(claim.name, "citizen")) } @@ -340,20 +276,14 @@ export function makeGovClaimsService(deps: GovClaimsServiceDeps): GovClaimsServi note: input.note, }) if (!updated) { - // The claim moved out of pending between the check and the write (a concurrent decision). The user - // has NOT been elevated, so there is nothing to compensate. + // A concurrent decision won. The user has not been elevated, so there is nothing to compensate. throw AppError.conflict("Gov claim is not pending") } - // The approved claim is committed; now grant gov_admin (idempotent). A failure here surfaces (500) - // with the claim already approved + the link recorded, which is the recoverable direction (an - // operator can re-approve or the role can be re-granted) - the unrecoverable orphan-elevation the - // review flagged (granted role, no claim) can no longer happen. - // M4: the grant goes through applyRoleChange, which ALWAYS revokes the user's sessions after the - // write. The escalation direction needs it as much as a demotion would: the account's live sessions - // carry the OLD role in the Redis session projection, and sliding expiry pushes their expiry out - // indefinitely, so without the revoke the new gov_admin keeps browsing as a plain citizen. (The - // operator-demotion case this guarded is now refused outright above.) + // A failure from here on leaves the claim approved without the role, the recoverable direction: + // the role can be re-granted. applyRoleChange always revokes the user's sessions, which escalation + // needs too: live sessions carry the old role in the Redis projection and sliding expiry keeps them + // alive indefinitely. if (user.role !== GOV_ADMIN_ROLE) { const target = user await applyRoleChange( @@ -372,7 +302,6 @@ export function makeGovClaimsService(deps: GovClaimsServiceDeps): GovClaimsServi async reject(id: string, input: { reason: string; actorId: string }): Promise { const updated = await deps.repo.reject(id, input) if (!updated) { - // Distinguish missing from already-decided so the operator gets an accurate error. const claim = await deps.repo.getClaim(id) if (!claim) throw AppError.notFound("Gov claim not found") throw AppError.conflict("Gov claim is not pending") diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts index fdc0c3c3..866c15bc 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts @@ -360,15 +360,10 @@ export function makeDrizzleJurisdictionContactsRepository( WHERE r2.jurisdiction_geoid = j.geoid AND rt.status = 'acknowledged' ) AS last_routed_at, ( - -- A contact is 'bounced' when ANY of the geoid's contact rows has a bounce marker (the inbound - -- bounce handler stamps jurisdiction_contacts.bounced_at; this takes precedence over - -- verified/pending). The legacy mail_events signal is OR'd in for threads with no per-contact - -- row (e.g. a digest-only bounce), so an existing bounce never silently disappears. - -- - -- That fallback is scoped to events NEWER than the last contact save: mail_events rows are never - -- deleted, so an unscoped EXISTS pinned the directory to 'bounced' forever — re-entering a good - -- address cleared bounced_at (and cleared the flag in the in-memory repo, which is what the - -- offline tests asserted) yet the row still read bounced in production. + -- The legacy mail_events signal is OR'd in for threads with no per-contact row (a digest-only + -- bounce), so an existing bounce never silently disappears. It is scoped to events newer than + -- the last contact save: mail_events rows are never deleted, so an unscoped EXISTS would pin the + -- row to 'bounced' forever even after a good address is re-entered. EXISTS ( SELECT 1 FROM jurisdiction_contacts bc WHERE bc.geoid = j.geoid AND bc.bounced_at IS NOT NULL diff --git a/services/api/src/services/admin/jurisdiction-contacts-service.ts b/services/api/src/services/admin/jurisdiction-contacts-service.ts index e547c819..0b4cd100 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-service.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-service.ts @@ -1,17 +1,5 @@ -/** - * Admin jurisdiction-contacts service (Phase 2): the "Save & route" core action + the jurisdictions - * directory. - * - * "Save & route" persists a per-category routing contact for a GEOID (upserts jurisdiction_contacts + - * mirrors the legacy contact_emails[]/report_form_url), sets contact_updated_at, marks the geoid's open - * discovery task done, and enqueues throttled outreach when the digest is enabled. It does NOT touch the - * geoid's reports: nothing is mailed per report here, so nothing is marked routed. Audit is the in-tx - * repo's responsibility (H4); the service returns the outcome so the route can audit + the test can - * assert. See endpoints #13/#14/#15. - * - * The pure types live in jurisdiction-contacts-types.ts and the pure directory projectors in - * jurisdiction-directory-projection.ts; this module re-exports both so external importers keep resolving. - */ +// "Save & route" does not touch the geoid's reports: nothing is mailed per report here, so nothing is +// marked routed. The types and directory projectors are re-exported so external importers keep resolving. import { AppError } from "@civfix/shared" import type { JurisdictionListQuery } from "@civfix/shared" @@ -47,17 +35,16 @@ export function makeJurisdictionContactsService( throw AppError.validation({ contacts: "At least one contact is required to route." }) } - // The save + the audit run atomically in the repo (H4); the outreach enqueue is a post-commit - // action, audited separately by the worker on send. + // The save and its audit commit together in the repo; the outreach enqueue is post-commit and is + // audited separately by the worker on send. const { taskResolved } = await deps.repo.saveAndRoute(geoid, input, { actorId }) const outreachEnqueued = await maybeEnqueueOutreach(geoid) return { geoid, taskResolved, outreachEnqueued } }, async patch(geoid: string, input: PatchContactsInput, actorId: string): Promise { - // Reject a reserved @handle BEFORE the write (the same blocklist that bars user handles from - // impersonating system/jurisdiction names). A null/empty handle (clearing it) is always allowed; the - // DB-dependent uniqueness check lives in the repo (it needs the live table, in-transaction). + // The same blocklist that bars user handles from impersonating system or jurisdiction names. + // Uniqueness is checked in the repo, which needs the live table in-transaction. if ( typeof input.handle === "string" && input.handle !== "" && @@ -70,8 +57,7 @@ export function makeJurisdictionContactsService( }, async listDirectory(query: JurisdictionListQuery) { - // query.filter / query.sort are the shared Zod enums enforced at the wire boundary, so they are - // structurally the ListDirectoryArgs unions; default to "all" / "population" when omitted. + // The shared Zod enums enforced at the wire boundary are structurally the ListDirectoryArgs unions. const { records, nextCursor, total, facets } = await deps.repo.listDirectory({ q: query.q && query.q.trim() !== "" ? query.q.trim() : null, filter: (query.filter ?? "all") as DirectoryFilter, @@ -83,7 +69,6 @@ export function makeJurisdictionContactsService( return { items: records.map(toDirectoryDTO), nextCursor, - // Omit the first-page-only fields on deeper pages (null -> undefined) so the wire stays clean. ...(total !== null ? { total } : {}), ...(facets !== null ? { facets } : {}), } @@ -96,9 +81,8 @@ export function makeJurisdictionContactsService( }, } - // maybeEnqueueOutreach is best-effort throttling: the worker's outreach_state stamp is the real guard, - // so a lost race here only risks a redundant enqueue (deduped by the singletonKey under a deduping queue - // policy), never a missed send. + // Best-effort throttling: the worker's outreach_state stamp is the real guard, so a lost race here only + // risks a redundant enqueue (deduped by singletonKey), never a missed send. async function maybeEnqueueOutreach(geoid: string): Promise { if (!deps.outreachDigestEnabled) return false const state = await deps.repo.getOutreachState(geoid) diff --git a/services/api/src/services/admin/jurisdiction-contacts-types.ts b/services/api/src/services/admin/jurisdiction-contacts-types.ts index f2f512a1..e2cfd374 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-types.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-types.ts @@ -6,86 +6,71 @@ import type { ReportCategory, } from "@civfix/shared" -/** - * The pg-boss queue the outreach digest runs on: enqueued here by save-and-route and scheduled/worked by - * registerOutreachJobs (which re-exports this constant). Declared in this zero-runtime module so both sides - * share ONE literal without the service importing the job registration. - */ +/** Lives in this zero-runtime module so the service and the job registration share one literal. */ export const OUTREACH_DIGEST_JOB = "outreach.digest" /** - * Sentinel geoid for the synthetic "Unmapped / Unknown jurisdiction" directory row. It aggregates every - * WAITING report whose jurisdiction could not be resolved (jurisdiction_geoid IS NULL) OR whose geoid no - * longer exists in the jurisdictions table (orphaned). Without this row those reports are invisible: the - * directory is sourced FROM jurisdictions, so a report with no jurisdiction has no row to live under. The - * row is read-only triage (it is NOT a real jurisdiction, so Save & route / PATCH 404 on it). The frontend - * keeps a matching constant (discovery-page.tsx UNMAPPED_GEOID). + * Sentinel geoid for the synthetic directory row that aggregates waiting reports with no jurisdiction or + * an orphaned geoid. The directory is sourced from jurisdictions, so without it those reports would be + * invisible. It is read-only triage: Save & route and PATCH 404 on it. The admin frontend keeps a matching + * constant (discovery-page.tsx UNMAPPED_GEOID). */ export const UNMAPPED_GEOID = "__unmapped__" export const UNMAPPED_NAME = "Unmapped / Unknown jurisdiction" -/** A directory row record (raw columns the repo derives; the service projects coverage/method labels). */ export interface JurisdictionDirectoryRecord { geoid: string name: string layer: JurisdictionLayer population: number | null - /** Legacy default contact emails (contact_emails[]); first is the directory's primary email. */ + /** Legacy contact_emails[]. */ defaultEmails: string[] categoryContacts: { category: ReportCategory; email: string | null }[] hasDefaultContact: boolean reportFormUrl: string | null - /** Total open reports in the geoid still waiting on a routing contact (un-routed, un-closed). */ + /** Open reports still waiting on a routing contact (un-routed, un-closed). */ reportsWaiting: number perCategoryCounts: Partial> - /** The oldest still-waiting report's created_at (agrees with reportsWaiting's predicate), or null. */ + /** Shares reportsWaiting's predicate. */ oldestReportAt: Date | null lastRoutedAt: Date | null - /** Whether the contact has a recorded bounce (takes precedence over verified/pending). */ + /** Takes precedence over verified/pending. */ bounced: boolean contactUpdatedAt: Date | null flaggedAt: Date | null - /** The discussion @handle (the "@sf" mentionable in a report discussion), or null when unset. */ handle: string | null - /** Custom forwarding email templates (null = use the built-in default packet). Prefill the editor. */ + /** Null means the built-in default packet. */ forwardSubjectTemplate: string | null forwardBodyTemplate: string | null } /** - * Directory routing-posture facet. "routed" = any contact on file (email OR form); "none" = neither. - * "needs_mapping" = has WAITING reports AND no routing contact (the actionable backlog to map). + * "routed" = any contact on file (email or form); "none" = neither; "needs_mapping" = waiting reports and + * no routing contact. */ export type DirectoryFilter = "all" | "email" | "form" | "none" | "routed" | "needs_mapping" -/** - * Directory sort key. "population" (default) and "reports" are DESC; "name" is A->Z; "oldest" surfaces the - * jurisdiction whose oldest still-waiting report is oldest first (the longest-unrouted backlog). - */ +/** "oldest" ranks by each jurisdiction's oldest still-waiting report: the longest-unrouted backlog. */ export type DirectorySort = "population" | "reports" | "name" | "oldest" -/** Normalized directory list arguments (search + method facet + type filter + sort + page window). */ export interface ListDirectoryArgs { q: string | null filter: DirectoryFilter - /** Narrow to one jurisdiction TYPE (state/county/place/federal/tribal), or null for every type. */ layer: JurisdictionLayer | null sort: DirectorySort cursor: string | null limit: number } -/** The directory page plus the first-page-only totals (active-filter count + search/type chip facets). */ export interface ListDirectoryResult { records: JurisdictionDirectoryRecord[] nextCursor: string | null - /** Count of jurisdictions matching the search, type, and active filter; only on the first page. */ + /** First page only. */ total: number | null - /** Routed/unrouted split of the search result; only computed on the first page. */ + /** First page only. */ facets: { routed: number; unrouted: number } | null } -/** One jurisdiction's boundary geometry for the verification map (simplified GeoJSON + bbox + interior point). */ export interface JurisdictionGeometryRecord { geoid: string name: string @@ -95,15 +80,12 @@ export interface JurisdictionGeometryRecord { geometry: { type: string; coordinates: unknown[] } } -/** The outcome of a contact save, returned for the route to audit + the test to assert. */ export interface SaveAndRouteResult { geoid: string taskResolved: boolean - /** Whether outreach was enqueued (false when throttled/suppressed). */ outreachEnqueued: boolean } -/** The contact-save input shared by save-and-route and the patch path. */ export interface SaveContactsInput { contacts: Partial> defaultEmails: string[] @@ -113,72 +95,54 @@ export interface SaveContactsInput { } /** - * The optional-field contacts/notes/form/flag patch input (shared by the repo + service + route). - * - * CLEARING: per-category contacts, the @handle and the forward templates all clear on null/"". The legacy - * `defaultEmails` and `formUrl` do NOT: the shared contacts upsert only ever writes a non-empty array and - * COALESCEs the form URL, so `[]` / null read as "leave alone" rather than "clear". Both repository - * bindings behave the same way, so it is consistent — just asymmetric, and clearing a stale legacy email or - * form URL is a wire-semantics decision (an operator form that always posts the field would otherwise wipe - * it) plus a change to upsertJurisdictionContacts. + * Per-category contacts, the @handle and the forward templates clear on null/"". The legacy + * `defaultEmails` and `formUrl` do not: the shared contacts upsert only writes a non-empty array and + * COALESCEs the form URL, so `[]` / null mean "leave alone". Making them clearable is a wire-semantics + * decision, since an operator form that always posts the field would otherwise wipe it. */ export interface PatchContactsInput { contacts?: Partial> - /** Legacy default emails. An empty array is "unchanged", NOT a clear (see the interface note). */ + /** An empty array is "unchanged", not a clear. */ defaultEmails?: string[] - /** Report form URL. null/"" is "unchanged", NOT a clear (see the interface note). */ + /** null/"" is "unchanged", not a clear. */ formUrl?: string | null notes?: string | null flagged?: boolean flagReason?: string | null - /** Set / clear the discussion @handle (normalized + shape-checked by the shared schema; null/"" clears). */ handle?: string | null - /** Set / clear the custom forwarding subject template (bounded by the shared schema; null/"" clears). */ forwardSubjectTemplate?: string | null - /** Set / clear the custom forwarding body template (bounded by the shared schema; null/"" clears). */ forwardBodyTemplate?: string | null } export interface JurisdictionContactsRepository { - /** Whether the jurisdiction exists (so the route can 404 an unknown geoid). */ jurisdictionExists(geoid: string): Promise /** - * Persist contacts. ONE short transaction commits the operator's input: upsert jurisdiction_contacts - * (+ legacy mirror), set contact_updated_at, mark the open discovery task done, AND write the - * discovery.contacts_saved audit row (H4: "did + recorded" is atomic; an audit failure rolls the save - * back). Saving a contact mails nobody, so it no longer flips the geoid's waiting reports to - * `acknowledged` - that claimed a routing that never happened; operators route each report themselves - * from the Reports screen. Does NOT enqueue outreach (the service does, via Jobs, after the write commits). + * One short transaction commits the contacts, the discovery task's resolution and the + * discovery.contacts_saved audit row, so an audit failure rolls the save back. Saving a contact mails + * nobody, so the geoid's waiting reports are left alone: flipping them to `acknowledged` would claim a + * routing that never happened. Outreach is enqueued by the service after the commit. */ saveAndRoute( geoid: string, input: SaveContactsInput, audit: { actorId: string | null }, ): Promise<{ taskResolved: boolean }> - /** - * Patch contacts/notes/form WITHOUT routing, AND write the jurisdiction.patched audit row, all in ONE - * transaction (H4). Returns false when the geoid is unknown. - */ + /** Writes the jurisdiction.patched audit row in the same transaction. False for an unknown geoid. */ patch( geoid: string, input: PatchContactsInput, audit: { actorId: string | null }, ): Promise - /** Read the outreach throttle state for a geoid (last_outreach_at + suppressed), or null when absent. */ getOutreachState( geoid: string, ): Promise<{ lastOutreachAt: Date | null; suppressed: boolean } | null> - /** Page the directory rows (org/coverage/method derived by the service), searched + filtered + sorted. */ listDirectory(args: ListDirectoryArgs): Promise - /** One jurisdiction's simplified boundary geometry for the map, or null when it has no stored boundary. */ getGeometry(geoid: string): Promise - // NOTE: bounce stamping is NOT on this seam. The inbound bounce handler owns it end to end - // (inbound-bounce.ts markBouncedContact), because it runs from the mail path with only a raw Sql handle - // and also needs geoidForContact to re-open discovery. A parallel repo method existed here with zero - // callers and a duplicate UPDATE; it was removed rather than left to drift. + // Bounce stamping is deliberately not on this seam: inbound-bounce.ts markBouncedContact owns it, since + // it runs from the mail path with only a raw Sql handle and also needs geoidForContact to re-open + // discovery. } -/** The Jobs seam slice the service needs (enqueue only); structurally compatible with the shared Jobs. */ export interface OutreachEnqueuer { enqueue(name: string, data: unknown, opts?: { singletonKey?: string }): Promise } @@ -186,30 +150,22 @@ export interface OutreachEnqueuer { export interface JurisdictionContactsServiceDeps { repo: JurisdictionContactsRepository jobs: OutreachEnqueuer - /** Outreach throttle window in days (env.OUTREACH_THROTTLE_DAYS); suppresses re-enqueue inside it. */ throttleDays: number outreachDigestEnabled: boolean - /** Injectable clock (defaults to Date.now) so the throttle window is deterministic in tests. */ now?: () => Date } /** - * `actorId` is NON-NULL on both mutating methods: the only callers are the operator-guarded jurisdiction - * routes, where `requireOperator(request)` returns a `string` or throws, so an unattributed audit row for - * a "Save & route" is not a reachable state. The REPOSITORY audit slot above stays nullable — its writes - * are also driven by the system-owned outreach/autoforward jobs, which genuinely have no operator. + * `actorId` is non-null because only operator-guarded routes call the mutations. The repository's audit + * slot stays nullable: the outreach and autoforward jobs also drive its writes and have no operator. */ export interface JurisdictionContactsService { - /** Save & route: persist contacts, route pending pins, enqueue throttled outreach. Audited in-tx (H4). */ saveAndRoute( geoid: string, input: SaveContactsInput, actorId: string, ): Promise - /** Patch a jurisdiction's contacts/notes/form WITHOUT routing. Audited in-tx (H4). */ patch(geoid: string, input: PatchContactsInput, actorId: string): Promise - /** List the jurisdiction directory (org/dept/email/form/method/status/coverage/lastRouted). */ listDirectory(query: JurisdictionListQuery): Promise - /** One jurisdiction's boundary geometry for the verification map; 404s when the geoid has no boundary. */ getGeometry(geoid: string): Promise } diff --git a/services/api/src/services/admin/jurisdiction-directory-projection.ts b/services/api/src/services/admin/jurisdiction-directory-projection.ts index 724d13f6..2f22284d 100644 --- a/services/api/src/services/admin/jurisdiction-directory-projection.ts +++ b/services/api/src/services/admin/jurisdiction-directory-projection.ts @@ -9,11 +9,6 @@ import { type SaveContactsInput, } from "./jurisdiction-contacts-types.js" -/** - * Derive the directory coverage label from the contact posture: "All categories" when a default contact - * covers everything (or every category has a contact), the comma-joined category labels when only some - * categories are covered, or "No routing" when nothing is on file. - */ export function coverageLabel(record: JurisdictionDirectoryRecord): string { const covered = new Set( record.categoryContacts @@ -23,8 +18,7 @@ export function coverageLabel(record: JurisdictionDirectoryRecord): string { const hasDefault = record.hasDefaultContact || record.defaultEmails.some((e) => e.trim() !== "") if (hasDefault || covered.size === ADMIN_CATEGORIES.length) return "All categories" if (covered.size === 0) return "No routing" - // ADMIN_CATEGORIES drives the ORDER of the joined label list (category-counts.ts derives it from the - // contract enum), so a new category appears in the coverage label with no edit here. + // ADMIN_CATEGORIES derives from the contract enum, so a new category appears here with no edit. return ADMIN_CATEGORIES.filter((c) => covered.has(c)) .map((c) => REPORT_CATEGORY_LABELS[c]) .join(", ") @@ -47,7 +41,6 @@ export function directoryStatus( return "pending" } -/** The primary directory email (first non-empty default, else the first category email, else null). */ export function primaryEmail(record: JurisdictionDirectoryRecord): string | null { const def = record.defaultEmails.find((e) => e.trim() !== "") if (def) return def @@ -55,7 +48,7 @@ export function primaryEmail(record: JurisdictionDirectoryRecord): string | null return cat?.email ?? null } -/** Whether at least one contact field is filled (the server-side "Save & route" precondition). */ +/** The server-side "Save & route" precondition. */ export function hasAnyContact(input: SaveContactsInput): boolean { if (input.defaultEmails.some((e) => e.trim() !== "")) return true if (input.formUrl !== null && input.formUrl.trim() !== "") return true @@ -63,9 +56,8 @@ export function hasAnyContact(input: SaveContactsInput): boolean { } /** - * Build the synthetic "Unmapped / Unknown jurisdiction" directory record from the waiting-report totals - * (see UNMAPPED_GEOID). `layer` is a placeholder ("place"); the admin special-cases UNMAPPED_GEOID and - * never renders the type chip / routing controls for it. It always reads as method "none". + * `layer` is a placeholder: the admin special-cases UNMAPPED_GEOID and never renders the type chip or + * routing controls for it. */ export function buildUnmappedRecord( total: number, @@ -82,7 +74,6 @@ export function buildUnmappedRecord( reportFormUrl: null, reportsWaiting: total, perCategoryCounts, - // The unmapped aggregate does not carry a per-row oldest timestamp; it is read-only triage, so null. oldestReportAt: null, lastRoutedAt: null, bounced: false, @@ -95,11 +86,8 @@ export function buildUnmappedRecord( } /** - * Whether the synthetic unmapped row should be included for these list args: only on the FIRST page (no - * cursor), never under a type (layer) filter (it has no real jurisdiction type — it's layer-less triage), - * only under a facet a contact-less row matches (all | none — its method is "none"), and only when a - * search term, if present, matches its name/geoid. The caller still suppresses it when its waiting total - * is 0. + * The row has no real jurisdiction type, so a type filter excludes it, and its method is "none", so only + * the all and none facets match it. The caller still suppresses it when its waiting total is 0. */ export function shouldIncludeUnmapped(args: ListDirectoryArgs): boolean { if (args.cursor !== null) return false @@ -112,12 +100,11 @@ export function shouldIncludeUnmapped(args: ListDirectoryArgs): boolean { return true } -/** Project a directory record into the wire DTO (org/dept/email/form/method/status/coverage/lastRouted). */ export function toDirectoryDTO(record: JurisdictionDirectoryRecord): JurisdictionDirectoryDTO { return { geoid: record.geoid, org: record.name, - // Department is not modeled separately in Phase 2; the directory shows the jurisdiction as the org. + // Department is not modeled separately; the jurisdiction is shown as the org. dept: null, email: primaryEmail(record), form: record.reportFormUrl, @@ -130,7 +117,7 @@ export function toDirectoryDTO(record: JurisdictionDirectoryRecord): Jurisdictio reportsWaiting: record.reportsWaiting, oldestReportAt: record.oldestReportAt?.toISOString() ?? null, perCategoryCounts: record.perCategoryCounts, - // Drop empty-string emails to null so they satisfy the DTO's email-or-null contract. + // The DTO's contract is email-or-null, never "". contacts: record.categoryContacts.map((c) => ({ category: c.category, email: c.email !== null && c.email.trim() !== "" ? c.email : null, diff --git a/services/api/src/services/admin/like.ts b/services/api/src/services/admin/like.ts index 6da497e9..96e42fed 100644 --- a/services/api/src/services/admin/like.ts +++ b/services/api/src/services/admin/like.ts @@ -1,28 +1,21 @@ /** - * Shared LIKE/ILIKE escaping for operator search filters. + * LIKE/ILIKE escaping for operator search terms. Unescaped, `%` and `_` act as wildcards (and `\` as the + * escape): a term can broaden a match arbitrarily and defeat the gin_trgm_ops indexes + * (0014_search_trgm.sql), since a leading-wildcard `%%%` term forces an unindexed full scan, a cheap DoS + * lever for any operator. * - * Operator `q` search terms flow into `... ILIKE '%' || term || '%'` patterns. Without escaping, the - * LIKE metacharacters `%` and `_` (and the escape char `\`) in a term are interpreted as wildcards. - * That lets a term broaden a match arbitrarily and, more importantly, defeats the gin_trgm_ops indexes - * (0014_search_trgm.sql) — a leading-wildcard `%%%` term forces an unindexed full scan, i.e. a cheap - * CPU/DoS lever for any authenticated operator. Escaping makes the term match LITERALLY. - * - * Always pair the escaped value with `ESCAPE '\\'` in the SQL (PostgreSQL defaults the LIKE escape to - * backslash, but stating it is explicit and future-proof). The one shared definition for every repo that - * does operator search. + * Always pair the escaped value with `ESCAPE '\\'` in the SQL. PostgreSQL defaults the LIKE escape to + * backslash, but stating it keeps the query independent of that default. */ -/** Escape %, _ and \ in a term so they are treated literally inside a LIKE/ILIKE pattern. */ export function escapeLike(term: string): string { return term.replace(/[\\%_]/g, (ch) => `\\${ch}`) } -/** Build a literal "contains" pattern: %%. Use with `ESCAPE '\\'` in the query. */ export function likeContains(term: string): string { return "%" + escapeLike(term) + "%" } -/** Build a literal "starts with" pattern: %. Use with `ESCAPE '\\'` in the query. */ export function likePrefix(term: string): string { return escapeLike(term) + "%" } diff --git a/services/api/src/services/admin/moderation-repository.drizzle.ts b/services/api/src/services/admin/moderation-repository.drizzle.ts index a36c20a9..dc17e545 100644 --- a/services/api/src/services/admin/moderation-repository.drizzle.ts +++ b/services/api/src/services/admin/moderation-repository.drizzle.ts @@ -518,18 +518,16 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository signals, "similar", status, meta, created_at ) SELECT - -- L5: derive the kind from the held media instead of hardcoding 'image'. A report with any - -- media asset is a media hold ('image'; the moderation kind enum has no 'video', so image is - -- the closest media kind), one without media is a content/behavioral hold ('pattern'). + -- A report with any media asset is a media hold ('image': the moderation kind enum has no + -- 'video'), one without media is a content/behavioral hold ('pattern'). CASE WHEN EXISTS (SELECT 1 FROM media_assets ma WHERE ma.report_id = r.id) THEN 'image' ELSE 'pattern' END, 'report', r.id, 'Held report', 'Awaiting automated review', r.category, j.name, 'med', 'Hidden pending review', '[]'::jsonb, '[]'::jsonb, 'open', jsonb_build_object( 'reporter', COALESCE(u.display_name, 'Anonymous'), - -- For a held report the reporter display string IS the report's own reporter (the person - -- who filed it), so reporterUserId deep-links to that same account (null when anonymous). - -- This is NOT the subject-author conflation: here reporter == author genuinely. + -- For a held report the reporter is the report's own author, so reporterUserId deep-links to + -- that account (null when anonymous). This is not the subject-author conflation. 'reporterUserId', u.id::text, 'desc', COALESCE(r.description, ''), 'user', CASE WHEN u.id IS NOT NULL THEN jsonb_build_object( diff --git a/services/api/src/services/admin/outbound-mail-service.ts b/services/api/src/services/admin/outbound-mail-service.ts index 204814c5..e4a83dd3 100644 --- a/services/api/src/services/admin/outbound-mail-service.ts +++ b/services/api/src/services/admin/outbound-mail-service.ts @@ -135,8 +135,8 @@ export class OutboundSendDeadlineError extends AppError { constructor(deadlineMs: number) { super( ErrorCode.CONFLICT, - "The send to this jurisdiction is still in progress. Check back shortly — the outcome will " + - "appear on the outreach trail once the mail server answers.", + "The send to this jurisdiction is still in progress. Its outcome will appear on the outreach " + + "trail once the mail server answers, so check back shortly.", ) this.deadlineMs = deadlineMs } diff --git a/services/api/src/services/admin/pagination.ts b/services/api/src/services/admin/pagination.ts index df866434..33a65390 100644 --- a/services/api/src/services/admin/pagination.ts +++ b/services/api/src/services/admin/pagination.ts @@ -1,12 +1,3 @@ -/** - * Admin (Phase 2) list-endpoint pagination helpers — thin aliases over the shared cursor primitives in - * db/cursor-helpers.ts. The admin layer keeps its own limit defaults + a {createdAt,id}-shaped anchor - * (the admin repos read/write that shape); the cursor encode/decode itself is the one shared core. - * - * The shared @civfix/shared exports (PaginationQuerySchema, CursorSchema, pageResponse) remain the WIRE - * contract; these are the server-side encode/decode + limit clamp around them. - */ - import { encodeTimeCursor, parseKeysetCursor } from "../../db/cursor-helpers.js" export { @@ -17,34 +8,29 @@ export { paginateKeyset, } from "../../db/cursor-helpers.js" -/** Default page size when the request omits `limit`. */ export const ADMIN_DEFAULT_LIMIT = 25 -/** Hard cap on page size (mirrors the shared AdminListQuery limit ceiling). */ +/** Mirrors the shared AdminListQuery limit ceiling. */ export const ADMIN_MAX_LIMIT = 100 -/** A decoded keyset cursor anchor: page rows ordered by (createdAt DESC, id DESC) strictly before this. */ export interface CursorAnchor { createdAt: Date id: string } -/** A decoded anchor that also keeps the cursor's instant as text, which is what keysetPredicate binds. */ +/** Keeps the cursor's instant as text because that is what keysetPredicate binds. */ export interface KeysetAnchor extends CursorAnchor { atText: string } -/** Encode a keyset anchor into the opaque "|" cursor string. */ export function encodeCursor(anchor: CursorAnchor): string { return encodeTimeCursor({ at: anchor.createdAt, id: anchor.id }) } /** - * Decode a "|" cursor into its anchor, or null when absent/malformed. Repos whose keyset casts - * `${id}::uuid` pass requireUuidId=true so a forged non-UUID id degrades to "from the start" (null) rather - * than raising a Postgres 22P02 -> 500: reports, users, events, discovery, moderation, mail, inbound, - * gov-claims and audit all do. Repos keyed on something else (activity's synthetic composite ids) leave it - * false. A legacy timestamp-only cursor anchors at the max uuid for that instant (created_at-only paging) - * instead of erroring. + * Repos whose keyset casts `${id}::uuid` pass requireUuidId=true so a forged non-UUID id degrades to "from + * the start" rather than raising a Postgres 22P02 (a 500). Repos keyed on something else (activity's + * synthetic composite ids) leave it false. A legacy timestamp-only cursor anchors at the max uuid for that + * instant instead of erroring. */ export function decodeCursor( cursor: string | null | undefined, @@ -56,11 +42,9 @@ export function decodeCursor( } /** - * Encode an offset-pagination cursor (opaque base64url of the NEXT row offset). Offset paging is used by - * the jurisdictions directory specifically: the table is static reference data browsed/searched under an - * arbitrary sort (population / reports / name), where keyset's drift-immunity buys nothing and a free - * choice of ORDER BY is worth more. The cursor stays an opaque string so the wire `nextCursor` contract - * (and the typed client's infinite-scroll loop) is unchanged. + * Offset paging serves the jurisdictions directory: static reference data browsed under an arbitrary sort + * (population / reports / name), where keyset's drift-immunity buys nothing and a free choice of ORDER BY + * is worth more. The cursor stays opaque so the wire `nextCursor` contract is unchanged. */ export function encodeOffsetCursor(offset: number): string { return Buffer.from(JSON.stringify({ o: Math.max(0, Math.floor(offset)) }), "utf8").toString( @@ -69,17 +53,13 @@ export function encodeOffsetCursor(offset: number): string { } /** - * Hard ceiling on a decoded offset (L22). The cursor is opaque but NOT authenticated, so a caller can - * mint one carrying any integer; an unbounded OFFSET makes Postgres walk and discard that many rows per - * request. Mirrors clampOffset in services/volunteer-hours-service.ts. The directory it serves is static - * reference data in the low thousands, so a legitimate deep page is never near this. + * The cursor is opaque but not authenticated, so a caller can mint one carrying any integer, and an + * unbounded OFFSET makes Postgres walk and discard that many rows per request. Mirrors clampOffset in + * services/volunteer-hours-service.ts. The directory is in the low thousands of rows, so a legitimate + * deep page is never near this. */ export const ADMIN_MAX_OFFSET = 100_000 -/** - * Decode an offset cursor to its row offset; absent/malformed -> 0 (start from the top). Clamped at BOTH - * ends: [0, ADMIN_MAX_OFFSET]. - */ export function decodeOffsetCursor(cursor: string | null | undefined): number { if (!cursor) return 0 try { @@ -92,22 +72,17 @@ export function decodeOffsetCursor(cursor: string | null | undefined): number { } /** - * Page a PRE-SORTED in-memory list by the shared "|" cursor: find the anchor row by id, then take - * a one-extra-row probe. `anchorOf` returns the {createdAt,id} the cursor encodes — encoding the row's REAL - * sort value (not a placeholder) so the opaque cursor has the same shape as the Drizzle impl for the same page. - * The id alone drives the slice position; the cursor's createdAt is informational here. - * - * THE one implementation for every in-memory admin repo (six hand-rolled copies of - * decode -> findIndex -> slice(limit+1) -> encode drifted apart before this existed). + * Pages a pre-sorted list. `anchorOf` must return the row's real sort value (not a placeholder) so the + * cursor has the same shape as the Drizzle impl's for the same page; the id alone drives the slice + * position. * - * `requireUuid` mirrors the Drizzle twin's decodeCursor flag: a fake whose production counterpart casts the - * anchor to uuid must DISCARD a non-uuid anchor the same way, or the offline twin silently accepts cursors - * prod throws away. Fakes whose tests seed synthetic row ids ("rep-1") leave it false — for those the - * strictness is a prod hardening against a forged cursor, not an observable behavior the fake must copy. + * `requireUuid` mirrors the Drizzle twin's decodeCursor flag: a fake whose production counterpart casts + * the anchor to uuid must discard a non-uuid anchor the same way, or it silently accepts cursors prod + * throws away. Fakes whose tests seed synthetic ids ("rep-1") leave it false. * - * ANCHOR MISS: when the anchor row is no longer in the list (it left the filtered set between pages — a - * discovery task whose contacts were just saved, a report that was removed), paging ENDS rather than - * restarting from the top, which would loop the client forever. + * When the anchor row left the filtered set between pages (a discovery task whose contacts were just + * saved, a removed report), paging ends rather than restarting from the top, which would loop the client + * forever. */ export function pageInMemoryById( rows: readonly T[], @@ -130,11 +105,8 @@ export function pageInMemoryById( return { records, nextCursor: last !== undefined ? encodeCursor(anchorOf(last)) : null } } -/** - * Clamp a requested page limit into [1, ADMIN_MAX_LIMIT], defaulting to ADMIN_DEFAULT_LIMIT when - * undefined/invalid. The wire schema already coerces + caps; this is the defensive server-side clamp so a - * repo never receives a 0 / negative / huge LIMIT. - */ +// The wire schema already coerces and caps; this clamp keeps a repo from ever receiving a 0, negative or +// huge LIMIT. export function clampLimit(limit: number | undefined): number { if (limit === undefined || !Number.isFinite(limit)) return ADMIN_DEFAULT_LIMIT const n = Math.floor(limit) diff --git a/services/api/src/services/admin/system-health-probes.ts b/services/api/src/services/admin/system-health-probes.ts index cb810ee7..f13a6c48 100644 --- a/services/api/src/services/admin/system-health-probes.ts +++ b/services/api/src/services/admin/system-health-probes.ts @@ -7,9 +7,8 @@ export const PROBE_TIMEOUT_MS = 2000 const PG_UNDEFINED_TABLE = "42P01" /** - * LIKE pattern for the media worker's pg-boss queues (today: "media.checks"). The depth probe used to count - * EVERY queue's pending jobs, so an outreach-digest or inbound-sweep backlog warned on the "Media worker" - * tile. + * Only the media worker's pg-boss queues, so another queue's backlog (outreach digest, inbound sweep) + * never warns on the "Media worker" tile. */ const MEDIA_QUEUE_LIKE = "media.%" @@ -56,8 +55,8 @@ export function makeSystemHealthProbes(deps: SystemProbeDeps): SystemHealthProbe const probes: SystemHealthProbes = {} if (deps.getSql) { - // Resolved INSIDE each probe: `getSql` is a lazy seam (the DB handle may not exist when the probes are - // constructed), and calling it here defeated that. + // `getSql` is a lazy seam (the DB handle may not exist when the probes are constructed), so it is + // resolved inside each probe. const getSql = deps.getSql probes.postgres = async (): Promise => { const sql = getSql() diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 801450e4..85a45978 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -1,47 +1,25 @@ /** - * Postgres-backed implementations of the anonymous-reporting persistence seams. + * Postgres implementations of the anonymous-reporting seams: AnonReportRepository (held create, status, + * the anon_tokens store), AnonHoldReleaseRepo (the worker's release gate) and ClaimRepository + * (claim-by-code linking and the nudge lookup). * - * Three repositories share this file because they all touch the same tables (reports, media_assets, - * report_timeline, anon_tokens, abuse_flags, idempotency_keys) and the same PostGIS geometry handling: + * Raw postgres-js rather than the Drizzle query builder, because every report row carries PostGIS geometry + * that Drizzle does not model and the held create must run as a single transaction. * - * makeDrizzleAnonReportRepository -> AnonReportRepository (held-create tx + status + the anon_tokens - * resolve/issue store; the claim code is per-report and rests - * ONLY as its SHA-256 on reports.claim_code_hash (0091), never - * in plaintext and never on the anon_tokens row) - * makeDrizzleAnonHoldReleaseRepo -> AnonHoldReleaseRepo (the worker's release gate) - * makeDrizzleClaimRepository -> ClaimRepository (claim-by-code linking + nudge lookup) + * The claim code is per report and rests only as its SHA-256 on reports.claim_code_hash, never in + * plaintext and never on the shared anon_tokens row: the plaintext exists only in the one-time response to + * the submitter. Storing the digest per report, not on the token row a later submit would overwrite, is + * what keeps each of a token's reports independently status-queryable and claimable. * - * Like report-repository.drizzle.ts, everything runs against the raw postgres-js tag (`Sql`) rather than - * the Drizzle query builder, because every report row carries PostGIS geometry - * (ST_SetSRID(ST_MakePoint(lng,lat),4326) on write) which Drizzle does not model, and because the - * held-create flow must run as a SINGLE transaction (sql.begin) to guarantee the no-duplicate + - * no-orphan + quota-consistency contract. + * A UNIQUE(idempotency_key) race rolls the held-create transaction back and answers the retryable 409: the + * winner's claim code is not at rest to hand back, and rotating it here would kill the code the winner's + * response is carrying at that moment. * - * HELD-CREATE TRANSACTION (createAnonReportTx): - * 1. ATOMIC per-token cap: UPDATE anon_tokens SET report_count = report_count + 1 - * WHERE id = token AND report_count < cap. 0 rows -> cap reached -> rollback. (The cap is a TOKEN - * property; the claim code is NOT stamped here anymore - it lives on the report row, see step 2.) - * 2. INSERT the report (reporter_user_id NULL, anon_session_id = token id, status 'held', - * visibility 'public', published_at NULL, geom from the point, h3_cell precomputed, AND the - * SHA-256 of the per-report single-use claim code). The plaintext code is NEVER persisted - * (F150): it exists only in the one-time response to the submitter, and every read path hashes - * the presented code to match reports.claim_code_hash (0091). Storing the digest PER REPORT - * (not on the shared anon_tokens row, which a later submit would overwrite) is what makes each - * of a token's up-to-5 reports independently status-queryable + claimable (0005). - * 3. Attach each media_asset by setting report_id, but only when unattached or already ours (never - * steal a foreign asset; unknown ids no-op) - identical safety to the authed path. - * 4. INSERT the initial timeline rows: 'submitted' then 'held'. - * 5. INSERT the response snapshot WITHOUT its claim code into idempotency_keys.response_snapshot, - * owner-scoped by user_or_anon = the anon token id (0078/0079). - * All five happen atomically. A UNIQUE(idempotency_key) (or unique-index) race rolls the tx back and - * answers the retryable 409: the winner's claim code is not at rest to hand back, and rotating it here - * would kill the code the winner's response is carrying at that moment. - * - * REPLAY: the snapshot holds only { reportId, status }, so a replay mints a fresh claim code and rotates - * that report's claim_code_hash onto it (the code the first response carried stops working). That keeps + * Replay: the idempotency snapshot holds only { reportId, status }, so a replay mints a fresh claim code + * and rotates that report's claim_code_hash onto it (the first response's code stops working). That keeps * the plaintext out of idempotency_keys and its backups while the replayed response still carries a - * working code, and keeps exactly one live code per report. A replay is only ever a request that - * arrived after the winner committed, which is a client that lost the first response. + * working code, and keeps exactly one live code per report. A replay is only ever a request that arrived + * after the winner committed, which is a client that lost the first response. */ import type { Sql } from "../db/client.js" @@ -61,7 +39,6 @@ import type { AnonReportResponse, ReportStatus } from "@civfix/shared" import { insertModerationItem } from "./admin/moderation-repository.drizzle.js" import { claimableAsReportMedia, lockUploadsForClaim } from "./media-bindings.js" -/** Postgres unique-violation SQLSTATE; surfaced on the idempotency-key race. */ const PG_UNIQUE_VIOLATION = "23505" function isUniqueViolation(err: unknown): boolean { @@ -72,7 +49,6 @@ function isUniqueViolation(err: unknown): boolean { ) } -/** Project an anon_tokens row select to the AnonTokenRecord shape. */ interface AnonTokenRowSelect { id: string created_at: Date @@ -93,7 +69,6 @@ function toTokenRecord(r: AnonTokenRowSelect): AnonTokenRecord { } } -/** The shared AnonTokenStore over anon_tokens (both anon repos compose it). */ function anonTokenStore(sql: Sql): AnonTokenStore { return { async insert(row: AnonTokenRecord): Promise { @@ -173,19 +148,14 @@ export function makeDrizzleAnonReportRepository( async createAnonReportTx(args: CreateAnonReportTxArgs): Promise { try { const snapshot = await sql.begin(async (tx) => { - // 0) D4 LOCK ORDER: allocate the reference code FIRST — the reference_counters upsert must be the - // FIRST write in EVERY create tx so each path takes the counter-row lock before any report/token - // row lock (a consistent acquisition order that rules out an ABBA deadlock). jurCode is resolved - // pre-tx (0 = unknown bucket when no jurisdiction, D5). Anon reports still get a code (#56 / M3); - // they simply never auto-forward. + // The reference_counters upsert must be the first write in every create transaction, so each + // path takes the counter-row lock before any report or token row lock; that consistent order rules + // out an ABBA deadlock. Anon reports still get a code; they simply never auto-forward. const referenceCode = await allocateReportReferenceCode(tx, args.type, args.jurCode) - // 1) ATOMIC per-token cap (bugs P0-1): bump report_count ONLY while the token is still under - // the cap. Folding the cap into the WHERE makes the check-and-consume a single statement, so N - // concurrent submits on one token cannot all pass a stale read and overshoot. 0 rows updated - // means the cap is reached: throw, which rolls the whole tx back. NOTE: the claim code is NOT - // stamped here - it is a PER-REPORT secret stored on the report row in step 2 (0005), so each - // of the token's reports keeps its own code instead of the latest submit overwriting the rest. + // Folding the cap into the WHERE makes check-and-consume a single statement, so concurrent submits + // on one token cannot all pass a stale read and overshoot. Zero rows means the cap is reached, and + // the throw rolls the whole transaction back. const bumped = await tx<{ report_count: number }[]>` UPDATE anon_tokens SET report_count = report_count + 1 @@ -198,11 +168,9 @@ export function makeDrizzleAnonReportRepository( ) } - // 2) Insert the report HELD (anon: reporter_user_id NULL, anon_session_id = token id) with the - // SHA-256 of its own single-use claim code (0005 + 0091). The plaintext column is written NULL: - // the code never rests in the database (F150), so a dump/backup/replica leak cannot bind anyone - // else's anonymous report to an account, and the deferred DROP of reports.claim_code is a no-op - // for every write and read here. + // The plaintext claim_code column is written NULL: the code never rests in the database, so a + // dump, backup or replica leak cannot bind anyone else's anonymous report to an account, and the + // column's deferred DROP changes nothing here. await tx` INSERT INTO reports ( id, reporter_user_id, anon_session_id, idempotency_key, geom, geom_source, @@ -234,14 +202,11 @@ export function makeDrizzleAnonReportRepository( ) ` - // 3) Attach media (set report_id only when unattached or already ours; never steal a foreign). - // Set-based UPDATE over all upload ids in ONE round-trip (postgres-js array binding) instead of - // a per-id loop, so attaching N photos doesn't lengthen the held-create tx by N statements while - // it holds the report + token row locks. Skipped when there are no ids, since `IN ()` is invalid - // SQL. Semantics are unchanged: each row's report_id is set only when unattached or already ours, - // foreign assets stay untouched, and unknown ids no-op. An asset bound to a post, a chat/DM - // message or any other owner is never re-bindable to a report, or the holder of an uploadId - // could cross-publish private media into a public report gallery. + // An asset bound to a post, a chat or DM message or any other owner is never re-bindable to a + // report, or the holder of an uploadId could cross-publish private media into a public report + // gallery. One set-based UPDATE rather than a per-id loop, so N photos don't lengthen the + // transaction while it holds the report and token row locks; skipped with no ids because `IN ()` + // is invalid SQL. if (args.mediaUploadIds.length > 0) { await lockUploadsForClaim(tx, args.mediaUploadIds) const claimed = await tx<{ upload_id: string }[]>` @@ -254,10 +219,10 @@ export function makeDrizzleAnonReportRepository( AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` - // M-media-claim: reject the whole submit when any id is unclaimable (unknown, rejected, or - // already bound elsewhere) instead of committing a report with the photo silently missing — - // the same rule the authed create path and post-repository.createPost enforce. The throw rolls - // the tx back, so the token quota bump does not persist either. + // Reject the whole submit when any id is unclaimable (unknown, rejected, or already bound + // elsewhere) rather than commit a report with the photo silently missing, the same rule the + // authed create path and post-repository.createPost enforce. The throw also rolls back the + // token quota bump. if (claimed.length !== new Set(args.mediaUploadIds).size) { throw AppError.validation({ mediaUploadIds: "One or more media uploads are unavailable.", @@ -265,10 +230,9 @@ export function makeDrizzleAnonReportRepository( } } - // 4) Initial timeline: submitted, then held (the anon flow records both transitions). Both - // rows share now() (constant within a transaction) and report_timeline.id is a random uuid, so - // ORDER BY created_at, id had NO stable tiebreaker — a reader could get held-before-submitted. - // Stamp held 1ms after submitted so the chronological order is deterministic for every reader. + // Both rows would share now() (constant within a transaction) and report_timeline.id is a random + // uuid, so ORDER BY created_at, id has no stable tiebreaker; held is stamped 1ms later so every + // reader sees submitted first. await tx` INSERT INTO report_timeline (report_id, status, note, actor_id, created_at) VALUES @@ -276,12 +240,9 @@ export function makeDrizzleAnonReportRepository( (${args.reportId}, ${"held"}, ${"Awaiting automated review"}, ${null}, now() + interval '1 millisecond') ` - // 4b) Enqueue the moderation_items row for this held report (Phase 2 producer hook). The anon - // hold-then-publish path holds EVERY anon report pending automated review, so the operator - // moderation queue surfaces it immediately. Done inside the SAME tx as the report insert so the - // item and the held report are atomic (never an item without its report, or vice versa). Kept - // lean: kind 'image', subject the report, the dominant category + description carried for the - // detail; signals/user are enriched later by the media-worker / abuse detection if applicable. + // Every anon report is held pending automated review, so the operator moderation queue surfaces it + // immediately. The same transaction keeps the item and the held report atomic; signals and user + // are enriched later by the media worker or abuse detection. await insertModerationItem(tx, { kind: "image", subjectType: "report", @@ -294,8 +255,7 @@ export function makeDrizzleAnonReportRepository( desc: args.description ?? "", }) - // 5) Persist the snapshot under the idempotency key (same tx), without the plaintext claim - // code: a replay mints a fresh one instead. + // Stored without the plaintext claim code: a replay mints a fresh one instead. const storedSnapshot: StoredAnonSnapshot = { reportId: args.responseSnapshot.reportId, status: args.responseSnapshot.status, @@ -328,9 +288,9 @@ export function makeDrizzleAnonReportRepository( }, async findAnonReportStatus(reportId: string): Promise { - // The claim code DIGEST is stored PER REPORT (0005 + 0091), so read it straight off the report row - // - no anon_tokens join (which used to return the LATEST submit's code, breaking older reports). - // The caller hashes the presented code and compares digests, so no secret is read back here. + // Read off the report row, not through anon_tokens, which held only the latest submit's code and + // broke older reports. The caller hashes the presented code and compares digests, so no secret is + // read back here. const rows = await sql< { id: string @@ -366,12 +326,11 @@ export function makeDrizzleClaimRepository(sql: Sql): ClaimRepository { tokenId: string, claimCodeHash: string, ): Promise { - // Only the DIGEST of a claim code is stored (0091), so the nudge cannot read a code back: it - // stamps the caller's freshly minted code onto the token's most recent not-deleted, not-yet-claimed - // report (reporter_user_id IS NULL = not yet claimed; claim_code_hash IS NOT NULL = not yet - // consumed), which supersedes whatever code that report carried. Still exactly one live code per - // report, still single-use, and older reports stay claimable directly via /claim/report by their - // own code. Selecting FOR UPDATE serializes two concurrent nudges on one token. + // Only the digest of a claim code is stored, so the nudge cannot read a code back: it stamps the + // caller's freshly minted code onto the token's most recent unclaimed report (reporter_user_id IS + // NULL) whose code is not yet consumed (claim_code_hash IS NOT NULL), superseding that report's old + // code. Still one live single-use code per report; older reports stay claimable via /claim/report by + // their own code. FOR UPDATE serializes two concurrent nudges on one token. const rows = await sql<{ id: string }[]>` UPDATE reports SET claim_code_hash = ${claimCodeHash}, claim_code = ${null} @@ -394,13 +353,11 @@ export function makeDrizzleClaimRepository(sql: Sql): ClaimRepository { async claimByCode(claimCodeHash: string, userId: string): Promise<{ reportId: string } | null> { return sql.begin(async (tx) => { - // Atomically claim THE report whose stored digest matches: lock + link + clear in one statement. - // The caller hashes the presented code, so the lookup is an index probe on the partial-unique - // reports_claim_code_hash_key (0091) - exactly one report, and no timing oracle on the secret. - // Backfilled pre-0091 rows already carry their digest, so old reports stay claimable. A consumed - // code is cleared (both columns, so a legacy plaintext row cannot linger), so it no longer matches - // (single-use). reporter_user_id IS NULL guards against double-claim; anon_session_id is KEPT as - // an audit trail (documented in claim-service). + // Lock, link and clear in one statement. The caller hashes the presented code, so the lookup is an + // index probe on the partial-unique reports_claim_code_hash_key: exactly one report, and no timing + // oracle on the secret. Clearing both columns makes the code single-use and leaves no legacy + // plaintext behind. reporter_user_id IS NULL guards against double-claim; anon_session_id is kept + // as an audit trail (documented in claim-service). const updated = await tx<{ id: string }[]>` UPDATE reports SET reporter_user_id = ${userId}, claim_code = ${null}, claim_code_hash = ${null} diff --git a/services/api/src/services/certificate-code.ts b/services/api/src/services/certificate-code.ts index 40fb880c..ca635b8a 100644 --- a/services/api/src/services/certificate-code.ts +++ b/services/api/src/services/certificate-code.ts @@ -1,9 +1,9 @@ /** - * The public capability printed on a service-hours transcript (P5). + * The public capability printed on a service-hours transcript. * * The code IS the capability: it travels on the paper the holder hands to a registrar, and the verify * endpoint accepts nothing else. 12 Crockford base32 symbols is 32^12 ~= 2^60, so enumeration is not a - * threat model — but the draw still has to be a real CSPRNG draw, because a predictable code would let + * threat model, but the draw still has to be a real CSPRNG draw, because a predictable code would let * anyone assert someone else's service record. * * The format itself (alphabet, canonicalization, the `CFX-` display prefix) lives in @civfix/shared so @@ -25,11 +25,8 @@ export { formatCertificateCode, normalizeCertificateCode } from "@civfix/shared" export const CERTIFICATE_CODE_MINT_ATTEMPTS = 5 /** - * Mint one canonical 12-char code: `CERTIFICATE_CODE_LENGTH` independent unbiased draws over the 32-symbol - * alphabet, via the audited rejection-sampling primitive. - * - * NEVER `Math.random` (not a CSPRNG) and NEVER `randomBytes(n) % 32` (modulo bias) — `randomIntBelow` is - * the one place in this codebase that gets uniformity right, and OTP generation already relies on it. + * NEVER `Math.random` (not a CSPRNG) and NEVER `randomBytes(n) % 32` (modulo bias): `randomIntBelow` is + * the audited rejection-sampling primitive that gets uniformity right, and OTP generation relies on it. */ export function generateCertificateCode(): string { let out = "" diff --git a/services/api/src/services/certificate-fonts.ts b/services/api/src/services/certificate-fonts.ts index b262ab76..0d2bd0fa 100644 --- a/services/api/src/services/certificate-fonts.ts +++ b/services/api/src/services/certificate-fonts.ts @@ -1,17 +1,17 @@ /** - * Font resolution for the service-hours transcript PDF (P5). + * Font resolution for the service-hours transcript PDF. * * The faces live in `services/api/assets/fonts/` and reach the image because `.dockerignore` excludes no * `assets/` path and `services/api/package.json` declares no `files` field, so `pnpm --filter @civfix/api - * --prod deploy` copies the whole package directory — exactly how `drizzle/*.sql` reaches the migrate + * --prod deploy` copies the whole package directory, exactly how `drizzle/*.sql` reaches the migrate * container. If a `files` field is ever added it MUST list `assets` (and keep listing `drizzle`). * - * ⚠ THE PATH TRAP. `tsup.config.ts` has `splitting: false`, so this module is bundled INTO - * `dist/main.js` — depth 1 under the package root — while under tsx/vitest it runs from `src/services/` + * THE PATH TRAP. `tsup.config.ts` has `splitting: false`, so this module is bundled INTO + * `dist/main.js` (depth 1 under the package root), while under tsx/vitest it runs from `src/services/` * at depth 2. An `import.meta.url`-relative path is therefore different in dev and prod, so the directory * is found by PROBING for `MANIFEST.json` and memoized. * - * ⚠ STATIC INSTANCES ONLY. pdfkit/fontkit embed a variable font's default instance only; a `wght`-axis VF + * STATIC INSTANCES ONLY. pdfkit/fontkit embed a variable font's default instance only; a `wght`-axis VF * would render SemiBold as Regular with no error. See `assets/fonts/PROVENANCE.md`. */ @@ -25,7 +25,6 @@ const CANDIDATES = ["..", "../..", "../../.."] as const let cachedDir: string | undefined -/** Absolute path of the vendored font directory. Probed once, then memoized for the process. */ export function fontsDir(): string { if (cachedDir) return cachedDir for (const up of CANDIDATES) { @@ -38,7 +37,6 @@ export function fontsDir(): string { throw new Error(`certificate fonts not found; probed ${CANDIDATES.join(", ")} from ${HERE}`) } -/** One vendored face, as recorded in `assets/fonts/MANIFEST.json`. */ export interface FontManifestEntry { file: string family: string @@ -56,7 +54,6 @@ export interface FontManifest { let cachedManifest: FontManifest | undefined -/** Parsed `MANIFEST.json`. Read once per process. */ export function fontManifest(): FontManifest { if (cachedManifest) return cachedManifest const parsed = JSON.parse(readFileSync(join(fontsDir(), "MANIFEST.json"), "utf8")) as FontManifest @@ -92,7 +89,7 @@ export const FONT = { /** Certificate code, hours column, document fingerprint. */ mono: "JetBrainsMono-Regular.ttf", /** - * Korean/CJK fallback. Only the Regular weight is vendored (C1): a CJK Bold is another ~4.8 MB in the + * Korean/CJK fallback. Only the Regular weight is vendored: a CJK Bold is another ~4.8 MB in the * image for one typographic nuance, so `fontFor(text, "bold")` returns this same face for CJK text. */ cjk: "NotoSansKR-Regular.otf", @@ -120,7 +117,6 @@ const CJK_RANGES: readonly (readonly [number, number])[] = [ [0xff00, 0xffef], // Halfwidth and fullwidth forms ] -/** Does this string contain a character no Latin brand face can render? */ export function needsCjk(text: string): boolean { for (const ch of text) { const cp = ch.codePointAt(0) ?? 0 diff --git a/services/api/src/services/certificate-layout.ts b/services/api/src/services/certificate-layout.ts index 0d3c7d78..804c840a 100644 --- a/services/api/src/services/certificate-layout.ts +++ b/services/api/src/services/certificate-layout.ts @@ -1,9 +1,9 @@ /** - * PURE pagination for the transcript table (P5). Measured row heights come IN as numbers — the renderer - * supplies real `doc.heightOfString` values — so this module has no pdfkit dependency and every page + * PURE pagination for the transcript table. Measured row heights come IN as numbers (the renderer + * supplies real `doc.heightOfString` values), so this module has no pdfkit dependency and every page * boundary is unit-testable. * - * Geometry (DP §3.1/§3.3/§3.4), all in PostScript points on US Letter 612x792: + * Geometry, all in PostScript points on US Letter 612x792: * page 1 first row at y 344 (below the letterhead, holder card, summary tiles, column band) * continuation first row at y 100 (below the running header + a redrawn column band) * content floor y 730; nothing may cross it, because the footer band starts at y 738 @@ -48,7 +48,7 @@ export interface PagePlan { * Split measured row heights into pages. Always returns at least one page, so an empty ledger still * yields a well-formed (row-less) document rather than a zero-page one. * - * A row taller than a whole page is placed alone on its page rather than dropped — pdfkit will clip it, + * A row taller than a whole page is placed alone on its page rather than dropped: pdfkit will clip it, * which is visible, whereas silently losing a credited activity is not. */ export function planPages( @@ -81,21 +81,19 @@ export function planPages( } /** - * Does the totals row still fit under the last table row? When it does not, the renderer starts a - * continuation page for it — a totals line stranded across a page break reads as a different number. + * When the totals row does not fit under the last table row, the renderer starts a continuation page + * for it: a totals line stranded across a page break reads as a different number. */ export function totalsFitsOnPage(endY: number, opts: Partial = {}): boolean { const { contentFloor, totalsHeight } = { ...DEFAULT_PAGE_PLAN_OPTIONS, ...opts } return endY + totalsHeight <= contentFloor } -/** DP §3.5: the issuer/attestation block is forced onto a fresh page when < `issuerHeight` remains. */ export function issuerNeedsNewPage(y: number, opts: Partial = {}): boolean { const { contentFloor, issuerHeight } = { ...DEFAULT_PAGE_PLAN_OPTIONS, ...opts } return contentFloor - y < issuerHeight } -/** Rows that fit on a page whose first row starts at `top`, at a uniform `rowHeight`. */ export function rowsPerPage( top: number, rowHeight: number, diff --git a/services/api/src/services/certificate-model.ts b/services/api/src/services/certificate-model.ts index 100b535f..1e038d37 100644 --- a/services/api/src/services/certificate-model.ts +++ b/services/api/src/services/certificate-model.ts @@ -101,7 +101,8 @@ export interface BuildTranscriptModelInput { t?: CertificateTranslator } -const EMPTY_CELL = "—" +// An en dash, not a hyphen: it reads as "no value" in print and matches the period range glyph. +export const EMPTY_VALUE = "–" function toIso(value: Date | string): string { return typeof value === "string" ? new Date(value).toISOString() : value.toISOString() @@ -125,7 +126,7 @@ function activityLabel(row: TranscriptLedgerRow, t: CertificateTranslator): stri case "manual": return t("certificate.activity.manual") case "event": - return row.eventTitle?.trim() || row.eventReferenceCode?.trim() || EMPTY_CELL + return row.eventTitle?.trim() || row.eventReferenceCode?.trim() || EMPTY_VALUE } } @@ -150,7 +151,7 @@ export function buildTranscriptModel(input: BuildTranscriptModelInput): Transcri const rows: TranscriptModelRow[] = included.map((row) => { const occurredAt = toIso(row.occurredAt) - const community = row.jurisdictionName?.trim() || EMPTY_CELL + const community = row.jurisdictionName?.trim() || EMPTY_VALUE return { id: row.id, source: row.source, @@ -165,7 +166,7 @@ export function buildTranscriptModel(input: BuildTranscriptModelInput): Transcri const jurisdictions: string[] = [] for (const row of rows) { - if (row.community !== EMPTY_CELL && !jurisdictions.includes(row.community)) { + if (row.community !== EMPTY_VALUE && !jurisdictions.includes(row.community)) { jurisdictions.push(row.community) } } diff --git a/services/api/src/services/certificate-pdf.ts b/services/api/src/services/certificate-pdf.ts index fdf16afc..159eece0 100644 --- a/services/api/src/services/certificate-pdf.ts +++ b/services/api/src/services/certificate-pdf.ts @@ -10,6 +10,7 @@ import { CERTIFICATE_TIME_ZONE, certificateTranslator, communitiesLabel, + EMPTY_VALUE, type CertificateTranslator, type TranscriptModel, } from "./certificate-model.js" @@ -221,7 +222,7 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise const period = model.periodStart && model.periodEnd ? `${formatDate(new Date(model.periodStart), locale)} – ${formatDate(new Date(model.periodEnd), locale)}` - : "—" + : EMPTY_VALUE labelledValue(rx, top + 14, rw, t("certificate.holder.period"), period) labelledValue(rx, top + 48, rw, t("certificate.holder.issued"), issuedLabel) } diff --git a/services/api/src/services/certificate-repository.drizzle.ts b/services/api/src/services/certificate-repository.drizzle.ts index fb162f86..0b093064 100644 --- a/services/api/src/services/certificate-repository.drizzle.ts +++ b/services/api/src/services/certificate-repository.drizzle.ts @@ -1,13 +1,11 @@ /** - * Drizzle/postgres.js implementation of `CertificateRepository` (P5). - * - * ⚠ THE RULE FOR THIS FILE: **every read SELECTs an explicit column list; none of them selects + * THE RULE FOR THIS FILE: **every read SELECTs an explicit column list; none of them selects * `snapshot`, and none of them uses `SELECT *`.** * * `service_hours_certificates.snapshot` is the exact rendered model. At the 1000-entry cap it is ~200 KB * of jsonb, which Postgres stores out of line in TOAST. A `SELECT *` (or any projection naming - * `snapshot`) detoasts that blob on EVERY holder list read and on EVERY anonymous public verification — - * the highest-traffic read in this feature, served to a school registrar who needs six scalars. The + * `snapshot`) detoasts that blob on EVERY holder list read and on EVERY anonymous public verification + * (the highest-traffic read in this feature, served to a school registrar who needs six scalars). The * column is written once and read back by nothing; `service-hours-certificates-pg.test.ts` greps this * source to keep it that way. * @@ -173,7 +171,7 @@ export function makeDrizzleCertificateRepository(sql: Sql): CertificateRepositor /** * The PUBLIC verification read. The join to `users` exists ONLY to surface the tombstone: filtering * `deleted_at IS NULL` out of the result would collapse "that account was closed" into "no such - * code", which are different answers for the person holding the paper (DP §5.3). + * code", which are different answers for the person holding the paper. */ async findByCode(code: string): Promise { const rows = await sql<(CertificateRowSelect & { holder_deleted: boolean })[]>` @@ -195,7 +193,7 @@ export function makeDrizzleCertificateRepository(sql: Sql): CertificateRepositor /** * `COALESCE` makes this idempotent: revoking an already-revoked code returns the row unchanged * (200, "still revoked") rather than the 404 a `WHERE revoked_at IS NULL` guard would produce on a - * double tap. Null comes back only for an unknown code or someone else's — both 404 at the service. + * double tap. Null comes back only for an unknown code or someone else's; both 404 at the service. */ async revoke( userId: string, diff --git a/services/api/src/services/certificate-repository.memory.ts b/services/api/src/services/certificate-repository.memory.ts index fc0b3102..26f884ce 100644 --- a/services/api/src/services/certificate-repository.memory.ts +++ b/services/api/src/services/certificate-repository.memory.ts @@ -8,13 +8,12 @@ * * Mirrored deliberately: * - BOTH unique indexes, as the typed `CertificateConflictError`: `(code)` and the PARTIAL - * `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL`. The partial-ness is the point — revoking + * `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL`. The partial-ness is the point: revoking * frees the slot so the holder can re-issue over the same ledger. * - `revoke` is idempotent via a COALESCE-equivalent, and returns null for an unknown code OR another * user's code, so the service's "404, never 403" rule is exercised here too. * - `findByCode` reports a tombstoned holder rather than hiding the row. - * - reads hand back CLONES, so a test mutating a returned row cannot corrupt the store (the Drizzle - * repo obviously cannot be corrupted that way). + * - reads hand back CLONES, so a test mutating a returned row cannot corrupt the store. * * `snapshot` is stored but never read back, exactly as in production. */ diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index 480d18e5..d1284a8c 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -1,20 +1,15 @@ /** - * Service-hours transcript orchestration (P5 / DP §4.3): ledger read -> pure model -> fingerprint -> - * render -> R2 -> row, plus the holder's list/revoke reads and the PUBLIC verification projection. + * The only impure half of the certificate stack: `certificate-model.ts`, `certificate-layout.ts` and + * `certificate-pdf.ts` are pure, so every I/O and every `AppError` lives here. * - * This is the only impure half of the certificate stack. `certificate-model.ts` (the model + - * fingerprint), `certificate-layout.ts` (pagination) and `certificate-pdf.ts` (the renderer) are pure; - * everything below does I/O and throws `AppError`. Routes never build error bodies. + * THE MEDIA-WORKER ORPHAN SWEEP MUST NEVER REAP `certificates/`. * - * ───────────────────────────────────────────────────────────────────────────────────────────────── - * ⚠ DB T2 — THE MEDIA-WORKER ORPHAN SWEEP MUST NEVER REAP `certificates/`. - * - * These PDFs live in the MEDIA bucket (B41b: `container.storage`, no new env var) under the + * These PDFs live in the MEDIA bucket (`container.storage`, no new env var) under the * `certificates/service-hours/…` prefix, but they are deliberately NOT `media_assets` rows: creating one * would drag in the byte quota, the NSFW check and the orphan sweep, and would widen `MediaPurposeSchema` * for something that is not user media. * - * Today that is safe, because the media-worker's orphan reaper is ROW-DRIVEN — it walks + * Today that is safe, because the media-worker's orphan reaper is ROW-DRIVEN: it walks * `media_reap_tombstones` / `media_assets` and deletes the keys those rows name; it never lists the * bucket by prefix. A certificate object has no row there, so nothing can reach it. * @@ -22,7 +17,6 @@ * Every object under this prefix is referenced by a `service_hours_certificates` row and is the durable * cache behind a document a volunteer has already handed to a school or a court. A prefix sweep that does * not know about this table would silently break every issued transcript. - * ───────────────────────────────────────────────────────────────────────────────────────────────── */ import { createHash, randomUUID } from "node:crypto" @@ -55,19 +49,15 @@ import type { VolunteerHoursRepository, } from "./volunteer-hours-service.js" -// ---- storage seam ------------------------------------------------------------------------------- - /** - * Structural slice of the storage adapter, declared locally exactly as `services/media-presign.ts:9-15` - * does — and for the same reason (H9): - * - * the shared `Storage` interface declares `presignGet(key, ttlSec)` with only TWO parameters. The third + * Structural slice of the storage adapter, declared locally for the same reason as in + * `services/media-presign.ts`: the shared `Storage` interface declares `presignGet(key, ttlSec)` with only TWO parameters. The third * `opts` argument is honoured by `R2Storage` but is not on the interface, so a slice that declares it * keeps `FakeStorage` (two params, method-bivariant) assignable while making `{ forceSigned: true }` * expressible at every call site. * * `forceSigned` is NOT optional in practice here: `R2_PUBLIC_BASE` is set in production, and without it - * `R2Storage.presignGet` returns an UNSIGNED, PERMANENT CDN URL — which would publish every volunteer's + * `R2Storage.presignGet` returns an UNSIGNED, PERMANENT CDN URL, which would publish every volunteer's * itemised service record forever, with no expiry and no revocation path. Always * `presignGet(key, CERTIFICATE_GET_URL_TTL_SEC, { forceSigned: true })`. * @@ -80,8 +70,6 @@ export interface CertificateStorage { delete(key: string): Promise } -// ---- repository contract ------------------------------------------------------------------------ - /** The holder identity frozen onto the document, read from `users`. */ export interface CertificateHolder { userId: string @@ -92,11 +80,9 @@ export interface CertificateHolder { } /** - * One certificate row as every read path projects it. - * * `snapshot` is DELIBERATELY ABSENT. At 1000 entries the jsonb is ~200 KB and lives out of line in TOAST; * a read path that selects it detoasts on every list and every public verification. Every repo query - * therefore names its columns explicitly (never `SELECT *`) and none of them names `snapshot` — + * therefore names its columns explicitly (never `SELECT *`) and none of them names `snapshot`; * `service-hours-certificates-pg.test.ts` greps the repo source to keep it that way. */ export interface CertificateRow { @@ -124,7 +110,7 @@ export interface CertificateRow { /** * The verification read. `holderDeleted` mirrors `users.deleted_at IS NOT NULL` from the join: the * projection cannot simply filter tombstoned holders out, because "no such code" and "that account was - * closed" are materially different answers for the person holding the paper (DP §5.3). + * closed" are materially different answers for the person holding the paper. */ export interface CertificateVerifyRow extends CertificateRow { holderDeleted: boolean @@ -178,11 +164,11 @@ export interface CertificateRepository { findByCode(code: string): Promise /** * Idempotent: returns the row whether or not it was already revoked, and null only when the code does - * not exist OR does not belong to `userId` — the service turns that null into a 404, never a 403, so + * not exist OR does not belong to `userId`: the service turns that null into a 404, never a 403, so * the endpoint is not an existence oracle over someone else's codes. */ revoke(userId: string, code: string, reason: string, at: Date): Promise - /** The DP §4.4 operator-error path: the row survived, its object did not, and it was re-rendered. */ + /** The operator-error path: the row survived, its object did not, and it was re-rendered. */ markRegenerated(args: { id: string documentSha256: string @@ -192,8 +178,6 @@ export interface CertificateRepository { findHolder(userId: string): Promise } -// ---- service ------------------------------------------------------------------------------------ - export interface CertificateServiceDeps { repo: CertificateRepository /** The ledger read lives on the volunteer-hours repo; this service owns only the certificate rows. */ @@ -216,10 +200,9 @@ export interface CertificateService { } /** - * `certificates/service-hours/{YYYY}/{MM}/{id}.pdf` (C16), in the EXISTING media bucket (B41b — no new - * env var, which is DL R13's rule). The `{id}` segment is the row's `gen_random_uuid()` and is NEVER - * disclosed by the public verify endpoint, so even `/` is unguessable — the same - * defence-in-depth posture as `buildR2Key(uploadId, now)` in media-intake-service.ts. + * Lives in the EXISTING media bucket (no new env var). The `{id}` segment is the row's + * `gen_random_uuid()` and is NEVER disclosed by the public verify endpoint, so even + * `/` is unguessable: the same defence-in-depth posture as `buildR2Key(uploadId, now)` in media-intake-service.ts. */ export function certificateObjectKey(id: string, issuedAt: Date): string { const year = issuedAt.getUTCFullYear().toString().padStart(4, "0") @@ -228,7 +211,7 @@ export function certificateObjectKey(id: string, issuedAt: Date): string { } /** - * `inline`, NOT `attachment` (C17). On web the holder opens the URL in a new tab: `attachment` fires a + * `inline`, NOT `attachment`. On web the holder opens the URL in a new tab: `attachment` fires a * download and leaves an empty orphan tab behind, while `inline` renders in the browser's own PDF viewer, * which already offers Download and Print. On mobile it is what makes Safari/Chrome show the document * with a Share affordance. @@ -241,7 +224,6 @@ function sha256Hex(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex") } -/** The ledger row shape the pure model consumes, from the repo's join-hydrated view. */ function toLedgerRow(view: VolunteerHoursEntryView): TranscriptLedgerRow { return { id: view.id, @@ -250,7 +232,7 @@ function toLedgerRow(view: VolunteerHoursEntryView): TranscriptLedgerRow { occurredAt: view.occurredAt, eventTitle: view.cleanupTitle, eventReferenceCode: view.cleanupReferenceCode, - // The ledger read does not join `reports` — a public list of every report a user filed is a privacy + // The ledger read does not join `reports`: a public list of every report a user filed is a privacy // leak, and the transcript deliberately names no report (certificate-model.activityLabel prints the // localized "Verified report" label, whose {{ref}} is simply empty here). reportReferenceCode: null, @@ -266,7 +248,7 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat const mintCode = (): string => deps.mintCode?.() ?? generateCertificateCode() function presign(key: string): Promise { - // H9: `forceSigned` is load-bearing, not decoration. See the CertificateStorage doc comment. + // `forceSigned` is load-bearing, not decoration. See the CertificateStorage doc comment. return storage.presignGet(key, CERTIFICATE_GET_URL_TTL_SEC, { forceSigned: true }) } @@ -319,9 +301,9 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat const locale = resolveLocale(requestedLocale ?? holder.locale) - // v1 issues over the WHOLE ledger: no geoid / from / to filters (C2). `entryCount` is the full - // matching count; `totalHours` is the sum of the RETURNED rows (B40b — a printed total that does not - // equal the sum of the printed lines is a self-contradicting document). + // v1 issues over the WHOLE ledger: no geoid / from / to filters. `entryCount` is the full matching + // count; `totalHours` is the sum of the RETURNED rows, because a printed total that does not equal + // the sum of the printed lines is a self-contradicting document. const page = await hours.entriesForCertificate({ userId, geoid: null, @@ -351,15 +333,14 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat if (existing !== null) { const head = await storage.head(existing.r2Key) if (head !== null) { - // The common repeat call renders NOTHING: a row lookup, a HEAD and a presign (DP §4.1). + // The common repeat call renders NOTHING: a row lookup, a HEAD and a presign. return { certificate: toCertificateDTO(existing, await presign(existing.r2Key), at), reused: true, } } - // DP §4.4, expected to be exercised approximately never (operator error / a bucket incident). The - // row is the record of truth, so re-render THIS document — same code, same issue date, same key — - // rather than minting a second certificate over the same ledger, which the partial unique index + // Expected to be exercised approximately never (operator error / a bucket incident). The row is + // the record of truth, so re-render THIS document (same code, same issue date, same key) rather than minting a second certificate over the same ledger, which the partial unique index // would reject anyway. The model rebuilt above has the same fingerprint as the stored snapshot, so // it is used directly instead of detoasting `snapshot` on a path that is otherwise free. const bytes = await buildServiceHoursPdf({ @@ -473,7 +454,7 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat async function revoke(userId: string, code: string): Promise { const at = now() const row = await repo.revoke(userId, code, "holder", at) - // Someone else's code is 404, NOT 403 — a 403 would confirm that the code exists (the existence-oracle + // Someone else's code is 404, NOT 403: a 403 would confirm that the code exists (the existence-oracle // rule documented in media.routes.ts). if (row === null) throw AppError.notFound() await bestEffortDelete(row.r2Key, "revoke") @@ -492,11 +473,11 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat * code far more damaging than a leaked page. * * `jurisdictionNames` is deliberately OMITTED even though the contract allows it: the only source is - * the ~200 KB TOASTed `snapshot`, and no verification read may detoast that (DP §4.5). The field is + * the ~200 KB TOASTed `snapshot`, and no verification read may detoast that. The field is * `.optional()`, so an absent value parses cleanly on every client. * - * `showVolunteerHours` does NOT gate this (C2/DB B32c). That flag governs the public PROFILE - * projection — a surface the holder never explicitly shared. A certificate is a document the holder + * `showVolunteerHours` does NOT gate this. That flag governs the public PROFILE projection, a + * surface the holder never explicitly shared. A certificate is a document the holder * deliberately handed to a verifier, and conflating the two would break every already-issued * transcript the moment someone toggled a profile switch. Revocation is the control, and it exists. */ @@ -512,13 +493,13 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat if (row.holderDeleted) { // A tombstoned holder (docs/erasure-behavior.md). The document stops being good, and the identity - // fields are omitted entirely rather than echoing a name the account has erased — which is now + // fields are omitted entirely rather than echoing a name the account has erased. That is now // belt-and-braces, because erasure also BLANKS `holder_name`/`holder_handle`/`snapshot` on the row // (softDeleteAndAnonymize) and deletes the object. return { ...facts, status: "revoked", - // Erasure stamps `revoked_at` (reason `account_closed`), so this is normally the closure time — + // Erasure stamps `revoked_at` (reason `account_closed`), so this is normally the closure time, // or the holder's own earlier revocation, which COALESCE preserves. It stays NULLABLE for rows // tombstoned before that scrub existed: this projection does not read `users.deleted_at`'s // timestamp, and inventing one (e.g. echoing issuedAt) would put a false date in front of a @@ -538,7 +519,7 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat if (row.revokedAt !== null) { // Revoked keeps holderName + issuedAt on purpose: the person holding the paper learns WHY it is not - // good. That is a mild oracle over a 60-bit secret and is worth the clarity (DP §5.3). + // good. That is a mild oracle over a 60-bit secret and is worth the clarity. return { ...facts, ...identity, diff --git a/services/api/src/services/chat-bells.ts b/services/api/src/services/chat-bells.ts index 877e662e..9b567f0d 100644 --- a/services/api/src/services/chat-bells.ts +++ b/services/api/src/services/chat-bells.ts @@ -1,37 +1,12 @@ /** - * P2 Task 2.5: post-send BELL notifiers for the chat rooms, extracted from chat-gateway-wiring so the - * gate logic is single-sourced and testable (unit + pg-integration) instead of living in inline - * closures. Three factories, one per bell shape: - * - * - makeChatMentionNotifier — the @-mention bell for GROUP rooms (cleanup + report + group; dm is a - * no-op: a dm message already bells via the delivered-bell). Mute-gated, membership-gated, - * block-gated, and `prefs.mentions`-gated. Report rooms bell as `report_chat` -> - * /messages/report/:id, cleanup rooms as `cleanup_chat` -> /cleanups/:id, group rooms (P4 4.5) as - * `group_chat` -> /messages/group/:id (D11 re-enables report mentions; the resolver in - * chat-mention-resolver.ts scopes WHO can be mentioned). - * - makeChatReplyNotifier — the reply bell for GROUP rooms: when a sent message replies to YOUR - * message you get a bell EVEN IF you muted the room. This is deliberately the ONE bell path with - * no conversation-mute gate (replies pierce room mutes). Reply urgency is mention-class, so - * `prefs.mentions` still gates it; membership, blocks, and presence (target watching the room - * live) still suppress; push master + quiet hours ride createNotification as everywhere else. - * - makeDmBellNotifier — the DM delivered bell (previously inline `onDmDelivered`), now carrying the - * reply override: a reply TO the recipient pierces a MUTED thread (again `prefs.mentions`-gated); - * an unmuted thread takes the normal path. Because this is the SINGLE dm bell site, a dm reply can - * never double-bell — the reply flavor only changes the title copy and the mute behavior. - * - * DEDUPE MAP (where each "no double bell" decision lives): - * - reply target also @-mentioned -> ws/frame-handler fireMentionBells skips the mention bell - * for the reply target (reply bell preferred). - * - report-room member fan-out -> report-chat-notifier excludes the reply target and the - * @-mentioned members from the fan-out set (they get the - * richer reply/mention bell instead). - * - group-room member fan-out (P4 4.5) -> group-chat-notifier excludes the reply target and the - * @-mentioned members from the fan-out set (same shape as - * the report fan-out; one bell per member per message). - * - dm reply vs dm delivered bell -> one call site (makeDmBellNotifier), one bell by - * construction. + * Where each "no double bell" decision lives: + * - reply target also @-mentioned: ws/frame-handler fireMentionBells skips the mention bell for the + * reply target (the reply bell wins). + * - report and group member fan-out: report-chat-notifier and group-chat-notifier exclude the reply + * target and the @-mentioned members, who get the richer reply or mention bell instead. + * - dm reply vs dm delivered bell: makeDmBellNotifier is the single dm bell site, so one bell by + * construction. */ - import type { NotificationType, RoomKind } from "@civfix/shared" import type { NotificationService } from "./notification-service.js" import { CONVERSATION_BELL } from "./conversation-bell.js" @@ -39,9 +14,8 @@ import { dmAuthorName, mentionAuthorName, textPreview } from "../routes/chat-not import type { GatewayChatMentions, OnChatReply, OnDmDelivered } from "../ws/types.js" /** - * Notification type + deep link for a group-room bell, per room kind. Straight off CONVERSATION_BELL — - * clear-on-open (clearByTypeAndLink) matches on exactly these two strings, so a second hand-written copy - * of the map would silently strand bells the moment either side changed. + * Straight off CONVERSATION_BELL: clear-on-open (clearByTypeAndLink) matches on exactly these two + * strings, so a second hand-written copy of the map would silently strand bells once either side changed. */ function groupBellRoute( kind: "cleanup" | "report" | "group", @@ -53,7 +27,7 @@ function groupBellRoute( export interface ChatBellDeps { notificationService: Pick - /** Best-effort "has this user muted this room?" (absent store / lookup error => false). */ + /** Best-effort: an absent store or a lookup error reads as not muted. */ isMutedFor( userId: string, kind: "dm" | "cleanup" | "report" | "group", @@ -61,7 +35,7 @@ export interface ChatBellDeps { ): Promise isCleanupMember(cleanupId: string, userId: string): Promise isReportChatMember(reportId: string, userId: string): Promise - /** P4 4.5: chat_group_members membership (fail-closed false when the group repo is unwired). */ + /** Fails closed (false) when the group repo is unwired. */ isChatGroupMember(groupId: string, userId: string): Promise isBlockedEitherWay(a: string, b: string): Promise /** Optional presence source; when absent no one is treated as present. */ @@ -69,7 +43,6 @@ export interface ChatBellDeps { roomKeyFor(kind: RoomKind, id: string): string } -/** True when `userId` is a member of the group room (kind-aware). */ async function isGroupMember( deps: ChatBellDeps, kind: "cleanup" | "report" | "group", @@ -81,16 +54,12 @@ async function isGroupMember( return deps.isCleanupMember(roomId, userId) } -/** - * The @-mention bell (GROUP rooms only). Same gate order the pre-2.5 inline closure applied for - * cleanup rooms — mute, membership, blocks, mentions pref — now kind-aware for report rooms (D11). - */ +/** A dm message already bells through makeDmBellNotifier, so dm mentions are a no-op here. */ export function makeChatMentionNotifier( deps: ChatBellDeps, ): GatewayChatMentions["notifyChatMention"] { return async (input) => { const { kind, roomId, actorUserId, mentionedUserId, message } = input - // dm bells ride makeDmBellNotifier (a dm message already bells via the delivered bell). if (kind === "dm") return if (await deps.isMutedFor(mentionedUserId, kind, roomId)) return if (!(await isGroupMember(deps, kind, roomId, mentionedUserId))) return @@ -110,27 +79,23 @@ export function makeChatMentionNotifier( } /** - * The reply bell (GROUP rooms only; dm replies ride makeDmBellNotifier). NO isMutedFor gate BY - * DESIGN: a reply to your message pierces the room mute — that is the entire point of this bell. - * Everything else mirrors the mention bell (membership, blocks, mentions pref) plus presence - * suppression: a target watching the room live sees the reply as it lands and needs no bell. + * No isMutedFor gate by design: a reply to your message pierces the room mute, which is the point of + * this bell. A target watching the room live sees the reply as it lands and needs no bell. */ export function makeChatReplyNotifier(deps: ChatBellDeps): OnChatReply { return async (input) => { const { kind, roomId, actorUserId, targetUserId, message } = input - // dm replies ride makeDmBellNotifier (the single dm bell site owns dm reply flavor). if (kind === "dm") return if (!(await isGroupMember(deps, kind, roomId, targetUserId))) return if (await deps.isBlockedEitherWay(actorUserId, targetUserId)) return - // Reply urgency is mention-class: the mentions pref gates the bell entirely (row + push); push - // master switch + quiet hours are applied inside createNotification as for every other bell. + // Reply urgency is mention-class, so the mentions pref gates the bell entirely (row and push). if (!(await deps.notificationService.getPrefs(targetUserId)).mentions) return if (deps.presence) { let online: string[] = [] try { online = await deps.presence.online(deps.roomKeyFor(kind, roomId)) } catch { - online = [] // best-effort presence: a lookup failure never suppresses the bell + online = [] // a presence lookup failure never suppresses the bell } if (online.includes(targetUserId)) return } @@ -147,19 +112,13 @@ export function makeChatReplyNotifier(deps: ChatBellDeps): OnChatReply { } } -/** The (much smaller) dep set the dm delivered bell needs. */ export type DmBellDeps = Pick /** - * The DM delivered bell (the wiring's `onDmDelivered`), with the P2 2.5 reply override. The caller - * (frame-handler fireDmBell) already applies presence suppression and never fires for the sender, and - * the send path already rejects blocked pairs — so this owns only the mute/reply/copy logic. - * - * Reply semantics: `message.replyTo.from.id === recipientId` marks "this dm replies to YOU". - * - MUTED thread: only such a reply bells (pierces the mute), and only when `prefs.mentions` allows - * (mute-piercing is mention-class urgency). Everything else stays silent. - * - UNMUTED thread: the normal single bell fires either way — being a reply only swaps the title to - * the reply-flavored copy. One call site => never two bells for one dm message. + * The caller (frame-handler fireDmBell) already applies presence suppression and never fires for the + * sender, and the send path already rejects blocked pairs, so this owns only mute, reply and copy. + * In a muted thread only a reply to the recipient bells, and only when `prefs.mentions` allows it + * (mute-piercing is mention-class urgency); in an unmuted thread a reply only swaps the title copy. */ export function makeDmBellNotifier(deps: DmBellDeps): OnDmDelivered { return async (threadId, recipientId, message) => { diff --git a/services/api/src/services/chat-edit-service.ts b/services/api/src/services/chat-edit-service.ts index f6a0c9f7..d347011d 100644 --- a/services/api/src/services/chat-edit-service.ts +++ b/services/api/src/services/chat-edit-service.ts @@ -1,31 +1,12 @@ /** - * Unified chat-message EDIT service (P0 Task 0.2): one roomKind-dispatching `editMessage` behind the DM - * edit route today and the upcoming PATCH /messages route (Task 0.3). Mirrors chat-reaction-service's - * shape: a factory over optional per-room deps, so a DM-only caller (dm.routes) wires only the dm half. - * - * Gate ladder (in order): - * 1. Room-send permission still held (the SAME checks the WS send path runs): cleanup member, report - * chat member (preceded by the report VISIBILITY check when deps.isReportVisible is wired -> 404), - * group member, dm thread peer + not blocked either way -> plain 403. This runs BEFORE the message - * lookup, like chat-reaction-service, so a non-member probing leaked UUIDs gets the same generic - * 403 whether or not the message exists, belongs to the room, is deleted, or is a system row. - * 2. Resolve the message by id in the correct table (dm_messages for "dm", chat_messages otherwise) - * and verify its room ref matches roomId -> 404 otherwise (also plain-missing). - * 3. Sender-only -> 403 (machine code "not_sender" in the error envelope's `fields.code`). A - * sender-less SYSTEM row skips this gate and fails the kind gate below instead (422) — "not your - * message" would be misleading for a message nobody authored. - * 4. Not soft-deleted -> 409. - * 5. kind === "text" only -> 422. - * 6. Within EDIT_WINDOW_HOURS of created_at -> 403 (code "edit_window_expired"). - * Then: slur filter (App Store 1.2a, same helper as WS send / the old DM edit), the sender-gated UPDATE - * (body + edited_at = now()), mention re-resolution under the same scope rules the send path records - * with (the recorded set is REPLACED so dropped @mentions clear), a {type:"message_update"} - * broadcast to the room key, and the refreshed fully-hydrated ChatMessageDTO back to the caller. + * The room-send permission check (the same one the WS send path runs) comes BEFORE the message lookup, + * so a non-member probing leaked UUIDs gets the same generic 403 whether or not the message exists, + * belongs to the room, is deleted, or is a system row. A sender-less SYSTEM row skips the sender gate + * and fails the kind gate instead (422): "not your message" would mislead for a message nobody wrote. * * The machine subcodes ride AppError's `fields` ({ code: "..." }) because ErrorCode is a closed enum; * clients key off httpStatus + fields.code. */ - import { AppError, EDIT_WINDOW_HOURS, ErrorCode } from "@civfix/shared" import type { ChatMessageDTO, ChatMessageKind, RoomKind, WsServerMessage } from "@civfix/shared" import { assertNoSlur } from "../abuse/slur-filter.js" @@ -46,24 +27,18 @@ export interface ChatEditServiceDeps { isCleanupMember?: IsRoomMemberFn isReportMember?: IsRoomMemberFn /** - * Report VISIBILITY (isReportVisibleTo: a publicly-visible status + public, or the reporter's own). - * Optional; when wired it runs BEFORE the membership gate so an unlisted / held / soft-deleted report - * answers 404 exactly like report-chat.routes' requireVisibleReport — the same shape and ordering - * chat-reaction-service's report lane uses. Absent = not enforced in-service, which is why the route - * keeps its own requireVisibleReport pre-gate; wiring this closes the gap for any OTHER caller (a - * membership row survives a report being held, so membership alone is not the visibility gate). + * Runs before the membership gate so an unlisted, held or soft-deleted report answers 404 like + * report-chat.routes' requireVisibleReport. A membership row survives a report being held, so + * membership alone is not the visibility gate; when this is absent the route's own pre-gate is the + * only one. */ isReportVisible?: (reportId: string, userId: string) => Promise - /** P4 4.4 group lane: chat_group_members membership (the SAME gate the WS group send runs). */ isGroupMember?: IsRoomMemberFn dmPeerOf?: (threadId: string, userId: string) => Promise isBlockedEitherWay?: (a: string, b: string) => Promise - /** - * Optional mention seam (resolve + record). Absent -> the edit leaves the recorded mentions as-is. - * notifyChatMention is deliberately excluded: editing a message never re-fires mention bells. - */ + /** notifyChatMention is deliberately excluded: editing a message never re-fires mention bells. */ chatMentions?: Pick - /** Room fan-out seam (chatService.broadcastEvent). Best-effort: a failure never fails the edit. */ + /** Best-effort: a failure never fails the edit. */ broadcastEvent?: (roomKey: string, frame: WsServerMessage) => Promise | void } @@ -88,7 +63,6 @@ const editWindowExpired = () => fields: { code: "edit_window_expired" }, }) -/** Gates 2-5 over the resolved row metadata (shared by the dm and chat flows). */ function assertEditable( meta: { senderId: string | null; kind: ChatMessageKind; createdAt: Date; deletedAt: Date | null }, userId: string, @@ -105,11 +79,6 @@ function assertEditable( } export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService { - /** - * Re-resolve + REPLACE the message's recorded mention set from the edited body (the resolver scopes it - * to the dm peer or the room's members). Best-effort like the WS send path: a mention failure never - * fails the edit. - */ async function rerecordMentions( kind: RoomKind, roomId: string, @@ -141,7 +110,6 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService } } - /** Fire-and-forget the {type:"message_update"} frame to the room key (SAME helper the delete routes use). */ function fireMessageUpdate(roomKind: RoomKind, roomId: string, message: ChatMessageDTO): void { broadcastMessageUpdate( { broadcastEvent: deps.broadcastEvent }, @@ -167,12 +135,11 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService assertEditable(meta, userId) assertNoSlur(body, "body") - // Record mentions BEFORE the sender-gated edit so the returned (re-read) DTO already carries them. - // Lost-race residue is harmless: if the row is tombstoned underneath us the edit below no-ops (409) - // and the replaced mention set sits on a deleted message no reader ever hydrates. + // Mentions are recorded before the sender-gated edit so the re-read DTO already carries them. If + // the row is tombstoned underneath us the edit no-ops (409) and the residue sits on a deleted + // message no reader ever hydrates. await rerecordMentions("dm", roomId, userId, messageId, body, input.mentionedUserIds) const updated = await dm.editMessage(roomId, messageId, userId, body) - // The gate ladder passed above, so a null here is a lost race (e.g. deleted underneath us). if (updated === null) throw AppError.conflict("This message was deleted.") fireMessageUpdate("dm", roomId, updated) return updated @@ -185,8 +152,8 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService const isReport = roomKind === "report" const isGroup = roomKind === "group" if (isReport) { - // Visibility first (when wired), so a report that went held/unlisted answers 404 like the routes' - // requireVisibleReport rather than leaking a 403 keyed on a stale membership row. + // Visibility first, so a held or unlisted report answers 404 rather than leaking a 403 keyed on a + // stale membership row. if (deps.isReportVisible && !(await deps.isReportVisible(roomId, userId))) { throw AppError.notFound("Report not found") } @@ -214,8 +181,8 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService assertEditable(meta, userId) assertNoSlur(body, "body") - // Mentions are REPLACED before the sender-gated UPDATE (so the re-read DTO carries them); a lost - // race leaves the residue on a tombstoned row, which is harmless — no reader hydrates it. + // Mentions are replaced before the sender-gated UPDATE so the re-read DTO carries them; a lost race + // leaves the residue on a tombstoned row no reader hydrates. await rerecordMentions(roomKind, roomId, userId, messageId, body, input.mentionedUserIds) const updated = isReport ? await chat.editReportMessage(roomId, messageId, userId, body) diff --git a/services/api/src/services/chat-history-window.ts b/services/api/src/services/chat-history-window.ts index befaf832..6ef5d671 100644 --- a/services/api/src/services/chat-history-window.ts +++ b/services/api/src/services/chat-history-window.ts @@ -1,27 +1,13 @@ /** - * Around-mode history windows (P2 Task 2.4): pure split/merge helpers shared by the chat + dm - * repositories (drizzle AND in-memory twins) so all history endpoints center a page on a target - * message the same way. - * - * WINDOW SEMANTICS (for `?around=`): - * - The target's (created_at, id) is the anchor. The window is ceil(limit/2) rows at-or-older than - * the anchor (INCLUSIVE — the target row itself is always in the window) + floor(limit/2) rows - * strictly newer, re-sorted into the endpoint's normal newest-first order. - * - `nextCursor` = the OLDEST id in the window when more older rows exist beyond it (same meaning as - * before-mode: pass it as `before` to page older), else null. - * - `prevCursor` = the NEWEST id in the window when more rows exist between it and the live head, - * else null (the window reaches the head). NOTE: there is no `after` request param today — clients - * currently consume prevCursor only as a "there are newer messages" signal (e.g. to show a - * jump-to-latest affordance or re-fetch around it); it is NOT a follow-cursor like nextCursor. - * - * Both sides fetch limit+1 (olderLimit+1 / newerLimit+1) so has-more is computed correctly on EACH end. + * Shared by the chat and dm repositories (drizzle and in-memory) so every `?around=` page + * centers on its target the same way. There is no `after` request param: clients read `prevCursor` + * only as a "there are newer messages" signal, not as a follow-cursor like `nextCursor`. Both sides + * fetch limit+1 so has-more is correct on each end. */ -/** How a `limit` splits across the two sides of the anchor (older side includes the target row). */ export interface AroundLimits { - /** Rows at-or-older than the anchor, INCLUDING the target itself: ceil(limit/2). */ + /** Includes the target row itself, so the target is always in the window. */ olderLimit: number - /** Rows strictly newer than the anchor: floor(limit/2). */ newerLimit: number } @@ -30,20 +16,15 @@ export function aroundLimits(limit: number): AroundLimits { } export interface AroundWindow { - /** The merged window in newest-first order (identical ordering to a before-mode page). */ rows: T[] - /** More rows exist OLDER than the window (drives nextCursor). */ hasOlder: boolean - /** More rows exist NEWER than the window (drives prevCursor). */ hasNewer: boolean } /** - * Merge the two per-side fetches into one newest-first window. - * @param olderDesc rows at-or-older than the anchor, newest-first (anchor row first), fetched with - * LIMIT olderLimit+1. - * @param newerAsc rows strictly newer than the anchor, OLDEST-first (ascending — the natural fetch - * order when seeking forward from the anchor), fetched with LIMIT newerLimit+1. + * @param olderDesc rows at-or-older than the anchor, newest-first, fetched with LIMIT olderLimit+1. + * @param newerAsc rows strictly newer than the anchor, oldest-first (the natural order when seeking + * forward from the anchor), fetched with LIMIT newerLimit+1. */ export function mergeAroundWindow( olderDesc: T[], @@ -54,6 +35,5 @@ export function mergeAroundWindow( const hasNewer = newerAsc.length > newerLimit const older = hasOlder ? olderDesc.slice(0, olderLimit) : olderDesc const newer = hasNewer ? newerAsc.slice(0, newerLimit) : newerAsc - // Newer rows arrive ascending; reverse into DESC and prepend so the merged window reads newest-first. return { rows: [...newer.slice().reverse(), ...older], hasOlder, hasNewer } } diff --git a/services/api/src/services/chat-mention-resolver.ts b/services/api/src/services/chat-mention-resolver.ts index 2b47be96..00e72cc4 100644 --- a/services/api/src/services/chat-mention-resolver.ts +++ b/services/api/src/services/chat-mention-resolver.ts @@ -1,17 +1,8 @@ /** - * Room-scope rules for resolving chat @-mentions — the SINGLE source consumed by both the WS gateway - * wiring (send path) and the PATCH /messages route (edit path): - * - * - report rooms resolve only current report_chat_members (D11, P2 2.5): a @mention of a non-member - * silently resolves to nothing — no row, no bell; - * - dm resolves only the thread PEER (a @mention of anyone else silently drops); - * - cleanup resolves only current cleanup_members; - * - group rooms (P4 4.4) resolve only current chat_group_members. - * - * Membership is checked for the resolved users only, never against a capped roster listing, so a member - * of any seniority stays mentionable. The user lookup, dm-peer lookup, and member filters are injected so - * each call site wires its own repo instances; only the scope rules live here. Room kinds added later - * extend THIS file. + * The single source of mention scope for both the send and the edit path: a @mention of anyone outside + * the room (the dm peer, or the room's current members) silently resolves to nothing, so no row and no + * bell. Membership is checked for the resolved users only, never against a capped roster listing, so a + * member of any seniority stays mentionable. */ import type { RoomKind, UserMentionDTO } from "@civfix/shared" @@ -64,23 +55,18 @@ export async function resolveAndRecordChatMentions( } export interface ChatMentionResolverDeps { - /** Handle/id -> mentionable-user lookup (drizzle `resolveMentionTargets` over the shared sql tag). */ resolveTargets(input: { handles: string[] userIds: string[] authorUserId: string }): Promise - /** The OTHER dm participant, or null when the author is not in the thread. */ + /** null when the author is not in the thread. */ dmPeerOf(threadId: string, userId: string): Promise - /** The cleanup members among `candidateIds`. */ listCleanupMemberIds(cleanupId: string, candidateIds: string[]): Promise - /** The report chat members (report_chat_members) among `candidateIds`. */ listReportChatMemberIds(reportId: string, candidateIds: string[]): Promise - /** The group room members (chat_group_members; P4 4.4) among `candidateIds`. */ listGroupMemberIds(groupId: string, candidateIds: string[]): Promise } -/** Build the seam's `resolveChatMentions` half over the injected lookups. */ export function makeChatMentionResolver( deps: ChatMentionResolverDeps, ): GatewayChatMentions["resolveChatMentions"] { diff --git a/services/api/src/services/chat-mentions.drizzle.ts b/services/api/src/services/chat-mentions.drizzle.ts index 8b23ac80..078bf651 100644 --- a/services/api/src/services/chat-mentions.drizzle.ts +++ b/services/api/src/services/chat-mentions.drizzle.ts @@ -1,8 +1,6 @@ /** - * Chat/DM message USER @-mentions: thin binders over the table-parameterized message-mentions repo - * (message-mentions.drizzle.ts). chat_message_mentions serves BOTH the cleanup group chat and 1:1 DMs - * (message ids are globally-unique uuids across both). The WRITE path is driven from the WS gateway seam - * AFTER persist (a mention failure never blocks the message); these are the chat-table bindings. + * chat_message_mentions serves both room chat and 1:1 DMs because message ids are globally unique uuids + * across both tables. Writes run after the message persists, so a mention failure never blocks it. */ import type { Queryable, Sql } from "../db/client.js" @@ -11,7 +9,6 @@ import { loadMentionsFor, makeMentionRepo } from "./message-mentions.drizzle.js" const CHAT_MENTIONS = "chat_message_mentions" as const -/** Load the resolved @-mentions on ONE chat/dm message. */ export async function loadChatMentions( tag: Queryable, messageId: string, @@ -19,7 +16,6 @@ export async function loadChatMentions( return (await loadMentionsFor(tag, CHAT_MENTIONS, [messageId])).get(messageId) ?? [] } -/** Batched: one grouped join for a whole page of message ids (the N+1 fix for list reads). */ export function loadChatMentionsFor( tag: Queryable, messageIds: string[], @@ -28,10 +24,9 @@ export function loadChatMentionsFor( } /** - * Replace a chat/dm message's mention set (caller dedupes + self-excludes the ids). The replace is - * delete-then-insert, so it runs in ONE transaction as recordFor documents: handed the bare pool tag it - * was two independent statements, and a crash or a concurrent edit landing between them could leave the - * message with no mentions at all (or a merged set from both writers). + * The caller dedupes and self-excludes the ids. The replace is delete-then-insert, so it must run in one + * transaction: on the bare pool tag a crash or a concurrent edit between the two statements could leave + * the message with no mentions, or a merged set from both writers. */ export async function recordChatMentions( sql: Sql, @@ -59,8 +54,8 @@ const MEMBER_TABLES: Record< } /** - * Which of `candidateIds` are current members of the room. A primary-key probe per candidate, bounded by - * the mentions a message can carry, so a member who joined after any roster cap is still mentionable. + * A primary-key probe per candidate, bounded by the mentions a message can carry, so a member who joined + * after any roster cap is still mentionable. */ export async function roomMemberIdsAmong( sql: Queryable, diff --git a/services/api/src/services/chat-poll-repository.drizzle.ts b/services/api/src/services/chat-poll-repository.drizzle.ts index e995eb57..f67a1ac6 100644 --- a/services/api/src/services/chat-poll-repository.drizzle.ts +++ b/services/api/src/services/chat-poll-repository.drizzle.ts @@ -1,29 +1,17 @@ /** - * P6 Task 6.3/6.4: chat_polls + chat_poll_options + chat_poll_votes persistence (migration 0048). + * A poll IS a chat_messages row with kind='poll' (body = the question, for previews and excerpts) plus a + * three-table subtree keyed on that message's id. * - * A poll IS a chat_messages row with kind='poll' (body = the question, for previews/excerpts) plus the - * three-table poll subtree keyed on that message's id. This repo owns the WRITE side (create the message - * + poll rows in ONE transaction, atomic vote-replace, close) and the poll HYDRATION reader - * (`loadPollsFor`) that chat-repository.drizzle wires into its page/single-row hydration so history reads, - * broadcasts, and re-reads all carry the poll DTO. + * This repo never re-reads a ChatMessageDTO itself: the service re-reads through the chat repository, + * which depends on `loadPollsFor` here. One direction, so no import cycle. * - * SEAM SPLIT (avoids an import cycle): the poll repo NEVER re-reads a ChatMessageDTO itself — after a - * write, the chat-poll-service re-reads through the chat repository's findXMessage (which now attaches - * `poll` via loadPollsFor). So this file depends only on `sql` + the shared PollDTO shape; chat-repository - * depends on THIS file's `loadPollsFor`. One direction, no cycle. - * - * COUNTS + `mine` + totalVoters (loadPollsFor): - * - option.count = COUNT(votes) for that (poll, idx). - * - option.mine = the viewer cast a vote for that idx. - * - totalVoters = COUNT(DISTINCT user_id) across the poll (a multi-select ballot is ONE voter). - * - myVote = the idxs the viewer chose (derived from option.mine). - * anonymous never exposes voter identity — the DTO simply has no voter field (privacy by construction). + * An anonymous poll never exposes voter identity because the DTO has no voter field at all. */ import type { Queryable, Sql } from "../db/client.js" import type { PollDTO, PollOptionDTO } from "@civfix/shared" -/** The room the poll message lands in. dm is excluded upstream (a poll needs an audience). */ +/** dm is excluded upstream: a poll needs an audience. */ export type PollRoomColumn = "cleanup_id" | "report_id" | "group_id" export interface CreatePollRow { @@ -36,37 +24,25 @@ export interface CreatePollRow { createdBy: string } -/** Poll-body metadata for the vote/close gate ladder (no hydration). Null when the id is not a poll. */ export interface PollMeta { messageId: string createdBy: string closedAt: Date | null allowMultiple: boolean - /** The poll's valid option ordinals (for the invalid-idx 422 check). */ optionIdxs: number[] } export interface ChatPollRepository { - /** - * Create a poll: INSERT the chat_messages row (kind 'poll', body = question, correct room ref) + - * chat_polls + chat_poll_options in ONE transaction. Returns the new message id; the caller re-reads - * the hydrated ChatMessageDTO through the chat repository. `messageId` is injected (the container's id - * factory) so it matches the rest of the chat write paths. - */ + /** `messageId` comes from the container's id factory so it matches the other chat write paths. */ create(input: CreatePollRow, messageId: string): Promise - /** Resolve poll gate metadata by message id alone. Null when the id is not a poll (or unknown). */ findPollMeta(messageId: string): Promise /** - * Atomic vote replace: DELETE the voter's existing ballots for this poll, then INSERT the new set, in - * ONE transaction. An empty `optionIdxs` retracts (delete only). The option idxs are assumed already - * validated against the poll's options (the service does that off findPollMeta). The tx re-asserts the - * poll is still OPEN under a share lock, so a close racing the vote wins and the replace no-ops. + * An empty `optionIdxs` retracts. The idxs must already be validated against the poll's options. The + * tx re-asserts the poll is still open under a share lock, so a close racing the vote wins and the + * replace no-ops. */ replaceVotes(pollId: string, userId: string, optionIdxs: number[]): Promise - /** - * Close the poll (set closed_at). IDEMPOTENT: a re-close keeps the ORIGINAL closed_at (COALESCE), so a - * second close is a no-op that never moves the timestamp. - */ + /** Idempotent: a re-close keeps the original closed_at and never moves the timestamp. */ close(pollId: string): Promise } @@ -77,8 +53,7 @@ export function makeChatPollRepository(sql: Sql): ChatPollRepository { const reportId = input.roomColumn === "report_id" ? input.roomId : null const groupId = input.roomColumn === "group_id" ? input.roomId : null await sql.begin(async (tx) => { - // The poll message: kind 'poll', body = the question (drives previews/excerpts/threads), - // no attachments / reply. Exactly one room ref is set (the 0047 three-way XOR). + // Exactly one room ref is set, as the chat_messages three-way XOR check requires. await tx` INSERT INTO chat_messages (id, cleanup_id, report_id, group_id, sender_id, body, kind) VALUES (${messageId}, ${cleanupId}, ${reportId}, ${groupId}, ${input.createdBy}, ${input.question}, 'poll') @@ -173,12 +148,8 @@ interface PollOptionRowSelect { } /** - * Batch-hydrate the poll DTO for a set of poll-kind message ids (viewer-aware). Returns a map keyed by - * message id; ids that are not polls simply don't appear. Three grouped queries (polls, options+counts, - * distinct voters) regardless of page size — zero queries when the page carries no polls. - * - * Callers (chat-repository.drizzle) pass ONLY live (non-tombstoned) poll-kind ids: a deleted poll - * hydrates as a plain tombstone with no `poll` field (the poll body must not survive its deletion). + * Three grouped queries regardless of page size. Callers pass only live poll-kind ids: a deleted poll + * hydrates as a plain tombstone with no `poll` field, because the poll body must not survive deletion. */ export async function loadPollsFor( sql: Queryable, @@ -194,8 +165,7 @@ export async function loadPollsFor( FROM chat_polls WHERE message_id = ANY(${distinct}::uuid[]) `, - // Per-option tally + whether the VIEWER voted this idx, in one grouped scan. A viewer-null filter - // (`v.user_id = NULL`) is never true, so mine_count = 0 for anonymous/unauthenticated reads. + // A viewer-null filter (`v.user_id = NULL`) is never true, so mine_count is 0 for signed-out reads. sql` SELECT o.poll_id, @@ -209,7 +179,7 @@ export async function loadPollsFor( GROUP BY o.poll_id, o.idx, o.text ORDER BY o.poll_id, o.idx ASC `, - // totalVoters = DISTINCT users, NOT vote rows (a multi-select ballot is one voter). + // Distinct users, not vote rows: a multi-select ballot is one voter. sql<{ poll_id: string; total: number }[]>` SELECT poll_id, COUNT(DISTINCT user_id)::int AS total FROM chat_poll_votes diff --git a/services/api/src/services/chat-reaction-service.ts b/services/api/src/services/chat-reaction-service.ts index 76a64607..1fa79a4d 100644 --- a/services/api/src/services/chat-reaction-service.ts +++ b/services/api/src/services/chat-reaction-service.ts @@ -1,20 +1,11 @@ /** - * The reaction toggle for every room kind: resolve the target, authorize, tombstone-gate, toggle, re-read. + * Authorize BEFORE resolving the target: a caller with no power in the room must not be able to tell an + * unknown message id from a real one in a private room (the reverse order is an existence oracle), so the + * 404 only ever reaches someone allowed to react there. The tombstone check follows authorization for the + * same reason, and precedes the write so a reaction to a deleted message never inserts an orphan row. * - * `toggleReaction` is the roomKind-dispatching entry point (the chat-edit-service pattern) and the one the - * unified POST /messages/reactions route should call; the three legacy per-room methods are thin binders - * onto the same core so the gate ladder exists ONCE. chat_message_reactions is room-agnostic (keyed on - * message id), so only the resolve + authorize steps differ per kind. - * - * GATE ORDER — authorize BEFORE resolving the target. A caller with no power in the room must not be able - * to tell an unknown message id from a real one in a private room (the reverse order is an existence - * oracle), so the 403 comes first and the 404 only ever reaches someone allowed to react there. The - * tombstone check runs AFTER authorization for the same reason, and BEFORE the write so a reaction to a - * deleted message never inserts an orphan reactions row. - * - * WIRING: every dep is optional on the factory (the offline harnesses and the legacy routes wire only what - * their lane needs) but each lane asserts its own deps at call time — a missing one is a wiring bug, so it - * throws rather than silently skipping a gate. + * Every dep is optional on the factory, but each lane asserts its own deps at call time: a missing one is + * a wiring bug, so it throws rather than silently skipping a gate. */ import { AppError, ReactionEmojiSchema } from "@civfix/shared" @@ -25,38 +16,29 @@ import type { DmRepository } from "./dm-repository.drizzle.js" export const CHAT_REACTION_FORBIDDEN = "You can't react in this conversation." /** - * The REPORT lane's own 403 copy. Report chat is view-only until you Join, and that is actionable, so the - * room says how to fix it instead of the generic refusal. Lives here (not in the route) because the gate - * itself lives here now: both the legacy per-room route and the unified /messages/reactions route reach - * the report lane through this service, and they must not answer the same refusal with different copy. + * Report chat is view-only until you Join, which is actionable, so the refusal says how to fix it. It + * lives beside the gate so the legacy and unified routes never answer the same refusal differently. */ export const REPORT_CHAT_REACTION_FORBIDDEN = "Join the chat to react to messages." export type IsCleanupMemberFn = (cleanupId: string, userId: string) => Promise -/** The room kinds a reaction can target (the shared ToggleMessageReactionRequest vocabulary). */ export type ReactionRoomKind = "cleanup" | "report" | "group" | "dm" export interface ChatReactionServiceDeps { chat?: ChatRepository dm?: DmRepository isCleanupMember?: IsCleanupMemberFn - /** - * report_chat_members membership. The report lane's in-service gate: report chat is view-only until you - * Join, and relying on the route to have checked left any other caller of this service unauthorized-by- - * default (the cleanup and dm lanes have always gated in-service). - */ + /** Gated in-service so a caller other than the report route is never unauthorized by default. */ isReportChatMember?: (reportId: string, userId: string) => Promise /** - * chat_group_members membership. Reacting follows MEMBERSHIP, not send permission: a channel's read-only - * member may react (and vote in polls — the same product stance), which is why this is not the - * canPostToGroup gate the edit lane uses. + * Reacting follows membership, not send permission: a channel's read-only member may react (and vote in + * polls), which is why this is not the canPostToGroup gate the edit lane uses. */ isChatGroupMember?: (groupId: string, userId: string) => Promise /** - * Report VISIBILITY (isReportVisibleTo: published+public, or the reporter's own). Optional: when wired - * it runs before membership so an unlisted / soft-deleted report answers 404 exactly like the - * report-chat routes' requireVisibleReport. Absent = not enforced in-service. + * Runs before membership so an unlisted or soft-deleted report answers 404 like the report-chat + * routes' requireVisibleReport. Absent means not enforced in-service. */ isReportVisible?: (reportId: string, userId: string) => Promise dmPeerOf?: (threadId: string, userId: string) => Promise @@ -72,7 +54,6 @@ export interface ToggleReactionInput { } export interface ChatReactionService { - /** Room-kind dispatching toggle (see the module banner for the gate ladder). */ toggleReaction(input: ToggleReactionInput): Promise toggleCleanupReaction( cleanupId: string, @@ -104,21 +85,17 @@ const notWired = (what: string): Error => new Error(`chat-reaction-service: ${wh const messageNotFound = (): AppError => AppError.notFound("Message not found") -/** The room-ref slice of a chat_messages row a lane matches its roomId against. */ type RoomRefs = Pick -/** One room kind's resolve / authorize / write seam, bound to the deps its lane needs. */ interface ReactionLane { - /** Authorization for the room (throws 403/404); runs BEFORE the target is resolved. */ authorize(roomId: string, userId: string): Promise - /** The target's tombstone state, or null when it does not exist in THIS room. */ + /** null when the message does not exist in THIS room. */ resolve(roomId: string, messageId: string): Promise<{ deletedAt: Date | null } | null> toggle(messageId: string, userId: string, emoji: ReactionEmoji): Promise read(roomId: string, messageId: string, userId: string): Promise } export function makeChatReactionService(deps: ChatReactionServiceDeps): ChatReactionService { - /** The report lane's gates: visibility (when wired) then membership. Throws 404 / 403; never returns false. */ async function gateReportRoom(reportId: string, userId: string, strict: boolean): Promise { if (deps.isReportVisible && !(await deps.isReportVisible(reportId, userId))) { throw AppError.notFound("Report not found") @@ -161,7 +138,6 @@ export function makeChatReactionService(deps: ChatReactionServiceDeps): ChatReac const chat = deps.chat if (!chat) throw notWired("chat") - // The chat_messages meta carries all three room refs; the lane checks its own. const resolveScoped = async ( roomId: string, messageId: string, @@ -215,14 +191,10 @@ export function makeChatReactionService(deps: ChatReactionServiceDeps): ChatReac const { roomId, messageId, userId } = input const lane = laneFor(input.roomKind, strict) await lane.authorize(roomId, userId) - // Cheap meta resolve (id + room ref + deleted_at), NOT a full hydration: the pre-2026-07 version - // hydrated the whole DTO (reactions, mentions, attachment presigns, reply map, polls) purely to check - // existence and then threw the result away. const target = await lane.resolve(roomId, messageId) if (target === null || target.deletedAt !== null) throw messageNotFound() await lane.toggle(messageId, userId, emoji) const updated = await lane.read(roomId, messageId, userId) - // The gates above passed, so a null re-read is a lost race (the row vanished underneath us). if (updated === null) throw messageNotFound() return updated } diff --git a/services/api/src/services/chat-reactions.drizzle.ts b/services/api/src/services/chat-reactions.drizzle.ts index 72264a6e..d12344b7 100644 --- a/services/api/src/services/chat-reactions.drizzle.ts +++ b/services/api/src/services/chat-reactions.drizzle.ts @@ -1,9 +1,5 @@ -/** - * Chat/DM message reactions: thin binders over the table-parameterized message-reactions repo - * (message-reactions.drizzle.ts). chat_message_reactions serves BOTH the cleanup group chat and 1:1 DMs - * (message ids are globally-unique uuids across both), so the chat + dm repos toggle/aggregate it - * identically — the shared module is the single algorithm; these are the chat-table bindings. - */ +// chat_message_reactions serves both room chat and 1:1 DMs because message ids are globally unique +// uuids across both tables. import type { Queryable, Sql } from "../db/client.js" import type { ReactionEmoji, ReactionSummaryDTO } from "@civfix/shared" @@ -11,7 +7,6 @@ import { loadReactionsFor, makeReactionRepo } from "./message-reactions.drizzle. const CHAT_REACTIONS = "chat_message_reactions" as const -/** Aggregate ONE chat/dm message's reactions for the viewer. */ export async function loadChatReactions( tag: Queryable, messageId: string, @@ -22,7 +17,6 @@ export async function loadChatReactions( ) } -/** Batched: one grouped query for a whole page of message ids (the N+1 fix for list reads). */ export function loadChatReactionsFor( tag: Queryable, messageIds: string[], @@ -31,7 +25,7 @@ export function loadChatReactionsFor( return loadReactionsFor(tag, CHAT_REACTIONS, messageIds, viewerUserId) } -/** Toggle a reaction in one tx; true when now present (added), false when removed. */ +/** true when the reaction is now present, false when it was removed. */ export function toggleChatReaction( sql: Sql, messageId: string, diff --git a/services/api/src/services/chat-read-state.drizzle.ts b/services/api/src/services/chat-read-state.drizzle.ts index 3225cf41..93f51425 100644 --- a/services/api/src/services/chat-read-state.drizzle.ts +++ b/services/api/src/services/chat-read-state.drizzle.ts @@ -1,28 +1,15 @@ /** - * Postgres-backed ChatReadState: the durable half of the chat read watermark. - * - * Phase 1 stored the per-(user, cleanup) last-read timestamp in process memory (InMemoryChatReadState), - * so unread counts reset on every API restart and could not be shared across instances. This impl - * persists the watermark on `cleanup_members.last_read_at` (migration 0008) so unread counts decrement - * when a conversation is read AND survive restarts / span workers. - * - * markRead is MONOTONIC: it only ever moves the watermark forward (GREATEST), so an out-of-order ack from - * a slow client can never un-read newer messages. It targets the existing membership row (only a member - * can mark a cleanup read), so a non-member ack is a silent no-op (0 rows updated). Reads come straight - * off the composite-PK row, an index point-lookup. + * Watermarks only ever move forward (GREATEST), so an out-of-order ack from a slow client can never + * un-read newer messages. COALESCE from epoch 0 treats a NULL prior value as the floor. */ import type { Queryable, Sql } from "../db/client.js" import type { ChatReadState } from "./threads-service.js" /** - * Monotonically advance a per-user read watermark via INSERT … ON CONFLICT DO UPDATE: the watermark only - * ever moves forward (GREATEST against the existing value, COALESCEd from epoch 0 so a NULL prior value is - * treated as the floor). Shared by dm_read_state (and any future per-user read-state table with a - * two-column key + a `last_read_at` column). `table`/key columns are caller-supplied module constants - * (never user input), interpolated as postgres.js identifiers. The membership tables (cleanup_members, - * report_chat_members, chat_group_members) take the UPDATE-shape sibling below instead: their row always - * pre-exists, so an INSERT would violate their other NOT NULL columns. + * `table` and the key columns are caller-supplied module constants, never user input, interpolated as + * postgres.js identifiers. The membership tables take the UPDATE-shape sibling below instead: their row + * always pre-exists, so an INSERT would violate their other NOT NULL columns. */ export async function monotonicReadWatermark( tag: Queryable, @@ -48,16 +35,9 @@ export async function monotonicReadWatermark( } /** - * The UPDATE-shape sibling of monotonicReadWatermark, for the membership tables whose row always - * pre-exists (cleanup_members / report_chat_members / chat_group_members — an INSERT would violate their - * other NOT NULL columns). Same monotonic guarantee: GREATEST against the current value, COALESCEd from - * epoch 0 so a NULL prior watermark is the floor. A non-member is a silent no-op (0 rows matched). - * - * `at` is EITHER a timestamp or a message reference: `{ messagesTable, messageId, scopeColumn }` sets the - * watermark to that message's created_at via a FROM-join, and matches nothing unless the message belongs - * to the SAME room (so an ack naming another room's message can't move this watermark). `table` / key - * columns / the message table + scope column are caller-supplied module constants (never user input), - * interpolated as postgres.js identifiers. + * A non-member is a silent no-op (0 rows matched). A message reference for `at` matches nothing unless + * the message belongs to the SAME room, so an ack naming another room's message can't move this + * watermark. Every identifier is a caller-supplied module constant, never user input. */ export async function monotonicReadWatermarkUpdate( tag: Queryable, diff --git a/services/api/src/services/chat-reply-hydration.ts b/services/api/src/services/chat-reply-hydration.ts index 50d2e209..b0e7b2f7 100644 --- a/services/api/src/services/chat-reply-hydration.ts +++ b/services/api/src/services/chat-reply-hydration.ts @@ -1,29 +1,11 @@ /** - * Reply threading (P2 Task 2.3): validation + hydration for chat/dm reply targets. + * A body-less target gets an empty excerpt and the client renders a kind-based descriptor from `kind`, + * so localization stays client-side. A tombstoned target also gets an empty excerpt: the original text + * must not survive its deletion through the reply preview. `from` is null for a sender-less SYSTEM row + * and for a deleted sender account. * - * chat_messages and dm_messages both carry a nullable reply_to_id (migration 0045) referencing another - * row IN THE SAME TABLE. This module is the single home for: - * - * - assertReplyTarget: persist-time validation that the target exists in the SAME room (cleanup_id / - * report_id equality for chat, thread_id equality for dm) and is not tombstoned. Violations throw a - * 422 AppError whose machine subcode rides `fields.code` ("reply_wrong_room" / - * "reply_deleted_target"), following the chat-edit-service convention (ErrorCode is a closed enum, - * so clients key off httpStatus + fields.code). On success it returns the hydrated ReplyToDTO so - * the ack/broadcast DTO carries the preview without a second fetch. - * - loadReplyTargets / replyMapForRows: batched hydration for history pages and re-read single rows — - * ONE `id = ANY(...)` query per page over the SAME table, keyed by target id. - * - * DTO MAPPING (toReplyToDTO): - * - excerpt = the first REPLY_EXCERPT_MAX (120) chars of the target's body when a body is present; - * otherwise "" — the DTO carries raw data and the CLIENT renders a kind-based descriptor (e.g. - * "Photo" for an attachment-only message) from `kind`, so localization stays client-side. - * - from = {id, displayName} of the target's sender; null for sender-less SYSTEM rows and for - * tombstoned (deleted) sender accounts. - * - deleted: true + excerpt "" when the target itself is tombstoned — the original text must not - * survive its deletion via the reply preview. - * - * Kept as a pure-ish standalone service (no repo coupling) so later tasks (2.4 around-mode, 2.5 - * notifications) can consume the same loaders. + * The machine subcodes ride `fields.code` because ErrorCode is a closed enum; clients key off + * httpStatus + fields.code. */ import { AppError, ErrorCode } from "@civfix/shared" @@ -31,13 +13,10 @@ import type { ChatMessageKind, ReplyToDTO } from "@civfix/shared" import type { Queryable } from "../db/client.js" import { isUuid } from "../db/cursor-helpers.js" -/** The two reply-capable tables. Trusted internal identifiers (rendered via sql(...) as idents). */ +/** Trusted internal identifiers, rendered via sql(...) as idents. */ export type ReplyTable = "chat_messages" | "dm_messages" -/** - * The room-equality column for reply validation: chat rooms scope on cleanup_id/report_id/group_id, - * dm threads on thread_id. Column names are trusted internal identifiers. - */ +/** Column names are trusted internal identifiers. */ export interface ReplyRoomScope { column: "cleanup_id" | "report_id" | "group_id" | "thread_id" id: string @@ -45,7 +24,6 @@ export interface ReplyRoomScope { export const REPLY_EXCERPT_MAX = 120 -/** The target-row shape both loaders select (room_ref only on the validation path). */ export interface ReplyTargetRow { id: string room_ref?: string | null @@ -67,18 +45,15 @@ export const replyDeletedTarget = (): AppError => fields: { code: "reply_deleted_target" }, }) -/** The poll reply-excerpt prefix (P6): a bar-chart glyph so a quoted poll reads as "📊 ". */ +/** A bar-chart glyph so a quoted poll reads as a poll. */ const POLL_EXCERPT_PREFIX = "\u{1F4CA} " -/** Pure mapper: one target row -> the denormalized reply preview (see DTO MAPPING above). */ export function toReplyToDTO(row: ReplyTargetRow): ReplyToDTO { const deleted = row.deleted_at !== null const from = row.sender_id !== null && row.sender_deleted_at === null ? { id: row.sender_id, displayName: row.sender_display_name ?? "" } : null - // A poll's body IS its question; prefix the chart glyph so the quote reads as a poll, truncated at the - // shared cap. Every other kind excerpts its raw body (client renders a kind descriptor for empty ones). const excerpt = deleted ? "" : row.kind === "poll" @@ -94,11 +69,9 @@ export function toReplyToDTO(row: ReplyTargetRow): ReplyToDTO { } /** - * Validate a reply target at persist/send time (same room + not deleted) and return its hydrated - * preview. ONE query: the target row + its sender, with the scope column projected as room_ref. - * A missing row and a wrong-room row are indistinguishable to the sender (both "not in this - * conversation"), so both throw reply_wrong_room; a non-uuid id can never match and short-circuits - * the same way (avoiding a 22P02 cast error -> 500). + * A missing row and a wrong-room row are indistinguishable to the sender, so both throw + * reply_wrong_room; a non-uuid id can never match and short-circuits the same way instead of hitting a + * 22P02 cast error (a 500). */ export async function assertReplyTarget( sql: Queryable, @@ -128,11 +101,7 @@ export async function assertReplyTarget( return toReplyToDTO(row) } -/** - * Batch-hydrate reply previews for a set of target ids from ONE table in ONE query. Returns a map - * keyed by TARGET id (distinct ids deduped; null/undefined entries skipped). Zero queries when the - * page has no replies. - */ +/** Keyed by TARGET id. */ export async function loadReplyTargets( sql: Queryable, table: ReplyTable, @@ -156,7 +125,6 @@ export async function loadReplyTargets( return new Map(rows.map((r) => [r.id, toReplyToDTO(r)])) } -/** Convenience over loadReplyTargets for a page of selected rows carrying `reply_to_id`. */ export function replyMapForRows( sql: Queryable, table: ReplyTable, diff --git a/services/api/src/services/chat-room-scope.drizzle.ts b/services/api/src/services/chat-room-scope.drizzle.ts index 429d743b..bc24cccb 100644 --- a/services/api/src/services/chat-room-scope.drizzle.ts +++ b/services/api/src/services/chat-room-scope.drizzle.ts @@ -1,34 +1,9 @@ /** - * The TABLE-PARAMETERIZED room-scope core shared by chat-repository.drizzle.ts (chat_messages) and - * dm-repository.drizzle.ts (dm_messages). - * - * The two tables are structural twins: dm_messages mirrors chat_messages column-for-column apart from its - * room reference (thread_id vs cleanup_id/report_id/group_id) and the report-only extras (system payload, - * @city forward). Every room-scoped READ was therefore written twice — the same keyset cursor anchor, the - * same around-window, the same one-row seek, the same pin flip, the same pin list — and each copy is a - * place the other can silently drift. The subtle invariants that drift first are exactly the ones that - * cost the most to rediscover: - * - the `before` anchor's (created_at, id) tuple NEVER leaves the database (a row-valued subquery), so - * the driver's millisecond truncation of created_at cannot make same-millisecond rows repeat/skip; - * - the anchor EXISTENCE pre-check, which keeps an unknown/foreign cursor falling back to the newest - * page instead of an all-NULL filter -> empty page; - * - around-mode's `(deleted_at IS NULL OR id = )` exception, which lets a jump target's own - * tombstone ride in its window while every other tombstone stays filtered out; - * - the pin flip's `(pinned_at IS NULL) = ` gate, which makes a repeat pin an idempotent no-op - * that does NOT refresh pinned_at. - * - * ONE definition each lives here, parameterized by a RoomScopeSql descriptor supplying the table, the row - * alias, the room predicate, the SELECT list, the users join, the per-query side context and the two - * hydrators. What genuinely DIVERGES stays in the repositories and is deliberately NOT pushed through this - * seam: the row shape and DTO mapping (system rows, roomKind, @city chips, poll bodies vs the flat dm - * row), persistence, edit/soft-delete writes (chat has a moderator sender-gate bypass, dm does not), meta - * lookups, and everything dm-only (threads, peers, read state, blocks). - * - * SQL SEMANTICS ARE UNCHANGED from the two originals: identifiers render through postgres.js's `sql(...)` - * ident helper over closed unions (never string concatenation), values stay parameterized, and every - * predicate / ORDER BY / LIMIT is equivalent to the copy it replaces. The only textual difference is that - * idents which used to be bare (`chat_messages`, `cm.created_at`) now render quoted (`"chat_messages"`, - * `"cm".created_at`) — the same objects, since both spellings are already lower-case. + * chat_messages and dm_messages are structural twins, so their room-scoped reads live here once: two + * copies of the keyset anchor, around-window and pin flip would drift on exactly the invariants that are + * costliest to rediscover. What genuinely diverges (row shape and DTO mapping, writes, meta lookups and + * everything dm-only) deliberately stays in the repositories. Identifiers render through `sql(...)` over + * closed unions, never string concatenation. */ import { AppError } from "@civfix/shared" @@ -40,79 +15,54 @@ import { isUuid } from "../db/cursor-helpers.js" import { aroundLimits, mergeAroundWindow } from "./chat-history-window.js" import type { ReplyTable } from "./chat-reply-hydration.js" -/** A composable SQL fragment (postgres.js Fragment); what a `sql\`...\`` expression yields. */ type SqlFragment = postgres.Fragment -/** The two room-scoped message tables — the same closed set as chat-reply-hydration's ReplyTable. */ export type RoomTable = ReplyTable /** - * The row alias each table is selected through. A CLOSED union rather than a bare string, so the ident - * positions the core renders can never receive anything but these two literals (the convention the - * dynamic-SQL guard documents: dynamic identifiers go through `${sql(name)}` over a closed union). + * A closed union rather than a bare string, so the ident positions the core renders can never receive + * anything but these two literals (the dynamic-SQL guard's convention). */ export type RoomAlias = "cm" | "dm" -/** Cap for a room's pin list (both the listPins query and the initial-history `pins` array). */ export const PIN_LIST_CAP = 25 -/** - * The only field the core itself reads off a selected row (both cursor ends). Everything else about the - * row shape belongs to the owning repository's hydrator/mapper. - */ +/** Everything else about the row shape belongs to the owning repository's hydrator. */ export interface RoomScopeRow { id: string } /** - * Everything the core needs to run the room-scoped reads for ONE room of ONE table. Built per call by the - * owning repository (the room id is baked into `scope`), so a chat spec can specialize on its scope column - * (e.g. the report-only forward column in `columns`) exactly as the hand-written copies did. + * Built per call by the owning repository with the room id baked into `scope`, so a chat spec can + * specialize on its scope column (e.g. the report-only forward column in `columns`). */ export interface RoomScopeSql { - /** The room's message table. A trusted internal identifier, rendered via sql(...) as an ident. */ + /** A trusted internal identifier, rendered via sql(...) as an ident. */ table: RoomTable - /** The row alias every query below selects through, matching the alias `from` declares. */ alias: RoomAlias /** - * The room predicate: `. = `. `prefix` is the alias to qualify the - * column with, or null for the statements that have no alias to qualify it WITH (the anchor - * pre-check and the pin UPDATE). Values stay parameterized; the column name is a trusted ident. + * `prefix` is null for the statements with no alias to qualify the column with (the anchor pre-check + * and the pin UPDATE). */ scope(prefix: string | null): SqlFragment - /** The SELECT list for a full row: the table's columns off `alias` + the sender columns off `u`. */ columns: SqlFragment - /** - * `FROM
[LEFT] JOIN users u ON u.id = .sender_id`. The join KIND is part of the - * divergence: chat LEFT-joins (a report SYSTEM row has no sender), dm inner-joins. - */ + /** Chat LEFT-joins users because a report SYSTEM row has no sender; dm inner-joins. */ from: SqlFragment - /** - * Per-query side context, resolved IN PARALLEL with the row fetch (chat: the report's jurisdiction for - * the @city chip — constant per report; dm: nothing, so an already-resolved null). - */ + /** Resolved in parallel with the row fetch (chat: the report's jurisdiction for the @city chip). */ context(): Promise - /** Batch hydration for a page of rows (attachments/reactions/mentions/reply previews [+ polls]). */ hydratePage(rows: Row[], viewerUserId: string | null, ctx: Ctx): Promise - /** - * Single-row hydration for a one-row seek. Owns its own context resolution (the single-id loaders and - * the context query share one Promise.all in both repositories). - */ + /** Resolves its own context so the single-id loaders and the context query share one Promise.all. */ hydrateOne(row: Row, viewerUserId: string | null): Promise } /** - * Newest-first page of a room's live messages, optionally before a cursor id or centered on a target. - * - * The `before` cursor resolves to a keyset anchor whose (created_at, id) tuple deliberately NEVER leaves - * the database (a row-valued subquery on the anchor id): round-tripping created_at through the driver - * truncates microseconds to milliseconds (postgres-js serializes Date params via a JS Date), so - * same-millisecond messages could repeat/skip across pages. The existence pre-check preserves the - * stale-cursor fallback (an unknown/foreign-room `before` returns the newest page; without it the NULL-row - * subquery would instead produce an all-NULL filter => empty page). No deleted_at filter on the anchor: - * it is used solely for its keyset position, so a tombstoned cursor id must still page correctly. The - * isUuid guard keeps `id = ${before}` from raising 22P02 -> 500 on a non-uuid string (DmHistoryQuerySchema - * validates the uuid, ChatHistoryQuerySchema does not, so the guard belongs at the seam). + * The `before` anchor's (created_at, id) tuple deliberately never leaves the database (a row-valued + * subquery): postgres-js round-trips created_at through a JS Date, truncating microseconds, so + * same-millisecond messages could repeat or skip across pages. The existence pre-check keeps an + * unknown or foreign-room `before` falling back to the newest page instead of an all-NULL filter and + * an empty page. The anchor has no deleted_at filter: it is used only for its keyset position, so a + * tombstoned cursor id must still page correctly. The isUuid guard keeps a non-uuid string from + * raising 22P02 (a 500): ChatHistoryQuerySchema does not validate the uuid, so the guard belongs here. */ export async function roomHistory( sql: Sql, @@ -122,8 +72,7 @@ export async function roomHistory( viewerUserId: string | null, around?: string, ): Promise { - // Around-mode (P2 2.4): a center-window fetch is a separate path; the before-mode fast path below stays - // untouched. The route schemas reject around+before together, so `before` is undefined here. + // The route schemas reject around+before together, so `before` is undefined on this path. if (around !== undefined) return roomHistoryAround(sql, spec, around, limit, viewerUserId) const alias = sql(spec.alias) @@ -166,17 +115,11 @@ export async function roomHistory( } /** - * Around-mode history (P2 2.4): a window of ceil(limit/2) rows at-or-older than the target (the target row - * INCLUDED) + floor(limit/2) strictly newer, merged newest-first — the same ordering as a before-mode - * page. See chat-history-window.ts for the window/cursor semantics (nextCursor = older end, prevCursor = - * newer end, each null when that side reaches the edge). - * - * The anchor lookup is scoped to THIS room and — unlike the `before` anchor — INCLUDES soft-deleted - * targets: jumping to a deleted message's position is valid, and its tombstone rides in the window (every - * OTHER deleted row stays filtered out, as in before-mode). A missing/foreign-room id is a 404: a jump - * target the client explicitly named must exist, whereas an unknown `before` cursor just falls back to the - * newest page. The anchor tuple stays entirely in SQL for the same microsecond reason as `before` — here a - * truncated round-trip would eject the target from its own <=-window. + * The anchor lookup includes soft-deleted targets: jumping to a deleted message's position is valid + * and its tombstone rides in the window, while every other deleted row stays filtered out. A missing + * or foreign-room id is a 404, because a jump target the client named must exist, whereas an unknown + * `before` cursor just falls back to the newest page. The anchor tuple stays in SQL for the same + * microsecond reason as `before`; here a truncated round-trip would eject the target from its window. */ export async function roomHistoryAround( sql: Sql, @@ -185,7 +128,7 @@ export async function roomHistoryAround( limit: number, viewerUserId: string | null, ): Promise { - // Non-uuid ids can never match; short-circuit to the same 404 (avoids a 22P02 cast error -> 500). + // A non-uuid id can never match; the short-circuit avoids a 22P02 cast error (a 500). if (!isUuid(around)) throw AppError.notFound("Message not found") const anchorRows = await sql<{ id: string }[]>` SELECT id FROM ${sql(spec.table)} @@ -201,7 +144,6 @@ export async function roomHistoryAround( const limits = aroundLimits(limit) const alias = sql(spec.alias) - // Fetch +1 on EACH side so has-more resolves independently per end. const [olderDesc, newerAsc, ctx] = await Promise.all([ sql` SELECT ${spec.columns} @@ -233,8 +175,8 @@ export async function roomHistoryAround( } /** - * One LIVE message of this room, fully hydrated for the viewer. Null when the id is unknown, belongs to - * another room, or is soft-deleted — the caller maps that to 404/403 without distinguishing them. + * Null when the id is unknown, belongs to another room, or is soft-deleted; the caller maps all three to + * the same answer without distinguishing them. */ export async function roomFindMessage( sql: Sql, @@ -255,11 +197,9 @@ export async function roomFindMessage( } /** - * Pin/unpin flip (P3). The WHERE gates room scope + live row + non-system kind + an ACTUAL state change - * (`(pinned_at IS NULL) = pin` matches unpinned rows when pinning and pinned rows when unpinning), so a - * repeat pin is a no-op that keeps the original pinned_at. The current DTO is then re-read (hydrated like - * any history row) regardless of whether the UPDATE matched — idempotent calls return the same payload. - * Authorization (who may pin) lives in the routes via the chat-powers resolver. + * `(pinned_at IS NULL) = pinned` only matches an actual state change, so a repeat pin is a no-op that + * keeps the original pinned_at, and the re-read returns the same payload either way. Who may pin is + * decided in the routes via the chat-powers resolver. */ export async function roomSetPinned( sql: Sql, @@ -281,10 +221,6 @@ export async function roomSetPinned( return roomFindMessage(sql, spec, messageId, userId) } -/** - * The room's pins, newest-pin first over the partial pin index, hydrated like a history page and capped at - * PIN_LIST_CAP. Tombstoned rows never surface. - */ export async function roomListPins( sql: Sql, spec: RoomScopeSql, diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index 9716ff9c..500d8473 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -271,8 +271,8 @@ export interface CleanupServiceDeps { resolveJurisdictionGeoid?: (lat: number, lng: number) => Promise resolveJurisdictionCode?: (geoid: string | null) => Promise /** - * Only ever called for an OLD client (one that sends no `addressSource`) that also sent no address — - * the compat shim in resolveEventAddress. A new client always confirms its own address with the host, + * Only ever called for an OLD client (one that sends no `addressSource`) that also sent no address, + * via the compat shim in resolveEventAddress. A new client always confirms its own address with the host, * which is the whole point of the feature; the server never resolves one behind a host's back. */ resolveAddress?: AddressResolver @@ -585,13 +585,13 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { * * addressSource PRESENT -> a new client. It resolved the pin, showed the line to the host, and the * host published with it on screen. That is the confirmation, so the - * server only has to refuse a blank one — a client that sends a source + * server only has to refuse a blank one; a client that sends a source * without an address has a bug, and storing it would produce an event * whose address is "verified" and empty. * addressSource ABSENT -> an old client. Whatever it sent in `address` is the host's own "name the * spot" text, so it is 'manual' (the same call migration 0179 makes for * existing rows). If it sent nothing, the shim resolves the pin and stores - * 'resolved' — unverified, but an event with a street line beats an event + * 'resolved': unverified, but an event with a street line beats an event * with "Meeting point", and only while old clients are still in the wild. * * The shim stores NOTHING when the ladder only reached `locality`: "Los Angeles, CA" is not a meeting @@ -1641,7 +1641,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { ): Promise { const { record, standing } = await requireCapabilityOn(id, actorId, "manage_event") if (targetUserId === actorId) { - throw AppError.conflict("You can't remove yourself — leave the event instead.") + throw AppError.conflict("You can't remove yourself. Leave the event instead.") } if (targetUserId === record.organizerUserId) { throw AppError.forbidden("The organizer can't be removed from their own event.") @@ -1832,5 +1832,5 @@ function guestVisibleChange( function resourceRequestNote(message: string): string { const collapsed = message.replace(/\s+/g, " ").trim() const preview = collapsed.length > 140 ? `${collapsed.slice(0, 140)}…` : collapsed - return preview.length > 0 ? `Resources requested — ${preview}` : "Resources requested" + return preview.length > 0 ? `Resources requested: ${preview}` : "Resources requested" } diff --git a/services/api/src/services/conversation-bell.ts b/services/api/src/services/conversation-bell.ts index c4f7fbeb..b335d315 100644 --- a/services/api/src/services/conversation-bell.ts +++ b/services/api/src/services/conversation-bell.ts @@ -10,7 +10,6 @@ export const CONVERSATION_BELL: Record< dm: { type: "dm", link: (id) => `/messages/dm/${id}` }, cleanup: { type: "cleanup_chat", link: (id) => `/cleanups/${id}` }, report: { type: "report_chat", link: (id) => `/messages/report/${id}` }, - // P4 group lane (chat-p0): opening a group room clears its group_chat bells, same clear-on-open pattern. group: { type: "group_chat", link: (id) => `/messages/group/${id}` }, } diff --git a/services/api/src/services/conversation-mutes-repository.drizzle.ts b/services/api/src/services/conversation-mutes-repository.drizzle.ts index 9f755bfb..5c290003 100644 --- a/services/api/src/services/conversation-mutes-repository.drizzle.ts +++ b/services/api/src/services/conversation-mutes-repository.drizzle.ts @@ -1,49 +1,27 @@ -/** - * Task D-E1: per-conversation mute store (conversation_mutes, migration 0042). - * - * A user may mute notifications for a single room (a cleanup group chat, a DM thread, or a report - * chat) without leaving/muting globally. Composite PK(user_id, room_kind, room_id) means "muted" is - * simply "a row exists" -- no separate boolean column. See src/db/schema/conversation_mutes.ts for the - * full column rationale (in particular why room_id is NOT a foreign key). - * - * Written against the raw postgres-js tag (`Sql`) to match the rest of the backend (e.g. - * report-chat-repository.drizzle.ts). - */ +// "Muted" means a row exists; there is no boolean column. src/db/schema/conversation_mutes.ts records +// why room_id is NOT a foreign key. import type { FastifyBaseLogger } from "fastify" import type { Sql } from "../db/client.js" import type { ConversationMuteRoomKind } from "../db/schema/conversation_mutes.js" export interface ConversationMutesRepository { - /** Whether `userId` has muted this room. */ isMuted(userId: string, roomKind: ConversationMuteRoomKind, roomId: string): Promise - /** - * Set the mute state for a room. `muted: true` upserts the mute row (idempotent: ON CONFLICT DO - * NOTHING, so re-muting an already-muted room is a no-op rather than an error). `muted: false` - * deletes the row (also idempotent: deleting an absent row is a no-op). - */ + /** Idempotent both ways: re-muting and unmuting an unmuted room are no-ops, not errors. */ setMuted( userId: string, roomKind: ConversationMuteRoomKind, roomId: string, muted: boolean, ): Promise - /** - * Batch lookup for a threads list: of the given `roomIds` (all the same `roomKind`), which ones has - * `userId` muted? Returns a Set for O(1) membership checks. Empty `roomIds` short-circuits to an - * empty Set with NO query -- callers (e.g. rendering an empty threads page) should not pay for a - * round trip that can only ever return nothing. - */ mutedRoomIdsFor( userId: string, roomKind: ConversationMuteRoomKind, roomIds: string[], ): Promise> /** - * The INVERSE batch shape, for a room fan-out: of the given `userIds`, which ones have muted THIS room? - * Same short-circuit on an empty list. OPTIONAL on the interface so the offline fakes that predate it - * still satisfy it; callers must therefore probe (`repo.mutedUserIdsFor?.(...)`) and fall back to the - * per-user `isMuted`. + * Optional so the offline fakes that predate it still satisfy the interface; callers must probe + * (`repo.mutedUserIdsFor?.(...)`) and fall back to the per-user `isMuted`. */ mutedUserIdsFor?( roomKind: ConversationMuteRoomKind, diff --git a/services/api/src/services/discussion-repository.drizzle.ts b/services/api/src/services/discussion-repository.drizzle.ts index 84c15668..4a712920 100644 --- a/services/api/src/services/discussion-repository.drizzle.ts +++ b/services/api/src/services/discussion-repository.drizzle.ts @@ -1,10 +1,5 @@ -/** - * Postgres-backed report lookup for the report-chat @city-forward + visibility path. - * - * All that remains of the former discussion persistence layer is a single read: resolve a report's - * visibility handle + its jurisdiction + first usable contact email. (The discussion message CRUD / - * reaction / mention repository was removed with the discussion system — report chat replaced it.) - */ +// The only read left from the retired discussion system (report chat replaced it); it backs report-chat +// visibility and the @city forward. import type { Sql } from "../db/client.js" import { firstUsableLegacyContactExpr, usableContactRowExpr } from "./admin/sql-fragments.js" diff --git a/services/api/src/services/discussion-types.ts b/services/api/src/services/discussion-types.ts index c4a87f0e..914c14a8 100644 --- a/services/api/src/services/discussion-types.ts +++ b/services/api/src/services/discussion-types.ts @@ -1,13 +1,5 @@ -/** - * Report-jurisdiction lookup types shared by the report-chat @city-forward path. - * - * These are all that survive of the former per-report DISCUSSION domain (the discussion comment tables, - * service, and message DTOs were removed with the discussion system — report chat replaced it). What - * report chat still needs is a single read: "given a report id, what is its visibility + resolved - * jurisdiction + first usable contact email?" — used to gate report-chat room visibility and to decide - * whether an @city mention in a chat message should be forwarded. That read is `findReportForDiscussion` - * on the minimal `DiscussionRepository` below (name kept for continuity with its call sites). - */ +// All that survives of the retired per-report discussion domain (report chat replaced it). The +// "Discussion" names are kept for continuity with their call sites. export interface ReportJurisdictionView { geoid: string @@ -28,8 +20,7 @@ export interface DiscussionReportView { place: string | null } -// Persistence seam for the single report-lookup the report-chat surface needs. The Drizzle impl runs raw -// SQL; keeping it behind an interface makes the report-chat visibility + @city-forward code fakeable. +// An interface so the report-chat visibility and @city-forward code can run against a fake. export interface DiscussionRepository { findReportForDiscussion(reportId: string): Promise } diff --git a/services/api/src/services/dm-peer.ts b/services/api/src/services/dm-peer.ts index 0899d908..1ed7ae42 100644 --- a/services/api/src/services/dm-peer.ts +++ b/services/api/src/services/dm-peer.ts @@ -1,15 +1,8 @@ -/** - * The DM "who is the other participant" resolution, single-sourced. - * - * dm_threads stores the pair as (user_lo, user_hi) with user_lo < user_hi, so every DM gate — history, - * send, edit, react, pin powers, mention scoping — needs the same ternary over that shape. It had drifted - * into four hand-written copies; they must agree, because "no peer" is also how each of those gates - * decides "not a participant". - */ +// Every DM gate (history, send, edit, react, pin powers, mention scoping) must agree on this, because +// "no peer" is also how each of them decides "not a participant". import type { DmRepository, DmThread } from "./dm-repository.drizzle.js" -/** The OTHER participant of an already-loaded thread, or null when `userId` is not in it. */ export function dmPeerOfThread(thread: DmThread, userId: string): string | null { if (thread.userLo === userId) return thread.userHi if (thread.userHi === userId) return thread.userLo @@ -17,8 +10,8 @@ export function dmPeerOfThread(thread: DmThread, userId: string): string | null } /** - * Thread-loading peer resolver: null when the thread is unknown OR the caller is not a participant (the - * two cases are deliberately indistinguishable — callers answer one generic 403 for both). + * An unknown thread and a thread the caller is not in are deliberately indistinguishable: callers answer + * one generic 403 for both. */ export function makeDmPeerOf( dm: Pick, diff --git a/services/api/src/services/geocode-cache.ts b/services/api/src/services/geocode-cache.ts index 7b241f18..b20ef274 100644 --- a/services/api/src/services/geocode-cache.ts +++ b/services/api/src/services/geocode-cache.ts @@ -1,7 +1,7 @@ /** * Read-through cache for reverse geocodes (`geocode_cache`, migration 0179). * - * WHY. A reverse geocode is stable per point and the SAME point is resolved several times over one + * A reverse geocode is stable per point and the SAME point is resolved several times over one * creation flow: the client previews it while the host drags the pin (debounced, rounded to 5 decimals), * then the create path resolves it again server-side. Keying on the shared `geocodePointKey` - the same * 5-decimal rounding the client's preview query uses - collapses all of that onto one row, so a pin @@ -33,9 +33,7 @@ import { isLocatedPrecision, type AddressPrecision } from "@civfix/shared" import type { Queryable } from "../db/client.js" -/** A chain answer past this age is re-resolved (and the row overwritten) on next read. */ export const GEOCODE_CACHE_TTL_MS = 180 * 24 * 60 * 60 * 1000 -/** A negative entry ("the chain had nothing") is trusted for minutes, not months. */ export const GEOCODE_CACHE_NEGATIVE_TTL_MS = 15 * 60 * 1000 export interface GeocodeCacheEntry { @@ -59,8 +57,8 @@ interface GeocodeCacheRowSelect { } /** - * Did the provider chain prove a located rung for this row? The one definition of "worth the long TTL", - * read by the freshness rule here and by the resolver that decides what to store. + * The one definition of "worth the long TTL", read by the freshness rule here and by the resolver that + * decides what to store. */ export function isChainAnswer(entry: { address: string | null @@ -69,7 +67,6 @@ export function isChainAnswer(entry: { return entry.address !== null && isLocatedPrecision(entry.precision) } -/** True while the row may still be served. Anything but a chain answer expires far sooner. */ export function isFreshEntry( row: { address: string | null; precision: AddressPrecision | null; resolvedAt: Date }, now: Date, @@ -145,7 +142,7 @@ export function makeGeocodeCache(opts: GeocodeCacheOptions): GeocodeCache { } } -/** A cache that never hits and never stores - the wiring for a process with no database. */ +/** The wiring for a process with no database. */ export const NO_GEOCODE_CACHE: GeocodeCache = { read: async () => null, write: async () => undefined, diff --git a/services/api/src/services/host/announcement-service.ts b/services/api/src/services/host/announcement-service.ts index 2525dff6..dc8a26e8 100644 --- a/services/api/src/services/host/announcement-service.ts +++ b/services/api/src/services/host/announcement-service.ts @@ -61,7 +61,7 @@ export interface AnnouncementService { export function announcementRateLimited(): AppError { return AppError.rateLimited( - "Announcement limit reached for today — use the group chat for ongoing discussion.", + "Announcement limit reached for today. Use the group chat for ongoing discussion.", ) } diff --git a/services/api/src/services/host/event-consents-repository.drizzle.ts b/services/api/src/services/host/event-consents-repository.drizzle.ts index 23cdd3a3..072cd2cf 100644 --- a/services/api/src/services/host/event-consents-repository.drizzle.ts +++ b/services/api/src/services/host/event-consents-repository.drizzle.ts @@ -29,10 +29,10 @@ export interface InsertConsentInput { export function assertCurrentConsentVersions(consent: EventConsentInput): void { const fields: Record = {} if (!isCurrent("terms", consent.termsVersion)) { - fields.termsVersion = "is out of date — reload and accept the current terms" + fields.termsVersion = "is out of date, so reload and accept the current terms" } if (!isCurrent("privacy", consent.disclosureVersion)) { - fields.disclosureVersion = "is out of date — reload and accept the current privacy notice" + fields.disclosureVersion = "is out of date, so reload and accept the current privacy notice" } if (Object.keys(fields).length > 0) throw AppError.validation(fields) } diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.types.ts index 84ed0d6a..d78a94fe 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -250,7 +250,7 @@ export interface AdminSetMemberRoleArgs { now: Date } -/** `sole_owner`: the target IS the owner and no transfer was requested — demotion needs a new owner first. */ +/** `sole_owner`: the target IS the owner and no transfer was requested; demotion needs a new owner first. */ export type AdminSetMemberRoleOutcome = "updated" | "not_member" | "sole_owner" export interface OrganizationInviteRecord { @@ -385,7 +385,6 @@ export interface OrganizationRepository { decideVerificationTx(args: DecideOrgVerificationArgs): Promise scrubDecidedEins(before: Date, limit: number): Promise - // ---- Admin org management (0.41.0) ---- adminFindOrganization(id: string): Promise adminListOrganizations(query: AdminOrganizationListQuery): Promise<{ items: AdminOrganizationRecord[] @@ -402,7 +401,6 @@ export interface OrganizationRepository { adminAddMemberTx(args: AdminAddMemberArgs): Promise adminSetMemberRoleTx(args: AdminSetMemberRoleArgs): Promise - // ---- Org invites (0.41.0) ---- countPendingInvites(organizationId: string, now: Date): Promise createInviteTx(args: CreateOrganizationInviteArgs): Promise /** Pending first, newest first; rows past expires_at are flipped to `expired` on the way out. */ @@ -418,7 +416,7 @@ export interface OrganizationRepository { userId: string now: Date }): Promise - /** The invitee's own open invites: addressed to their id OR their verified email. Bounded by `limit`. */ + /** The invitee's own open invites: addressed to their id OR their verified email. */ listPendingInvitesForUser(args: { userId: string now: Date diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index 8e11918f..4411e92e 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -62,14 +62,14 @@ export const ORG_MEMBERS_DEFAULT_LIMIT = 25 export const EIN_RETENTION_DAYS = 90 -/** Org invites expire after 14 days, like event team invites. */ +/** Same lifetime as event team invites. */ export const ORG_INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000 export const ORG_INVITE_TOKEN_BYTES = 32 export const ORG_INVITE_LIST_CAP = 100 -/** The invitee's own inbox is a short triage list, not a feed: newest 20 open invites. */ +/** The invitee's own inbox is a short triage list, not a feed. */ export const MY_ORG_INVITES_CAP = 20 export const ADMIN_ORGS_DEFAULT_LIMIT = 25 diff --git a/services/api/src/services/host/page-service.ts b/services/api/src/services/host/page-service.ts index 32a57048..b62bf3f4 100644 --- a/services/api/src/services/host/page-service.ts +++ b/services/api/src/services/host/page-service.ts @@ -231,7 +231,7 @@ export function validatePageBlocks( if (mediaUrlPrefixes.some((prefix) => media.url.startsWith(prefix))) continue throw AppError.validation({ [`blocks.${index}.${media.field}`]: - "must reference platform media — upload the image and send its mediaId", + "must reference platform media: upload the image and send its mediaId", }) } } diff --git a/services/api/src/services/host/registration-service.ts b/services/api/src/services/host/registration-service.ts index 457d9ca1..e7aea04e 100644 --- a/services/api/src/services/host/registration-service.ts +++ b/services/api/src/services/host/registration-service.ts @@ -192,11 +192,11 @@ export function makeRegistrationService(deps: RegistrationServiceDeps): Registra function consentWriteOf(consent: EventConsentInput | undefined): ConsentWrite | null { if (consent === undefined) return null if (consent.termsVersion !== currentVersion("terms")) { - throw AppError.validation({ "consent.termsVersion": "out of date — re-accept the terms" }) + throw AppError.validation({ "consent.termsVersion": "out of date, so re-accept the terms" }) } if (consent.disclosureVersion !== currentVersion("privacy")) { throw AppError.validation({ - "consent.disclosureVersion": "out of date — re-accept the privacy notice", + "consent.disclosureVersion": "out of date, so re-accept the privacy notice", }) } return { diff --git a/services/api/src/services/host/ticket-type-service.ts b/services/api/src/services/host/ticket-type-service.ts index 41029e86..bdfa6a33 100644 --- a/services/api/src/services/host/ticket-type-service.ts +++ b/services/api/src/services/host/ticket-type-service.ts @@ -48,7 +48,7 @@ function capacityExceededError(eventCapacity: number, used: number): AppError { return AppError.validation({ capacity: `the ticket types on this event may hold at most ${eventCapacity} seats in total ` + - `(${used} already allocated) — raise the event capacity first`, + `(${used} already allocated), so raise the event capacity first`, }) } @@ -216,7 +216,7 @@ export function makeTicketTypeService(deps: TicketTypeServiceDeps): TicketTypeSe throw nameTakenError() case "capacity_below_reserved": throw AppError.validation({ - capacity: `already holding ${outcome.reservedSeats} seats — cancel registrations before lowering it`, + capacity: `already holding ${outcome.reservedSeats} seats, so cancel registrations before lowering it`, }) case "capacity_exceeded": throw capacityExceededError(outcome.eventCapacity, outcome.used) diff --git a/services/api/src/services/media-byte-quota.ts b/services/api/src/services/media-byte-quota.ts index a257a46e..345e576b 100644 --- a/services/api/src/services/media-byte-quota.ts +++ b/services/api/src/services/media-byte-quota.ts @@ -1,6 +1,4 @@ /** - * Cumulative presigned-BYTE quota for media uploads (security review M10). - * * The media presign endpoint is reachable without an account, and its only control was a 30/min/IP * REQUEST cap. Requests are the wrong unit: 30 requests x 50 MB x 60 minutes is 90 GB/hour from a * single source, against an orphan sweep that reclaims a bounded batch per hour. This meters the @@ -11,23 +9,22 @@ * be extended by continuing to spend, while a key that lost its expiry still gets one. * * FAIL CLOSED: `charge` never swallows a store error. A meter that cannot be read must not silently - * become "unlimited" — that is exactly the failure mode H4 flagged in the rate limiter. + * become "unlimited". */ import type { RedisClient } from "../adapters/redis.js" import { attachAtomicIncrBy } from "../adapters/redis-incr.js" -/** Per-subject daily budget. Generous for a real reporter (a handful of photos and a short video). */ +/** Generous for a real reporter: a handful of photos and a short video. */ export const MEDIA_UPLOAD_BYTES_PER_DAY = 512 * 1024 * 1024 -/** Window length for the byte budget, in seconds. */ export const MEDIA_UPLOAD_BYTE_WINDOW_SECONDS = 24 * 60 * 60 -/** Redis key prefix. Distinct bucket from every other abuse counter (see abuse/counter-store.ts). */ +/** Distinct bucket from every other abuse counter (see abuse/counter-store.ts). */ export const MEDIA_UPLOAD_BYTE_PREFIX = "abuse:media:bytes:" export interface ByteMeter { - /** Add `bytes` to the subject's window and return the running total after the add. */ + /** Returns the running total after the add. */ add(subject: string, bytes: number): Promise } @@ -43,7 +40,7 @@ export class RedisByteMeter implements ByteMeter { } } -/** In-memory meter for offline harnesses/tests. Same create-anchored-TTL semantics as the Redis one. */ +/** Same create-anchored-TTL semantics as the Redis meter. */ export class InMemoryByteMeter implements ByteMeter { private readonly store = new Map() private readonly clock: () => number diff --git a/services/api/src/services/media-presign.ts b/services/api/src/services/media-presign.ts index 8e067ede..10a7c329 100644 --- a/services/api/src/services/media-presign.ts +++ b/services/api/src/services/media-presign.ts @@ -5,8 +5,8 @@ export type PresignMedia = ( thumbKey: string | null, ) => Promise<{ url: string; thumbUrl?: string }> -// Structural slice of the storage seam (presigned-GET issuer) so this module pulls in neither Container -// nor the Storage interface — avoids an import cycle through di.ts. +// A structural slice of the storage seam: importing Container or the Storage interface here would +// create an import cycle through di.ts. // // `opts.forceSigned` is optional on purpose: an adapter that has no public-CDN mode (FakeStorage) simply // declares two parameters and stays assignable here, while R2Storage honors it (adapters/storage.r2.ts). @@ -17,7 +17,7 @@ interface PresignStorage { export function makeMediaPresigner(storage: PresignStorage): PresignMedia { return async (r2Key, thumbKey) => { const url = await storage.presignGet(r2Key, MEDIA_GET_URL_TTL_SEC) - if (thumbKey === null) return { url } // images: only url, no thumbnail + if (thumbKey === null) return { url } const thumbUrl = await storage.presignGet(thumbKey, MEDIA_GET_URL_TTL_SEC) return { url, thumbUrl } } @@ -25,9 +25,9 @@ export function makeMediaPresigner(storage: PresignStorage): PresignMedia { /** * Presigner for PRIVATE media (chat/DM attachments, an owner's own held/unlisted report media, - * not-yet-committed uploads). Two differences from the public presigner, both load-bearing (H9): + * not-yet-committed uploads). Two differences from the public presigner, both load-bearing: * - * - `forceSigned: true` — never emit a public CDN URL. A CDN URL is unsigned and permanent, so a DM + * - `forceSigned: true`: never emit a public CDN URL. A CDN URL is unsigned and permanent, so a DM * attachment served through one stays world-readable to anyone who ever saw the link, and no later * unlist / delete / block can revoke it. * - a much shorter TTL, so a leaked URL expires in minutes rather than an hour. @@ -54,11 +54,11 @@ export function makePacketMediaPresigner(storage: PresignStorage): PresignPacket storage.presignGet(r2Key, PACKET_MEDIA_URL_TTL_SEC, { forceSigned: !publiclyVisible }) } -// Concurrency cap for presign fan-outs (bounds concurrent SigV4 signings / R2 ops per page). +// Bounds concurrent SigV4 signings / R2 ops per page. export const PRESIGN_CONCURRENCY = 8 -// Bounded-concurrency map preserving input order. Replaces unbounded `Promise.all(arr.map(fn))` on -// presign/sub-query fan-outs so a large page can't fire hundreds of concurrent R2/DB ops. +// Order-preserving, used instead of `Promise.all(arr.map(fn))` on presign/sub-query fan-outs so a large +// page can't fire hundreds of concurrent R2/DB ops. export async function mapWithLimit( items: readonly T[], limit: number, diff --git a/services/api/src/services/message-mentions.drizzle.ts b/services/api/src/services/message-mentions.drizzle.ts index 681d30c4..81b3b453 100644 --- a/services/api/src/services/message-mentions.drizzle.ts +++ b/services/api/src/services/message-mentions.drizzle.ts @@ -1,23 +1,16 @@ import type { Queryable, Sql } from "../db/client.js" import type { UserMentionDTO } from "@civfix/shared" -// The USER @-mention tables across the messaging stacks share an identical layout -// (, mentioned_user_id) with PK(, mentioned_user_id); chat + DM share one physical -// table. Only the table name (and, for posts, the id-column name) differs, so one parameterized repo -// serves all of them. `table`/`idColumn` are module-constant union literals (never user input), -// interpolated as postgres.js identifiers (`sql(table)` / `sql(idColumn)`). -// ("report_message_user_mentions" is gone with the per-report discussion stack — report messages ride the -// chat table; see src/db/schema/message_mentions.ts.) +// `table` and `idColumn` are module-constant union literals, never user input, interpolated as postgres.js +// identifiers. export type MentionTable = "chat_message_mentions" | "post_mentions" -// The owning-row FK column: 'message_id' for the chat/report tables, 'post_id' for post_mentions (0051). export type MentionIdColumn = "message_id" | "post_id" export interface MessageMentionRepo { - // Replace the message's mention set (delete-then-insert). `mentionedUserIds` must already be deduped + - // self-excluded by the caller. Pass the create/edit tx so the replace is atomic with the message write. + // `mentionedUserIds` must already be deduped and self-excluded. Pass the create/edit tx so the + // delete-then-insert replace is atomic with the message write. recordFor(tx: Queryable, messageId: string, mentionedUserIds: string[]): Promise - // Batched: one grouped join for the whole id set, never one query per message (the N+1 fix). loadFor(messageIds: string[]): Promise> } @@ -46,9 +39,8 @@ export function makeMentionRepo( } } -// Standalone batched loader so a repo can pass its own transaction/tag. UserMentionDTO.handle is -// non-null; a mentioned user always has a handle in practice (mentions resolve from @handles), but -// coalesce defensively so a NULL-handle row never breaks the contract. +// Standalone so a repo can pass its own transaction. UserMentionDTO.handle is non-null and mentions +// resolve from @handles, but the coalesce keeps a NULL-handle row from breaking the contract. export async function loadMentionsFor( tag: Queryable, table: MentionTable, diff --git a/services/api/src/services/message-reactions.drizzle.ts b/services/api/src/services/message-reactions.drizzle.ts index b3f3ee26..ba928aa6 100644 --- a/services/api/src/services/message-reactions.drizzle.ts +++ b/services/api/src/services/message-reactions.drizzle.ts @@ -1,18 +1,12 @@ import type { Queryable, Sql } from "../db/client.js" import type { ReactionEmoji, ReactionSummaryDTO } from "@civfix/shared" -// The reaction tables share an identical layout (message_id, user_id, emoji, created_at) and a -// PK(message_id, user_id, emoji), so one parameterized repo serves them. `table` is a module-constant union -// literal (never user input), interpolated as a postgres.js identifier (`sql(table)`). Today cleanup chat, -// report chat, group chat and DMs all ride the ONE chat table (message ids are globally-unique uuids), so -// the union has a single member; the removed "report_message_reactions" belonged to the deleted per-report -// discussion stack and had no caller left. +// `table` is a module-constant union literal, never user input, interpolated as a postgres.js identifier. +// Every room kind and DMs share the one chat table because message ids are globally unique uuids. export type ReactionTable = "chat_message_reactions" export interface MessageReactionRepo { - // True when the reaction is now PRESENT (added), false when it was removed. toggle(messageId: string, userId: string, emoji: ReactionEmoji): Promise - // Batched: one grouped query for the whole id set, never one query per message (the N+1 fix). loadFor( messageIds: string[], viewerUserId: string | null, @@ -45,8 +39,8 @@ export function makeReactionRepo(sql: Sql, table: ReactionTable): MessageReactio } } -// Standalone batched loader so a repo can pass its own transaction/tag (history reads run inside the -// same connection as the page select). `mine` is false for an anonymous viewer. +// Standalone so a repo can pass its own transaction: history reads run on the same connection as the +// page select. export async function loadReactionsFor( tag: Queryable, table: ReactionTable, diff --git a/services/api/src/services/public-author.ts b/services/api/src/services/public-author.ts index cc207a6e..546ad0d4 100644 --- a/services/api/src/services/public-author.ts +++ b/services/api/src/services/public-author.ts @@ -1,36 +1,22 @@ -/** - * Public author identity projection helper (App-Store-audit remediation: "Deleted User" rendering). - * - * Account deletion is a SOFT delete: the users row + its PII survive (so the ADMIN panel keeps the real - * identity), but every PUBLIC author projection must render a tombstoned author as the shared - * DELETED_USER_LABEL with NO handle, NO avatar, and a `deleted:true` flag (clients drop the profile link). - * - * This is the single source of truth shared by the PUBLIC chat/dm/discussion author projections so they - * stay byte-identical. The ADMIN projections deliberately do NOT use it (operators keep the truth). - */ +// Account deletion is a soft delete: the users row and its PII survive so the admin panel keeps the +// real identity, but every public author projection must show a deleted author as DELETED_USER_LABEL +// with no handle and no avatar photo. The public chat, DM and discussion projections share this helper +// so they stay identical; admin projections deliberately do not use it. import { DELETED_USER_LABEL, avatarGradient } from "@civfix/shared" -/** The deterministic avatar monogram seed pair (the return shape of avatarGradient). */ type AvatarPair = ReturnType -/** The public-facing author identity fields, after applying the deleted-user tombstone rule. */ export interface PublicAuthorIdentity { name: string handle: string | null avatar: AvatarPair - /** Provider photo URL — omitted (undefined) for a deleted author. */ + /** Provider photo URL; undefined for a deleted author. */ avatarUrl: string | undefined - /** True when the account is tombstoned (clients render "Deleted User", no avatar, no profile link). */ + /** Clients drop the avatar and the profile link when set. */ deleted: boolean } -/** - * Resolve a joined author row into its public identity. When `deletedAt` is non-null the author's account - * has been deleted, so we render DELETED_USER_LABEL with a null handle, no avatar, and deleted:true. Live - * accounts pass through their real name/handle, the deterministic avatar monogram (seeded on the stable - * id), and any provider avatar URL. - */ export function publicAuthorIdentity(input: { id: string displayName: string | null @@ -42,8 +28,8 @@ export function publicAuthorIdentity(input: { return { name: DELETED_USER_LABEL, handle: null, - // Keep a deterministic monogram seed (off the stable id) so a deleted author still renders a neutral - // placeholder rather than a broken avatar; clients suppress it via `deleted`. + // Seeded off the stable id so a deleted author still renders a neutral placeholder rather than a + // broken avatar; clients suppress it via `deleted`. avatar: avatarGradient(input.id), avatarUrl: undefined, deleted: true, diff --git a/services/api/src/services/report-chat-emitter.ts b/services/api/src/services/report-chat-emitter.ts index af75a809..d8713821 100644 --- a/services/api/src/services/report-chat-emitter.ts +++ b/services/api/src/services/report-chat-emitter.ts @@ -1,27 +1,10 @@ /** - * Task D-D1: build the report-chat SYSTEM-message emitter (the timeline choke point) from CONTAINER - * PRIMITIVES, so it can be constructed wherever the admin / citizen report services are wired — WITHOUT - * depending on the chat-gateway wiring's instances. + * Built from container primitives so the admin and citizen report services can emit timeline messages + * without depending on the chat-gateway wiring's instances. * - * The emitter's three deps are assembled exactly the way the chat gateway (chat-gateway-wiring.ts) builds - * its own report-chat notifier: - * - reportChat = makeReportChatRepository(sql) (system-message insert + member lookup) - * - broadcast = container.chatService.broadcast (fan the system frame to the live room) - * - notify = makeReportChatNotifier({ notificationService, reportChatRepo, isMuted, presence?, - * roomKeyFor }) (per-member bell, skipping present/muted) - * - notificationService = makeNotificationService(...) (same shape as the container/chat wiring) - * - isMuted = makeConversationMutesRepository(sql), scoped to roomKind "report" - * - roomKeyFor from ../ws/gateway.js - * - * PRESENCE: the room presence adapter (RedisChatPresence / InMemoryChatPresence) is built inside the chat - * gateway wiring and is NOT readily reachable from the admin/citizen service context, so `presence` is - * passed as undefined here. Effect: a member currently VIEWING the report chat also receives a push for a - * system event (minor over-notification; acceptable — see task note). Live delivery still happens via the - * broadcast, so the viewer sees the message immediately regardless. - * - * FAKE-CHAT GUARD: under USE_FAKE_CHAT (no DB / no real chat + notification services) this degrades to a - * NO-OP emitter rather than crashing — mirroring how the report's other best-effort side effects - * (onReportMessage / notifyReporter) are gated off under fake-chat. + * The room presence adapter lives inside the chat gateway wiring and is not reachable from here, so a + * member currently viewing the report chat also gets a push for a system event. That over-notification + * is accepted: the broadcast still delivers the message live. */ import type { FastifyBaseLogger } from "fastify" @@ -37,22 +20,16 @@ import { type ReportChatSystemEmitter, } from "./report-timeline-event.js" -/** A no-op emitter used under fake-chat (or any absent-service path). emit() resolves without side effects. */ export const NOOP_REPORT_CHAT_EMITTER: ReportChatSystemEmitter = { emit: () => Promise.resolve(), } -/** - * Build the real container-backed emitter, or the no-op emitter under fake-chat. `logger` (defaults to - * container-less no-op) is threaded into both the notification service and the emitter's swallowed-error - * warnings. - */ export function makeContainerReportChatEmitter( container: Container, logger?: Pick, opts: { propagateInsertFailure?: boolean } = {}, ): ReportChatSystemEmitter { - // Fake-chat: no DB + a no-op chat service, so mirror the other report side-effects and degrade to no-op. + // Fake chat has no DB, so this degrades to a no-op like the report's other best-effort side effects. if (container.env.USE_FAKE_CHAT) return NOOP_REPORT_CHAT_EMITTER const sql = container.getDb().sql @@ -68,13 +45,9 @@ export function makeContainerReportChatEmitter( const conversationMutes = makeConversationMutesRepository(sql) /** - * Batch mute shape: one query for the room's whole member set instead of one per recipient. - * - * PROBED, never bound to an empty-Set default (the chat-gateway-wiring stance): the fan-out treats a - * present `mutedUserIdsFor` as AUTHORITATIVE and skips the per-user `isMuted` entirely, so a - * `?? Promise.resolve(new Set())` fallback would silently UNMUTE the whole room the moment this is - * built over a mutes store without the batch method (it is optional on the interface). Absent => the - * dep is omitted and the notifier keeps its per-candidate `isMuted` gate. + * Probed, never bound to an empty-Set default: the fan-out treats a present `mutedUserIdsFor` as + * authoritative and skips the per-user `isMuted`, so a `new Set()` fallback would silently unmute the + * whole room over a mutes store without the batch method. */ const mutedUserIdsFor = ((): | ((roomId: string, userIds: string[]) => Promise>) @@ -89,19 +62,12 @@ export function makeContainerReportChatEmitter( reportChatRepo, isMuted: (userId, roomId) => conversationMutes.isMuted(userId, "report", roomId), ...(mutedUserIdsFor ? { mutedUserIdsFor } : {}), - // presence intentionally omitted — not reachable from the admin/citizen service context (see header). roomKeyFor, - // M11: this emitter only ever fans out SENDER-LESS `kind:"system"` messages (a timeline reflection - // has no author), so the block gate can never fire on this path — the notifier short-circuits on a - // null actor. Wired to the real repo anyway rather than a `() => false` stub: the dep is required - // precisely so nobody has to reason about whether a given caller "needs" it, and if a future - // timeline event ever gains an author this path is already correct. - // Resolved LAZILY (inside the closure), like every other container read on this best-effort path: the - // factory is called per timeline event from services whose own wiring may not have a blocks repo at - // all, and a throw HERE would abort the caller (the emit() try/catch only covers the emit itself). - // The BATCH form (blockedIdsAmong) is deliberately NOT wired here for the same reason — probing it - // requires the repo at construction time, and it would buy nothing: see above, a null actor - // short-circuits the gate before either shape is consulted. + // System messages have no author, so the notifier short-circuits this gate on a null actor. It is + // wired to the real repo rather than a `() => false` stub so a timeline event that gains an author + // is already correct. Resolved lazily: a caller's wiring may have no blocks repo, and a throw at + // construction would abort the caller (emit()'s try/catch only covers the emit). The batch form + // (blockedIdsAmong) is not wired because probing it needs the repo at construction time. isBlockedEitherWay: (a, b) => container.getBlocksRepo().isBlockedEitherWay(a, b), logger, }) diff --git a/services/api/src/services/report-clustering.ts b/services/api/src/services/report-clustering.ts index 550714ad..61207784 100644 --- a/services/api/src/services/report-clustering.ts +++ b/services/api/src/services/report-clustering.ts @@ -2,27 +2,26 @@ import { latLngToCell } from "h3-js" import type { ReportCategory, ReportClusterDTO, ReportStatus, ReportType } from "@civfix/shared" import { REPORT_H3_RESOLUTION, type ReportMapPoint } from "./report-service.types.js" -// Max candidate report points pulled from the DB for a single bbox/cluster query. Bounds the payload and -// per-request work for a wide bbox; clustering then collapses them to far fewer pins. +// Bounds the payload and per-request work for a wide bbox; clustering then collapses them to far fewer pins. export const MAP_REPORTS_CANDIDATE_CAP = 2000 // Zoom at/above which the map returns INDIVIDUAL pins; below it points snap to a grid and return as // clusters with counts. 10 is "city" zoom: with the bbox clamp below, a phone-sized viewport (whose -// fetch bbox is padded 1.6x per axis) clears it from map zoom ~9.2 — the whole LA basin in view — so the +// fetch bbox is padded 1.6x per axis) clears it from map zoom ~9.2 (the whole LA basin in view), so the // CLIENT clusterer owns every grouping decision from there in and the server only aggregates at the // aerial scale where a pin payload would be unbounded anyway. export const CLUSTER_ZOOM_THRESHOLD = 10 /** - * M14 — the bbox, not the client, decides the effective zoom. + * The bbox, not the client, decides the effective zoom. * * `zoom` arrives as a FREE query parameter that was never correlated with `bbox`, so an anonymous * caller sent `bbox=&zoom=22`, skipped clustering entirely, and forced 2000 full report - * rows plus 2000 media presign round-trips per request — with the 60s Cache-Control defeated by + * rows plus 2000 media presign round-trips per request, with the 60s Cache-Control defeated by * jittering the bbox by a metre. Zoom and viewport extent are not independent in any real map client: * a Web-Mercator viewport `W` pixels wide at zoom `z` shows `360 * (W/256) / 2^z` degrees of longitude. * Inverting that gives the zoom a given span actually implies, and we take the MINIMUM of that and what - * the client asked for. A client can still ask to be zoomed further OUT than its viewport (harmless — + * the client asked for. A client can still ask to be zoomed further OUT than its viewport (harmless: * that only coarsens clustering); it can no longer claim street-level zoom over a continent. * * The reference viewport is deliberately generous (2048 CSS px ≈ 8 tiles) so that a genuine desktop map @@ -40,13 +39,12 @@ export interface MapBBox { north: number } -/** The largest zoom a viewport of this extent could plausibly be displaying. Clamped to [0, 22]. */ +/** The largest zoom a viewport of this extent could plausibly be displaying. */ export function impliedZoomForBBox(bbox: MapBBox): number { const lngSpan = bbox.east - bbox.west // Latitude runs over a 180° axis where longitude runs over 360°, so double it before comparing. const latSpan = (bbox.north - bbox.south) * 2 - // Guard a degenerate/non-finite span (the route's BBoxQueryParam refine already rejects west >= east, - // this is defense-in-depth): a zero span would send log2 to +Infinity, i.e. no clamp at all. + // The route already rejects west >= east, but a zero span would send log2 to +Infinity, i.e. no clamp. const span = Math.max(lngSpan, latSpan) if (!Number.isFinite(span) || span <= 0) return 0 const z = Math.log2((360 * MAP_VIEWPORT_REFERENCE_TILES) / span) @@ -54,23 +52,20 @@ export function impliedZoomForBBox(bbox: MapBBox): number { return Math.max(0, Math.min(22, Math.floor(z))) } -/** The zoom the map read should actually use: never more than the bbox extent can justify. */ export function effectiveMapZoom(bbox: MapBBox, requestedZoom: number): number { const requested = Number.isFinite(requestedZoom) ? requestedZoom : 0 return Math.min(requested, impliedZoomForBBox(bbox)) } -// Pure; wraps h3-js. The per-report H3 index stored on each row (reports.h3_cell). export function reportH3Cell(lat: number, lng: number): string { return latLngToCell(lat, lng, REPORT_H3_RESOLUTION) } -// Map CLUSTER grid cell size in DEGREES (separate from the H3 reports.h3_cell). Halves each zoom step so -// the world stays partitioned into ~constant screen-space tiles. +// Separate from the H3 reports.h3_cell. Halves each zoom step so the world stays partitioned into +// ~constant screen-space tiles. export function clusterCellSizeDeg(zoom: number): number { - // Defense-in-depth NaN/non-finite guard: a non-finite zoom would yield a NaN cell size -> every point - // maps to a "NaN:NaN" grid key -> one cluster at NaN coords (serializes to null = a broken pin). Treat a - // non-finite zoom as 0 (the coarsest cell). The route additionally rejects a bad zoom with 422 first. + // The route rejects a bad zoom first, but a non-finite one here would put every point in one cluster + // at NaN coords, which serializes to null (a broken pin). const safeZoom = Number.isFinite(zoom) ? zoom : 0 const z = Math.max(0, Math.floor(safeZoom)) return 360 / Math.pow(2, z + 1) @@ -110,10 +105,7 @@ export function mapPointToUnsignedPin(p: ReportMapPoint): UnsignedReportPin { } } -// PURE server-side clustering keyed by zoom. At/above CLUSTER_ZOOM_THRESHOLD: every point becomes an -// individual (unsigned) pin. Below it: points snap to a clusterCellSizeDeg grid, one ReportClusterDTO per -// non-empty cell at the cell centroid with a count. Deterministic (cells iterated in insertion order) so -// tests can assert exact output. +// Cells are iterated in insertion order so the output is deterministic and tests can assert it exactly. export function clusterByZoom( points: ReportMapPoint[], zoom: number, @@ -149,8 +141,8 @@ export function clusterByZoom( return { clusters, pins: [] } } -// Per-category count over ALL candidates (independent of the cluster/pin split) so the filter popover -// shows how many reports of each category are in view. Only non-zero categories are present. +// Counts ALL candidates, independent of the cluster/pin split, so the filter popover shows how many +// reports of each category are in view. export function countByCategory(points: ReportMapPoint[]): Partial> { const counts: Partial> = {} for (const p of points) { diff --git a/services/api/src/services/report-forward-audit.drizzle.ts b/services/api/src/services/report-forward-audit.drizzle.ts index ae482639..f818fad4 100644 --- a/services/api/src/services/report-forward-audit.drizzle.ts +++ b/services/api/src/services/report-forward-audit.drizzle.ts @@ -1,26 +1,19 @@ /** - * Task D-C4: @city forward AUDIT writes onto report_message_forwards (migration 0043). + * @city forward audit rows (report_message_forwards, migration 0043), one per (message, geoid). + * - recordMention runs BEFORE the forward is attempted, so "@city was mentioned" is captured even when + * there is no city contact to forward to. ON CONFLICT DO NOTHING keeps a retry from erroring or + * clobbering an already-forwarded row's forwarded_at. + * - markForwarded runs only after a SUCCESSFUL send; COALESCE keeps the first forward time on a re-run. * - * A report-chat message that @mentions its own jurisdiction handle records ONE row per (message, geoid): - * - recordMention: written BEFORE the forward is attempted, forwarded_at NULL. This captures - * "@city was mentioned" even when there is no city contact to forward to (mentioned-but-not-forwarded). - * Idempotent via ON CONFLICT DO NOTHING so a re-send / retry within the same message id never errors and - * never clobbers an already-forwarded row's forwarded_at. - * - markForwarded: run only AFTER a SUCCESSFUL send, stamping forwarded_at = now(). COALESCE keeps the - * first forward time on an idempotent re-run. - * - * Both are best-effort from the caller's perspective: forwardReportCityMention swallows their errors so a - * failed/absent audit write can never reject the chat message (the message already persisted). The table has - * no FK on message_id (chat_messages is range-partitioned) -- app-level integrity, exactly like the - * reactions/mentions side tables. + * forwardReportCityMention swallows both errors so a failed audit write can never reject the chat + * message, which is already persisted. The table has no FK on message_id (chat_messages is + * range-partitioned), so integrity is app-level, like the reactions/mentions side tables. */ import type { Sql } from "../db/client.js" export interface ReportForwardAudit { - /** Insert the (message, geoid) audit row with forwarded_at NULL; idempotent (no-op on conflict). */ recordMention(messageId: string, geoid: string): Promise - /** Stamp forwarded_at = now() for the (message, geoid) row (keeps the earliest time on re-run). */ markForwarded(messageId: string, geoid: string): Promise } diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index c1f873f8..f68b7fb4 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -100,7 +100,6 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { ...(pin.title !== null ? { title: pin.title } : {}), description: pin.description, thumbUrl, - // " headline). addr stays nullable; referenceCode is omitted when null, mirroring title. addr: pin.addr, ...(pin.referenceCode !== null ? { referenceCode: pin.referenceCode } : {}), } diff --git a/services/api/src/services/report-timeline-event.ts b/services/api/src/services/report-timeline-event.ts index f19ff754..e625028f 100644 --- a/services/api/src/services/report-timeline-event.ts +++ b/services/api/src/services/report-timeline-event.ts @@ -1,35 +1,26 @@ /** - * Task D-D1: the CHOKE POINT that mirrors every report TIMELINE event into that report's group chat. + * The single seam every report timeline writer routes through AFTER writing its `report_timeline` row, so + * each event is mirrored into the report's group chat as a sender-less `kind:"system"` message. * - * Today a report status/timeline change writes a `report_timeline` row (the source of truth) but never - * touches the chat. This module is the single seam every timeline writer routes through AFTER it has - * written its row: it posts a sender-less `kind:"system"` chat message into the report room, broadcasts it - * to the room's live sockets, and pushes the (non-present/non-muted) members. + * The timeline row is authoritative and the chat message only a reflection of it, so `emit` is best-effort: + * a failure at insert, broadcast or notify is logged and never propagates, and a failed system message can + * never fail or roll back the status change. Keep the call OUTSIDE the transaction that wrote the timeline + * row, like the report's other post-commit side effects (onReportMessage / notifyReporter). * - * The timeline row is authoritative; the system chat message is a REFLECTION of it. So `emit` is FULLY - * best-effort: any failure at any of the three steps (insert / broadcast / notify) is caught + logged and - * NEVER propagates. Callers may still `await emit(...)` — it just never rejects, so a failed system - * message can never fail or roll back the underlying status change. Keep the call OUTSIDE the DB - * transaction that wrote the timeline row (a post-commit side effect, like the report's other - * best-effort side effects — onReportMessage / notifyReporter). - * - * Constructed from CONTAINER PRIMITIVES (see makeContainerReportChatEmitter in report-chat-emitter.ts) so - * it can be built wherever the admin/citizen services are wired, without depending on the chat-gateway - * wiring's instances. Injectable as a fake in tests (mirrors the existing service override seams). + * Built from container primitives (makeContainerReportChatEmitter in report-chat-emitter.ts) so it can be + * wired wherever the admin/citizen services are, without the chat-gateway wiring's instances. */ import type { ChatMessageDTO } from "@civfix/shared" -/** A single report timeline event to reflect into the report chat. */ export interface ReportTimelineEvent { reportId: string /** The report status AT this event (a valid ReportStatus; validated by insertSystemMessage). */ status: string /** Timeline kind (submit|route|status|done|remove|reply|...), or null/omitted. */ kind?: string | null - /** Short preview note. */ note?: string | null - /** Full text (e.g. an inbound city reply body). */ + /** e.g. an inbound city reply body. */ body?: string | null } @@ -50,17 +41,10 @@ export interface ReportChatSystemEmitterDeps { broadcast: (roomKey: string, message: ChatMessageDTO) => void | Promise notify: (reportId: string, message: ChatMessageDTO) => Promise roomKeyFor: (kind: "report", id: string) => string - /** Optional logger for the swallowed best-effort failures (defaults to a no-op). */ logger?: { warn: (obj: unknown, msg?: string) => void } propagateInsertFailure?: boolean } -/** - * Build the report-chat SYSTEM-message emitter. `emit(event)`: - * 1. persists the system chat row (reportChat.insertSystemMessage) - * 2. broadcasts it to the live room (broadcast(roomKeyFor("report", id), msg)) - * 3. pushes the members (notify(id, msg)) - */ export function makeReportChatSystemEmitter( deps: ReportChatSystemEmitterDeps, ): ReportChatSystemEmitter { diff --git a/services/api/src/services/report-visibility.ts b/services/api/src/services/report-visibility.ts index 2729c8ab..0d562586 100644 --- a/services/api/src/services/report-visibility.ts +++ b/services/api/src/services/report-visibility.ts @@ -8,12 +8,12 @@ import type { OwnerToggleStatus } from "./report-service.types.js" * The lifecycle is submitted -> held -> published -> acknowledged -> in_progress -> resolved (+ rejected; * see admin/admin-report-status.ts). A pin goes live at `published` and STAYS live while the city works * it: `acknowledged`, `in_progress` and `resolved` are progress states of an already-public report, not a - * retraction of it. Only the PRE-publication states (`submitted`, `held` — the anon hold-then-publish + * retraction of it. Only the PRE-publication states (`submitted`, and `held`, the anon hold-then-publish * gate) and the moderator's `rejected` are hidden, plus the orthogonal owner `visibility = 'hidden'` and * the soft delete. * - * H8-b: this used to be `status = 'published'` exactly, so ANY progression 404'd the report for everyone - * but its owner and dropped it from the map/search pins — while the feeds' `fixes` filter selects posts + * This used to be `status = 'published'` exactly, so ANY progression 404'd the report for everyone but + * its owner and dropped it from the map/search pins, while the feeds' `fixes` filter selects posts * whose report is `resolved`, i.e. the product's headline "a fix happened" surface was structurally * unrenderable. Every consumer of this set (the SQL twin `publicReportFilter` in ./report-sql.ts, * `isReportVisibleTo` below, report-service.getReport, media-authorization's report lane, and the @@ -26,7 +26,6 @@ export const PUBLIC_REPORT_STATUSES = [ "resolved", ] as const satisfies readonly ReportStatus[] -/** Whether a raw `reports.status` value is one of PUBLIC_REPORT_STATUSES. */ export function isPubliclyVisibleStatus(status: string): boolean { return (PUBLIC_REPORT_STATUSES as readonly string[]).includes(status) } @@ -50,9 +49,8 @@ export function ownerStatusTransition( * * The SQL twin of the non-owner half of this predicate is `publicReportFilter()` in ./report-sql.ts * (`status IN PUBLIC_REPORT_STATUSES AND visibility = 'public' AND deleted_at IS NULL`). Any read path - * that filters reports in the database MUST use that fragment rather than re-typing the conditions — H8 - * was exactly two hand-rolled copies of it in post-repository.drizzle.ts that had each dropped a - * different term. + * that filters reports in the database MUST use that fragment rather than re-typing the conditions: two + * hand-rolled copies of it in post-repository.drizzle.ts once each dropped a different term. */ export function isReportVisibleTo( report: DiscussionReportView | null, diff --git a/services/api/src/services/route-geo-helpers.ts b/services/api/src/services/route-geo-helpers.ts index fa601e4a..60d0bf32 100644 --- a/services/api/src/services/route-geo-helpers.ts +++ b/services/api/src/services/route-geo-helpers.ts @@ -1,11 +1,8 @@ /** - * Route-level wiring for the jurisdiction + reverse-geocode seams. - * - * The reports, anon, cleanups and map plugins all need the same two things: "which government owns this - * point" and "what address is at this point". Each used to build the closures inline, so the wiring drifted - * — map.routes silently omitted `jurisdictionLookup`, which made POST /map/resolve-jurisdiction answer - * "not covered" for a point that self-maps via the Census fallback the moment the same user submits a - * report there. One builder per seam keeps the coverage answer consistent across every surface. + * The reports, anon, cleanups and map plugins all need "which government owns this point" and "what + * address is at this point". Each used to build the closures inline and the wiring drifted: map.routes + * silently omitted `jurisdictionLookup`, which made POST /map/resolve-jurisdiction answer "not covered" + * for a point that self-maps via the Census fallback the moment the same user submits a report there. One builder per seam keeps the coverage answer consistent across every surface. * * Everything here touches `container.getDb()` INSIDE the returned closure: a route may build a resolver at * mount time, and merely mounting a plugin must never open a DB connection. @@ -20,13 +17,12 @@ import { makeJurisdictionService, type JurisdictionService } from "./jurisdictio import { makeAddressResolver, type AddressResolver } from "./address-resolver.js" import { makeGeocodeCache } from "./geocode-cache.js" -/** Coords -> the owning jurisdiction's geoid, or null outside coverage. Matches the service deps. */ export type PointToString = (lat: number, lng: number) => Promise /** * ONE memoizing lookup wrapper per container, kept at module scope because the SERVICE is rebuilt per - * request (a per-service cache would never see a second call). Keyed by container — not a bare module - * singleton — so a process holding several containers (the test suites do) never serves one container's + * request (a per-service cache would never see a second call). Keyed by container, not a bare module + * singleton, so a process holding several containers (the test suites do) never serves one container's * cached coverage answers to another, and so the cache is collected with the container. */ const CACHED_LOOKUPS = new WeakMap() @@ -42,7 +38,7 @@ function cachedLookupFor(container: Container): JurisdictionLookup { export interface RouteJurisdictionServiceOptions { /** * Wrap the Census lookup in the process-local TTL memo (CachedJurisdictionLookup) instead of calling it - * per request. OFF by default; see makeRouteJurisdictionService for which surfaces opt in and why. + * per request. See makeRouteJurisdictionService for which surfaces opt in and why. */ cacheLookup?: boolean } @@ -52,9 +48,9 @@ export interface RouteJurisdictionServiceOptions { * PostGIS miss the point self-maps to the most specific Census place instead of staying "Unmapped" * (fake/no-op outside production). Census spend is bounded by the callers' per-route rate limits. * - * `cacheLookup` does NOT change the coverage answer — same resolver, same fallback, same lazily-inserted - * row; it only stops the SAME point from re-fetching Census on every request (the fallback's row has a NULL - * geom, so it never becomes a local hit — see jurisdiction-service's header). It is opted into by the + * `cacheLookup` does NOT change the coverage answer (same resolver, same fallback, same lazily-inserted + * row); it only stops the SAME point from re-fetching Census on every request (the fallback's row has a + * NULL geom, so it never becomes a local hit; see jurisdiction-service's header). It is opted into by the * anon-ok read surface (POST /map/resolve-jurisdiction: unauthenticated, csrf-less, IP-keyed, and a pure * question about a coordinate) and deliberately NOT by the report/anon/cleanup SUBMIT paths, which write a * row whose jurisdiction_geoid is an FK into `jurisdictions` and must therefore observe live coverage @@ -73,7 +69,6 @@ export function makeRouteJurisdictionService( }) } -/** resolveJurisdictionGeoid dep: the owning jurisdiction's geoid for a point, or null when uncovered. */ export function makeGeoidResolver(container: Container): PointToString { return async (lat, lng) => { const resolved = await makeRouteJurisdictionService(container).resolveForPoint(lat, lng) @@ -90,7 +85,7 @@ export function makeGeoidResolver(container: Container): PointToString { * returned closure (mounting a plugin must not open a connection), and the cache swallows the throw a * DB-less process raises, degrading to an uncached resolve rather than an error. * - * Best-effort by contract — a null address leaves the field empty and never blocks a submit. + * Best-effort by contract: a null address leaves the field empty and never blocks a submit. */ export function makeCachedAddressResolver(container: Container): AddressResolver { return makeAddressResolver({ diff --git a/services/api/src/services/route-notifier.ts b/services/api/src/services/route-notifier.ts index e2162bce..c060cf40 100644 --- a/services/api/src/services/route-notifier.ts +++ b/services/api/src/services/route-notifier.ts @@ -1,17 +1,12 @@ -/** - * Route-level wiring for the notification pipeline (in-app row + push + user-channel signal). - * - * The identical repo/pushSender/userChannel/logger block was copy-pasted into every plugin that rings a - * bell (notifications, social, cleanups), so a change to the notifier's dependencies meant finding every - * copy. One builder instead. Built per call — the Drizzle repo is a thin wrapper over the lazily-created - * `sql` tag, so this opens no connection until a handler actually notifies. - */ - import type { FastifyBaseLogger } from "fastify" import type { Container } from "../di.js" import { makeNotificationService, type NotificationService } from "./notification-service.js" import { makeDrizzleNotificationRepository } from "./notification-repository.drizzle.js" +/** + * Built per call: the Drizzle repo is a thin wrapper over the lazily-created `sql` tag, so this opens + * no connection until a handler actually notifies. + */ export function makeRouteNotificationService( container: Container, logger?: Pick, diff --git a/services/api/src/services/user-search.drizzle.ts b/services/api/src/services/user-search.drizzle.ts index 051de318..3d2048c8 100644 --- a/services/api/src/services/user-search.drizzle.ts +++ b/services/api/src/services/user-search.drizzle.ts @@ -1,25 +1,11 @@ -/** - * @handle / display-name search reads for the social domain: the DM-start prefix search and the - * @-mention typeahead. Split out of social-repository.drizzle.ts (these are free functions over the raw - * postgres-js tag, a distinct responsibility from the SocialRepository row/follow persistence). - */ - import type { Sql } from "../db/client.js" import { avatarGradient } from "@civfix/shared" import type { UserSearchResultDTO } from "@civfix/shared" import { escapeLike } from "./admin/like.js" -/** - * @handle PREFIX search for starting a DM (GET /users/search). Returns the minimal, privacy-conscious - * UserSearchResultDTO (no email/bio/follower counts). Matches `handle ILIKE %` case-insensitively - * (handle is citext) with the prefix escaped so %/_/\ are literal. Exclusions (the locked product rules): - * - self (u.id <> viewerId); - * - soft-deleted users (deleted_at IS NOT NULL); - * - users with NULL handle (not searchable); - * - users with allow_direct_messages = false (DM-disabled accounts are hidden from search); - * - users blocked either way w.r.t. the viewer (NOT EXISTS over user_blocks in both directions). - * `q` is the raw query with a leading `@` already stripped by the route. Ordered by handle asc, capped. - */ +// Locked product rule: DM search never surfaces the viewer, deleted or handle-less users, accounts with +// DMs turned off, or anyone blocked in either direction. The result shape carries no email, bio or +// follower counts. The route has already stripped a leading `@` from `q`. export async function searchByHandlePrefix( sql: Sql, q: string, @@ -56,15 +42,9 @@ export async function searchByHandlePrefix( })) } -/** - * @handle / display-name search for the @-mention picker (GET /users/mention-search). BROADER than - * searchByHandlePrefix in that it does NOT exclude DM-disabled accounts (anyone is taggable), but — like - * searchByHandlePrefix — it DOES exclude users blocked either way (a blocked user should never surface as - * a suggested mention target). Excludes self + soft-deleted + handle-less users. Matches a substring on - * handle OR display_name (ILIKE %q%), capped. Returns the SAME UserSearchResultDTO shape searchUsers does. - * A hand-typed @handle for a blocked user is still resolvable, but the resulting mention BELL is already - * block-gated in the notifiers. - */ +// Keeps DM-disabled accounts (anyone is taggable) but, like DM search, never suggests a user blocked in +// either direction. A hand-typed @handle for a blocked user still resolves; the mention notification is +// block-gated in the notifiers instead. export async function searchMentionable( sql: Sql, q: string, diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 9ba32ef0..964b0ce7 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -617,7 +617,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu for (const entry of input.entries) { if (entry.userId === input.actorId) { throw AppError.forbidden( - "You can't log volunteer hours for yourself — another host must credit you.", + "You can't log volunteer hours for yourself. Another host must credit you.", ) } if (!(entry.hours >= MIN_EVENT_HOURS) || entry.hours > MAX_EVENT_HOURS) { diff --git a/services/api/src/version.ts b/services/api/src/version.ts index 329a2412..da5515cf 100644 --- a/services/api/src/version.ts +++ b/services/api/src/version.ts @@ -1,11 +1,7 @@ /** - * Service version string, surfaced by /healthz and used as the GlitchTip `release`. Kept here rather - * than imported from package.json so the build does not depend on JSON module resolution and the value - * is stable in both ESM dist and tests. - * - * The deploy injects the real build identity via SERVICE_VERSION (GIT_SHA / image tag) so releases are - * distinguishable in /healthz + error reports; the "0.0.0" fallback is the local/dev placeholder. Read - * directly from process.env (not the validated env loader) so this module has no env-load dependency. + * The deploy injects the build identity (git sha / image tag) as SERVICE_VERSION for the GlitchTip + * `release`. Not read from package.json so the build needs no JSON module resolution, and read from + * process.env directly so this module has no env-load dependency. */ export const SERVICE_NAME = "civfix-api" export const SERVICE_VERSION = process.env.SERVICE_VERSION || "0.0.0" diff --git a/services/api/src/versioning/policy.ts b/services/api/src/versioning/policy.ts index 6ba38819..53d21a62 100644 --- a/services/api/src/versioning/policy.ts +++ b/services/api/src/versioning/policy.ts @@ -1,88 +1,51 @@ /** - * Served-versions policy — the operational, backend-owned half of API versioning. - * - * The shared contract (`@civfix/shared/client`) owns the version→path *mapping* (which version each - * endpoint is declared under, and how `versionedPath` turns that into a wire path). This module owns - * the orthogonal *operational* question: of the versions the contract knows about, which does THIS - * deployment actually serve right now, and what is each one's lifecycle state (current / deprecated / - * sunset)? - * - * THIS IS THE SINGLE PLACE to flip a version's lifecycle. To deprecate `v1` (announce a sunset while - * still serving it), set its entry to `{ status: "deprecated", sunset: "" }`; to stop - * serving it entirely, set `{ status: "sunset" }` and drop it from `SERVED_VERSIONS` (or raise - * `MIN_SUPPORTED_VERSION`). The version-gate plugin reads only this module, so no other code changes. - * - * Today only `v1` exists and it is `current`, so the gate's only active behavior is rejecting unknown - * `/vN` segments — which is the desired immediate hardening. + * The contract owns which version each endpoint is declared under; this module owns which versions THIS + * deployment serves and each one's lifecycle. It is the single place to flip a lifecycle: deprecate with + * `{ status: "deprecated", sunset: "" }`; retire with `{ status: "sunset" }` plus dropping it from + * `SERVED_VERSIONS` (or raising `MIN_SUPPORTED_VERSION`). The version gate reads only this module. */ import type { ApiVersion } from "@civfix/shared/client" -/** Lifecycle state of a served API version. */ export type VersionLifecycle = "current" | "deprecated" | "sunset" -/** Per-version lifecycle descriptor. `sunset` (ISO-8601 date) is only meaningful for "deprecated". */ export interface VersionStatus { readonly status: VersionLifecycle - /** ISO-8601 date a deprecated version stops being served. Surfaced in the `Sunset` response header. */ + /** ISO-8601 date, only meaningful for "deprecated"; surfaced in the `Sunset` response header. */ readonly sunset?: string } -/** - * Versions this deployment serves, newest-supported first. A `/vN` segment not in this set is rejected - * by the gate (400 UNSUPPORTED_API_VERSION) unless it is a known-but-sunset version (410, see below). - */ export const SERVED_VERSIONS: readonly ApiVersion[] = ["v1"] as const -/** - * Oldest version still accepted. A syntactically valid `/vN` whose N is below this floor is rejected as - * unsupported (400) even if the segment is otherwise well-formed. Raise this to retire old majors. - */ export const MIN_SUPPORTED_VERSION: ApiVersion = "v1" -/** - * Lifecycle map keyed by every `ApiVersion` the contract defines. `Record` makes this - * exhaustive by construction: adding a new `ApiVersion` to the shared union forces a compile error here - * until its lifecycle is declared, so a new version can never be silently un-policied. - */ +/** `Record` forces a compile error here when the contract gains a version with no policy. */ export const VERSION_STATUS: Record = { v1: { status: "current" }, } as const const VERSION_SEGMENT_RE = /^v\d+$/ -/** Numeric major extracted from a `vN` segment, or `null` if the segment is not of that shape. */ function majorOf(seg: string): number | null { const match = /^v(\d+)$/.exec(seg) if (!match) return null return Number.parseInt(match[1]!, 10) } -/** True iff `seg` matches the `/^v\d+$/` shape of an API-version path segment (e.g. "v1", "v2"). */ export function isVersionSegment(seg: string): boolean { return VERSION_SEGMENT_RE.test(seg) } -/** True iff `seg` is a version this deployment currently serves (current OR deprecated, not sunset). */ export function isServedVersion(seg: string): boolean { return SERVED_VERSIONS.includes(seg as ApiVersion) } -/** - * Lifecycle status for a version segment, or `null` if the contract does not define it at all (an - * unknown `vN`). A known-but-sunset version returns `{ status: "sunset" }` so the gate can answer 410 - * rather than 400. - */ +/** A known-but-sunset version returns its entry so the gate can answer 410 rather than 400. */ export function versionStatus(seg: string): VersionStatus | null { if (!isVersionSegment(seg)) return null return VERSION_STATUS[seg as ApiVersion] ?? null } -/** - * True iff `seg` is a well-formed `vN` whose major is below `MIN_SUPPORTED_VERSION` — i.e. a version we - * have retired below the floor. Such a segment is "unsupported" (400) regardless of its presence in the - * lifecycle map. - */ export function isBelowMinSupported(seg: string): boolean { const major = majorOf(seg) if (major === null) return false @@ -91,12 +54,7 @@ export function isBelowMinSupported(seg: string): boolean { return major < min } -/** - * Cross-validate the three policy sources at startup: every SERVED_VERSIONS member (and the - * MIN_SUPPORTED floor) must have a non-sunset VERSION_STATUS entry, else the gate would advertise a - * version it can't serve. Throws an Error listing the offending versions so a misconfiguration fails boot - * instead of surfacing as confusing 4xxs in prod. - */ +/** Fails boot on an inconsistent policy instead of surfacing it as confusing 4xxs in prod. */ export function validateVersionPolicy(): void { const problems: string[] = [] for (const v of [MIN_SUPPORTED_VERSION, ...SERVED_VERSIONS]) { diff --git a/services/api/src/versioning/route.ts b/services/api/src/versioning/route.ts index f4e2d57f..88a7f2a3 100644 --- a/services/api/src/versioning/route.ts +++ b/services/api/src/versioning/route.ts @@ -1,41 +1,18 @@ /** - * Typed, version-aware route registration helper. - * - * `route(app, "createReport", handler)` registers a Fastify route whose METHOD, PATH, and VERSION are - * all derived from the shared endpoint registry (`@civfix/shared/client` → `endpoints` + `versionedPath`), - * which is the single source of truth shared with the typed API client. Because the backend computes its - * wire path from the same registry the client uses to *call* it, the two can never drift: bump an - * endpoint's `version` (or its `path`) in the contract and both move together. - * - * route(app, name, handler) // no extra Fastify options - * route(app, name, opts, handler) // with preHandler / config.rateLimit / schema / etc. - * - * `name` is `keyof typeof endpoints`, so it autocompletes and a typo is a compile error. ALL Fastify - * route options passed in `opts` are preserved (merged into the route object). This pass intentionally - * does NOT thread the contract's request/response types onto the handler — registration only — so the - * route refactor stays mechanical and low-risk; handlers keep their existing Fastify typing. - * - * `app` is a `FastifyInstance`, which also covers an encapsulated child instance (e.g. the admin - * operator scope created by `app.register(async (operator) => …)`), so admin routes register on the - * guarded scope exactly the same way. + * The method and versioned path come from the same shared registry the typed client calls through, so + * client and server cannot drift. Only method and path are copied: the registry's `csrf` flag is not + * wired here, so a mutation adds `preHandler: csrfProtect` itself. */ import { endpoints, versionedPath } from "@civfix/shared/client" import type { FastifyInstance } from "fastify" import type { RouteHandlerMethod, RouteShorthandOptions } from "fastify" -/** A key of the shared endpoint registry — the stable operation id (e.g. "createReport"). */ export type EndpointName = keyof typeof endpoints -/** - * Fastify route options accepted by the shorthands (`preHandler`, `config`, `schema`, `constraints`, …), - * minus `method`/`url`/`handler`, which `route()` supplies from the registry and its own arguments. - */ export type RouteOptions = Omit -/** Register a contract endpoint with no extra Fastify options. */ export function route(app: FastifyInstance, name: EndpointName, handler: RouteHandlerMethod): void -/** Register a contract endpoint, preserving the given Fastify route options. */ export function route( app: FastifyInstance, name: EndpointName, @@ -51,7 +28,6 @@ export function route( const ep = endpoints[name] const url = versionedPath(ep) - // Disambiguate the two arities: with 3 args the third is the handler; with 4 it is the options object. const handler = (maybeHandler ?? optsOrHandler) as RouteHandlerMethod const opts = (maybeHandler ? optsOrHandler : {}) as RouteOptions diff --git a/services/api/src/ws/gateway.ts b/services/api/src/ws/gateway.ts index 1e7efa43..f1c6d123 100644 --- a/services/api/src/ws/gateway.ts +++ b/services/api/src/ws/gateway.ts @@ -1,33 +1,19 @@ /** - * WebSocket chat gateway, mounted at GET /ws via @fastify/websocket. Split into focused modules; this - * barrel preserves the public surface (chat.routes `roomKeyFor`/`registerChatGateway`/types, - * discussion.routes `roomKeyFor`, and the unit tests' `resolveWsUser`/`isAllowedWsOrigin`/ - * `checkWsHandshake`/`handleClientFrame`/`subscribeUserChannel`). See ws/handshake.ts, ws/frame-handler.ts, - * ws/socket-lifecycle.ts, ws/types.ts. + * Origin allowlist (anti-CSWSH): the browser attaches the httpOnly session cookie to a cross-origin + * WebSocket upgrade and CORS preflight does not apply, so the Origin is checked against WEB_ORIGINS + * before auth resolves. A handshake carrying a session cookie REQUIRES an allowlisted Origin, since a + * real browser always sends one and a missing Origin there means a non-browser client replaying a stolen + * cookie. A cookie-less handshake (native ?ticket, server-to-server, tests) may omit Origin because it + * carries no ambient credential and the ticket path still validates it. * - * ORIGIN ALLOWLIST (anti-CSWSH): the cookie handshake path is vulnerable to Cross-Site WebSocket Hijacking - * because the browser attaches the httpOnly session cookie to a cross-origin ws() connection automatically - * (the CORS preflight does NOT apply to WebSocket upgrades). So BEFORE resolving auth we check the upgrade - * request's Origin against the configured WEB_ORIGINS allowlist. NO-ORIGIN HANDLING (P1-4): a handshake - * with a session cookie REQUIRES a present, allowlisted Origin (a real browser always sends one, so a - * missing Origin on the cookie path is a non-browser client replaying a stolen cookie — the CSWSH second - * factor); a cookie-less handshake (native mobile ?ticket, server-to-server, tests) may omit Origin - * because it carries no ambient cookie and the ticket/bearer path still validates the credential. + * A React Native WebSocket cannot set an Authorization header, so mobile redeems a single-use, + * short-lived ?ticket minted over authenticated HTTP. The legacy ?token= session bearer is off by default + * because a full-privilege credential in a URL leaks into every proxy, CDN and APM log. A handshake that + * resolves to no user is rejected, and a live socket is re-authorized on every heartbeat. * - * DUAL HANDSHAKE AUTH: (a) COOKIE (web, same-origin SPA) — the httpOnly session cookie is sent - * automatically on the upgrade, so the auth onRequest hook already resolved req.auth from it. (b) - * ?ticket= (mobile) — a React Native WebSocket cannot set an Authorization - * header, so the native client mints a connect ticket over authenticated HTTP and redeems it here. The - * legacy ?token=<30-day session bearer> query param is DISABLED by default (H5: a full-privilege - * credential in a URL leaks into every proxy/CDN/APM log) and only re-enabled by the temporary - * WS_ALLOW_QUERY_TOKEN break-glass env flag; see ws/handshake.ts. A handshake that resolves to no user - * is REJECTED (closed with a policy-violation code + one {type:"error"} frame), so unauthenticated - * sockets never join. A LIVE socket is re-authorized on every heartbeat (M1, ws/socket-lifecycle.ts). - * - * MEMBERSHIP-GATED ROOMS: chat membership == cleanup membership. join/send verify the user is a - * cleanup_member before the ChatService admits the socket or accepts a message. Inbound frames are - * validated against WsClientMessageSchema; a malformed frame is answered with an error frame and never - * crashes the socket. Outbound frames conform to WsServerMessageSchema. + * Implemented in ws/handshake.ts (origin allowlist, ?ticket, the WS_ALLOW_QUERY_TOKEN break-glass for + * ?token=), ws/socket-lifecycle.ts (heartbeat re-authorization) and ws/frame-handler.ts (per-room + * membership checks in authorizeRoom). */ export * from "./types.js" diff --git a/services/api/test/global-setup-pg.ts b/services/api/test/global-setup-pg.ts index 1b6f83b3..9c0391fd 100644 --- a/services/api/test/global-setup-pg.ts +++ b/services/api/test/global-setup-pg.ts @@ -1,19 +1,16 @@ /** * vitest globalSetup: ONE PostGIS container for the whole run. * - * Before this existed, every Docker-gated test file called withPg() and started its OWN - * `postgis/postgis:16-3.4` container — ~44 boots per CI run, each re-applying 60 migrations and the - * jurisdiction seed. Now the container starts once here, the migrations + seed are applied once into a - * TEMPLATE database, and each test file clones that template into its own database (see - * test/helpers/pg.ts). Per-file isolation is unchanged — a file still gets a private database nothing - * else writes to — while the fixed cost is paid a single time. + * The container starts once, the migrations + seed are applied once into a TEMPLATE database, and each + * test file clones that template into its own database (see test/helpers/pg.ts). A file still gets a + * private database nothing else writes to, but pays for a clone instead of a container boot plus every + * migration. * - * Docker-absent semantics on a DEVELOPER MACHINE are preserved EXACTLY: a failed container start is - * reported to the workers as `unavailable` (never thrown), withPg() then returns null, and - * `describe.skipIf(!pg)` keeps the suite green on a machine with no Docker. In CI (or under - * CIVFIX_REQUIRE_PG) startSharedPg instead THROWS, so a broken Docker socket fails the run rather than - * deleting the entire integration suite from a green build — see assertPgSkipAllowed in - * helpers/pg-container.ts. A migrations/seed failure always throws: that is a real error, not a skip. + * On a DEVELOPER MACHINE a failed container start is reported to the workers as `unavailable` (never + * thrown), withPg() then returns null, and `describe.skipIf(!pg)` keeps the suite green with no Docker. + * In CI (or under CIVFIX_REQUIRE_PG) startSharedPg THROWS instead, so a broken Docker socket fails the run + * rather than silently dropping the integration suite from a green build (see assertPgSkipAllowed in + * helpers/pg-container.ts). A migrations/seed failure always throws: that is a real error, not a skip. * * This file runs in vitest's main process, NOT in a worker: it must not import the `vitest` entrypoint * (directly or transitively), hence the split into pg-container.ts / pg-selection.ts. @@ -26,21 +23,20 @@ import type { GlobalSetupContext } from "vitest/node" import { startSharedPg, type SharedPg } from "./helpers/pg-container.js" import { cliFileFilters, selectSharedPgStart } from "./helpers/pg-selection.js" -/** What the workers read back via `inject("civfixPg")` — see test/helpers/pg.ts. */ +/** What the workers read back via `inject("civfixPg")` (see test/helpers/pg.ts). */ export type ProvidedPg = /** Container is up: clone `templateDb` over the maintenance connection `adminUri`. */ | { kind: "ready"; adminUri: string; templateDb: string } - /** Docker is unavailable — tests must SKIP (describe.skipIf(!pg)). */ + /** Docker is unavailable: tests must SKIP (describe.skipIf(!pg)). */ | { kind: "unavailable"; reason: string } /** No pg-using test file appeared to be selected, so nothing was started. withPg() may boot its own. */ | { kind: "not-started"; reason: string } -/** Absolute path of this service's test root. */ const TEST_ROOT = fileURLToPath(new URL(".", import.meta.url)) let shared: SharedPg | undefined -/** Every `*.test.ts` under test/, absolute paths — the population vitest's filters select from. */ +/** Every `*.test.ts` under test/ (absolute paths): the population vitest's CLI filters select from. */ async function listTestFiles(): Promise { const entries = await readdir(TEST_ROOT, { recursive: true, withFileTypes: true }) return entries @@ -56,7 +52,7 @@ const PG_HARNESS_IMPORT = /from\s*["'][^"']*\/pg\.js["']/ /** * True when a test file needs the harness: it imports it, or it lives in test/integration (where every - * file is Docker-gated — the belt to the import-check's braces, so a helper that starts importing the + * file is Docker-gated; the belt to the import-check's braces, so a helper that starts importing the * harness on a file's behalf cannot make this answer "no"). */ async function usesPgHarness(file: string): Promise { @@ -85,7 +81,7 @@ export async function setup({ provide }: GlobalSetupContext): Promise { const result = await startSharedPg() if (!result.ok) { // Docker not installed / daemon not running / image unavailable: skip, do not fail. Only reachable - // where a skip is legitimate — startSharedPg throws in CI rather than returning !ok. + // where a skip is legitimate: startSharedPg throws in CI rather than returning !ok. console.warn(`[pg globalSetup] skipped: docker unavailable (${firstLine(result.reason)})`) provide("civfixPg", { kind: "unavailable", reason: result.reason }) return diff --git a/services/api/test/helpers/anon.ts b/services/api/test/helpers/anon.ts index 35e4ac16..e4f8801e 100644 --- a/services/api/test/helpers/anon.ts +++ b/services/api/test/helpers/anon.ts @@ -8,9 +8,9 @@ * submitted+held timeline, bumps the token's report_count, stamps the claim-code DIGEST, and stores * the snapshot under (scope, key, anon session) - and on a duplicate idempotency key from the SAME * anon session does NONE of that and replays the stored snapshot. A key already spent by a - * DIFFERENT anon session never replays (F028); it answers the retryable 409 Postgres produces via + * DIFFERENT anon session never replays; it answers the retryable 409 Postgres produces via * the globally-unique reports.idempotency_key. - * - claimByCode matches the stored digest of the presented code (F150 / 0091), links the (unclaimed) + * - claimByCode matches the stored digest of the presented code (0091), links the (unclaimed) * report to the user and clears the digest (single-use); the plaintext code is never stored. * * The Drizzle-backed repos are covered by the Docker-gated integration test; these fakes exercise the @@ -58,14 +58,13 @@ export interface StoredAnonReport { claimCodeHash: string | null /** The submit's idempotency key, globally unique across reports (reports_idempotency_key_key). */ idempotencyKey: string | null - /** The immutable reference code minted at create (#56 / M3). Null on rows seeded without one. */ + /** The immutable reference code minted at create. Null on rows seeded without one. */ referenceCode: string | null createdAt: Date publishedAt: Date | null deletedAt: Date | null } -/** A stored media asset for the anon/release flow. */ export interface StoredAnonMedia { id: string uploadId: string @@ -101,10 +100,10 @@ export class InMemoryAnonStore { readonly timeline: StoredTimeline[] = [] readonly flags: StoredFlag[] = [] readonly idempotency = new Map() // `${scope}:${key}:${userOrAnon}` - /** Per-scope reference-code counter, mirroring reference_counters (D4). */ + /** Per-scope reference-code counter, mirroring reference_counters. */ private readonly refCounters = new Map() - /** Allocate the next reference code for (type, jurCode), mirroring allocateReportReferenceCode (M3). */ + /** Mirrors allocateReportReferenceCode. */ private allocateReferenceCode(type: ReportType, jurCode: number): string { const typeCode = typeCodeFor(type) const scope = reportScopeKey(typeCode, jurCode) @@ -119,8 +118,6 @@ export class InMemoryAnonStore { return new Date(Date.UTC(2026, 0, 1, 0, 0, 0, this.tick)) } - // ---- seed helpers ---- - seedToken(over: Partial = {}): AnonTokenRecord { // Default the expiry to 24h from the REAL current time so a seeded token is valid under a service // using the default Date.now() clock (the deterministic 2026-01-01 nextDate() is only used for @@ -183,8 +180,6 @@ export class InMemoryAnonStore { this.flags.push({ subjectType, subjectId, reason, resolvedAt: null }) } - // ---- AnonReportRepository ---- - anonReportRepo(): AnonReportRepository { // Arrow-function properties so `this` is the store instance (no this-aliasing). return { @@ -213,7 +208,7 @@ export class InMemoryAnonStore { // reports.idempotency_key is globally unique (0001), so a key already spent by ANOTHER anon // session rolls the real transaction back with a 23505 whose owner-scoped snapshot read then - // misses - the retryable 409 (F028). Modeled here so the fakes answer what Postgres answers + // misses: the retryable 409. Modeled here so the fakes answer what Postgres answers // instead of silently minting a second report on a squatted key. const keyTaken = [...this.reports.values()].some( (r) => r.idempotencyKey === args.idempotencyKey, @@ -222,7 +217,7 @@ export class InMemoryAnonStore { return Promise.reject(AppError.conflict("Report submit is still settling; retry")) } - // ATOMIC per-token cap (bugs P0-1), mirroring the Drizzle tx: bump report_count ONLY while the + // ATOMIC per-token cap, mirroring the Drizzle tx: bump report_count ONLY while the // token is under the cap, and abort (throw, no writes) otherwise. The whole body runs // synchronously here, so two interleaved calls cannot both pass the cap check - exactly the // atomic check-and-consume the real UPDATE ... WHERE report_count < cap provides. The claim-code @@ -240,7 +235,7 @@ export class InMemoryAnonStore { token.reportCount += 1 } - // D4: allocate the reference code (mirrors the Drizzle held-create tx; anon reports get a code too). + // Mirrors the Drizzle held-create tx: anon reports get a reference code too. const referenceCode = this.allocateReferenceCode(args.type, args.jurCode) this.seedReport({ @@ -263,14 +258,12 @@ export class InMemoryAnonStore { referenceCode, publishedAt: null, }) - // Attach media (only unattached or already-ours). for (const uploadId of args.mediaUploadIds) { const asset = this.media.find((m) => m.uploadId === uploadId) if (asset && (asset.reportId === null || asset.reportId === args.reportId)) { asset.reportId = args.reportId } } - // Timeline: submitted + held. this.timeline.push({ reportId: args.reportId, status: "submitted", @@ -302,8 +295,6 @@ export class InMemoryAnonStore { } } - // ---- AnonHoldReleaseRepo ---- - holdReleaseRepo(): AnonHoldReleaseRepo { // Arrow-function properties so `this` is the store instance (no this-aliasing). return { @@ -360,8 +351,6 @@ export class InMemoryAnonStore { } } - // ---- ClaimRepository ---- - claimRepo(): ClaimRepository { // Arrow-function properties so `this` is the store instance (no this-aliasing). return { diff --git a/services/api/test/helpers/auth-harness.test.ts b/services/api/test/helpers/auth-harness.test.ts index 08f58713..15d92c2d 100644 --- a/services/api/test/helpers/auth-harness.test.ts +++ b/services/api/test/helpers/auth-harness.test.ts @@ -131,7 +131,7 @@ describe("makeAuthHarness (buildServer override pass-through)", () => { const h = await harness() expect(h.container.env.NODE_ENV).toBe("test") // USE_FAKE_CHAT is on for the test env, so the chat seam is the in-memory fake a suite can assert on. - // roomSize is the fake's own test helper — the real WS adapter has no such method. + // roomSize is the fake's own test helper: the real WS adapter has no such method. const chat = h.container.chatService as FakeChatService expect(typeof chat.persist).toBe("function") expect(chat.roomSize("no-such-room")).toBe(0) @@ -155,7 +155,7 @@ describe("makeAuthHarness (buildServer override pass-through)", () => { /** * The isolation claim has to hold on the DEFAULT path, not only when an override forces an explicit * source. It previously did not: with no options the harness passed loadEnv(undefined), so the ambient - * environment — a developer's real DATABASE_URL among it — became the offline app's config. + * environment (a developer's real DATABASE_URL among it) became the offline app's config. */ it("ignores the ambient environment even with NO options passed", async () => { // Real infrastructure in the developer's shell, and a flag that would swap a fake for a live adapter. diff --git a/services/api/test/helpers/auth.ts b/services/api/test/helpers/auth.ts index eb6dfbdd..7c013a40 100644 --- a/services/api/test/helpers/auth.ts +++ b/services/api/test/helpers/auth.ts @@ -22,7 +22,7 @@ import type { OAuthConfig } from "../../src/auth/oauth.js" /** A JWKS verifier stub: maps a fixed set of opaque "tokens" to canned verified claims. */ export class StubJwksVerifier implements JwksVerifier { private readonly tokens = new Map() - /** Optional per-token bound nonce: when set, verify enforces params.expectedNonce against it (P2-3). */ + /** Optional per-token bound nonce: when set, verify enforces params.expectedNonce against it. */ private readonly nonces = new Map() /** Register a token string that should verify to the given claims (optionally bound to a nonce). */ @@ -36,7 +36,7 @@ export class StubJwksVerifier implements JwksVerifier { if (!claims) { return Promise.reject(new Error("stub verifier: unknown token")) } - // Mirror the real verifier's nonce binding (P2-3): when the caller expects a nonce, it must match the + // Mirror the real verifier's nonce binding: when the caller expects a nonce, it must match the // token's bound nonce, else reject. A token with no bound nonce but an expectedNonce is a mismatch. if (params.expectedNonce !== undefined) { const bound = this.nonces.get(idToken) @@ -62,7 +62,7 @@ export interface SignedIn { * the FakeMailer, verify it as a mobile client (which returns the bearer token in the body instead of * setting cookies). Creates the account on first use, exactly as production first sign-in does. * - * Throws — with the offending status + body — if any leg fails. ~10 unit suites hand-rolled this and all + * Throws (with the offending status + body) if any leg fails. ~10 unit suites hand-rolled this and all * of them non-null-asserted their way past a failure, so a broken sign-in surfaced later as a puzzling * 401 on the endpoint actually under test rather than here. */ @@ -99,7 +99,6 @@ export async function signIn( return { token: body.token, userId: body.user.id, user: body.user } } -/** The bearer header for a signed-in token. */ export function bearer(token: string): Record { return { authorization: `Bearer ${token}` } } @@ -129,11 +128,11 @@ export interface MakeAuthHarnessOptions { startMs?: number /** WEB_ORIGINS allowlist to load into env (e.g. to test the OAuth redirect allowlist). */ webOrigins?: string[] - /** Enforce the H1 mandatory-nonce gate on native sign-in (OAUTH_REQUIRE_NONCE). Defaults off, as in prod. */ + /** Enforce the mandatory-nonce gate on native sign-in (OAUTH_REQUIRE_NONCE). Defaults off, as in prod. */ requireOauthNonce?: boolean /** * Extra env variables for loadEnv, layered on top of (and able to override) the harness's minimal - * source — see HARNESS_ENV_SOURCE for what that source is and why the harness never reads process.env. + * source; see HARNESS_ENV_SOURCE for what that source is and why the harness never reads process.env. */ env?: Record /** @@ -147,7 +146,7 @@ export interface MakeAuthHarnessOptions { /** * The env every harness app is loaded from. loadEnv REPLACES process.env when given a source, and this - * source is passed on EVERY path — including `makeAuthHarness()` with no options — so an offline test can + * source is passed on EVERY path, including `makeAuthHarness()` with no options, so an offline test can * never inherit a developer's shell: a real DATABASE_URL, REDIS_URL or set of R2 credentials must not be * reachable from a suite that believes it is talking to fakes. * @@ -214,7 +213,7 @@ export async function makeAuthHarness(opts: MakeAuthHarnessOptions = {}): Promis // ALWAYS an explicit source (see HARNESS_ENV_SOURCE): loadEnv(undefined) would read process.env, and a // developer's DATABASE_URL / REDIS_URL / R2 credentials must never reach an offline app. const env = loadEnv(envSource) - // Built explicitly (rather than left to buildServer) so the caller can reach the container's seams — + // Built explicitly (rather than left to buildServer) so the caller can reach the container's seams: // container.chatService / container.pushSender are the fakes several suites assert against. const container = opts.server?.container ?? buildContainer(env) const app = await buildServer({ ...opts.server, env, container, authServices: services }) diff --git a/services/api/test/helpers/chat-threads-repo.test.ts b/services/api/test/helpers/chat-threads-repo.test.ts index f19a6ba1..2d252fdc 100644 --- a/services/api/test/helpers/chat-threads-repo.test.ts +++ b/services/api/test/helpers/chat-threads-repo.test.ts @@ -5,7 +5,7 @@ * page with the identical (activity, id) predicate, trusting each family not to re-offer rows the caller * already saw. This fake used to accept `(userId, limit)` and drop the cursor entirely, so page 2 of an * offline inbox re-returned page 1's newest rows, the service's exact filter discarded them, and the page - * came back short — invisible in a single-page test, wrong the moment pagination is exercised. + * came back short: invisible in a single-page test, wrong the moment pagination is exercised. * * The order under test is the Drizzle repository's: activity = COALESCE(last message created_at, * joined_at) DESC, then room id DESC. diff --git a/services/api/test/helpers/cleanups.ts b/services/api/test/helpers/cleanups.ts index eaa9900e..5e46b9a6 100644 --- a/services/api/test/helpers/cleanups.ts +++ b/services/api/test/helpers/cleanups.ts @@ -392,7 +392,7 @@ export class InMemoryCleanupRepository implements CleanupRepository { ) } - /** `withDefaultSlot: false` reproduces a LEGACY slot-less event — the shape 0169 backfilled away. */ + /** `withDefaultSlot: false` reproduces a LEGACY slot-less event (the shape 0169 backfilled away). */ seedCleanup( over: Partial & { id?: string; withDefaultSlot?: boolean }, ): StoredCleanup { diff --git a/services/api/test/helpers/discussion.ts b/services/api/test/helpers/discussion.ts index c44305f3..fb95c81d 100644 --- a/services/api/test/helpers/discussion.ts +++ b/services/api/test/helpers/discussion.ts @@ -20,7 +20,6 @@ export interface SeededJurisdiction { contactEmail: string | null } -/** An in-memory DiscussionRepository faithful to the Drizzle impl's observable report lookup. */ export class InMemoryDiscussionRepository implements DiscussionRepository { /** Reports keyed by id (the visibility handle + resolved jurisdiction). */ readonly reports = new Map() diff --git a/services/api/test/helpers/pg-container.ts b/services/api/test/helpers/pg-container.ts index 2c8595e4..f5e0a83d 100644 --- a/services/api/test/helpers/pg-container.ts +++ b/services/api/test/helpers/pg-container.ts @@ -2,12 +2,10 @@ * The Postgres container + template-database primitives shared by the vitest globalSetup * (test/global-setup-pg.ts) and the per-file harness (test/helpers/pg.ts). * - * WHY this module exists: the integration suite used to start ONE `postgis/postgis:16-3.4` container PER - * TEST FILE (~44 boots per CI run, each paying image start + 60 migrations + the jurisdiction seed). - * The container is now started ONCE by globalSetup, which applies the migrations + seed into a TEMPLATE + * The container is started ONCE by globalSetup, which applies the migrations + seed into a TEMPLATE * database; every test file then clones that template with `CREATE DATABASE ... TEMPLATE ...` (a - * file-level copy inside the server, ~100ms) and gets its own fully isolated database — the same - * isolation a private container gave it, without the boot cost. + * file-level copy inside the server, ~100ms) and gets its own fully isolated database, without paying a + * container boot plus every migration per file. * * Nothing here imports `vitest`: globalSetup runs in a different context, where importing the `vitest` * entrypoint is unsupported (it reaches for per-worker state that does not exist there). @@ -55,7 +53,7 @@ const POSTGRES_TUNING = [ ] export interface SharedPg { - /** Connection URI of the container's default database — the maintenance connection for CREATE/DROP. */ + /** Connection URI of the container's default database: the maintenance connection for CREATE/DROP. */ adminUri: string /** Name of the migrated template database to clone per test file. */ templateDb: string @@ -84,16 +82,16 @@ export interface PgSkipDecision { * * The skip exists for ONE reason: this repo is developed on machines with no Docker, and a developer * running `pnpm test` must get a green unit suite instead of 44 hard failures. In CI that same leniency - * is a silent hole — a broken Docker socket, a pulled image, a testcontainers upgrade — would make the + * is a silent hole: a broken Docker socket, a pulled image or a testcontainers upgrade would make the * ENTIRE integration suite vanish from the run while the job still reports success. The suite is the only * thing that exercises the real SQL, so "green because nothing ran" must be impossible in CI. * * Resolution order (first match wins): - * 1. CIVFIX_ALLOW_PG_SKIP — explicit escape hatch for a CI job that intentionally has no Docker. - * 2. CIVFIX_REQUIRE_PG — explicit demand (usable locally to prove the harness really ran). - * 3. CI — set to `true` by GitHub Actions (and by every other provider), so the - * guard is on in CI with no workflow change. - * 4. otherwise — a developer machine: skipping is the whole point. + * 1. CIVFIX_ALLOW_PG_SKIP: explicit escape hatch for a CI job that intentionally has no Docker. + * 2. CIVFIX_REQUIRE_PG: explicit demand (usable locally to prove the harness really ran). + * 3. CI: set to `true` by GitHub Actions (and by every other provider), so the guard is on in CI with + * no workflow change. + * 4. otherwise: a developer machine; skipping is the whole point. */ export function pgSkipDecision(env: NodeJS.ProcessEnv = process.env): PgSkipDecision { if (isOn(env.CIVFIX_ALLOW_PG_SKIP)) { @@ -128,7 +126,7 @@ export function assertPgSkipAllowed(reason: string, env: NodeJS.ProcessEnv = pro * Start the container and build the migrated template database. * * Returns `{ ok: false, reason }` when Docker is unavailable (this repo is developed on machines with no - * Docker) so callers can SKIP rather than fail — UNLESS this environment forbids that skip (CI, see + * Docker) so callers can SKIP rather than fail, UNLESS this environment forbids that skip (CI, see * assertPgSkipAllowed), in which case it throws rather than let the integration suite disappear from a * green run. A migration/seed failure is never the skip case: it is a real error and throws, after * cleaning up the container. @@ -138,7 +136,7 @@ export async function startSharedPg(): Promise { try { started = await new PostgreSqlContainer(POSTGIS_IMAGE).withCommand(POSTGRES_TUNING).start() } catch (err) { - // Docker not installed / daemon not running / image unavailable: skip, do not fail — but only where + // Docker not installed / daemon not running / image unavailable: skip, do not fail, but only where // a skip is legitimate. In CI this throws. const reason = err instanceof Error ? err.message : String(err) assertPgSkipAllowed(reason) @@ -241,11 +239,11 @@ export async function dropDatabaseIfExists(adminUri: string, name: string): Prom * Test-harness-only column defaults for two NOT-NULL columns the PRODUCTION app always supplies but * that raw fixture inserts here would otherwise have to hand-roll at ~30 call sites: * - * - users.handle — made NOT NULL (no default) by 0026_user_handle_required.sql. The app assigns a - * handle during registration; fixtures that insert a bare user don't care about it. - * - reports.type — 0021_report_type.sql adds it with a default 'other' then DROPS the default, so - * the app must send a type. Fixtures that set up a report to exercise a read query - * don't care about the fine type. + * - users.handle: made NOT NULL (no default) by 0026_user_handle_required.sql. The app assigns a + * handle during registration; fixtures that insert a bare user don't care about it. + * - reports.type: 0021_report_type.sql adds it with a default 'other' then DROPS the default, so the + * app must send a type. Fixtures that set up a report to exercise a read query don't care about the + * fine type. * * These defaults change ONLY the throwaway test template (never the canonical migrations / production * schema) and weaken NO assertion: the suite has no test that a bare insert of these columns is rejected, diff --git a/services/api/test/helpers/pg-harness.test.ts b/services/api/test/helpers/pg-harness.test.ts index 267ac662..4cd74187 100644 --- a/services/api/test/helpers/pg-harness.test.ts +++ b/services/api/test/helpers/pg-harness.test.ts @@ -33,7 +33,7 @@ describe.skipIf(!pg)("pg harness: per-file database", () => { it("came from the ONE globalSetup container, not a per-file boot", () => { // globalSetup decides from the CLI filters whether any selected file needs Postgres; a file that // imports this harness must be recognized, so the shared container is what served it. (withPg's - // fallback would still make the tests pass — silently paying a container boot per file, which is the + // fallback would still make the tests pass, silently paying a container boot per file, which is the // regression this whole harness exists to prevent.) expect(inject("civfixPg")?.kind).toBe("ready") }) @@ -51,7 +51,7 @@ describe.skipIf(!pg)("pg harness: per-file database", () => { // One container now serves every worker at once (each test file holds a 4-connection raw pool + a // 2-connection Drizzle pool), so the default max_connections=100 would start refusing connections // under parallel load. fsync off is what makes 60 migrations + ~44 clones cheap. Both ride on - // withCommand — if a testcontainers upgrade ever drops it, this fails instead of flaking later. + // withCommand: if a testcontainers upgrade ever drops it, this fails instead of flaking later. const [row] = await h.sql<{ mc: string; fsync: string }[]>` SELECT current_setting('max_connections') AS mc, current_setting('fsync') AS fsync ` @@ -104,7 +104,7 @@ describe.skipIf(!pg)("pg harness: per-file database", () => { it("does not share writes with another clone of the same template", async () => { const h = pg! // A second clone off the same template, created through this file's own connection (same server, - // same credentials) — the exact mechanism another test FILE would get its database by. + // same credentials): the exact mechanism another test FILE would get its database by. const otherDb = uniqueTestDbName() await createDatabase(h.uri, otherDb, TEMPLATE_DB) const other = postgres(uriWithDatabase(h.uri, otherDb), { max: 1, onnotice: () => {} }) diff --git a/services/api/test/helpers/pg-selection.test.ts b/services/api/test/helpers/pg-selection.test.ts index 975bf0be..8ac7264c 100644 --- a/services/api/test/helpers/pg-selection.test.ts +++ b/services/api/test/helpers/pg-selection.test.ts @@ -2,7 +2,7 @@ * Tests for the pg TEST HARNESS itself (test/helpers/pg-selection.ts + the pure half of * pg-container.ts). These decide whether a vitest run boots the shared PostGIS container and what * database each test file clones, so a silent mistake here either taxes every unit-test run with a - * container nobody uses or — much worse — hands two test files the same database. + * container nobody uses or (much worse) hands two test files the same database. * * The bias under test is deliberate: an ambiguous CLI always resolves to "start the container", because * over-starting only costs time while under-starting would let an integration file quietly skip. diff --git a/services/api/test/helpers/pg-selection.ts b/services/api/test/helpers/pg-selection.ts index 9abc771b..0a840239 100644 --- a/services/api/test/helpers/pg-selection.ts +++ b/services/api/test/helpers/pg-selection.ts @@ -1,7 +1,7 @@ /** * "Does this vitest run need the shared Postgres container at all?" * - * globalSetup runs for EVERY invocation, including `npx vitest run test/unit/foo.test.ts` — and booting + * globalSetup runs for EVERY invocation, including `npx vitest run test/unit/foo.test.ts`, and booting * PostGIS + applying 60 migrations to serve a run that never touches the database would tax every unit * test run on a machine that happens to have Docker up. vitest resolves its file list AFTER globalSetup, * and hands globalSetup no view of it, so the decision is made from the CLI's own positional file @@ -9,7 +9,7 @@ * * BIAS: every ambiguous case answers "start it". A filter we cannot resolve to a known test file (a * space-separated flag value such as the `dot` in `--reporter dot`, a typo, `--related`'s source paths) - * makes this return true, so the worst outcome of a misread argv is a container nobody uses — never a + * makes this return true, so the worst outcome of a misread argv is a container nobody uses, never a * silently-skipped integration suite. And withPg() still falls back to booting its own container if it * finds no shared one, so this module can only ever affect speed, not correctness. */ @@ -30,7 +30,7 @@ const SUBCOMMANDS = new Set([ * Extract the positional file filters from a vitest argv (`process.argv.slice(2)`). * * Flags are dropped; a leading subcommand is dropped. The VALUE of a space-separated flag - * (`--reporter dot`) is indistinguishable from a filter here and is deliberately kept — it resolves to + * (`--reporter dot`) is indistinguishable from a filter here and is deliberately kept: it resolves to * no test file, which `selectSharedPgStart` treats as "unrecognized, start anyway". */ export function cliFileFilters(argv: readonly string[]): string[] { diff --git a/services/api/test/helpers/pg.ts b/services/api/test/helpers/pg.ts index 09e809e9..e8f0b7d3 100644 --- a/services/api/test/helpers/pg.ts +++ b/services/api/test/helpers/pg.ts @@ -7,7 +7,7 @@ * The container is NOT started here. test/global-setup-pg.ts starts ONE `postgis/postgis:16-3.4` * container per vitest run and applies the canonical migrations + jurisdiction seed once into a TEMPLATE * database; this helper clones that template (`CREATE DATABASE ... TEMPLATE ...`, an in-server file copy) - * so each test FILE still gets an isolated database — the same isolation a private container gave it, + * so each test FILE still gets an isolated database (the same isolation a private container gave it), * without ~44 container boots and 44 × 60 migrations per run. * * CRITICAL for local dev: Docker may be absent (this repo is developed on machines with no Docker). @@ -59,7 +59,7 @@ export interface PgHarness { /** * Memoized outcome. `undefined` = not attempted yet; `null` = attempted and Docker unavailable (skip); * otherwise the live harness. Per worker/module-registry, which is per test file under vitest's default - * isolation — `torn` lets a file that already tore down (or a non-isolated worker running a second file) + * isolation; `torn` lets a file that already tore down (or a non-isolated worker running a second file) * get a fresh database instead of a closed pool. */ let memo: PgHarness | null | undefined @@ -70,7 +70,7 @@ let pending: Promise | undefined /** * Get this file's Postgres harness, or null when Docker is unavailable (in which case the caller should * skip). On a developer machine the Docker-absent case never throws; in CI (or under CIVFIX_REQUIRE_PG) - * it DOES — see assertPgSkipAllowed. A failed migration/seed/clone always throws, because that is a real + * it DOES (see assertPgSkipAllowed). A failed migration/seed/clone always throws, because that is a real * error and the test must fail rather than silently skip. */ export async function withPg(): Promise { @@ -90,14 +90,14 @@ async function create(): Promise { // No globalSetup in play (a bare/one-off vitest config), or it decided no pg test was selected and // one turned out to need it after all: fall back to this worker's own container, the pre-globalSetup - // behavior. Correctness first — a misread of the CLI filters must never silently skip a real test. + // behavior. Correctness first: a misread of the CLI filters must never silently skip a real test. if (provided === undefined || provided.kind === "not-started") { return await bootOwnContainer() } if (provided.kind === "unavailable") { // Belt to globalSetup's braces: it already refuses to report `unavailable` where a skip is - // forbidden, so reaching here in CI means the guard was bypassed — fail rather than skip. + // forbidden, so reaching here in CI means the guard was bypassed, so fail rather than skip. assertPgSkipAllowed(provided.reason) console.warn(`[pg harness] skipped: docker unavailable (${firstLine(provided.reason)})`) memo = null @@ -171,7 +171,7 @@ async function bootOwnContainer(): Promise { /** * Read the globalSetup channel. A config with no globalSetup provides nothing (inject yields undefined), - * and outside a vitest worker inject throws while reaching for worker state — both are the same + * and outside a vitest worker inject throws while reaching for worker state; both are the same * "no shared container was handed to me" answer, not an error. */ function readProvided(): ProvidedPg | undefined { @@ -189,8 +189,8 @@ function firstLine(s: string): string { /** * A valid, unique-enough @handle (matches HANDLE_REGEX ^[A-Za-z0-9_]{3,20}$) for a fixture user whose - * handle is immaterial to the test. Use where a helper passes handle EXPLICITLY (an explicit value — - * even null — bypasses the SET DEFAULT applied to the test template); pass a real handle instead when the + * handle is immaterial to the test. Use where a helper passes handle EXPLICITLY (an explicit value, + * even null, bypasses the SET DEFAULT applied to the test template); pass a real handle instead when the * test asserts on it. */ export function testHandle(): string { @@ -198,13 +198,13 @@ export function testHandle(): string { } /** - * Seed ONE follow edge the way production does — the edge row AND the two denormalized counters + * Seed ONE follow edge the way production does: the edge row AND the two denormalized counters * (drizzle/0059_users_follow_counters.sql). * * 0059 deliberately installs no trigger: "anything writing follows_people outside those two methods (a * psql session, a test fixture, a future bulk import) must bump the counters too". So a fixture that * `INSERT INTO follows_people` and nothing else leaves users.follower_count / following_count at 0 while - * the edge exists, and every profile/roster read in that file reports `followers: 0` — a wrong-by-fixture + * the edge exists, and every profile/roster read in that file reports `followers: 0`, a wrong-by-fixture * number that looks exactly like a product bug. Use this (or the repository's addFollow) instead of a raw * INSERT whenever the test might read a count. * diff --git a/services/api/test/helpers/query.ts b/services/api/test/helpers/query.ts index e8004071..cf3fb2d2 100644 --- a/services/api/test/helpers/query.ts +++ b/services/api/test/helpers/query.ts @@ -1,7 +1,7 @@ /** * Test helper that replicates the SHARED CLIENT's query serialization (shared/src/client/client.ts * buildQuery) byte-for-byte, so route tests exercise the EXACT query string the web + mobile clients - * produce. This is the regression guard for the P0 query-encoding mismatch: if the backend ever drifts + * produce. This is the regression guard for a query-encoding mismatch: if the backend ever drifts * from what the client sends, these tests fail. * * Mirrors buildQuery: diff --git a/services/api/test/integration/admin-activity.test.ts b/services/api/test/integration/admin-activity.test.ts index 3d362d82..c0e1d68a 100644 --- a/services/api/test/integration/admin-activity.test.ts +++ b/services/api/test/integration/admin-activity.test.ts @@ -64,23 +64,19 @@ describe.skipIf(!pg)("admin activity repository (integration: real schema)", () const reporter = await insertUser(h, "Reporter") const org = await insertUser(h, "Organizer") - // A report (2h ago). await h.sql` INSERT INTO reports (reporter_user_id, idempotency_key, geom, geom_source, category, status, visibility, h3_cell, jurisdiction_geoid, created_at) VALUES (${reporter}, gen_random_uuid(), ST_SetSRID(ST_MakePoint(-118.35,34.1),4326), 'manual', 'trash', 'submitted', 'public', 'h0', ${GEOID}, now() - interval '2 hours') ` - // A cleanup (3h ago). await seedCleanup(h.sql, { organizerUserId: org, title: "Park cleanup", createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000), }) - // An audit row (1h ago). await h.sql` INSERT INTO audit_log (actor_id, action, target, created_at) VALUES (${actor}, 'gov_claim.approved', 'gov_claim:1', now() - interval '1 hour') ` - // A mail thread + a bounced event (30m ago). const threadId = ( await h.sql<{ id: string }[]>` INSERT INTO mail_threads (thread_token, org, subject, status) @@ -101,18 +97,14 @@ describe.skipIf(!pg)("admin activity repository (integration: real schema)", () expect(records).toHaveLength(4) // Newest first: mail_event (30m) -> audit (1h) -> report (2h) -> cleanup (3h). expect(records.map((r) => r.source)).toEqual(["mail_event", "audit", "report", "cleanup"]) - // The whole feed fit in the page, so there is no next page. expect(nextCursor).toBeNull() - // The audit row carries the actor name + action. const audit = records.find((r) => r.source === "audit")! expect(audit.who).toBe("Operator") expect(audit.action).toBe("gov_claim.approved") - // The report carries the category subject + the jurisdiction place. const report = records.find((r) => r.source === "report")! expect(report.subject).toBe("trash") expect(report.where).toBe(LA_CITY.name) expect(report.who).toBe("Reporter") - // The mail event carries its type. const mail = records.find((r) => r.source === "mail_event")! expect(mail.eventType).toBe("bounced") }) diff --git a/services/api/test/integration/admin-events.test.ts b/services/api/test/integration/admin-events.test.ts index 68ee64a3..be76a9c6 100644 --- a/services/api/test/integration/admin-events.test.ts +++ b/services/api/test/integration/admin-events.test.ts @@ -25,7 +25,6 @@ import type { AdminEventRepository } from "../../src/services/admin/admin-event- const pg = await withPg() -/** Insert a user and return its id. */ async function insertUser(h: PgHarness, name = "Org"): Promise { const rows = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES (${name}) RETURNING id @@ -33,7 +32,6 @@ async function insertUser(h: PgHarness, name = "Org"): Promise { return rows[0]!.id } -/** Insert a cleanup and return its id. */ async function insertCleanup( h: PgHarness, opts: { @@ -175,7 +173,7 @@ describe.skipIf(!pg)("admin event repository (integration: real schema)", () => ` const result = await repo.postMessage(id, { body: "Rescheduled", actorId: org }) expect(result).not.toBeNull() - // L4: notifications are fanned out in the SAME transaction; the count matches the member count. + // Notifications are fanned out in the SAME transaction; the count matches the member count. expect(result?.notified).toBe(2) const chat = await h.sql< { id: string; body: string | null; sender_id: string }[] diff --git a/services/api/test/integration/admin-gov-claims.test.ts b/services/api/test/integration/admin-gov-claims.test.ts index b3a4afdb..bfcfce2d 100644 --- a/services/api/test/integration/admin-gov-claims.test.ts +++ b/services/api/test/integration/admin-gov-claims.test.ts @@ -37,8 +37,8 @@ const GEOID = LA_CITY.geoid /** * The deciding operator's address. `actorId` is NON-NULL on the service's approve/reject (every caller is - * an operator-guarded gov route), and it lands in `audit_log.actor_id` / `gov_claims.decided_by` — both - * uuid columns REFERENCING users(id) — so the tests must pass a real user's id, not a placeholder string. + * an operator-guarded gov route), and it lands in `audit_log.actor_id` / `gov_claims.decided_by`, both + * uuid columns REFERENCING users(id), so the tests must pass a real user's id, not a placeholder string. * Kept off the '@lacity.gov' domain the per-test cleanup wipes so it cannot collide with a provisioned * gov user. */ @@ -62,7 +62,6 @@ function provisionerFromUserStore(store: UserStore): UserProvisioner { } } -/** Insert a pending gov claim and return its id. */ async function insertClaim( h: PgHarness, opts: { name?: string; org?: string; contactEmail?: string | null; geoid?: string | null }, diff --git a/services/api/test/integration/admin-moderation.test.ts b/services/api/test/integration/admin-moderation.test.ts index 2d3db06b..8b219b28 100644 --- a/services/api/test/integration/admin-moderation.test.ts +++ b/services/api/test/integration/admin-moderation.test.ts @@ -476,7 +476,7 @@ describe.skipIf(!pg)("admin moderation repository (integration: real schema)", ( // The account is erased after the suspension (softDeleteAndAnonymize). An overturn that upserted // account_status='active' unconditionally would resurrect it in the operator console as a live, - // unflagged account — and, with the session hook wired, lift its ban marker too. + // unflagged account and, with the session hook wired, lift its ban marker too. await h.sql`UPDATE users SET deleted_at = now() WHERE id = ${deleted}` const appealId = await insertModerationItem(h.sql, { diff --git a/services/api/test/integration/admin-users.test.ts b/services/api/test/integration/admin-users.test.ts index 28491d92..309c6539 100644 --- a/services/api/test/integration/admin-users.test.ts +++ b/services/api/test/integration/admin-users.test.ts @@ -12,7 +12,7 @@ * - toggleFlag upserts user_moderation.flagged + opens/resolves an abuse_flag (subject_type 'user') * + audit; * - setStatus upserts user_moderation.account_status (banned -> user.banned audit); - * - applyRole writes users.role + its user.role_changed audit row in ONE transaction (L5). + * - applyRole writes users.role + its user.role_changed audit row in ONE transaction. * * The ban -> revoke-all-sessions behavior lives in SessionService (covered by the auth-session unit * test); this repo only persists the status. When Docker is unavailable the block SKIPS; CI runs it. @@ -29,7 +29,6 @@ import { LA_CITY } from "../../src/db/seed-fixtures.js" const pg = await withPg() const GEOID = LA_CITY.geoid -/** Insert a user and return its id. */ async function insertUser( h: PgHarness, opts: { name?: string; handle?: string; role?: string; emailVerified?: boolean } = {}, @@ -48,7 +47,6 @@ async function insertUser( return rows[0]!.id } -/** Insert a report authored by a user. */ async function insertReport(h: PgHarness, reporterId: string, category = "trash"): Promise { const rows = await h.sql<{ id: string }[]>` INSERT INTO reports (reporter_user_id, idempotency_key, geom, geom_source, category, status, h3_cell, jurisdiction_geoid) @@ -110,7 +108,7 @@ describe.skipIf(!pg)("admin user repository (integration: real schema)", () => { it("search matches a user by the CITY (jurisdiction of their reports), not just name/handle", async () => { // The Drizzle search must match the derived city via the reports->jurisdictions join, like the - // in-memory repo + the documented contract (it previously only matched name/handle — a divergence). + // in-memory repo + the documented contract (it previously only matched name/handle, a divergence). const inLa = await insertUser(h, { name: "Ada", handle: "ada" }) await insertReport(h, inLa) // resolves to LA_CITY const elsewhere = await insertUser(h, { name: "Bo", handle: "bob" }) // no reports -> no city @@ -308,7 +306,7 @@ describe.skipIf(!pg)("admin user repository (integration: real schema)", () => { expect(audit).toHaveLength(1) }) - // L5: the role UPDATE and its audit row are ONE transaction, so a committed privilege change can never + // The role UPDATE and its audit row are ONE transaction, so a committed privilege change can never // be missing its audit. Assert both landed AND that the prior role was captured on the audit meta. it("applyRole writes users.role AND a user.role_changed audit row in one transaction", async () => { const u = await insertUser(h) diff --git a/services/api/test/integration/anon-hold-release-pg.test.ts b/services/api/test/integration/anon-hold-release-pg.test.ts index 5b4628dd..8c8eabd0 100644 --- a/services/api/test/integration/anon-hold-release-pg.test.ts +++ b/services/api/test/integration/anon-hold-release-pg.test.ts @@ -1,11 +1,11 @@ /** - * F132: every anonymous submit opens a moderation_items row ("Held report" / "Hidden pending review") - * inside the held-create transaction, but the AUTOMATED release path only ever flipped the report to - * published — nothing closed the item. The admin queue lists `WHERE status='open'` with no join to the + * Every anonymous submit opens a moderation_items row ("Held report" / "Hidden pending review") inside + * the held-create transaction, but the AUTOMATED release path used to only flip the report to published, + * so nothing closed the item. The admin queue lists `WHERE status='open'` with no join to the * report's current status, so it accumulated one permanently-open row per anonymous report ever * submitted, each asserting a report is hidden pending review while it is in fact live on the public map. * - * The item now closes ATOMICALLY with the release, in publishHeldReport's own transaction — which is the + * The item now closes ATOMICALLY with the release, in publishHeldReport's own transaction, which is the * only place it can be asserted, so this runs against the real schema (Docker-gated). The gate cases * matter as much as the happy one: a release that is REFUSED (media not ready, an open abuse flag) must * leave the item open for the human it is queued for. diff --git a/services/api/test/integration/anon-pg.test.ts b/services/api/test/integration/anon-pg.test.ts index e67ffb3a..acdf64e3 100644 --- a/services/api/test/integration/anon-pg.test.ts +++ b/services/api/test/integration/anon-pg.test.ts @@ -4,16 +4,16 @@ * service+repository layer (no HTTP/Redis) so it proves the spatial held-create transaction, the * held-stays-hidden invariant, the claim-code-gated status, and the release transition directly. * - * Proven here (the Phase-1 done-criterion "held items stay hidden"): + * Proven here (held items stay hidden): * - a held anon report has reporter_user_id NULL, anon_session_id = the token id, status 'held', * published_at NULL, the resolved jurisdiction, geom round-trips, and a submitted+held timeline; * - it is ABSENT from the report-service map candidates (published+public only) and 404s a stranger * via getReport, while its status is visible via anonReportStatus with the right claim code; * - releaseAnonHoldIfReady flips it to published once its media are ready + clean; * - claimReport links it to a user (single-use code) - matching on the stored SHA-256 only, since - * the plaintext code is never written to reports.claim_code (F150 / 0091); + * the plaintext code is never written to reports.claim_code (0091); * - the idempotency snapshot is owner-scoped: another anon session reusing the key gets the - * retryable 409, never the first submitter's snapshot + claim code (F028 / 0078+0079). + * retryable 409, never the first submitter's snapshot + claim code (0078+0079). * * Skips wholesale when Docker is unavailable (describe.skipIf), keeping the local suite green. */ @@ -98,7 +98,7 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = lng: over.lng ?? PROBE_INSIDE_CITY.lng, geomSource: "device", mediaUploadIds: over.mediaUploadIds ?? [], - // Forward a presented anon token when the caller supplies one (the P0-1 cap test relies on all + // Forward a presented anon token when the caller supplies one (the per-token cap test relies on all // submits presenting the SAME seeded token; without this the token was dropped and every submit // minted a fresh one, so the per-token cap was never actually exercised). ...(over.anonToken !== undefined ? { anonToken: over.anonToken } : {}), @@ -120,7 +120,6 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = const { response } = await anon.submitAnonReport(req(), { ip: "203.0.113.5", cfGeo: {} }) expect(response.status).toBe("held") - // The row is held, anon, not published, jurisdiction resolved. const [row] = await h.sql< { reporter_user_id: string | null @@ -139,7 +138,6 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = expect(row!.published_at).toBeNull() expect(row!.jurisdiction_geoid).toBeTruthy() - // Timeline: submitted + held. const tl = await h.sql<{ status: string }[]>` SELECT status FROM report_timeline WHERE report_id = ${response.reportId} ORDER BY created_at ASC, id ASC ` @@ -155,14 +153,12 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = const map = await reports.listReportsInBBox(bbox, null, null, 16) expect(map.pins.some((p) => p.id === response.reportId)).toBe(false) - // 404 to a stranger via getReport. await expect( reports.getReport(response.reportId, { userId: "stranger" }), ).rejects.toMatchObject({ code: "NOT_FOUND", }) - // Status visible with the right claim code; 404 with a wrong one. const status = await anon.anonReportStatus(response.reportId, response.claimCode) expect(status.status).toBe("held") await expect(anon.anonReportStatus(response.reportId, "wrong")).rejects.toMatchObject({ @@ -220,9 +216,9 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = ` expect(row!.status).toBe("published") expect(row!.published_at).not.toBeNull() - // Now it surfaces on the map. The bbox must be STREET-LEVEL: M14 clamps the effective zoom to what + // Now it surfaces on the map. The bbox must be STREET-LEVEL: the map query clamps the effective zoom to what // the bbox extent can imply (effectiveMapZoom), so the old +/-0.5 degree box implied zoom 10 and - // returned clusters only — `pins` would have been empty regardless of visibility. + // returned clusters only, so `pins` would have been empty regardless of visibility. const bbox = { west: PROBE_INSIDE_CITY.lng - 0.02, south: PROBE_INSIDE_CITY.lat - 0.01, diff --git a/services/api/test/integration/auth-pg.test.ts b/services/api/test/integration/auth-pg.test.ts index 396d689f..522d5abb 100644 --- a/services/api/test/integration/auth-pg.test.ts +++ b/services/api/test/integration/auth-pg.test.ts @@ -6,7 +6,7 @@ * the actual SQL of the Pg stores: session insert/find/expiry/delete, the email-OTP lifecycle, and * find-or-create over the real users + oauth_identities tables. * - * It also re-proves the section-17 property against the real store: once a session is warm in the + * It also re-proves the session-cache property against the real store: once a session is warm in the * cache, a second resolve is served from the cache with the Postgres store query NOT invoked (the * PgSessionStore.findById is wrapped with a spy and asserted to be untouched on the cache hit). */ @@ -207,7 +207,6 @@ describe.skipIf(!pg)("auth integration: Postgres stores", () => { const ids = new Set(results.map((r) => r.id)) expect(ids.size).toBe(1) // all converged on one user - // Exactly one row exists in the table for that email. const rows = await h.sql<{ n: number }[]>` SELECT count(*)::int AS n FROM users WHERE email = ${email} ` diff --git a/services/api/test/integration/chat-channels-pg.test.ts b/services/api/test/integration/chat-channels-pg.test.ts index 6fc01d4e..0cfd9271 100644 --- a/services/api/test/integration/chat-channels-pg.test.ts +++ b/services/api/test/integration/chat-channels-pg.test.ts @@ -1,5 +1,5 @@ /** - * P5 Task 5.2 integration test (Docker-gated): CHANNEL read-only enforcement + PUBLIC self-serve join, + * Integration test (Docker-gated): CHANNEL read-only enforcement + PUBLIC self-serve join, * against a live PostGIS container (via withPg). * * Channels are chat_groups with kind='channel': every member reads, but only owner/admin post. A @@ -220,7 +220,7 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr members: [{ id: memberId, role: "member" }], }) // A read-only member can't author, so target the OWNER's message: the gate 403s before the - // sender/window checks (belt-and-braces — a read-only member's edit is rejected like a WS send). + // sender/window checks (belt-and-braces: a read-only member's edit is rejected like a WS send). const msg = await chat().insertMessage( { cleanupId: channelId, roomKind: "group", userId: ownerId, body: "owner post" }, randomUUID(), @@ -250,7 +250,7 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr randomUUID(), ) - // A joined (read-only) member may react — reactions stay MEMBER-gated, not send-gated. + // A joined (read-only) member may react: reactions stay MEMBER-gated, not send-gated. const ok = await inject(await token(memberId), "POST", "/v1/messages/reactions", { roomKind: "group", roomId: channelId, diff --git a/services/api/test/integration/chat-groups-pg.test.ts b/services/api/test/integration/chat-groups-pg.test.ts index 3a75313e..eff6db6d 100644 --- a/services/api/test/integration/chat-groups-pg.test.ts +++ b/services/api/test/integration/chat-groups-pg.test.ts @@ -1,26 +1,26 @@ /** - * P4 Task 4.3 integration test (Docker-gated): the /groups management surface + the group message + * Integration test (Docker-gated): the /groups management surface + the group message * lanes, against a live PostGIS container (via withPg) over HTTP. * - * Create — POST /groups seeds the owner membership + invited members; dupes, self, and anyone the + * Create: POST /groups seeds the owner membership + invited members; dupes, self, and anyone the * creator is blocked-either-way with are silently skipped; avatarUploadId finalizes to * avatar_media_id (hydrated once 'ready'). * - * Read gates — private groups 403 non-members (fields.code not_a_member) and 200 members with + * Read gates: private groups 403 non-members (fields.code not_a_member) and 200 members with * myRole; PUBLIC groups are viewable pre-join (myRole null). * - * Management matrix — admin updates name (member 403 update_forbidden); visibility change is + * Management matrix: admin updates name (member 403 update_forbidden); visibility change is * owner-only (admin 403 visibility_owner_only); role set is owner-only (admin 403 role_owner_only); * owner removes an admin but an admin cannot (403 remove_forbidden); self-remove = leave; the * owner's leave 409s owner_must_stay; addGroupMembers is owner/admin-only (member 403). * - * Message lanes — GET /groups/:id/messages is member-gated for private rooms (non-member 403); + * Message lanes: GET /groups/:id/messages is member-gated for private rooms (non-member 403); * DELETE /groups/:id/messages/:messageId lets the sender self-delete and an admin tombstone a * member's message (member-on-other 403); PUT /messages/pin roomKind:"group" pins for an admin and * 403s pin_forbidden for a member (the chat-powers group lane end-to-end). * * Repos ride chatOverrides on the real Drizzle impls over the harness pool, chatOverrides.chatPowers - * injects the REAL resolver over the REAL pg lookups (incl. the new chat_group_members roleOf) — + * injects the REAL resolver over the REAL pg lookups (incl. the chat_group_members roleOf), * mirroring chat-pins-pg. Skips when Docker is unavailable; CI runs it for real. */ @@ -342,7 +342,7 @@ describe.skipIf(!pg)("chat groups service + routes (integration)", () => { const strangerId = await newUser("Remove Stranger") const nonMemberId = await newUser("Remove Nobody") - // Target IS a member: the stranger must not learn that — 403, not a role-specific error. + // Target IS a member: the stranger must not learn that: 403, not a role-specific error. const onMember = await inject( await token(strangerId), "DELETE", @@ -411,7 +411,7 @@ describe.skipIf(!pg)("chat groups service + routes (integration)", () => { const newbieId = await newUser("Add Real") const blockedId = await newUser("Add Blocked") await h.sql`INSERT INTO user_blocks (blocker_id, blocked_id) VALUES (${ownerId}, ${blockedId})` - const unknownId = randomUUID() // schema-valid, no users row — must not trip the membership FK + const unknownId = randomUUID() // schema-valid, no users row; must not trip the membership FK const res = await inject(await token(ownerId), "POST", `/v1/groups/${groupId}/members`, { memberIds: [unknownId, blockedId, newbieId], @@ -457,7 +457,7 @@ describe.skipIf(!pg)("chat groups service + routes (integration)", () => { expect(p2.members).toHaveLength(3) expect(p2.nextCursor).toBeNull() - // Resume correctness: the two pages tile the full roster exactly — no dupes, no gaps. + // Resume correctness: the two pages tile the full roster exactly: no dupes, no gaps. const seen = [...p1.members, ...p2.members].map((m) => m.user.id) expect(new Set(seen).size).toBe(6) expect(seen.sort()).toEqual([ownerId, ...invitees].sort()) diff --git a/services/api/test/integration/chat-groups-schema-pg.test.ts b/services/api/test/integration/chat-groups-schema-pg.test.ts index 84686d41..29a4e986 100644 --- a/services/api/test/integration/chat-groups-schema-pg.test.ts +++ b/services/api/test/integration/chat-groups-schema-pg.test.ts @@ -1,7 +1,6 @@ /** - * P4 Task 4.1 schema integration test (Docker-gated): migration 0047_chat_groups.sql against a live - * PostGIS container (via withPg). Raw SQL only — no repos exist yet (they land in the following P4 - * tasks). Verifies the things only a real postgres can prove: + * Schema integration test (Docker-gated): migration 0047_chat_groups.sql against a live PostGIS + * container (via withPg), in raw SQL. Verifies the things only a real postgres can prove: * * - chat_groups + chat_group_members exist, and the kind/visibility/role CHECKs reject bad values; * - the swapped chat_messages_scope_chk is a true exactly-one-of-three on the PARTITIONED parent: @@ -29,7 +28,6 @@ describe.skipIf(!pg)("chat groups schema (0047, integration)", () => { await h.teardown() }) - /** Insert a user and return its id. */ async function newUser(name: string): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES (${name}) RETURNING id @@ -46,7 +44,6 @@ describe.skipIf(!pg)("chat groups schema (0047, integration)", () => { return g!.id } - /** Insert a minimal cleanup and return its id. */ async function newCleanup(): Promise { const organizerId = await newUser("Cleanup organizer") return await seedCleanup(h.sql, { diff --git a/services/api/test/integration/chat-groups-threads-pg.test.ts b/services/api/test/integration/chat-groups-threads-pg.test.ts index 3d30612e..1fd3a6e0 100644 --- a/services/api/test/integration/chat-groups-threads-pg.test.ts +++ b/services/api/test/integration/chat-groups-threads-pg.test.ts @@ -1,5 +1,5 @@ /** - * P4 Task 4.5 integration test (Docker-gated): groups in the threads inbox + group_chat bells + + * Integration test (Docker-gated): groups in the threads inbox + group_chat bells + * the read-watermark / keyset carry-ins, against a live PostGIS container (via withPg). * * Threads inbox (service-level, the threads-report-pg pattern): @@ -11,7 +11,7 @@ * back (GREATEST semantics); * - a conversation_mutes ('group') row flips the thread's muted to true. * - * Bells (WS seam: handleClientFrame over the SAME pieces chat-gateway-wiring composes — + * Bells (WS seam: handleClientFrame over the SAME pieces chat-gateway-wiring composes: * makeGroupChatNotifier fan-out via onGroupMessage, chat-bells mention/reply notifiers, presence, * and the wiring-shaped markReadOnOpen): * - a member send bells the UNMUTED absent member exactly once (group_chat, @@ -23,7 +23,7 @@ * - markReadOnOpen (join) clears the room's group_chat bells and leaves other rooms' bells. * * Keyset carry-in: five messages sharing ONE transaction timestamp (identical created_at to the - * microsecond) page with limit 2 into 3 pages with no dupes and no skips — group history AND dm + * microsecond) page with limit 2 into 3 pages with no dupes and no skips, group history AND dm * history (the anchor tuple now stays in SQL; a ms-truncated JS-Date anchor would return an empty * second page). * @@ -415,7 +415,7 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration expect(await bellsFor(mutedU)).toHaveLength(0) }) - it("a reply in a MUTED group still bells the reply target — exactly ONE, reply-flavored", async () => { + it("a reply in a MUTED group still bells the reply target: exactly ONE, reply-flavored", async () => { const target = await newUser("Muted Reply Target") const actor = await newUser("Group Reply Actor") const groupId = await newGroup(target, [actor]) @@ -485,7 +485,7 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration }) await settle() - // Mentioned member: exactly ONE bell — the mention-flavored one, never the fan-out copy. + // Mentioned member: exactly ONE bell, the mention-flavored one, never the fan-out copy. const mentionedFinal = await bellsFor(mentioned) expect(mentionedFinal).toHaveLength(1) expect(mentionedFinal[0]).toMatchObject({ @@ -494,7 +494,7 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration link: `/messages/group/${groupId}`, }) expect(mentionedBells[0]!.title).toBe("Group Mention Author mentioned you") - // Other member: exactly ONE bell — the plain fan-out copy. + // Other member: exactly ONE bell, the plain fan-out copy. const otherFinal = await bellsFor(other) expect(otherFinal).toHaveLength(1) expect(otherFinal[0]).toMatchObject({ type: "group_chat", title: "Group Mention Author" }) @@ -546,7 +546,7 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration }) const remaining = await unreadLinks() expect(remaining).toEqual([`/messages/group/${otherGroupId}`]) - // ...and the join also stamped the read watermark (the 4.4 behavior, unchanged). + // ...and the join also stamped the read watermark. const rows = await h.sql<{ last_read_at: Date | null }[]>` SELECT last_read_at FROM chat_group_members WHERE group_id = ${groupId} AND user_id = ${member} ` diff --git a/services/api/test/integration/chat-groups-ws-pg.test.ts b/services/api/test/integration/chat-groups-ws-pg.test.ts index fe56d3a6..bd47ca12 100644 --- a/services/api/test/integration/chat-groups-ws-pg.test.ts +++ b/services/api/test/integration/chat-groups-ws-pg.test.ts @@ -1,17 +1,17 @@ /** - * P4 Task 4.4 integration test (Docker-gated): the group-room REALTIME lane + the unified reaction + * Integration test (Docker-gated): the group-room REALTIME lane + the unified reaction * toggle, against a live PostGIS container (via withPg). * - * WS seam (handleClientFrame over real Drizzle repos + WsChatService/InMemoryChatPubSub — the same + * WS seam (handleClientFrame over real Drizzle repos + WsChatService/InMemoryChatPubSub, the same * pieces chat-gateway-wiring composes): * - member send round-trips: sender gets the ack, a second joined member receives the broadcast * {type:"message"} frame, and the row lands group-scoped (roomKind "group"); - * - NON-member join / send / typing all reject FORBIDDEN (member-only for BOTH kinds until P5's - * public-channel read-joins) and persist nothing; + * - NON-member join / send / typing all reject FORBIDDEN and persist nothing (public-channel + * read-joins are chat-channels-pg's); * - joining marks the room read (chat_group_members.last_read_at stamped via markReadOnOpen) and * ack {upToId} advances the watermark to that message's created_at; * - @mention of a group MEMBER resolves + records the chat_message_mentions row; a non-member - * handle resolves to nothing (no row) — the chat-mention-resolver group scope end-to-end. + * handle resolves to nothing (no row): the chat-mention-resolver group scope end-to-end. * * HTTP (buildServer + chatOverrides on real repos, FakeChatService watcher for broadcast frames): * - PATCH /messages roomKind:"group" edits the sender's message (200 + message_update broadcast); @@ -82,7 +82,7 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () return u!.id } - /** Direct-SQL group fixture: owner + plain members (management gates are 4.3's suite, not this one). */ + /** Direct-SQL group fixture: owner + plain members (management gates are chat-groups-pg's, not this suite's). */ async function newGroup(ownerId: string, memberIds: string[]): Promise { const [g] = await h.sql<{ id: string }[]>` INSERT INTO chat_groups (name, owner_id) VALUES ('WS Crew', ${ownerId}) RETURNING id @@ -142,7 +142,7 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () listGroupMemberIds: (groupId) => groups.listMemberIds(groupId), }), recordChatMentions: (messageId, ids) => recordChatMentions(h.sql, messageId, ids), - // Bells are 4.5's — the WS lane only needs resolve+record here. + // Bells are chat-groups-threads-pg's; the WS lane only needs resolve+record here. notifyChatMention: () => Promise.resolve(), }, } @@ -204,7 +204,7 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () expect(received).toHaveLength(1) expect((received[0] as { message: { id: string } }).message.id).toBe(acked.id) - // Persisted group-scoped (group_id set, cleanup/report refs NULL — the 0047 three-way XOR). + // Persisted group-scoped (group_id set, cleanup/report refs NULL: the 0047 three-way XOR). const rows = await h.sql<{ group_id: string | null; cleanup_id: string | null }[]>` SELECT group_id, cleanup_id FROM chat_messages WHERE id = ${acked.id} ` @@ -579,7 +579,7 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () const frames = watcher.framesOfType("reaction") expect(frames).toHaveLength(1) expect(frames[0]).toMatchObject({ type: "reaction", cleanupId, message: { id: msg.id } }) - // Cleanup rooms omit roomKind on the legacy frame — mirrored exactly. + // Cleanup rooms omit roomKind on the legacy frame; mirrored exactly. expect(frames[0]).not.toHaveProperty("roomKind") }) diff --git a/services/api/test/integration/chat-pins-pg.test.ts b/services/api/test/integration/chat-pins-pg.test.ts index fbc1040d..415502a0 100644 --- a/services/api/test/integration/chat-pins-pg.test.ts +++ b/services/api/test/integration/chat-pins-pg.test.ts @@ -1,24 +1,24 @@ /** - * P3 Tasks 3.4 + 3.5 integration test (Docker-gated): message PINNING + the moderator delete-others + * Integration test (Docker-gated): message PINNING + the moderator delete-others * override, against a live PostGIS container (via withPg) over HTTP. * - * PUT /messages/pin — the chat-powers matrix end-to-end: cleanup organizer pins (member 403 + * PUT /messages/pin: the chat-powers matrix end-to-end: cleanup organizer pins (member 403 * pin_forbidden), report owner pins (member 403), a global OPERATOR pins in a report room WITHOUT a * membership row, a dm participant pins; unpin clears pinnedAt; system/deleted targets 422; a repeat * pin is IDEMPOTENT (same pinnedAt, no refresh); every successful flip broadcasts a * {type:"message_update"} frame carrying the new pin state to a second connected client. * - * History pins — the INITIAL page (no before/around) of all three history routes carries `pins` + * History pins: the INITIAL page (no before/around) of all three history routes carries `pins` * (newest-pin first, capped at PIN_LIST_CAP); before-paged and around-paged responses omit the key. * - * Delete override — a cleanup ORGANIZER deletes a member's message (tombstone broadcast); a member + * Delete override: a cleanup ORGANIZER deletes a member's message (tombstone broadcast); a member * deleting someone else's stays 403; an OPERATOR deletes in a report room without membership; a * report OWNER deleting someone else's stays 403 (owners curate pins, never erase speech); a dm peer * deleting the other's message stays 403 (unchanged). * * Repos ride chatOverrides on the real Drizzle impls over the harness pool (the established no-redis * pattern), and chatOverrides.chatPowers injects the REAL resolver wired to the REAL roleOf lookups - * (cleanup_members / report_chat_members / users.role) — so the powers matrix is exercised against pg. + * (cleanup_members / report_chat_members / users.role), so the powers matrix is exercised against pg. * When Docker is unavailable the whole block SKIPS so the local suite stays green; CI runs it for real. */ @@ -89,14 +89,14 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { chatRepo: makeDrizzleChatRepository(h.sql), blocksRepo: makeDrizzleBlocksRepository(h.sql), reportChat, - // The REAL resolver over the REAL pg-backed lookups (incl. the new roleOf queries) — the same + // The REAL resolver over the REAL pg-backed lookups (incl. the roleOf queries): the same // wiring shape wireChatPowers builds in production, pointed at the harness pool. chatPowers: makeChatPowersResolver({ isDmParticipant: (threadId, userId) => dm.isParticipant(threadId, userId), cleanupRoleOf: (cleanupId, userId) => cleanups.roleOf(cleanupId, userId), reportChatRoleOf: (reportId, userId) => reportChat.roleOf(reportId, userId), globalRoleOf: (userId) => chatAuthorityRoleOf(h.sql, env, userId), - // P4 group lane, wired to the real repo for parity (this file exercises no group rooms). + // Group lane, wired to the real repo for parity (this file exercises no group rooms). groupRoleOf: (groupId, userId) => makeChatGroupRepository(h.sql).roleOf(groupId, userId), }), } @@ -435,7 +435,7 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { expect(before.json()).not.toHaveProperty("pins") // Around-mode: the cleanup around-page reads through the container chatService (FakeChatService - // under the test env), which has no such message — the pins key must be absent regardless, and + // under the test env), which has no such message; the pins key must be absent regardless, and // that is what this asserts (the fake 404s the unknown target). const around = await app.inject({ method: "GET", @@ -565,7 +565,6 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { headers: { authorization: `Bearer ${await token(memberId)}`, "x-client": "mobile" }, }) expect(res.statusCode).toBe(403) - // Still present. const reread = await chat().findMessage(cleanupId, msg.id, organizerId) expect(reread).not.toBeNull() }) diff --git a/services/api/test/integration/chat-polls-pg.test.ts b/services/api/test/integration/chat-polls-pg.test.ts index 4ed5e96f..aca0dcab 100644 --- a/services/api/test/integration/chat-polls-pg.test.ts +++ b/services/api/test/integration/chat-polls-pg.test.ts @@ -1,5 +1,5 @@ /** - * P6 Tasks 6.3/6.4 integration test (Docker-gated): poll CREATE / VOTE / CLOSE + hydration, against a + * Integration test (Docker-gated): poll CREATE / VOTE / CLOSE + hydration, against a * live PostGIS container (via withPg). * * A poll is a chat_messages row (kind='poll', body=question) plus the chat_polls trio. The three REST @@ -79,7 +79,7 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration const reportChat = makeReportChatRepository(h.sql) const groups = makeChatGroupRepository(h.sql) // Real chat-powers resolver over the DB (the offline override branch fails cleanup/global roles - // closed, which would 403 the cleanup-organizer close test — inject the real one instead). + // closed, which would 403 the cleanup-organizer close test, so inject the real one instead). const chatPowers = makeChatPowersResolver({ isDmParticipant: (t, u) => dmRepo.isParticipant(t, u), cleanupRoleOf: (c, u) => cleanups.roleOf(c, u), @@ -195,8 +195,6 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration ...extra, }) - // -- CREATE ----------------------------------------------------------------- - it("create in a group by a member 200; the poll rides the response + the broadcast message frame", async () => { const ownerId = await newUser("Poll Owner") const memberId = await newUser("Poll Member") @@ -342,8 +340,6 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration expect(res.json().fields).toMatchObject({ code: "poll_forbidden" }) }) - // -- HYDRATION -------------------------------------------------------------- - it("history hydrates counts + myVote + totalVoters", async () => { const ownerId = await newUser("Hydr Owner") const bId = await newUser("Hydr B") @@ -379,8 +375,6 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration ]) }) - // -- VOTE ------------------------------------------------------------------- - it("vote -> switch -> retract adjusts counts; totalVoters stays distinct on a multi-ballot", async () => { const ownerId = await newUser("Vote Owner") const groupId = await newGroup(ownerId, {}) @@ -392,7 +386,6 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration ) const pollId = created.json().id - // Vote A(0). const a = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [0], @@ -447,7 +440,7 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration optionIdxs: [0], }) expect(voted.statusCode).toBe(200) - // The voter's OWN response stays viewer-aware — that half must not regress either. + // The voter's OWN response stays viewer-aware; that half must not regress either. expect(voted.json().poll.myVote).toEqual([0]) expect(voted.json().poll.options[0].mine).toBe(true) @@ -457,14 +450,14 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration } const voteFrame = watcher.framesOfType("message_update")[0]! const afterVote = (voteFrame as Framed).message.poll - // Tallies still ride the frame (the room needs them); the BALLOT does not — for an anonymous poll the + // Tallies still ride the frame (the room needs them); the BALLOT does not: for an anonymous poll the // voter's exact choices would otherwise be broadcast to every member, and clients reconciling the frame // in place would overwrite their own myVote with the voter's. expect(afterVote.options.map((o) => o.count)).toEqual([1, 0]) expect(afterVote.myVote).toEqual([]) expect(afterVote.options.every((o) => o.mine === false)).toBe(true) // WHY THE NEUTRAL VALUES ARE ASSERTED RATHER THAN OMITTED. A reviewer's instinct here is "don't send - // myVote/mine at all, then a merging client keeps its own" — and that is indeed the real fix, but it + // myVote/mine at all, then a merging client keeps its own", and that is indeed the real fix, but it // CANNOT be done from the server alone: PollDTOSchema requires both fields, and the clients parse every // inbound frame with WsServerMessageSchema and discard the whole frame on a miss (ui // chatSocketCore.handleRawFrame), so an omitted field would silently kill live vote counts and the @@ -500,7 +493,7 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration ) const pollId = created.json().id - // Schema-valid repeat idx: must NOT 500 on the votes PK — dedupe to a single ballot row. + // Schema-valid repeat idx: must NOT 500 on the votes PK; dedupe to a single ballot row. const res = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [0, 0], @@ -596,8 +589,6 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration expect(stranger.json().fields).toMatchObject({ code: "poll_not_member" }) }) - // -- CLOSE ------------------------------------------------------------------ - it("close by the author 200s + broadcasts message_update; a plain member 403s", async () => { const ownerId = await newUser("Close Owner") const memberId = await newUser("Close Member") @@ -678,7 +669,7 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration await reportChat.join(reportId, reportOwnerId, "owner") await reportChat.join(reportId, memberId, "member") - // Poll authored by the plain member; the report chat OWNER closes it — isModerator is true for a + // Poll authored by the plain member; the report chat OWNER closes it: isModerator is true for a // report owner (canPin), so close succeeds even though they hold no canDeleteOthers power. const created = await inject( await token(memberId), @@ -696,8 +687,6 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration expect(closed.json().poll.closed).toBe(true) }) - // -- TOMBSTONE -------------------------------------------------------------- - it("a deleted poll hydrates as a plain tombstone with NO poll field", async () => { const ownerId = await newUser("Tomb Owner") const groupId = await newGroup(ownerId, {}) @@ -725,12 +714,10 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration expect(tombstone!.poll).toBeUndefined() }) - // -- FAN-OUT (createPoll -> member bells) ----------------------------------- - // // The bells the poll route raises come from makeContainerPollNotifier, which builds its report/group // lane notifiers out of container primitives and is a NO-OP under USE_FAKE_CHAT (true in the test env, // hence a no-op for the app above). Here it is constructed for real over the HARNESS pool, so createPoll - // writes actual `notifications` rows through the actual Drizzle notification repo — and, crucially, the + // writes actual `notifications` rows through the actual Drizzle notification repo and, crucially, the // mute and block gates run as their REAL SQL (conversation_mutes.mutedUserIdsFor and user_blocks), which // no unit test can reach. describe("createPoll member fan-out (real notifier over pg)", () => { @@ -766,7 +753,7 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration /** * The fan-out is fire-and-forget behind createPoll's return AND every gate in it is a real round trip, * so there is no promise to await. Wait for the EXPECTED bell to land (proof the whole recipient batch - * ran — they are dispatched together, after the block/mute verdicts for the full set are resolved), + * ran, since they are dispatched together, after the block/mute verdicts for the full set are resolved), * then give the losers of the same batch a real-time grace window before asserting they got nothing. */ const waitForBell = async (userId: string): Promise => { diff --git a/services/api/test/integration/chat-polls-schema-pg.test.ts b/services/api/test/integration/chat-polls-schema-pg.test.ts index a5de2f3c..1adb1de4 100644 --- a/services/api/test/integration/chat-polls-schema-pg.test.ts +++ b/services/api/test/integration/chat-polls-schema-pg.test.ts @@ -1,7 +1,6 @@ /** - * P6 Task 6.1 schema integration test (Docker-gated): migration 0048_chat_polls.sql against a live - * PostGIS container (via withPg). Raw SQL only — no repos exist yet (they land in the following P6 - * tasks). Verifies the things only a real postgres can prove: + * Schema integration test (Docker-gated): migration 0048_chat_polls.sql against a live PostGIS + * container (via withPg), in raw SQL. Verifies the things only a real postgres can prove: * * - the chat_polls / chat_poll_options / chat_poll_votes trio exists with sensible defaults; * - the vote composite FK rejects a vote whose (poll_id, option_idx) has no matching option row; @@ -27,7 +26,6 @@ describe.skipIf(!pg)("chat polls schema (0048, integration)", () => { await h.teardown() }) - /** Insert a user and return its id. */ async function newUser(name: string): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES (${name}) RETURNING id @@ -36,7 +34,7 @@ describe.skipIf(!pg)("chat polls schema (0048, integration)", () => { } /** - * Seed a poll with `optionCount` options. message_id is a bare uuid (no chat_messages row needed — + * Seed a poll with `optionCount` options. message_id is a bare uuid (no chat_messages row needed: * the table has no FK to the partitioned parent, by design). Returns the poll's message_id. */ async function newPoll(optionCount: number): Promise { diff --git a/services/api/test/integration/chat-replies-pg.test.ts b/services/api/test/integration/chat-replies-pg.test.ts index 6b5dabae..d211cb0a 100644 --- a/services/api/test/integration/chat-replies-pg.test.ts +++ b/services/api/test/integration/chat-replies-pg.test.ts @@ -1,5 +1,5 @@ /** - * Reply threading integration test (P2 Task 2.3, Docker-gated). Boots against a live PostGIS container + * Reply threading integration test (Docker-gated). Boots against a live PostGIS container * (via withPg) and exercises the DB-backed reply persist/validation/hydration paths: * * - persist accepts replyToId and the returned DTO + a later history read both carry the hydrated @@ -52,7 +52,6 @@ describe.skipIf(!pg)("chat replies (integration)", () => { await h.teardown() }) - /** Insert a user and return its id. */ async function newUser(name: string): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id diff --git a/services/api/test/integration/chat-reply-notifications-pg.test.ts b/services/api/test/integration/chat-reply-notifications-pg.test.ts index e1f0b0cb..2f6295a3 100644 --- a/services/api/test/integration/chat-reply-notifications-pg.test.ts +++ b/services/api/test/integration/chat-reply-notifications-pg.test.ts @@ -1,12 +1,12 @@ /** - * P2 Task 2.5 integration test (Docker-gated): reply bells + re-enabled report-room @mentions (D11), - * composed from the SAME production pieces the gateway wiring uses — chat-bells notifiers, the - * report-chat fan-out, the chat-mention resolver, recordChatMentions — over real Postgres rows + * Integration test (Docker-gated): reply bells + re-enabled report-room @mentions, composed from the + * SAME production pieces the gateway wiring uses (chat-bells notifiers, the report-chat fan-out, the + * chat-mention resolver, recordChatMentions) over real Postgres rows * (conversation_mutes, notification_prefs, cleanup/report members, notifications): * * - a reply bells the replied-to user EVEN when they muted the room (replies pierce mutes); * - prefs.mentions=false suppresses the reply bell (reply urgency is mention-class); - * - report-room member fan-out EXCLUDES the reply target — they get exactly ONE bell (the reply); + * - report-room member fan-out EXCLUDES the reply target: they get exactly ONE bell (the reply); * - @mention of a NON-member in a report room resolves empty (no row, no bell); a member resolves, * records the chat_message_mentions row, and bells as report_chat; * - a MUTED dm thread still bells on a reply to the recipient; an unmuted dm reply produces exactly @@ -83,7 +83,6 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () return created.id } - /** Insert a minimal report and return its id. */ async function newReport(): Promise { const [r] = await h.sql<{ id: string }[]>` INSERT INTO reports (idempotency_key, geom, geom_source, category, type, status, h3_cell) @@ -134,7 +133,7 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () repo: makeDrizzleCleanupRepository(h.sql), }).joinCleanup(cleanupId, actor) - // Target mutes the room — the normal room/mention bells would be silenced. + // Target mutes the room: the normal room/mention bells would be silenced. await makeConversationMutesRepository(h.sql).setMuted(target, "cleanup", cleanupId, true) const chatRepo = makeDrizzleChatRepository(h.sql) @@ -212,7 +211,7 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () expect(await bellsFor(target)).toHaveLength(0) }) - it("report room: member fan-out EXCLUDES the reply target — they get exactly ONE bell (the reply)", async () => { + it("report room: member fan-out EXCLUDES the reply target: they get exactly ONE bell (the reply)", async () => { const target = await newUser("Fanout Target") const actor = await newUser("Fanout Actor") const other = await newUser("Fanout Other") @@ -261,7 +260,7 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () message: reply, }) - // Target: exactly ONE bell — the reply-flavored one, not the fan-out copy. + // Target: exactly ONE bell, the reply-flavored one, not the fan-out copy. const targetBells = await bellsFor(target) expect(targetBells).toHaveLength(1) expect(targetBells[0]).toMatchObject({ @@ -269,7 +268,7 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () title: "Fanout Actor replied to you", link: `/messages/report/${reportId}`, }) - // Other member: exactly ONE bell — the plain fan-out copy. + // Other member: exactly ONE bell, the plain fan-out copy. const otherBells = await bellsFor(other) expect(otherBells).toHaveLength(1) expect(otherBells[0]).toMatchObject({ type: "report_chat", title: "Fanout Actor" }) @@ -391,7 +390,7 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () const m3 = await chatRepo.insertMessage({ cleanupId, userId: org, body: "three" }, randomUUID()) expect(await chatRepo.softDelete(cleanupId, m2.id, org)).not.toBeNull() - // before=m2 (deleted): must return the page OLDER than m2 (just m1) — not fall back to the newest + // before=m2 (deleted): must return the page OLDER than m2 (just m1), not fall back to the newest // page (which would start at m3). const page = await chatRepo.history(cleanupId, m2.id, 10) expect(page.items.map((m) => m.id)).toEqual([m1.id]) diff --git a/services/api/test/integration/cleanup-default-slot-pg.test.ts b/services/api/test/integration/cleanup-default-slot-pg.test.ts index e29f6055..2b171be8 100644 --- a/services/api/test/integration/cleanup-default-slot-pg.test.ts +++ b/services/api/test/integration/cleanup-default-slot-pg.test.ts @@ -2,7 +2,7 @@ * 0169_default_event_slot.sql against a live PostGIS container (Docker-gated). * * The template database this harness clones has already applied the whole chain, so the file under test - * has already run once against an EMPTY events table — which proves nothing. This file therefore seeds + * has already run once against an EMPTY events table, which proves nothing. This file therefore seeds * the legacy shapes the backfill exists for and then re-executes the migration's own text (read off * disk, run through `sql.unsafe` exactly as src/db/migrate.ts does), which is the only way to observe * what it writes: @@ -10,7 +10,7 @@ * - an OPEN slot-less event gains exactly one 'General volunteers' slot, untimed, carrying the event's * capacity, and one claim per existing cleanup_members row, each stamped with the moment that member * actually joined rather than the moment the deploy ran; - * - an ENDED slot-less event gains NOTHING — its roster is what credited hours were attested against, + * - an ENDED slot-less event gains NOTHING: its roster is what credited hours were attested against, * and cleanup-service already refuses every slot edit on it (a cancelled event is skipped for the * same reason, plus the obvious one); * - an open event that ALREADY has a board is untouched: no extra slot, and no claim conjured onto a @@ -143,7 +143,7 @@ describe.skipIf(!pg)("0169 default event slot backfill (integration)", () => { ]) const claims = await claimsOf(id) expect(claims.map((c) => c.userId)).toEqual([organizer, member].sort()) - // The claim is backdated to when the member actually joined, not to when the deploy ran — the + // The claim is backdated to when the member actually joined, not to when the deploy ran; the // roster is a volunteer-hours record, so a wall-clock stamp would rewrite everyone's history. const memberClaim = claims.find((c) => c.userId === member) expect(memberClaim?.claimedAt.getTime()).toBe(joinedAt.getTime()) @@ -186,7 +186,7 @@ describe.skipIf(!pg)("0169 default event slot backfill (integration)", () => { expect(await claimantsOf(cancelled)).toEqual([]) }) - it("leaves an open event that already has a board alone — no extra slot, no conjured claim", async () => { + it("leaves an open event that already has a board alone: no extra slot, no conjured claim", async () => { const organizer = await newUser("Olive Organizer") const member = await newUser("Mel Member") const id = await newCleanup(organizer) diff --git a/services/api/test/integration/cleanup-derived-status-pg.test.ts b/services/api/test/integration/cleanup-derived-status-pg.test.ts index c728e8c7..36080c80 100644 --- a/services/api/test/integration/cleanup-derived-status-pg.test.ts +++ b/services/api/test/integration/cleanup-derived-status-pg.test.ts @@ -2,14 +2,14 @@ * Derived event status against a live PostGIS container (Docker-gated). * * The unit suite runs the TS twin (`deriveCleanupStatus`) through the in-memory repository; this file - * runs the SQL half — `cleanupStatusExpr` and the `ends_at`-based filters — against a real database, + * runs the SQL half (`cleanupStatusExpr` and the `ends_at`-based filters) against a real database, * because a divergence between the two is invisible to CI otherwise. The four things only a real * database can show: * * - 0168's NOT NULL: an insert without `ends_at` is rejected outright, so no row can ever reach the * derivation without a right edge; * - a row whose stored status is still the legacy 'upcoming' but whose `ends_at` has passed reads back - * as 'done' — the projection, not the column, is what a DTO carries; + * as 'done': the projection, not the column, is what a DTO carries; * - `cancelCleanupTx` refuses a past event with "already_ended" and writes NOTHING (the guard * `status <> 'cancelled' AND ends_at > now()` is what keeps cancel from rewriting history); * - the `when=upcoming` list filter keeps an UNDERWAY event and drops an ended one. diff --git a/services/api/test/integration/cleanups-chat-pg.test.ts b/services/api/test/integration/cleanups-chat-pg.test.ts index 01a6fd83..732d7d34 100644 --- a/services/api/test/integration/cleanups-chat-pg.test.ts +++ b/services/api/test/integration/cleanups-chat-pg.test.ts @@ -43,7 +43,6 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { await h.teardown() }) - /** Insert a user and return its id. */ async function newUser(name: string): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES (${name}) RETURNING id @@ -324,7 +323,7 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { it("P1: reaction toggle honors the WIDENED allowlist end-to-end (laugh -> 200 + summary; unknown -> 422)", async () => { // Full server against the real DB (same wiring as the membership-gated route test above) so the - // POST /cleanups/:id/messages/:messageId/reactions path — shared-schema parse included — runs for real. + // POST /cleanups/:id/messages/:messageId/reactions path, shared-schema parse included, runs for real. const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: h.uri }) const container = buildContainer(env) const stores = makeInMemoryStores() @@ -364,7 +363,7 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { randomUUID(), ) - // "laugh" is one of the two P1 additions (append-only widening 6 -> 8): the shared schema at the + // "laugh" is one of the two later additions (append-only widening 6 -> 8): the shared schema at the // route boundary must accept it and the toggle must land in chat_reactions. const on = await app.inject({ method: "POST", @@ -448,7 +447,7 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { const removed = await service.removeMember(created.id, organizerId, bobId) expect(removed).toEqual({ ok: true, going: 2 }) expect(await repo.isMember(created.id, bobId)).toBe(false) - // M17: the same transaction wrote the ban that makes the removal stick — the self-service join + // The same transaction wrote the ban that makes the removal stick: the self-service join // now refuses instead of silently re-creating the membership row. expect(await repo.isBanned(created.id, bobId)).toBe(true) expect(await repo.joinCleanupTx(created.id, bobId, signupSeat())).toBe("banned") diff --git a/services/api/test/integration/erasure-host-ladder-pg.test.ts b/services/api/test/integration/erasure-host-ladder-pg.test.ts index a24a1a67..2ac5200a 100644 --- a/services/api/test/integration/erasure-host-ladder-pg.test.ts +++ b/services/api/test/integration/erasure-host-ladder-pg.test.ts @@ -112,7 +112,7 @@ describe.skipIf(!pg)("erasure: the host-transfer ladder", () => { await pg?.teardown() }) - it("does NOT cancel a sole-owner organization's events — the promoted admin takes them over", async () => { + it("does NOT cancel a sole-owner organization's events: the promoted admin takes them over", async () => { const h = pg! const owner = await user(h, "owner-sole") const admin = await user(h, "admin-successor") diff --git a/services/api/test/integration/follow-counters-pg.test.ts b/services/api/test/integration/follow-counters-pg.test.ts index fe0302a1..8b4fc66f 100644 --- a/services/api/test/integration/follow-counters-pg.test.ts +++ b/services/api/test/integration/follow-counters-pg.test.ts @@ -1,9 +1,9 @@ /** - * drizzle/0059_users_follow_counters.sql — the BACKFILL branch, against a real database. + * drizzle/0059_users_follow_counters.sql: the BACKFILL branch, against a real database. * * The migration adds users.follower_count / following_count and backfills them from follows_people. Every * other test in the suite gets its database from the template, where 0059 was applied to an EMPTY - * follows_people — so the backfill UPDATE rewrote zero rows and the only branch that matters in + * follows_people, so the backfill UPDATE rewrote zero rows and the only branch that matters in * production (a live database full of existing edges -> correct counters) had no coverage at all. The * write path (addFollow/removeFollow moving the counters) is covered in social-notifications-pg.test.ts; * this file covers the one-time convergence, the re-apply guard, and the documented tombstone semantics. @@ -62,7 +62,7 @@ describe.skipIf(!pg)("0059 follow-counter backfill (integration)", () => { return u!.id } - /** A raw edge, with NO counter maintenance — what a psql session or a bulk import leaves behind. */ + /** A raw edge, with NO counter maintenance: what a psql session or a bulk import leaves behind. */ async function rawEdge(followerId: string, followeeId: string): Promise { await h.sql` INSERT INTO follows_people (follower_id, followee_id) VALUES (${followerId}, ${followeeId}) @@ -83,7 +83,7 @@ describe.skipIf(!pg)("0059 follow-counter backfill (integration)", () => { } /** - * Every user whose stored counters disagree with a live count over follows_people — the drift detector + * Every user whose stored counters disagree with a live count over follows_people: the drift detector * from the operator runbook. Empty is the invariant the backfill exists to establish. */ async function drift(): Promise> { @@ -112,7 +112,7 @@ describe.skipIf(!pg)("0059 follow-counter backfill (integration)", () => { // EXACTLY the three users with edges: the WHERE guard skips `loner`, whose 0/0 already agrees with // the aggregate. That is what keeps a re-apply (and this apply, on a mostly-followless users table) - // off every row — see the guard's rationale in the migration. + // off every row; see the guard's rationale in the migration. expect(await runBackfill()).toBe(3) expect(await counters(bob)).toEqual({ followers: 2, following: 1 }) @@ -130,7 +130,7 @@ describe.skipIf(!pg)("0059 follow-counter backfill (integration)", () => { // First run fixes this file's drift (its own two rows, plus anything an earlier test left). expect(await runBackfill()).toBeGreaterThanOrEqual(2) - // Second run must touch nothing — this is what keeps a re-apply off the whole users table. + // Second run must touch nothing; this is what keeps a re-apply off the whole users table. expect(await runBackfill()).toBe(0) expect(await counters(b)).toEqual({ followers: 1, following: 0 }) }) @@ -175,7 +175,7 @@ describe.skipIf(!pg)("0059 follow-counter backfill (integration)", () => { expect(await counters(a)).toEqual({ followers: 0, following: 1 }) expect(await counters(b)).toEqual({ followers: 1, following: 0 }) - // The canonical backfill finds nothing to fix — the property a raw INSERT fixture does not have. + // The canonical backfill finds nothing to fix: the property a raw INSERT fixture does not have. expect(await runBackfill()).toBe(0) // Re-seeding the same edge is a no-op, not a double count (mirrors addFollow's ON CONFLICT gate). diff --git a/services/api/test/integration/inbound-bounce-pg.test.ts b/services/api/test/integration/inbound-bounce-pg.test.ts index 14475249..ba784013 100644 --- a/services/api/test/integration/inbound-bounce-pg.test.ts +++ b/services/api/test/integration/inbound-bounce-pg.test.ts @@ -1,16 +1,16 @@ /** - * F111: bounce handling against the REAL jurisdiction_contacts schema (Docker-gated). + * Bounce handling against the REAL jurisdiction_contacts schema (Docker-gated). * * A DSN echoes the failed recipient exactly as the sending MTA recorded it, which is routinely a * different CASE from the address the operator saved ("Clerk@LACity.Gov" vs "clerk@lacity.gov"). * markBouncedContact / geoidForContact used to compare `email = ${email}` verbatim while the ownership - * guard right before them already folded case — so a case-mismatched DSN passed the guard and then + * guard right before them already folded case, so a case-mismatched DSN passed the guard and then * updated ZERO rows: no bounced_at stamp, no bounced badge in the directory, and no discovery * re-onboarding job. Every failure is silent, which is why it survived (the pre-existing bounce tests all * use an all-lowercase recipient and pass either way). * - * These run against a template-cloned database, so the predicate — and the functional - * jurisdiction_contacts_email_lower_idx that keeps it off a seq scan (drizzle/0083) — are the real ones. + * These run against a template-cloned database, so the predicate and the functional + * jurisdiction_contacts_email_lower_idx that keeps it off a seq scan (drizzle/0083) are the real ones. */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" diff --git a/services/api/test/integration/inbound-mail-webhook.test.ts b/services/api/test/integration/inbound-mail-webhook.test.ts index cb24e4da..06e3fca2 100644 --- a/services/api/test/integration/inbound-mail-webhook.test.ts +++ b/services/api/test/integration/inbound-mail-webhook.test.ts @@ -35,8 +35,8 @@ function sign(body: string): string { } /** - * `auth` is the RFC 8601 `Authentication-Results` header our MTA stamps. M7 made the threading path - * FAIL CLOSED on anything but a DMARC-aligned pass — an ABSENT header is `unknown`, not `pass` — because + * `auth` is the RFC 8601 `Authentication-Results` header our MTA stamps. The threading path FAILS + * CLOSED on anything but a DMARC-aligned pass (an ABSENT header is `unknown`, not `pass`) because * threading is what grants a message authority (report status transitions, an "official city reply" * mirrored into the PUBLIC report chat, a push to the reporter). So a fixture that wants those effects * MUST carry a passing verdict; omit `auth` to exercise the unauthenticated lane. @@ -136,7 +136,6 @@ describe.skipIf(!pg)("inbound-mail webhook (integration: real schema)", () => { expect(storage.get(key)).toBeNull() }) - // --- M7 message-authentication gate -------------------------------------------------------------- // A valid thread token used to be sufficient to reach the THREADED path, so anyone who learned a token // could forge `From: publicworks@lacity.gov` and drive the whole side-effect chain: report status -> // in_progress, their text mirrored into the PUBLIC report chat as an official city reply, and a push to diff --git a/services/api/test/integration/map-pg.test.ts b/services/api/test/integration/map-pg.test.ts index cc2e48a1..c18b3d59 100644 --- a/services/api/test/integration/map-pg.test.ts +++ b/services/api/test/integration/map-pg.test.ts @@ -1,17 +1,3 @@ -/** - * Map routes integration test (Docker-gated). Boots the real Fastify app against a live PostGIS - * container (via withPg, which seeds the canonical jurisdiction set) and exercises the DB-backed map - * endpoints end-to-end through app.inject: - * - * POST /map/resolve-jurisdiction -> place for an in-city point, county for unincorporated land, - * and 200-null for a far point outside all coverage. - * GET /map/cleanups -> returns the pins whose Point falls inside the queried bbox, - * with a "going" count, and excludes ones outside the bbox. - * - * When Docker is unavailable the whole describe block SKIPS (describe.skipIf) so the local suite stays - * green; CI runs it for real. Reuses withPg() per the harness contract. - */ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import type { FastifyInstance } from "fastify" import type { CleanupStatus } from "@civfix/shared" @@ -45,7 +31,7 @@ describe.skipIf(!pg)("map routes (integration)", () => { beforeAll(async () => { h = pg as PgHarness - // Real DB; everything else stays a fake (jobs/geocoder/etc. default to fakes outside production). + // Every other seam defaults to its fake outside production. const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: h.uri }) const container = buildContainer(env) app = await buildServer({ env, container }) @@ -68,7 +54,6 @@ describe.skipIf(!pg)("map routes (integration)", () => { expect(body.geoid).toBe(LA_CITY.geoid) expect(body.layer).toBe("place") expect(typeof body.cityStateLabel).toBe("string") - // The public DTO now carries the routable flag (whether routing is configured for this jurisdiction). expect(typeof body.routable).toBe("boolean") }) @@ -95,7 +80,6 @@ describe.skipIf(!pg)("map routes (integration)", () => { }) it("GET /map/cleanups returns only pins inside the bbox, with going counts", async () => { - // One organizer user + one extra member so the going count is observable. const [organizer] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES ('Organizer') RETURNING id ` @@ -105,7 +89,6 @@ describe.skipIf(!pg)("map routes (integration)", () => { const organizerId = organizer!.id const memberId = member!.id - // Two cleanups inside the LA city box (near PROBE_INSIDE_CITY) and one far outside it. const insideLng = PROBE_INSIDE_CITY.lng const insideLat = PROBE_INSIDE_CITY.lat const near1Id = await seedCleanup(h.sql, { @@ -122,7 +105,6 @@ describe.skipIf(!pg)("map routes (integration)", () => { lat: insideLat + 0.01, scheduledAt: new Date(Date.now() + 8 * 86_400_000), }) - // Far away (outside the LA city bbox we will query). await seedCleanup(h.sql, { organizerUserId: organizerId, title: "Far", @@ -131,15 +113,13 @@ describe.skipIf(!pg)("map routes (integration)", () => { scheduledAt: new Date(Date.now() + 9 * 86_400_000), }) - // Two members going to near1 (organizer + member). await h.sql` INSERT INTO cleanup_members (cleanup_id, user_id, role) VALUES (${near1Id}, ${organizerId}, 'organizer'), (${near1Id}, ${memberId}, 'member') ` - // Query the LA city box (LA_CITY.bbox = [xmin(lng), ymin(lat), xmax(lng), ymax(lat)]). bbox is sent - // as the shared client encodes it (a single JSON param), via clientQuery. + // bbox is sent as the shared client encodes it: a single JSON param. const [west, south, east, north] = LA_CITY.bbox const res = await app.inject({ method: "GET", @@ -148,7 +128,6 @@ describe.skipIf(!pg)("map routes (integration)", () => { expect(res.statusCode).toBe(200) const body = res.json() const ids: string[] = body.pins.map((p: { id: string }) => p.id) - // Both near cleanups are inside; the far one is not. expect(ids).toContain(near1Id) expect(body.pins).toHaveLength(2) @@ -275,14 +254,10 @@ describe.skipIf(!pg)("map routes (integration)", () => { expect(rows[0]?.meta.source).toBe("anon") }) - /** - * The write-time Census fallback against a REAL sequence (A15 follow-up). - * - * The lazily-inserted row has a NULL geom, so it can never satisfy the resolver's ST_Contains — every - * repeat resolve of the same point re-enters the fallback. With `nextval` in a VALUES list that burned one - * `jurisdiction_code_seq` value per request (VALUES is evaluated before the conflict is detected), on an - * anon-ok endpoint. Only a real DB can prove the NOT EXISTS guard actually leaves the sequence alone. - */ + // The lazily-inserted row has a NULL geom, so it never satisfies the resolver's ST_Contains and every + // repeat resolve re-enters the fallback. `nextval` in a VALUES list burned one `jurisdiction_code_seq` + // value per request on an anon-ok endpoint (VALUES is evaluated before the conflict is detected). Only + // a real DB can prove the NOT EXISTS guard leaves the sequence alone. describe("write-time Census fallback (lazy jurisdiction insert)", () => { const FALLBACK_GEOID = "0699999" @@ -302,7 +277,7 @@ describe.skipIf(!pg)("map routes (integration)", () => { }) } - /** Counting lookup with a mutable answer, so a "Census renamed the place" case is expressible. */ + // The answer is mutable so a "Census renamed the place" case is expressible. function countingLookup(hit: JurisdictionLookupResult): { lookup: JurisdictionLookup calls: () => number @@ -338,17 +313,12 @@ describe.skipIf(!pg)("map routes (integration)", () => { ` expect(created).toHaveLength(1) expect(created[0]?.code).not.toBeNull() - // NULL geom is what makes this row un-resolvable spatially (hence the repeat-call path below). expect(created[0]?.geom).toBeNull() const afterInsert = await seqState() - // The insert drew from the sequence (the stamped code above), so it has been called and cannot have - // gone backwards. expect(afterInsert.is_called).toBe(true) expect(Number(afterInsert.last_value)).toBeGreaterThanOrEqual(Number(before.last_value)) - // Two more resolves of the same point: the lookup answers again (uncached service), the row already - // exists, and the sequence must not move. await service.resolveForPoint(PROBE_OUTSIDE_ALL.lat, PROBE_OUTSIDE_ALL.lng) await service.resolveForPoint(PROBE_OUTSIDE_ALL.lat, PROBE_OUTSIDE_ALL.lng) expect(probe.calls()).toBe(3) @@ -368,8 +338,7 @@ describe.skipIf(!pg)("map routes (integration)", () => { const service = serviceWith(probe.lookup) await service.resolveForPoint(PROBE_OUTSIDE_ALL.lat, PROBE_OUTSIDE_ALL.lng) - // A later lookup answers with a different label for the same geoid: the stored row keeps its name, - // so this fallback can never overwrite a curated/ingested one. + // The stored row keeps its name, so this fallback can never overwrite a curated or ingested one. probe.setName("Renamed By Census") const dto = await service.resolveForPoint(PROBE_OUTSIDE_ALL.lat, PROBE_OUTSIDE_ALL.lng) expect(dto?.geoid).toBe(FALLBACK_GEOID) @@ -391,7 +360,7 @@ describe.skipIf(!pg)("map routes (integration)", () => { await service.resolveForPoint(PROBE_OUTSIDE_ALL.lat, PROBE_OUTSIDE_ALL.lng) expect(probe.calls()).toBe(1) - // Row already existed from the tests above, so nothing was drawn at all here. + // The row already exists from the tests above. expect((await seqState()).last_value).toBe(before.last_value) }) }) diff --git a/services/api/test/integration/media-pg.test.ts b/services/api/test/integration/media-pg.test.ts index f5e4b036..26d27040 100644 --- a/services/api/test/integration/media-pg.test.ts +++ b/services/api/test/integration/media-pg.test.ts @@ -1,17 +1,3 @@ -/** - * Media intake integration test (Docker-gated). Boots the real Fastify app against a live PostGIS - * container (via withPg) with the Drizzle-backed MediaRepository (NO injected repo) and the default - * FakeStorage/FakeJobs seams, then exercises the intake flow end-to-end through app.inject: - * - * create -> a media_assets row exists (status validating, report_id null, content-addressed key). - * finalize -> status stays VALIDATING and a media.checks worker job is enqueued (the worker strips - * EXIF/GPS + runs moderation, then promotes the row to ready). - * getMedia -> a ready row renders a MediaDTO with a url; a validating row 404s on the public path. - * - * When Docker is unavailable the whole describe block SKIPS (describe.skipIf) so the local suite stays - * green; CI runs it for real. Reuses withPg() per the harness contract. - */ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import type { FastifyInstance } from "fastify" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -34,8 +20,7 @@ describe.skipIf(!pg)("media routes (integration)", () => { beforeAll(async () => { h = pg as PgHarness - // Real DB; storage + jobs stay fakes (they default to fakes outside production). No injected repo, - // so the routes use the Drizzle-backed MediaRepository against the live database. + // No injected repo, so the routes use the Drizzle-backed MediaRepository. const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: h.uri }) const container = buildContainer(env) app = await buildServer({ env, container }) @@ -65,7 +50,7 @@ describe.skipIf(!pg)("media routes (integration)", () => { expect(rows[0]!.status).toBe("validating") expect(rows[0]!.report_id).toBeNull() // Intake keys the object by the server-minted uploadId (uploads/YYYY/MM/), NOT the - // client-claimed sha256 — trusting the client's hash as the physical path would let a caller + // client-claimed sha256: trusting the client's hash as the physical path would let a caller // collide/overwrite another object. The worker later promotes verified bytes to the content- // addressed uploads/YYYY/MM/ key for dedup (see media-worker-repo). expect(rows[0]!.r2_key).toMatch(new RegExp(`^uploads/\\d{4}/\\d{2}/${uploadId}$`)) @@ -83,7 +68,6 @@ describe.skipIf(!pg)("media routes (integration)", () => { const [row] = await h.sql<{ id: string; r2_key: string }[]>` SELECT id, r2_key FROM media_assets WHERE upload_id = ${uploadId} ` - // Simulate the direct-to-R2 PUT having landed so finalize's HEAD check passes. await storage.put(row!.r2_key, new Uint8Array(2048), { contentType: "image/png" }) const finRes = await app.inject({ method: "POST", url: `/v1/media/${uploadId}/finalize` }) @@ -91,13 +75,12 @@ describe.skipIf(!pg)("media routes (integration)", () => { expect(finRes.json().status).toBe("validating") expect(finRes.json().mediaId).toBe(row!.id) - // The row stays VALIDATING until the worker strips EXIF/GPS + moderates, then promotes it to ready. + // Only the worker promotes to ready, after stripping EXIF/GPS and moderating. const [after] = await h.sql<{ status: string }[]>` SELECT status FROM media_assets WHERE upload_id = ${uploadId} ` expect(after!.status).toBe("validating") - // finalize enqueues exactly one media.checks job for this upload (deduped by uploadId singletonKey). const checks = jobs.jobsFor(MEDIA_CHECKS_JOB).filter((j) => { const d = j.data as { uploadId?: string } return d.uploadId === uploadId @@ -105,7 +88,7 @@ describe.skipIf(!pg)("media routes (integration)", () => { expect(checks).toHaveLength(1) }) - it("F087: a repeat finalize is idempotent — finalized_at is stamped ONCE and no second media.checks job is enqueued", async () => { + it("F087: a repeat finalize is idempotent: finalized_at is stamped ONCE and no second media.checks job is enqueued", async () => { const createRes = await app.inject({ method: "POST", url: "/v1/media/upload", @@ -124,8 +107,7 @@ describe.skipIf(!pg)("media routes (integration)", () => { ` expect(stamped!.finalized_at).not.toBeNull() - // Every later finalize loses the compare-and-set: same success body, same finalized_at, NO enqueue. - // The old status-CAS could not catch this — the row is 'validating' for the whole processing window. + // A status CAS could not catch this: the row is 'validating' for the whole processing window. for (let i = 0; i < 3; i++) { const again = await app.inject({ method: "POST", url: `/v1/media/${uploadId}/finalize` }) expect(again.statusCode).toBe(200) @@ -155,12 +137,10 @@ describe.skipIf(!pg)("media routes (integration)", () => { SELECT id FROM media_assets WHERE upload_id = ${uploadId} ` - // While validating -> 404 on the public path. const notReady = await app.inject({ method: "GET", url: `/v1/media/${row!.id}` }) expect(notReady.statusCode).toBe(404) - // Flip to ready (as the worker would: publish the processed bytes to served_key, then CAS) and - // re-fetch. A ready row whose served_key is still NULL stays a 404 — that is the pre-0097 state + // A ready row whose served_key is still NULL stays a 404: that is the pre-0097 state // db:backfill-served-key adopts. const servedKey = await publishMediaAsReady(h.sql, row!.id) await h.sql`UPDATE media_assets SET width = 800, height = 600 WHERE id = ${row!.id}` diff --git a/services/api/test/integration/media-upload-etag-pg.test.ts b/services/api/test/integration/media-upload-etag-pg.test.ts index a965a43d..bcacaa71 100644 --- a/services/api/test/integration/media-upload-etag-pg.test.ts +++ b/services/api/test/integration/media-upload-etag-pg.test.ts @@ -1,10 +1,6 @@ -/** - * media_assets.upload_etag against a live PostGIS database (Docker-gated; SKIPS without Docker). - * - * Finalize stores the HEAD etag in the same UPDATE that claims finalized_at, and the stuck sweep reads - * it back, so a requeued media.checks job keeps the overwrite check. Rows finalized before the column - * existed stay NULL and requeue with no etag, as before. - */ +// Finalize stores the HEAD etag in the same UPDATE that claims finalized_at, and the stuck sweep reads it +// back, so a requeued media.checks job keeps the overwrite check. Rows finalized before the column existed +// stay NULL and requeue with no etag. import { randomUUID } from "node:crypto" import { afterAll, beforeAll, describe, expect, it } from "vitest" diff --git a/services/api/test/integration/messages-edit-pg.test.ts b/services/api/test/integration/messages-edit-pg.test.ts index 8fc69896..3eeb6a18 100644 --- a/services/api/test/integration/messages-edit-pg.test.ts +++ b/services/api/test/integration/messages-edit-pg.test.ts @@ -1,19 +1,3 @@ -/** - * Unified chat-message EDIT integration test (Docker-gated; P0 Task 0.2). Boots against a live PostGIS - * container (via withPg) and exercises chat-edit-service — the roomKind-dispatching edit path behind the - * upcoming PATCH /messages route (Task 0.3) and the already-mounted DM edit route — against real Postgres: - * - * - edit own cleanup text message: body updated, edited_at stamped, hydrated DTO returned, and a - * message_update frame is handed to the broadcast seam with the room's key; - * - the gate ladder: non-sender 403 (code not_sender), >EDIT_WINDOW_HOURS-old 403 (code - * edit_window_expired), soft-deleted 409, non-text / sender-less SYSTEM rows 422, wrong roomId 404, - * room-send permission re-checked (membership revoked -> 403); - * - PATCH /dm/:threadId/messages/:messageId (the OLD route, now delegating to the service) still edits a - * DM end-to-end over HTTP. - * - * When Docker is unavailable the whole block SKIPS so the local suite stays green; CI runs it for real. - */ - import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -61,7 +45,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { await h.teardown() }) - /** Insert a user and return its id. */ async function newUser(name: string): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES (${name}) RETURNING id @@ -69,13 +52,8 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { return u!.id } - /** - * Insert a minimal report and return its id (fixture mirror of report-chat-members-pg). - * - * Defaults to 'submitted' — NOT a publicly-visible status. The service-level tests above wire no report - * lookup at all, so visibility is not part of what they exercise; the HTTP describe at the bottom - * ("report VISIBILITY gate") passes an explicit status because there the gate is live. - */ + // Defaults to 'submitted', which is not publicly visible: the service-level tests wire no report lookup, + // and the "report VISIBILITY gate" describe passes an explicit status because there the gate is live. async function newReport(status = "submitted"): Promise { const [r] = await h.sql<{ id: string }[]>` INSERT INTO reports (idempotency_key, geom, geom_source, category, type, status, h3_cell) @@ -85,7 +63,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { return r!.id } - /** Create a cleanup whose organizer is `organizerId` (organizer row == chat membership). */ + // The organizer row doubles as the chat membership. async function newCleanup(organizerId: string): Promise { const created = await makeCleanupService({ tickets: TEST_TICKET_SIGNER, @@ -105,7 +83,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { return created.id } - /** Build the service on the real Drizzle repos, capturing broadcast frames instead of fanning out. */ function makeService(frames: { roomKey: string; frame: WsServerMessage }[] = []) { const chat = makeDrizzleChatRepository(h.sql) const dm = makeDrizzleDmRepository(h.sql) @@ -154,12 +131,11 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { expect(updated.editedAt).toBeTruthy() expect(updated.mine).toBe(true) - // Persisted (not just projected): a fresh read shows the new body + edited_at. const reread = await chat.findMessage(cleanupId, msg.id, organizerId) expect(reread?.body).toBe("second draft") expect(reread?.editedAt).toBeTruthy() - // The message_update frame went to the cleanup's BARE room key with the refreshed DTO. + // Cleanup rooms use the bare cleanupId as their room key. expect(frames).toHaveLength(1) expect(frames[0]!.roomKey).toBe(cleanupId) expect(frames[0]!.frame).toMatchObject({ @@ -232,7 +208,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { { cleanupId, userId: organizerId, body: "old" }, randomUUID(), ) - // Backdate past the 48h window (60h). The DEFAULT partition catches any out-of-range month. + // The DEFAULT partition catches any out-of-range month. await h.sql`UPDATE chat_messages SET created_at = ${new Date(Date.now() - 60 * 3_600_000)} WHERE id = ${msg.id}` await expect( @@ -328,7 +304,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { }), ).rejects.toMatchObject({ httpStatus: 404 }) - // And an unknown message id is a plain 404 too. await expect( makeService().editMessage({ roomKind: "cleanup", @@ -348,7 +323,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { { cleanupId, userId: organizerId, body: "was member" }, randomUUID(), ) - // Revoke the membership out from under the sender (same check the WS send path uses). + // Same membership check the WS send path uses. await h.sql`DELETE FROM cleanup_members WHERE cleanup_id = ${cleanupId} AND user_id = ${organizerId}` await expect( @@ -363,8 +338,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { }) it("PATCH /dm/:threadId/messages/:messageId (old route) still edits a DM end-to-end", async () => { - // Real Drizzle dm/blocks repos injected via chatOverrides (the established no-redis harness), with - // sessions minted directly for pg-created user ids so the FK from dm_messages.sender_id resolves. + // Sessions are minted directly for pg-created user ids so the dm_messages.sender_id FK resolves. const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) @@ -405,12 +379,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { expect(dto.body).toBe("hi bob (edited)") expect(dto.editedAt).toBeTruthy() - // Persisted: a fresh read shows the edit. const reread = await dm.findMessage(thread.id, msg.id, aliceId) expect(reread?.body).toBe("hi bob (edited)") expect(reread?.editedAt).toBeTruthy() - // Bob (non-sender) cannot edit Alice's message via the route: 403. const bobToken = await authServices.sessions.createSession(bobId, []) const forbidden = await app.inject({ method: "PATCH", @@ -425,8 +397,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { }) describe("PATCH /messages route + delete broadcasts (HTTP, P0 Task 0.3)", () => { - // One app over real Drizzle repos (chatOverrides) + the container's FakeChatService, whose - // joinRoom/broadcastEvent let a MockConnection stand in for a second connected WS client. + // FakeChatService's joinRoom/broadcastEvent let a MockConnection stand in for a second WS client. let app: FastifyInstance let container: Container let authServices: AuthServices @@ -458,7 +429,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { await app.close() }) - /** Let the fire-and-forget broadcast microtasks flush. */ const flush = () => new Promise((resolve) => setImmediate(resolve)) it("PATCH /messages edits a report-room message: 200 DTO + message_update to a second WS client", async () => { @@ -474,7 +444,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { randomUUID(), ) - // A second connected client in the report room (FakeChatService joinRoom keys by room key). const watcher = new MockConnection("watcher") await container.chatService.joinRoom(roomKeyFor("report", reportId), watcher, watcherId) @@ -520,12 +489,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { expect(dto.body).toBe("hey bob (edited)") expect(dto.editedAt).toBeTruthy() - // Persisted, not just projected. const reread = await dm.findMessage(thread.id, msg.id, aliceId) expect(reread?.body).toBe("hey bob (edited)") expect(reread?.editedAt).toBeTruthy() - // Non-sender still 403s through the unified route. const bobToken = await authServices.sessions.createSession(bobId, []) const forbidden = await app.inject({ method: "PATCH", @@ -545,7 +512,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { randomUUID(), ) - // Cleanup rooms use the BARE cleanupId as their room key. + // Cleanup rooms use the bare cleanupId as their room key. const watcher = new MockConnection("del-watcher") await container.chatService.joinRoom(roomKeyFor("cleanup", cleanupId), watcher, organizerId) @@ -580,8 +547,8 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { ) const token = await authServices.sessions.createSession(organizerId, []) - // Same pattern as the anon-routes 30/min test: earlier tests in this app already consumed a few - // slots on this route+key, so assert only that a 429 arrives before 40 attempts (limit is 30/min). + // Earlier tests in this app already consumed a few slots on this route+key, so assert only that a + // 429 arrives before 40 attempts (limit is 30/min). let saw429 = false for (let i = 1; i <= 40 && !saw429; i++) { const res = await app.inject({ @@ -597,17 +564,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { }) }) - /** - * The report-VISIBILITY gate on the unified /messages surfaces, over an app that wires - * `discussionOverrides` — i.e. the PRODUCTION shape. - * - * The describe above (and chat-polls-pg) deliberately wires chatOverrides with NO report lookup, which - * makes messages.routes' `isReportVisible` short-circuit to `true`; that is why those tests can drive - * report rooms whose report sits at 'submitted'. Production always builds the lookup, so a - * report_chat_members row — which OUTLIVES the report being held, unlisted or removed — must stop - * granting edit / react / pin / poll rights. All four routes answer 404 (never 403): an invisible report - * must not be distinguishable from a missing one, matching report-chat.routes' requireVisibleReport. - */ + // Wires `discussionOverrides`, the production shape. The harness above has no report lookup, so + // `isReportVisible` short-circuits to true there. A report_chat_members row outlives the report being + // held, unlisted or removed, and must stop granting edit / react / pin / poll rights. All four answer + // 404, never 403, so an invisible report is indistinguishable from a missing one. describe("report VISIBILITY gate on the unified routes (discussionOverrides wired)", () => { let app: FastifyInstance let authServices: AuthServices @@ -635,8 +595,8 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { reportChat, groups, chatPolls: makeChatPollRepository(h.sql), - // The REAL powers resolver (the offline branch fails report/global roles closed, which would 403 - // the PUBLISHED control's pin before the visibility gate could be shown to be the difference). + // The offline branch fails report/global roles closed, which would 403 the published control's + // pin before the visibility gate could be shown to be the difference. chatPowers: makeChatPowersResolver({ isDmParticipant: (threadId, userId) => dm.isParticipant(threadId, userId), cleanupRoleOf: (cleanupId, userId) => cleanups.roleOf(cleanupId, userId), @@ -650,7 +610,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { container: buildContainer(env), authServices, chatOverrides: overrides, - // THE difference from the harness above: the real report lookup behind isReportVisible. discussionOverrides: { repo: makeDrizzleDiscussionRepository(h.sql) }, }) }) @@ -659,7 +618,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { await app.close() }) - /** A report at `status` whose chat has `ownerId` joined as OWNER, plus one message they authored. */ async function seedRoom( status: string, name: string, @@ -693,7 +651,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { }) } - /** The four unified surfaces, as (name, response) pairs for one seeded room. */ async function hitAll(room: { reportId: string messageId: string @@ -732,7 +689,6 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { expect(res.statusCode, `${label} on a held report`).toBe(404) } - // Nothing was written: the message is untouched and no poll row exists for the room. const reread = await makeDrizzleChatRepository(h.sql).findReportMessage( room.reportId, room.messageId, @@ -742,7 +698,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { expect(reread?.editedAt ?? null).toBeNull() expect(reread?.pinnedAt ?? null).toBeNull() expect(reread?.reactions).toEqual([]) - // chat_polls is keyed on the message id, so count the poll MESSAGES in this room instead. + // chat_polls is keyed on the message id, so count the poll messages in this room instead. const polls = await h.sql<{ count: number }[]>` SELECT COUNT(*)::int AS count FROM chat_messages WHERE report_id = ${room.reportId} AND kind = 'poll' @@ -758,7 +714,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { } }) - it("a PUBLISHED report allows all four — so the 404s above are the gate, not the harness", async () => { + it("a PUBLISHED report allows all four, so the 404s above are the gate, not the harness", async () => { const room = await seedRoom("published", "Published Report Owner") for (const [label, res] of await hitAll(room)) { @@ -799,8 +755,8 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const strangerId = await newUser("Vis Stranger") const tok = await authServices.sessions.createSession(strangerId, []) - // Visible report, no membership row: the gate ladder falls through to the membership/powers 403 — - // the visibility check must not be doing double duty as the authorization check. + // The visibility check must not double as the authorization check: with no membership row a + // visible report still falls through to the membership/powers 403. const edit = await call(tok, "PATCH", "/v1/messages", { roomKind: "report", roomId: reportId, diff --git a/services/api/test/integration/moderation-destination-refs-pg.test.ts b/services/api/test/integration/moderation-destination-refs-pg.test.ts index 76f0c5a9..25095683 100644 --- a/services/api/test/integration/moderation-destination-refs-pg.test.ts +++ b/services/api/test/integration/moderation-destination-refs-pg.test.ts @@ -1,17 +1,7 @@ -/** - * F160: the moderation queue's "where does this item live" column used to be a per-row correlated - * subquery — for a chat subject `SELECT … FROM chat_messages WHERE id = subject_id`, for a photo a - * media_assets → chat_messages id-only join. chat_messages is PARTITIONED on created_at, so an id-only - * lookup cannot prune and probes EVERY monthly partition; a page of up to 101 rows therefore cost - * ~101 × partitions index probes on top of the main scan. The page now resolves its chat/photo subjects - * in ONE batched `WHERE id = ANY(...)` per lane after the page is cut. - * - * The batched path is only reachable through the Drizzle repository against the real partitioned schema, - * which is what this file exercises (Docker-gated). The assertion that matters is EQUIVALENCE: the - * batched list must resolve exactly the destinations the single-row getItem resolves, for every subject - * shape (report / chat-in-report / chat-in-event / photo bound to a report / photo bound to an event - * chat / unbound photo), or the queue's "open" link silently goes somewhere else. - */ +// chat_messages is partitioned on created_at, so an id-only lookup probes every monthly partition; a +// per-row correlated subquery cost ~101 × partitions probes per page. The page now resolves subjects in +// one `WHERE id = ANY(...)` per lane. The batched list must resolve exactly what the single-row getItem +// resolves for every subject shape, or the queue's "open" link silently goes somewhere else. import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -200,11 +190,8 @@ describe.skipIf(!pg)("F160: batched destination refs match the per-item lookup", for (const [itemId, reportId] of expected) expect(seen.get(itemId)).toBe(reportId) }) - /** - * The SHAPE of the query is the finding. A statement-capturing connection (postgres-js `debug`) proves - * the page no longer carries the per-row correlated lookups into the partitioned chat_messages, and - * that each lane resolves through a single `= ANY(...)` batch instead. - */ + // The shape of the query is the finding, so a statement-capturing connection (postgres-js `debug`) + // asserts on the SQL itself. it("resolves the page with BATCHED lookups, not a per-row correlated subquery", async () => { const author = await newUser("Author") const reportId = await newReport() diff --git a/services/api/test/integration/posts-unrepost-pg.test.ts b/services/api/test/integration/posts-unrepost-pg.test.ts index ababf0f5..a253a65f 100644 --- a/services/api/test/integration/posts-unrepost-pg.test.ts +++ b/services/api/test/integration/posts-unrepost-pg.test.ts @@ -1,19 +1,7 @@ -/** - * F148 (HIGH, data-integrity): un-reposting must never destroy other people's content. - * - * 0051 created posts.reply_to_id / thread_root_id / repost_of_id and media_assets.post_id ON DELETE - * CASCADE, while unrepost() issued a REAL `DELETE FROM posts`. A repost row is a normal post — third - * parties can reply to or quote it — so one user's un-repost cascaded away other users' replies, quotes, - * their media_assets rows (stranding the R2 objects, because media_reap_tombstones is only written by the - * application delete path), their likes and saves, and left every denormalized counter drifting. - * - * Two independent fixes, both asserted here against the real schema (Docker-gated): - * 1. unrepost is a SOFT delete (deleted_at + a single repost_count decrement), like every other delete - * path in the product; - * 2. the self-FKs are ON DELETE RESTRICT and media_assets.post_id is ON DELETE SET NULL (0072/0073), so - * even a stray hard DELETE — a psql session, a future code path — cannot take someone else's post - * or strand an R2 object. - */ +// Un-reposting must never destroy other people's content. A repost is a normal post others can reply to +// or quote, and a hard delete once cascaded away their replies, quotes, media rows (stranding R2 objects) +// and likes. Two independent guards: unrepost is a soft delete, and the self-FKs are ON DELETE RESTRICT +// with media_assets.post_id ON DELETE SET NULL (0072/0073), so even a stray hard DELETE cannot do it. import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" diff --git a/services/api/test/integration/posts.test.ts b/services/api/test/integration/posts.test.ts index 92a98805..1a8880d7 100644 --- a/services/api/test/integration/posts.test.ts +++ b/services/api/test/integration/posts.test.ts @@ -1,13 +1,5 @@ -/** - * Posts integration test (Docker-gated). Exercises the REAL transaction path — the Drizzle/PostGIS - * PostRepository + PostService (+ NotificationService for the fan-out) against a live PostGIS container - * (via withPg). Driven at the service+repository layer (like reports-pg.test.ts / social-notifications), - * so it needs no Redis/HTTP: it proves the create→get→like→repost→reply→save→delete round-trip + count - * denormalization, home-feed follow fan-out, attach-event membership + delete authorization, and the - * interaction-notification fan-out (like notifies the author not self, blocked → none, @mention). - * - * When Docker is unavailable the whole block SKIPS (describe.skipIf) so the local suite stays green. - */ +// Driven at the service+repository layer against real PostGIS so the transaction paths and count +// denormalization run for real without needing Redis or HTTP. import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" import { afterAll, beforeAll, describe, expect, it } from "vitest" @@ -74,7 +66,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { } /** - * A finalized, unattached media_asset — what media-intake plus the media-checks worker leave behind + * A finalized, unattached media_asset: what media-intake plus the media-checks worker leave behind * once the upload validates. `ready` (not `validating`) because loadMedia only renders `ready` * assets into the PostDTO, and a `ready` row carries the worker's published served_key, which is * the key the DTO presigns (never the client-writable r2_key). @@ -92,7 +84,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { }) } - it("F087c: a CONCURRENT double repost cannot double-count — the revival UPDATE carries the tombstone predicate itself", async () => { + it("F087c: a CONCURRENT double repost cannot double-count; the revival UPDATE carries the tombstone predicate itself", async () => { const svc = makeService() const author = await newUser("Race Author") const actor = await newUser("Race Actor") @@ -108,7 +100,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // the SAME tombstone with real overlap: T1 takes the row lock and holds it; T2 blocks on that lock // and, under READ COMMITTED, re-evaluates its WHERE against the row T1 committed. Because // `deleted_at IS NOT NULL` lives IN the UPDATE (not in an unlocked subselect), T2 now matches ZERO - // rows and skips its `repost_count + 1` — the drift 0074 had to reconcile. + // rows and skips its `repost_count + 1`, the drift 0074 had to reconcile. const revive = (tx: typeof h.sql) => tx<{ id: string }[]>` UPDATE posts SET deleted_at = NULL, updated_at = now() WHERE author_id = ${actor} AND kind = 'repost' AND repost_of_id = ${post.id} @@ -146,7 +138,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(live[0]!.n).toBe(1) // The raw revive above bypassed the service, so the counter was never bumped for the row it made - // live — and the service stays consistent with that: a repost of an already-live repost is a no-op. + // live, and the service stays consistent with that: a repost of an already-live repost is a no-op. expect((await svc.repostPost(post.id, actor)).counts.reposts).toBe(0) }) @@ -203,30 +195,23 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(created.body).toBe("hello world") expect(created.counts).toEqual({ likes: 0, reposts: 0, replies: 0, saves: 0 }) - // get by another viewer const fetched = await svc.getPost(created.id, actor) expect(fetched.id).toBe(created.id) expect(fetched.viewer).toEqual({ liked: false, reposted: false, saved: false }) - // like const liked = await svc.likePost(created.id, actor) expect(liked.counts.likes).toBe(1) expect(liked.viewer.liked).toBe(true) - // idempotent (second like adds no count) expect((await svc.likePost(created.id, actor)).counts.likes).toBe(1) - // author's view reflects the count but not the viewer flag expect((await svc.getPost(created.id, author)).viewer.liked).toBe(false) expect((await svc.getPost(created.id, author)).counts.likes).toBe(1) - // repost (returns the target, patched) const reposted = await svc.repostPost(created.id, actor) expect(reposted.id).toBe(created.id) expect(reposted.counts.reposts).toBe(1) expect(reposted.viewer.reposted).toBe(true) - // idempotent expect((await svc.repostPost(created.id, actor)).counts.reposts).toBe(1) - // reply const reply = await svc.createPost( { kind: "reply", @@ -249,17 +234,14 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // ...and it survives the list projection too, not just the create response. expect(replies.items.find((p) => p.id === reply.id)?.replyTo?.id).toBe(created.id) - // A top-level post has no parent to preview. expect((await svc.getPost(created.id, author)).replyTo ?? null).toBeNull() - // save const saved = await svc.savePost(created.id, actor) expect(saved.counts.saves).toBe(1) expect(saved.viewer.saved).toBe(true) const saves = await svc.listSaves(actor, {}) expect(saves.items.map((p) => p.id)).toContain(created.id) - // delete (author-only) await expect(svc.deletePost(created.id, author)).resolves.toEqual({ ok: true }) await expect(svc.getPost(created.id, author)).rejects.toMatchObject({ httpStatus: 404 }) }) @@ -427,14 +409,9 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(inner.authorReplies).toEqual([]) }) - // --- post media: the 0054 CHECK bug + the claim predicate -------------------------------------- - // Every createPost in this file used to pass mediaUploadIds: [], so the claim path - // (`UPDATE media_assets SET post_id = $1, purpose = 'post'`) was NEVER exercised — and it could not - // succeed: 0051 introduced purpose='post' in the mirror, the shared enum and the claim SQL, but no - // migration widened the inline CHECK 0016 added (purpose IN ('report','verification')). Every post with - // a photo raised 23514, aborted the create transaction and 500'd. 0054_media_purpose_post.sql widened - // the CHECK; these are the tests that keep it widened. - // (The value-set half of the same class is guarded schema-wide in test/integration/schema.test.ts.) + // The post-media claim path once always failed: 0051 introduced purpose='post' but no migration widened + // the inline CHECK from 0016, so every post with a photo raised 23514 and 500'd. 0054 widened it; these + // tests keep it widened. The value-set half of the same class is guarded in schema.test.ts. it("creates a post WITH media: the asset is claimed (post_id + purpose='post') and renders on the DTO", async () => { const svc = makeService() @@ -460,7 +437,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(created.media[0]!.url).toBe(`m://${media.servedKey}`) expect(created.media[0]!.thumbUrl).toBe(`m://${media.thumbKey}`) - // The claim landed in the database: bound to THIS post, repurposed, and still unbound to any report. const [row] = await h.sql< { post_id: string | null; purpose: string; report_id: string | null }[] >` @@ -470,7 +446,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(row!.purpose).toBe("post") expect(row!.report_id).toBeNull() - // Another viewer sees the same gallery (media is not viewer-scoped). const fetched = await svc.getPost(created.id, reader) expect(fetched.media.map((m) => m.id)).toEqual([media.id]) }) @@ -506,10 +481,8 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(quote.repostOf?.id).toBe(target.id) expect(quote.repostOf?.media.map((m) => m.id)).toEqual([media.id]) expect(quote.repostOf?.media[0]!.url).toBe(`m://${media.servedKey}`) - // The quote itself still has none of its own. expect(quote.media).toEqual([]) - // It survives the LIST projection too, not just the create response. const listed = await svc.listUserPosts(quoter, quoter, {}) expect(listed.items.find((p) => p.id === quote.id)?.repostOf?.media.map((m) => m.id)).toEqual([ media.id, @@ -558,7 +531,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { SELECT post_id FROM media_assets WHERE id = ${media.id} ` expect(row!.post_id).toBe(first.id) - // The rejected create rolled back completely — no orphan post row for the thief. const posts = await h.sql<{ n: number }[]>` SELECT count(*)::int AS n FROM posts WHERE author_id = ${thief} ` @@ -583,7 +555,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { SELECT count(*)::int AS n FROM posts WHERE author_id = ${author} ` expect(posts[0]!.n).toBe(0) - // The rejected asset was not repurposed on the way out. const [row] = await h.sql<{ post_id: string | null; purpose: string }[]>` SELECT post_id, purpose FROM media_assets WHERE id = ${rejected.id} ` @@ -597,7 +568,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const media = await seedMedia("validating") // media_assets rows are born 'validating' at PRESIGN time. Claiming one before finalize bound an - // asset with no bytes and no media.checks job behind it — invisible to the orphan sweep (bound) and + // asset with no bytes and no media.checks job behind it, invisible to the orphan sweep (bound) and // to the stuck sweep (no finalized_at watermark), so nothing would ever have reclaimed it. await expect( svc.createPost( @@ -612,7 +583,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(unbound!.post_id).toBeNull() expect(unbound!.purpose).toBe("report") - // Once finalize stamps the watermark the same still-validating asset claims normally. await h.sql`UPDATE media_assets SET finalized_at = now() WHERE id = ${media.id}` const created = await svc.createPost( { kind: "post", body: "now ok", mediaUploadIds: [media.uploadId], mentionedUserIds: [] }, @@ -688,7 +658,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { author, ) await expect(svc.deletePost(post.id, other)).rejects.toMatchObject({ httpStatus: 403 }) - // still there expect((await svc.getPost(post.id, author)).id).toBe(post.id) }) @@ -719,13 +688,10 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(ids).not.toContain(hidden.id) }) - // --- REGRESSION: replies must never reach the timeline ------------------------------------------- - // `homeFeed` shipped WITHOUT the `AND p.reply_to_id IS NULL` term that `publicFeed` has always carried, - // so a signed-OUT reader got a clean timeline and a signed-IN reader got a reply dump. This is the only - // suite that executes the changed query (the route tests run against an in-memory fake), and it has to - // cover BOTH arms of the home-feed OR: the viewer's own reply arrives via `p.author_id = viewerId`, a - // followed author's via the follows_people subquery, and a test that seeds only one leaves the other - // unproven — which is exactly how the bug survived the fan-out test above. + // `homeFeed` once lacked the `AND p.reply_to_id IS NULL` term `publicFeed` carries, so signed-in readers + // got replies in the timeline. This is the only suite that runs the real query, and it must cover BOTH + // arms of the home-feed OR (own reply via `p.author_id = viewerId`, a followed author's via the + // follows_people subquery): seeding only one is how the bug survived the fan-out test above. it("home feed is TOP-LEVEL only: a reply never reaches the timeline, only the thread", async () => { const svc = makeService() const viewer = await newUser("Reply Viewer") @@ -736,7 +702,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { { kind: "post", body: "the original thought", mediaUploadIds: [], mentionedUserIds: [] }, author, ) - // Arm 1: an author the viewer FOLLOWS replies. const theirReply = await svc.createPost( { kind: "reply", @@ -747,7 +712,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { }, author, ) - // Arm 2: the VIEWER themself replies (the `p.author_id = viewerId` arm). const myReply = await svc.createPost( { kind: "reply", @@ -766,24 +730,22 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(homeIds).not.toContain(theirReply.id) expect(homeIds).not.toContain(myReply.id) - // The signed-out feed agrees — the whole point of reusing publicFeed's exact predicate is that the two - // timelines can no longer disagree about what a timeline row IS. + // Both feeds share publicFeed's exact predicate so the two timelines cannot disagree about what a + // timeline row is. const publicIds = (await svc.publicFeed({ filter: "all" })).items.map((p) => p.id) expect(publicIds).toContain(parent.id) expect(publicIds).not.toContain(theirReply.id) expect(publicIds).not.toContain(myReply.id) - // Nothing was hidden, only relocated: the thread still holds both replies... const threadIds = (await svc.listReplies(parent.id, viewer, {})).items.map((p) => p.id) expect(threadIds).toContain(theirReply.id) expect(threadIds).toContain(myReply.id) - // ...and a reply is still readable by permalink (notification deep links land here). + // Notification deep links land on the reply permalink, so it must stay readable. expect((await svc.getPost(theirReply.id, viewer)).id).toBe(theirReply.id) - // THE DELIBERATE CARVE-OUT: a REPOST of a reply stays in the timeline. The repost's own row is - // `kind='repost', repost_of_id=, reply_to_id=NULL`, so `reply_to_id IS NULL` keeps it — - // amplifying is a deliberate act by someone the viewer follows, exactly as on Twitter. Pinned here so - // a future "tighten the predicate" pass has to change this assertion on purpose. + // Deliberate carve-out: a repost of a reply stays in the timeline (its own row has reply_to_id NULL), + // because amplifying is a deliberate act by someone the viewer follows. Pinned so tightening the + // predicate has to change this assertion on purpose. await svc.repostPost(theirReply.id, viewer) const [repostRow] = await h.sql<{ id: string }[]>` SELECT id FROM posts @@ -857,7 +819,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { host, ) - // host is auto-joined as organizer → can attach const withEvent = await svc.createPost( { kind: "post", @@ -871,7 +832,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(withEvent.event?.id).toBe(event.id) expect(withEvent.event?.title).toBe("Park Sweep") - // an outsider cannot attach an event they neither host nor attend await expect( svc.createPost( { kind: "post", body: "nope", eventId: event.id, mediaUploadIds: [], mentionedUserIds: [] }, @@ -880,12 +840,9 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { ).rejects.toMatchObject({ httpStatus: 403 }) }) - // --- "announce to the feed": the event auto-link ------------------------------------------------ - // The create-an-event flow files the cleanup and then posts `{ eventId }` on the SAME wire surface a - // manual composer attachment uses. Two things have to hold for that to work with no server change: - // the organizer's `cleanup_members` row must exist in the SAME commit as the cleanup (otherwise the - // immediately-following createPost 403s on isEventMember), and the resulting post must reach the - // `events` filter, whose predicate is `p.event_id IS NOT NULL`. + // The create-an-event flow posts `{ eventId }` right after filing the cleanup, so the organizer's + // `cleanup_members` row must commit with the cleanup (else createPost 403s on isEventMember), and the + // post must reach the `events` filter (`p.event_id IS NOT NULL`). it("ANNOUNCE: the organizer can attach the event they just created, and the post lands in the `events` filter", async () => { const svc = makeService() @@ -910,8 +867,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { host, ) - // The organizer membership row is written inside createCleanupTx — this is what makes the very next - // createPost legal with no wait and no retry. + // createCleanupTx writes the organizer row in the same commit, so the next createPost needs no retry. const [member] = await h.sql<{ role: string }[]>` SELECT role FROM cleanup_members WHERE cleanup_id = ${event.id} AND user_id = ${host} ` @@ -940,18 +896,16 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(eventIds).toContain(announcement.id) expect(eventIds).not.toContain(plain.id) - // Signed-out readers see it too — that is the reach the announcement buys. const publicEvents = await svc.publicFeed({ filter: "events" }) expect(publicEvents.items.map((p) => p.id)).toContain(announcement.id) - // ...and it is NOT a "fix": the events post has no report. expect((await svc.homeFeed(viewer, { filter: "fixes" })).items.map((p) => p.id)).not.toContain( announcement.id, ) }) - // --- H8: post report-attachment bypassed the report visibility gate ------------------------------ - // Two independent holes, both now closed by the shared publicReportFilter() fragment: + // Post report-attachment once bypassed the report visibility gate through two holes, both closed by the + // shared publicReportFilter() fragment: // (a) isReportAttachable checked `visibility` but NOT `status`, so an anonymous submitter could // attach their own HELD (pre-moderation) report and publish its title, exact lat/lng, address // and photo into the SIGNED-OUT public feed before any moderator saw it; @@ -1030,17 +984,16 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(post.report?.id).toBe(report) expect(post.report?.title).toBe("Public report") - // The owner unlists it. This USED to change nothing about the rendered post: loadReports re-read the - // row every render and re-published the title, exact coordinates, address and photo regardless. + // Unlisting once changed nothing: loadReports re-read the row every render and republished the + // title, exact coordinates, address and photo regardless. await h.sql`UPDATE reports SET visibility = 'hidden' WHERE id = ${report}` const afterUnlist = await svc.getPost(post.id, reader) expect(afterUnlist.report).toBeNull() - // The post itself survives — hydrate degrades it to a body-only post rather than dropping the row. + // Hydrate degrades the post to body-only rather than dropping the row. expect(afterUnlist.id).toBe(post.id) expect(afterUnlist.body).toBe("my report") - // Same for a moderator pulling it back to `held`, and for a soft delete. await h.sql`UPDATE reports SET visibility = 'public', status = 'held' WHERE id = ${report}` expect((await svc.getPost(post.id, reader)).report).toBeNull() await h.sql`UPDATE reports SET status = 'published' WHERE id = ${report}` @@ -1049,7 +1002,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect((await svc.getPost(post.id, reader)).report).toBeNull() }) - // --- "share to the feed": the report auto-link, and the THUMB RACE ------------------------------ // The report flow files the report and then posts `{ reportId }` 200-400 ms later. At that moment the // report's photo has been finalized but the media-checks worker has NOT flipped it to `ready`, and // firstReadyStillLateral hard-requires `status = 'ready'`. So the authoritative PostDTO comes back with @@ -1057,7 +1009,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // optimistic thumbnail and then trusts the server row will make the photo appear and then vanish; this // test is the pin for that timing, so a client-side local-thumb overlay cannot be "optimized away". - /** A report-bound media asset in the state finalizeMedia leaves behind: `validating`, not `ready`. */ async function attachReportMedia( reportId: string, status: SeedMediaStatus, @@ -1082,7 +1033,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const reader = await newUser("Share Reader", "shrread") const reportId = await insertReport(author, "published", "public", "Couch on the sidewalk") await h.sql`UPDATE reports SET addr = '123 Main St' WHERE id = ${reportId}` - // Exactly what media-intake leaves behind when the wizard submits: finalized, checks still queued. const asset = await attachReportMedia(reportId, "validating") // The caption is optional on the wire: an attachment-only post is legal (PostComposeInputSchema's @@ -1098,22 +1048,17 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(post.report?.status).toBe("published") expect(post.report?.lat).toBeCloseTo(34.1, 5) expect(post.report?.lng).toBeCloseTo(-118.35, 5) - // THE RACE: the photo exists, is bound to the report, and is still invisible to the feed card. expect(post.report?.thumbUrl ?? null).toBeNull() - // A refetch while the asset is still validating does NOT rescue it — so an "invalidate and refetch" - // repair on the client is not a fix either. + // A refetch while still validating does not rescue it, so client-side invalidate-and-refetch is no fix. expect((await svc.getPost(post.id, reader)).report?.thumbUrl ?? null).toBeNull() - // Once the media-checks worker flips the asset, the very next read presigns the real thumb, with no - // write to the post and no cache bust on the server side. await publishMediaAsReady(h.sql, asset.id) const afterWorker = await svc.getPost(post.id, reader) expect(afterWorker.report?.thumbUrl).toBeTruthy() expect(afterWorker.report?.thumbUrl).toContain(".thumb") - // A brand-new report is `published`, so the post is an "all" post, never a "fix" — it migrates into - // the fixes filter by itself the day the city resolves the report. + // A new report is `published`, so the post is not a "fix" until the city resolves the report. await h.sql`INSERT INTO follows_people (follower_id, followee_id) VALUES (${reader}, ${author})` expect((await svc.homeFeed(reader, { filter: "all" })).items.map((p) => p.id)).toContain( post.id, @@ -1150,7 +1095,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { ` expect(rows[0]?.n).toBe(0) - // The same caption without the slur posts fine — the filter is slurs only, not profanity. + // The filter is slurs only, not profanity. const ok = await svc.createPost( { kind: "post", @@ -1164,14 +1109,9 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(ok.report?.id).toBe(reportId) }) - // --- the `fixes` filter x report-card regression guard (H8-b) ----------------------------------- - // H8's first fix routed BOTH post report paths through publicReportFilter(), whose status set was - // `status = 'published'` EXACTLY. That made the product's headline surface structurally unrenderable: - // the feeds' `fixes` filter selects posts whose report is `resolved`, so every post the filter could - // return was simultaneously stripped of the report card it exists to show — and `isReportAttachable` - // 404'd any attempt to create one in the first place. PUBLIC_REPORT_STATUSES now widens the predicate - // to published/acknowledged/in_progress/resolved (a report stays public while the city works it), and - // these tests are the guard: they FAIL if that set is ever narrowed back to 'published'. + // publicReportFilter() once matched `status = 'published'` only, so every `fixes` post (report + // `resolved`) lost its report card and could not be created at all. PUBLIC_REPORT_STATUSES keeps a + // report public while the city works it; these tests fail if that set narrows back to 'published'. it("FIXES FILTER: a RESOLVED report is attachable and its post renders the report card", async () => { const svc = makeService() @@ -1179,7 +1119,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const reader = await newUser("Fix Reader", "fixread") const resolved = await insertReport(author, "resolved", "public", "Pothole fixed") - // isReportAttachable must accept `resolved` — this is the create that used to 404. + // isReportAttachable must accept `resolved`; this create used to 404. const post = await svc.createPost( { kind: "post", @@ -1194,7 +1134,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(post.report?.title).toBe("Pothole fixed") expect(post.report?.status).toBe("resolved") - // ...and loadReports must still render the card on a later read, for a different viewer. const rendered = await svc.getPost(post.id, reader) expect(rendered.report?.id).toBe(resolved) expect(rendered.report?.status).toBe("resolved") @@ -1204,7 +1143,6 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const svc = makeService() const author = await newUser("Lifecycle Author", "lifeauth") - // The whole public set: a report stays visible while the city works it. for (const status of ["published", "acknowledged", "in_progress", "resolved"]) { const id = await insertReport(author, status, "public", `Report ${status}`) const post = await svc.createPost( @@ -1221,7 +1159,7 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect((await svc.getPost(post.id, author)).report?.status).toBe(status) } - // The pre-publication + moderator-rejected states stay unattachable (the H8 half must not regress). + // Pre-publication and moderator-rejected states must stay unattachable. for (const status of ["submitted", "held", "rejected"]) { const id = await insertReport(author, status, "public", `Report ${status}`) await expect( @@ -1278,24 +1216,22 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(homeIds).toContain(fixPost.id) expect(homeIds).not.toContain(wipPost.id) expect(homeIds).not.toContain(plainPost.id) - // THE regression the widened predicate exists for: the returned item carries its report card, so the - // filter and the renderer agree. Under `status = 'published'` this was null on every fixes-filter row. + // Under `status = 'published'` this was null on every fixes-filter row. expect(home.items.find((p) => p.id === fixPost.id)?.report?.id).toBe(resolved) - // Same for the signed-out public feed (no viewer, no follow scope). const publicFeed = await svc.publicFeed({ filter: "fixes" }) const publicIds = publicFeed.items.map((p) => p.id) expect(publicIds).toContain(fixPost.id) expect(publicIds).not.toContain(wipPost.id) expect(publicFeed.items.find((p) => p.id === fixPost.id)?.report?.title).toBe("Fixed thing") - // Unfiltered, all three are in the home feed — so the exclusions above are the filter, not visibility. + // Unfiltered, all three are present, so the exclusions above come from the filter, not visibility. const all = await svc.homeFeed(viewer, { filter: "all" }) const allIds = all.items.map((p) => p.id) expect(allIds).toContain(fixPost.id) expect(allIds).toContain(wipPost.id) expect(allIds).toContain(plainPost.id) - // ...and the in_progress report ALSO renders its card (mid-lifecycle is public, just not a "fix"). + // Mid-lifecycle is public, just not a "fix". expect(all.items.find((p) => p.id === wipPost.id)?.report?.id).toBe(working) }) @@ -1334,26 +1270,21 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { author, ) - // actor likes → author gets ONE post_like notification await svc.likePost(post.id, actor) const authorNotifs = await notifRepo.listNotifications(author, null, 50) const likeNotifs = authorNotifs.records.filter((n) => n.type === "post_like") expect(likeNotifs).toHaveLength(1) - // self-like → no new notification for the actor await svc.likePost(post.id, author) const actorNotifs = await notifRepo.listNotifications(author, null, 50) - // author still has exactly one post_like (their own self-like produced none) expect(actorNotifs.records.filter((n) => n.type === "post_like")).toHaveLength(1) - // blocked either way → the post is hidden and cannot be interacted with const blocker = await newUser("Blocker") await h.sql`INSERT INTO user_blocks (blocker_id, blocked_id) VALUES (${author}, ${blocker})` await expect(svc.likePost(post.id, blocker)).rejects.toMatchObject({ code: "NOT_FOUND" }) const afterBlock = await notifRepo.listNotifications(author, null, 50) expect(afterBlock.records.filter((n) => n.type === "post_like")).toHaveLength(1) - // @mention records a row + notifies the mentioned user const mentionPost = await svc.createPost( { kind: "post", body: "hey @notment", mediaUploadIds: [], mentionedUserIds: [mentioned] }, author, diff --git a/services/api/test/integration/report-chat-members-pg.test.ts b/services/api/test/integration/report-chat-members-pg.test.ts index 06019921..32b91158 100644 --- a/services/api/test/integration/report-chat-members-pg.test.ts +++ b/services/api/test/integration/report-chat-members-pg.test.ts @@ -1,18 +1,3 @@ -/** - * Task D-C1: report-chat membership repository (Docker-gated). Boots against a live PostGIS container - * (via withPg) and exercises the DB-backed paths the offline suite covers only by the pure mapSystemRow - * unit test (test/unit/report-system-message.test.ts): - * - * - join is an idempotent upsert (re-join keeps the existing role, never demotes an owner); - * - leave removes the membership row; isMember is an EXISTS check; - * - advanceReadWatermark moves last_read_at forward monotonically and no-ops for a non-member / bad id; - * - insertSystemMessage writes a sender-less kind:"system" chat_messages row and round-trips it through - * the report history mapper (from:null, kind:"system", structured system payload); - * - listMemberIds / countMembers reflect the membership set. - * - * When Docker is unavailable the whole block SKIPS so the local suite stays green; CI runs it for real. - */ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -35,7 +20,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( await h.teardown() }) - /** Insert a user and return its id. */ async function newUser(name: string): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES (${name}) RETURNING id @@ -43,7 +27,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( return u!.id } - /** Insert a minimal report and return its id. */ async function newReport(): Promise { const [r] = await h.sql<{ id: string }[]>` INSERT INTO reports (idempotency_key, geom, geom_source, category, type, status, h3_cell) @@ -63,7 +46,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( await repo.join(reportId, userId, "owner") expect(await repo.isMember(reportId, userId)).toBe(true) - // Re-join as a plain member must NOT demote the owner. await repo.join(reportId, userId, "member") const [row] = await h.sql<{ role: string }[]>` SELECT role FROM report_chat_members WHERE report_id = ${reportId} AND user_id = ${userId} @@ -88,7 +70,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( const userId = await newUser("Reader") await repo.join(reportId, userId) - // Two report messages with distinct created_at (older, then newer). const older = await repo.insertSystemMessage({ reportId, status: "submitted", body: "older" }) const newer = await repo.insertSystemMessage({ reportId, @@ -103,7 +84,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( expect(afterNewer!.last_read_at).not.toBeNull() const highWater = afterNewer!.last_read_at!.getTime() - // Advancing to the OLDER message must not move the watermark backward. await repo.advanceReadWatermark(reportId, userId, older.id) const [afterOlder] = await h.sql<{ last_read_at: Date | null }[]>` SELECT last_read_at FROM report_chat_members WHERE report_id = ${reportId} AND user_id = ${userId} @@ -116,7 +96,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( const reportId = await newReport() const stranger = await newUser("Stranger") const msg = await repo.insertSystemMessage({ reportId, status: "submitted", body: "hello" }) - // Non-member: nothing to update, must not throw. await expect(repo.advanceReadWatermark(reportId, stranger, msg.id)).resolves.toBeUndefined() const rows = await h.sql` SELECT 1 FROM report_chat_members WHERE report_id = ${reportId} AND user_id = ${stranger} @@ -142,7 +121,7 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( expect(dto.system?.status).toBe("acknowledged") expect(dto.body).toBe("Report was acknowledged.") - // The raw row is sender-less with a NULL cleanup_id (report/cleanup XOR holds). + // The report/cleanup XOR must hold. const [raw] = await h.sql< { sender_id: string | null; cleanup_id: string | null; kind: string }[] >` @@ -152,7 +131,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( expect(raw!.cleanup_id).toBeNull() expect(raw!.kind).toBe("system") - // Report history (the getChatRepo() path used by the reportMessages route) includes the system row. const page = await chat.reportHistory(reportId, undefined, 30, null) const found = page.items.find((m) => m.id === dto.id) expect(found, "system message should appear in report history").toBeDefined() @@ -160,7 +138,6 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( expect(found!.kind).toBe("system") expect(found!.system?.status).toBe("acknowledged") - // findReportMessage also maps the system row without assuming an author. const single = await chat.findReportMessage(reportId, dto.id, null) expect(single?.from).toBeNull() expect(single?.kind).toBe("system") @@ -181,13 +158,9 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( expect(ids).toHaveLength(2) }) - /** - * F154: the roster read drives the per-message bell fan-out and the mention resolver, and report chat - * is join-on-view — so an unbounded `SELECT user_id FROM report_chat_members` on a viral report both - * loads the whole roster into memory and hands the notifier an unbounded recipient list. The query - * carries a real LIMIT (default REPORT_CHAT_MEMBER_SCAN_CAP, mirroring the group lane), and the - * caller-supplied limit narrows it further. - */ + // Report chat is join-on-view and the roster drives the bell fan-out and mention resolver, so an + // unbounded roster read on a viral report would load it all into memory and hand the notifier an + // unbounded recipient list. it("F154: listMemberIds honors the caller's limit and is deterministic at the boundary", async () => { const repo = makeReportChatRepository(h.sql) const reportId = await newReport() diff --git a/services/api/test/integration/report-forward-audit-pg.test.ts b/services/api/test/integration/report-forward-audit-pg.test.ts index cc6c5e43..46d76ed0 100644 --- a/services/api/test/integration/report-forward-audit-pg.test.ts +++ b/services/api/test/integration/report-forward-audit-pg.test.ts @@ -1,15 +1,5 @@ -/** - * Task D-C4: @city forward audit table (Docker-gated). Boots a live PostGIS container (via withPg) and - * exercises the DB-backed report_message_forwards INSERT/UPDATE the offline suite covers only through the - * spy-audit orchestration test (test/unit/report-forward-audit.test.ts): - * - * - recordMention inserts a (message_id, geoid) row with forwarded_at NULL; - * - recordMention is idempotent (ON CONFLICT DO NOTHING) and never clobbers an already-stamped row; - * - markForwarded stamps forwarded_at = now(), and keeps the earliest time on an idempotent re-run. - * - * The table has no FK on message_id (chat_messages is range-partitioned), so a bare uuid suffices as the - * message id here. When Docker is unavailable the whole block SKIPS so the local suite stays green. - */ +// The table has no FK on message_id (chat_messages is range-partitioned), so a bare uuid suffices as the +// message id here. import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -53,11 +43,10 @@ describe.skipIf(!pg)("report_message_forwards audit writes (integration)", () => const stamped = await forwardedAt(msg, GEO) expect(stamped).not.toBeNull() - // A retry re-runs recordMention (ON CONFLICT DO NOTHING); the stamped time must survive unchanged. + // A retry re-runs recordMention; the stamped time must survive unchanged. await audit.recordMention(msg, GEO) expect((await forwardedAt(msg, GEO))!.getTime()).toBe(stamped!.getTime()) - // Exactly one row for the pair (composite PK). const rows = await h.sql` SELECT 1 FROM report_message_forwards WHERE message_id = ${msg} AND geoid = ${GEO} ` @@ -73,17 +62,11 @@ describe.skipIf(!pg)("report_message_forwards audit writes (integration)", () => const first = await forwardedAt(msg, GEO) expect(first).not.toBeNull() - // A second markForwarded (retry) must not advance the timestamp (COALESCE keeps the first). await audit.markForwarded(msg, GEO) expect((await forwardedAt(msg, GEO))!.getTime()).toBe(first!.getTime()) }) }) -/** - * D-C4 follow-up: the report message MAPPER surfaces forwardedToCity (+ cityMention) by LEFT-joining - * report_message_forwards and resolving the report's jurisdiction. This block drives the real SQL through - * makeDrizzleChatRepository.reportHistory / findReportMessage against a live DB. - */ describe.skipIf(!pg)( "report message mapper surfaces forwardedToCity + cityMention (integration)", () => { @@ -93,8 +76,8 @@ describe.skipIf(!pg)( h = pg as PgHarness }) - // Each case re-seeds a jurisdiction with the SAME @handle, so clear jurisdictions (and the reports / - // messages that FK to them) between tests to avoid colliding on jurisdictions_handle_lower_key. + // Each case re-seeds a jurisdiction with the same @handle, so clear them (and the rows that FK to + // them) between tests to avoid colliding on jurisdictions_handle_lower_key. beforeEach(async () => { await h.sql`TRUNCATE jurisdictions RESTART IDENTITY CASCADE` }) @@ -134,21 +117,18 @@ describe.skipIf(!pg)( const reportId = await newReport(geoid) const userId = await newUser() - // A report user message that @mentions the jurisdiction handle. const msgId = randomUUID() const dto = await chat.insertMessage( { cleanupId: reportId, roomKind: "report", userId, body: `pls fix @${HANDLE}` }, msgId, ) - // At insert time the async forward has not run: pill false, but the @city tint (cityMention) is present. + // The async forward has not run yet, but the @city tint is already present. expect(dto.forwardedToCity).toBe(false) expect(dto.cityMention).toMatchObject({ handle: HANDLE, geoid, forwarded: false }) - // The forward audit lands (recordMention + markForwarded), as the async onReportMessage path would do. await audit.recordMention(msgId, geoid) await audit.markForwarded(msgId, geoid) - // A fresh history read now reflects the stamped forward. const page = await chat.reportHistory(reportId, undefined, 30, userId) const found = page.items.find((m) => m.id === msgId) expect(found, "message should appear in report history").toBeDefined() @@ -160,7 +140,6 @@ describe.skipIf(!pg)( forwarded: true, }) - // findReportMessage maps the same surfacing. const single = await chat.findReportMessage(reportId, msgId, userId) expect(single!.forwardedToCity).toBe(true) expect(single!.cityMention?.forwarded).toBe(true) @@ -178,12 +157,10 @@ describe.skipIf(!pg)( { cleanupId: reportId, roomKind: "report", userId, body: `@${HANDLE} help` }, msgId, ) - // Only the NULL-forwarded row exists (no city contact case). await audit.recordMention(msgId, geoid) const single = await chat.findReportMessage(reportId, msgId, userId) expect(single!.forwardedToCity).toBe(false) - // Still tinted (@mention present), just not forwarded. expect(single!.cityMention).toMatchObject({ handle: HANDLE, forwarded: false }) }) @@ -205,9 +182,8 @@ describe.skipIf(!pg)( }, ) -// Tear down the shared, memoized withPg() harness only AFTER BOTH describe blocks above have run. -// A per-block afterAll would stop the container (and close h.sql) before the second block executes, -// so its tests would fail with CONNECTION_ENDED (see schema.test.ts for the same rule). +// A per-block afterAll would stop the shared withPg() container before the second block runs, failing it +// with CONNECTION_ENDED. afterAll(async () => { if (pg) await pg.teardown() }) diff --git a/services/api/test/integration/reports-pg.test.ts b/services/api/test/integration/reports-pg.test.ts index 7a7cacbd..61134ffa 100644 --- a/services/api/test/integration/reports-pg.test.ts +++ b/services/api/test/integration/reports-pg.test.ts @@ -1,20 +1,5 @@ -/** - * Reports integration test (Docker-gated). Exercises the REAL transaction path: the Drizzle/PostGIS - * ReportRepository + the report service against a live PostGIS container (via withPg, which seeds the - * canonical jurisdictions). It is driven at the service+repository layer (not over HTTP) so it needs no - * Redis/auth - it proves the spatial write/read, jurisdiction resolution, media attach, timeline, and - * the section-17 idempotency probe directly. - * - * Proven here (the Phase-1 done-criterion + the section-17 probe): - * - a created pin has the correct geom (ST_X/ST_Y round-trip), geom_source = device, the jurisdiction - * resolved from the seeded set, the media_asset attached (report_id set), and a timeline row; - * - submitting the SAME idempotency key again returns the SAME report id, inserts NO second report row, - * and does NOT orphan or duplicate the media (the asset stays attached to the original report). - * - a bbox query returns the inserted point. - * - * When Docker is unavailable the whole describe block SKIPS (describe.skipIf) so the local suite stays - * green; CI runs it for real. Reuses withPg() per the harness contract. - */ +// Driven at the service+repository layer against real PostGIS (withPg seeds the canonical +// jurisdictions), so it needs no Redis or auth. import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -33,7 +18,6 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { beforeAll(async () => { h = pg as PgHarness - // The real jurisdiction resolver runs the canonical spatial query against the seeded jurisdictions. service = makeReportService({ repo: makeDrizzleReportRepository(h.sql), resolveJurisdictionGeoid: async (lat, lng) => { @@ -45,7 +29,6 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { ` return rows[0]?.geoid ?? null }, - // Presign is irrelevant to the DB contract here; echo the key. presignMedia: (r2Key, thumbKey) => Promise.resolve( thumbKey === null @@ -54,7 +37,6 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { ), }) - // A real reporter user (reports.reporter_user_id FK -> users.id). const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES ('Reporter') RETURNING id ` @@ -65,7 +47,6 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { await h.teardown() }) - /** Seed a finalized-but-unattached media_asset (as media-intake would have left it). */ async function seedMedia(): Promise<{ id: string; uploadId: string }> { const uploadId = randomUUID() const [row] = await h.sql<{ id: string }[]>` @@ -97,7 +78,6 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { { userId }, ) - // The DTO is the inside-city point, published immediately, resolved to LA city. expect(dto.status).toBe("published") expect(dto.geomSource).toBe("device") expect(dto.jurisdictionGeoid).toBe(LA_CITY.geoid) @@ -106,7 +86,6 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { expect(dto.timeline).toHaveLength(1) expect(dto.timeline[0]!.status).toBe("published") - // Geom round-trip: ST_X/ST_Y match the submitted lng/lat. const [geo] = await h.sql<{ lng: number; lat: number; geom_source: string; h3_cell: string }[]>` SELECT ST_X(geom) AS lng, ST_Y(geom) AS lat, geom_source, h3_cell FROM reports WHERE id = ${dto.id} @@ -116,20 +95,17 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { expect(geo!.geom_source).toBe("device") expect(geo!.h3_cell.length).toBeGreaterThan(0) - // The media_asset is now attached to this report. const [m] = await h.sql<{ report_id: string | null }[]>` SELECT report_id FROM media_assets WHERE id = ${media.id} ` expect(m!.report_id).toBe(dto.id) - // The timeline row exists in the DB. const tl = await h.sql<{ status: string }[]>` SELECT status FROM report_timeline WHERE report_id = ${dto.id} ` expect(tl).toHaveLength(1) expect(tl[0]!.status).toBe("published") - // The idempotency snapshot was persisted in the same transaction. const idem = await h.sql<{ key: string }[]>` SELECT key FROM idempotency_keys WHERE key = ${key} AND scope = 'report_create' ` @@ -143,13 +119,12 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { const first = await service.createReport(req, { userId }) - // Count reports + media-attachments BEFORE the retry. const before = await h.sql<{ n: number }[]>` SELECT count(*)::int AS n FROM reports WHERE idempotency_key = ${key} ` expect(before[0]!.n).toBe(1) - // Submit the SAME key again (with a different-looking body to prove the original wins). + // A different body under the same key proves the original wins. const second = await service.createReport( createReq({ idempotencyKey: key, @@ -160,18 +135,15 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { { userId }, ) - // Same id returned, original content (category trash, not hazard). expect(second.id).toBe(first.id) expect(second.category).toBe(first.category) expect(second.category).toBe("trash") - // Still exactly ONE report row for that key (no duplicate). const after = await h.sql<{ n: number }[]>` SELECT count(*)::int AS n FROM reports WHERE idempotency_key = ${key} ` expect(after[0]!.n).toBe(1) - // The media asset is attached to exactly ONE report (the original) - not orphaned, not duplicated. const mediaRows = await h.sql<{ id: string; report_id: string | null }[]>` SELECT id, report_id FROM media_assets WHERE id = ${media.id} ` @@ -182,7 +154,7 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { it("F087e: REJECTS (422) a create whose media is VALIDATING but never finalized, leaving the row unbound", async () => { // A row is created 'validating' at PRESIGN time, before any bytes exist. Binding one of those made a // permanently stranded asset: bound (so findOrphans skips it), unfinalized (so the stuck sweep skips - // it), with no media.checks job behind it — and for an anon report it wedged the hold-release gate + // it), with no media.checks job behind it, and for an anon report it wedged the hold-release gate // forever, because the report waits on media that will never resolve. const uploadId = randomUUID() const [row] = await h.sql<{ id: string }[]>` @@ -209,8 +181,8 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { ` expect(after!.report_id).toBeNull() - // ...and the SAME upload binds fine the moment finalize stamps the watermark, so this is a timing - // gate on the intake handshake, not a new restriction on what may be attached. + // Once finalize stamps the watermark the same upload binds, so this is a timing gate on the intake + // handshake, not a restriction on what may be attached. await h.sql`UPDATE media_assets SET finalized_at = now() WHERE id = ${row!.id}` const ok = await service.createReport( createReq({ idempotencyKey: randomUUID(), mediaUploadIds: [uploadId] }), @@ -220,16 +192,14 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { }) it("REJECTS (422) a create whose media is already attached to a different report, and leaves it on A", async () => { - // Create report A owning the media. const media = await seedMedia() const a = await service.createReport( createReq({ idempotencyKey: randomUUID(), mediaUploadIds: [media.uploadId] }), { userId }, ) - // Report B tries to claim the same upload id. The claim UPDATE matches 0 rows, and (M-media-claim) - // an unclaimable id now FAILS THE WHOLE CREATE instead of being silently dropped: the reporter used - // to get a 201 with their photo missing from the gallery and no way to tell that had happened. + // An unclaimable id fails the whole create instead of being silently dropped: the reporter used to + // get a 201 with their photo missing from the gallery and no way to tell. const keyB = randomUUID() await expect( service.createReport(createReq({ idempotencyKey: keyB, mediaUploadIds: [media.uploadId] }), { @@ -241,15 +211,13 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { fields: { mediaUploadIds: "One or more media uploads are unavailable." }, }) - // THE SECURITY ASSERTION (unchanged): the asset stays bound to report A. B must never be able to - // re-point another report's photo at itself. + // Security: B must never be able to re-point another report's photo at itself. const [m] = await h.sql<{ report_id: string | null }[]>` SELECT report_id FROM media_assets WHERE id = ${media.id} ` expect(m!.report_id).toBe(a.id) - // ...and the rejection rolled the whole transaction back: no half-created report row, no timeline - // row, no idempotency snapshot that a retry would replay as a success. + // The whole transaction rolled back: no idempotency snapshot a retry would replay as a success. const rows = await h.sql<{ n: number }[]>` SELECT count(*)::int AS n FROM reports WHERE idempotency_key = ${keyB} ` @@ -262,7 +230,7 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { it("REJECTS (422) a create naming an UNKNOWN upload id, and creates nothing", async () => { // Same fail-closed rule for an id that matches no media_assets row at all (a typo, a client replaying - // a stale draft, or a probe). The pre-fix behavior was a 201 with an empty gallery. + // a stale draft, or a probe). const key = randomUUID() await expect( service.createReport(createReq({ idempotencyKey: key, mediaUploadIds: [randomUUID()] }), { @@ -336,16 +304,9 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { expect(m!.report_id).toBe(dto.id) }) - /** - * A genuinely street-level viewport around PROBE_INSIDE_CITY (where every fixture report is created). - * - * M14 made the client's `zoom` advisory: the effective zoom is min(requested, impliedZoomForBBox), so a - * bbox must be small enough to JUSTIFY per-pin zoom before listReportsInBBox will return pins at all. - * The seeded CALIFORNIA bbox (10.5° lng x 9.5° lat) implies 7, well under CLUSTER_ZOOM_THRESHOLD, so - * these tests use a ~0.04° box (implies 16) — asserting on `pins` with a state-wide bbox would silently - * assert on an empty array forever. The county bbox no longer works for this: at threshold 10 it - * implies exactly 10 and DOES return pins. - */ + // The client's `zoom` is advisory (effective zoom is min(requested, impliedZoomForBBox)), so the bbox + // must be small enough to justify per-pin zoom. A state-wide bbox implies 7 and would make every `pins` + // assertion check an empty array forever; this ~0.04° box implies 16. const STREET_BBOX = { west: PROBE_INSIDE_CITY.lng - 0.02, east: PROBE_INSIDE_CITY.lng + 0.02, @@ -359,28 +320,23 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { createReq({ idempotencyKey: key, category: "water", type: "infrastructure" }), { userId }, ) - // The fine-grained type (0021) round-trips through the create transaction onto the DTO. expect(created.type).toBe("infrastructure") - // Query a street-level bbox at high zoom (individual pins) and expect the new pin to be present. const res = await service.listReportsInBBox(STREET_BBOX, null, null, 16) const ids = res.pins.map((p) => p.id) expect(ids).toContain(created.id) const pin = res.pins.find((p) => p.id === created.id)! expect(pin.lat).toBeCloseTo(PROBE_INSIDE_CITY.lat, 6) expect(pin.lng).toBeCloseTo(PROBE_INSIDE_CITY.lng, 6) - // The pin carries the fine-grained type alongside category. expect(pin.type).toBe("infrastructure") - // The per-category counts cover every candidate regardless of the cluster/pin split. + // Counts cover every candidate regardless of the cluster/pin split. expect(res.counts?.water).toBeGreaterThanOrEqual(1) }) it("M14: the SAME point over a state-wide bbox clusters even when the client claims zoom 22", async () => { - // The anonymous-DoS fix: `zoom` used to be a free query parameter, so bbox=&zoom=22 - // skipped clustering and forced up to 2000 report rows + 2000 media presigns per request. The bbox - // extent now caps the zoom, so a claimed street-level zoom over a whole state cannot reach the per-pin - // branch. Proven against a live DB (not just the pure clustering unit test) because the presign - // fan-out lives on the service side of that branch. + // Anonymous DoS guard: a free `zoom` let bbox=&zoom=22 skip clustering and force up to + // 2000 rows + 2000 presigns per request, so the bbox extent caps the zoom. Proven against a live DB + // because the presign fan-out lives on the service side of that branch. const created = await service.createReport( createReq({ idempotencyKey: randomUUID(), category: "hazard", type: "pavement" }), { userId }, @@ -390,20 +346,18 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { const wide = await service.listReportsInBBox({ west, south, east, north }, null, null, 22) expect(wide.pins).toHaveLength(0) expect(wide.clusters.length).toBeGreaterThan(0) - // The report is still COUNTED (and clustered near its true location) — it is not filtered out. + // Still counted and clustered near its true location, not filtered out. const total = wide.clusters.reduce((n, c) => n + c.count, 0) expect(total).toBeGreaterThanOrEqual(1) expect(wide.counts?.hazard).toBeGreaterThanOrEqual(1) - // ...and the identical query over a street-level bbox DOES return it as a pin, so the zero above is - // the clamp and not a broken visibility filter. + // A street-level bbox returns it as a pin, so the zero above is the clamp, not a visibility filter. const tight = await service.listReportsInBBox(STREET_BBOX, null, null, 22) expect(tight.clusters).toHaveLength(0) expect(tight.pins.map((p) => p.id)).toContain(created.id) }) it("the type filter narrows a bbox query to matching reports only (0021)", async () => { - // Two published+public reports at the same point with DIFFERENT fine types; the type filter narrows. const dump = await service.createReport( createReq({ idempotencyKey: randomUUID(), category: "trash", type: "dump" }), { userId }, @@ -418,7 +372,7 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { expect(ids).toContain(dump.id) expect(ids).not.toContain(graffiti.id) expect(res.pins.every((p) => p.type === "dump")).toBe(true) - // Unfiltered, both are in view — so the exclusion above is the filter, not the bbox. + // Unfiltered, both are in view, so the exclusion above is the filter, not the bbox. const all = await service.listReportsInBBox(STREET_BBOX, null, null, 16) expect(all.pins.map((p) => p.id)).toContain(graffiti.id) }) diff --git a/services/api/test/integration/reports-preview-still-pg.test.ts b/services/api/test/integration/reports-preview-still-pg.test.ts index 84f0dec1..929c6a86 100644 --- a/services/api/test/integration/reports-preview-still-pg.test.ts +++ b/services/api/test/integration/reports-preview-still-pg.test.ts @@ -1,15 +1,7 @@ -/** - * "The report's first visible still" — ONE preview policy across every surface (Docker-gated). - * - * report-sql.ts:firstReadyStillLateral is the single join that decides which media asset stands in as a - * report's thumbnail. The event gallery (cleanup-repository.drizzle.ts:loadLinkedReportsForCleanups) used to - * hand-roll its own copy with a WIDER guard than the map/search/post-card sites, so a report whose only - * ready asset was a video-with-poster showed a thumbnail in the gallery and nowhere else. These tests assert - * the two surfaces agree, asset by asset — including the case the gallery's local copy was written for (a - * thumbless video must never hand its raw .mp4 key back as a thumbnail). - * - * Both surfaces are exercised through their real repositories against a live PostGIS container. - */ +// firstReadyStillLateral is the one join that picks a report's thumbnail. The event gallery once +// hand-rolled a wider copy, so a video-with-poster report had a thumbnail there and nowhere else. These +// assert the surfaces agree asset by asset, including that a thumbless video never hands back its raw +// .mp4 key as a thumbnail. import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -40,7 +32,6 @@ describe.skipIf(!pg)("first-visible-still preview policy (integration)", () => { return u!.id } - /** A published+public report at the in-city probe point (so it lands in the LA_CITY bbox query). */ async function newReport(reporterId: string, title: string): Promise { const [r] = await h.sql<{ id: string }[]>` INSERT INTO reports ( @@ -76,7 +67,6 @@ describe.skipIf(!pg)("first-visible-still preview policy (integration)", () => { }) } - /** The event gallery's view of a report (via a cleanup it is linked to). */ async function galleryThumb(reportId: string, organizerId: string): Promise { const cleanupId = await seedCleanup(h.sql, { organizerUserId: organizerId, @@ -95,7 +85,6 @@ describe.skipIf(!pg)("first-visible-still preview policy (integration)", () => { return view!.thumbKey } - /** The map pin's view of the same report (selectPublicPins -> firstReadyStillLateral). */ async function pinKeys( reportId: string, ): Promise<{ thumbKey: string | null; r2Key: string | null }> { @@ -116,11 +105,10 @@ describe.skipIf(!pg)("first-visible-still preview policy (integration)", () => { thumbKey: "media/clip-poster.jpg", }) - // Map pin: the poster is offered as the thumb (the service prefers thumbKey over r2Key). + // The service prefers thumbKey over r2Key. const pin = await pinKeys(reportId) expect(pin.thumbKey).toBe("media/clip-poster.jpg") expect(pin.r2Key).toBe(servedKeyFor("media/clip.mp4")) - // Gallery: the single rendered key is the SAME poster (was the only surface that showed one before). expect(await galleryThumb(reportId, org)).toBe("media/clip-poster.jpg") }) @@ -149,7 +137,6 @@ describe.skipIf(!pg)("first-visible-still preview policy (integration)", () => { it("only READY assets can preview, and the earliest qualifying one wins on both surfaces", async () => { const org = await newUser("Still Org D") const reportId = await newReport(org, "Ordering") - // Still validating (invisible), oldest. await addMedia(reportId, { kind: "image", r2Key: "media/pending.jpg", @@ -157,14 +144,12 @@ describe.skipIf(!pg)("first-visible-still preview policy (integration)", () => { status: "validating", createdAt: new Date("2026-01-01T00:00:00Z"), }) - // Earliest READY asset: a video poster. await addMedia(reportId, { kind: "video", r2Key: "media/first.mp4", thumbKey: "media/first-poster.jpg", createdAt: new Date("2026-01-02T00:00:00Z"), }) - // Later ready image must not win. await addMedia(reportId, { kind: "image", r2Key: "media/second.jpg", diff --git a/services/api/test/integration/schema.test.ts b/services/api/test/integration/schema.test.ts index 6562ed0d..9308fc16 100644 --- a/services/api/test/integration/schema.test.ts +++ b/services/api/test/integration/schema.test.ts @@ -851,7 +851,7 @@ describe.skipIf(!pg)("schema: enum mirrors match the DDL CHECK constraints", () } }) - it("media_assets.purpose accepts EVERY mirrored value — including 'post' (0054)", async () => { + it("media_assets.purpose accepts EVERY mirrored value, including 'post' (0054)", async () => { for (const purpose of schema.MEDIA_PURPOSE_VALUES) { const rows = await h.sql<{ purpose: string }[]>` INSERT INTO media_assets (upload_id, kind, r2_key, status, purpose) diff --git a/services/api/test/integration/service-hours-certificates-pg.test.ts b/services/api/test/integration/service-hours-certificates-pg.test.ts index 2fa8dfbb..6b92de80 100644 --- a/services/api/test/integration/service-hours-certificates-pg.test.ts +++ b/services/api/test/integration/service-hours-certificates-pg.test.ts @@ -1,19 +1,6 @@ -/** - * Service-hours certificates against a live Postgres (Docker-gated). - * - * The unit suite runs the routes against the in-memory twin, which cannot exercise the two things that - * actually make this feature safe: - * - * 1. The PARTIAL unique index `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL`. Two taps on - * "Prepare transcript" race with no lock and no advisory serialization: exactly ONE row must - * survive, the loser must recover by re-reading the winner, and both callers must see one document - * with one code. A twin that checks a Map in a single-threaded loop proves nothing about that. - * 2. The PARTIAL-ness itself — revoking must FREE the slot so the holder can re-issue over the same - * ledger and get a NEW code. - * - * Plus the TOAST rule (a source grep for `SELECT *`, per DP §4.5/§8.8) and the tombstoned-holder - * projection, which needs a real `users.deleted_at`. - */ +// The in-memory twin cannot exercise what makes this feature safe: the partial unique index +// `(user_id, ledger_fingerprint) WHERE revoked_at IS NULL` resolving two unserialized "Prepare +// transcript" taps to one row and one code, and revocation freeing that slot for a re-issue. import { afterAll, beforeAll, describe, expect, it } from "vitest" import { readFileSync } from "node:fs" @@ -52,7 +39,6 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { return u!.id } - /** A `done` event in LA_CITY, so `logEventHours` has something to credit against. */ async function newCleanup(organizerId: string, title: string): Promise { return await seedCleanup(h.sql, { organizerUserId: organizerId, @@ -65,7 +51,6 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { }) } - /** Credit `hours` to `userId` through the real ledger repo, exactly as a host would. */ async function credit( organizerId: string, userId: string, @@ -126,13 +111,11 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const row = rows[0]! expect(row.code).toBe(certificate.code) expect(row.entry_count).toBe(1) - // jsonb round-trip: the stored model is exactly what was rendered. expect(row.snapshot.v).toBe(1) expect(row.snapshot.holder.displayName).toBe("Cert Holder") expect(row.snapshot.rows).toHaveLength(1) expect(row.snapshot.rows[0]!.hours).toBe(3.5) - // The object landed in the media bucket under the C16 prefix. expect(row.r2_key).toMatch(/^certificates\/service-hours\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.pdf$/) expect(await storage.head(row.r2_key)).not.toBeNull() }) @@ -142,8 +125,7 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const holder = await newUser("Race Holder") await credit(org, holder, 2) - // Two independent service instances, as two in-flight requests would be — no shared state beyond - // the database and the bucket. + // Two independent service instances, sharing nothing but the database and the bucket. const storage = new FakeStorage() const a = makeService(storage).service const b = makeService(storage).service @@ -160,10 +142,9 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const winner = rows[0]! expect(first.certificate.code).toBe(winner.code) expect(second.certificate.code).toBe(winner.code) - // Exactly one of the two rendered documents survives; the loser's object is swept best-effort. + // The loser's object is swept best-effort. const keys = [...storage.objects.keys()] expect(keys).toEqual([winner.r2_key]) - // Exactly one of the two calls did a fresh render. expect([first.reused, second.reused].filter((r) => r === true)).toHaveLength(1) }) @@ -196,7 +177,6 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const revoked = await service.revoke(holder, first.certificate.code) expect(revoked.certificate.status).toBe("revoked") - // The download link dies with the object. expect(storage.objects.size).toBe(0) const second = await service.issue(holder) @@ -215,16 +195,9 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { expect(rows.find((r) => r.code === second.certificate.code)!.revoked_at).toBeNull() }) - /** - * THE OPERATOR REMEDY, end to end — `scripts/revoke-certificate.ts` (`pnpm db:certificate:revoke`). - * - * The product's revoke is holder-gated and hardcodes the reason `"holder"`, which would publicly blame - * the volunteer for a correction civfix made. The operator path is the SAME repository call with an - * operator reason, and it is the remedy `drizzle/0065_void_report_volunteer_hours.sql` and the runbook's - * §1b point at, so it is pinned here rather than left to a hand-written UPDATE: the reason must reach - * the PUBLIC verify projection verbatim, and the revoke must free the partial-index slot so the holder - * can re-issue a corrected transcript. - */ + // The operator remedy (`scripts/revoke-certificate.ts`). The product's revoke hardcodes the reason + // `"holder"`, which would publicly blame the volunteer for a correction civfix made. The reason must + // reach the public verify projection verbatim and the revoke must free the slot for a re-issue. it("an OPERATOR revoke reaches verify() with its own reason and frees the re-issue slot", async () => { const org = await newUser("Operator Org") const holder = await newUser("Operator Holder") @@ -234,8 +207,7 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const issued = await service.issue(holder) expect(storage.objects.size).toBe(1) - // Exactly what the script does: find the row by its printed code, then revoke it BY THAT ROW'S OWN - // user id (the operator is not the holder and has no session), with an operator reason. + // As the script does: revoke by the row's own user id, since the operator has no session. const repo = makeDrizzleCertificateRepository(h.sql) const found = await repo.findByCode(issued.certificate.code) expect(found?.userId).toBe(holder) @@ -247,17 +219,14 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { ) expect(row?.revokedReason).toBe("ledger_corrected") - // The person holding the paper is told WHY, and is not told the volunteer withdrew it. const verified = await service.verify(issued.certificate.code) expect(verified.status).toBe("revoked") expect(verified.revokedReason).toBe("ledger_corrected") - // Idempotent: a second run never rewrites the first revocation's reason or timestamp. const again = await repo.revoke(holder, issued.certificate.code, "issued_in_error", new Date()) expect(again?.revokedReason).toBe("ledger_corrected") expect(again?.revokedAt?.getTime()).toBe(row?.revokedAt?.getTime()) - // The partial unique index is free again, so the holder can re-issue once the ledger is corrected. const reissued = await service.issue(holder) expect(reissued.reused).toBe(false) expect(reissued.certificate.code).not.toBe(issued.certificate.code) @@ -299,11 +268,9 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const res = await service.verify(issued.certificate.code) expect(res.status).toBe("revoked") expect(res.revokedReason).toBe("account_closed") - // The tombstone means the identity is not echoed back, but the code still answers honestly. expect(Object.keys(res)).not.toContain("holderName") expect(res.totalHours).toBe(2.5) - // And a tombstoned account can no longer issue. await expect(service.issue(holder)).rejects.toMatchObject({ code: "NOT_FOUND" }) }) @@ -314,12 +281,8 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { expect(storage.objects.size).toBe(0) }) - /** - * DP §4.5: `snapshot` is ~200 KB of jsonb at the 1000-entry cap and lives out of line in TOAST. A - * `SELECT *` on the list or public-verify path would detoast it on every read. Cheap source grep, same - * shape as the "THE RULE" grep tests in @civfix/ui — an assertion about the code, because no runtime - * assertion can see a detoast. - */ + // `snapshot` is ~200 KB of jsonb at the 1000-entry cap and lives in TOAST; a `SELECT *` on the list or + // verify path would detoast it on every read. A source grep, because no runtime assertion sees a detoast. it("the certificate repo SQL never uses SELECT * and never SELECTs the snapshot column", () => { const raw = readFileSync( fileURLToPath( @@ -332,16 +295,13 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const code = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "") expect(code).not.toMatch(/SELECT\s+\*/i) - // `snapshot` may appear ONLY in the INSERT column list — never in a projection. The SQL keyword is - // matched CASE-SENSITIVELY at a line start: the TypeScript row type is named `CertificateRowSelect`, - // and a case-insensitive `/select/` happily matches that identifier and then swallows the next - // statement whole. + // Matched case-sensitively at a line start: a case-insensitive `/select/` would match the + // `CertificateRowSelect` type name and swallow the next statement whole. const selectBlocks = code.match(/^\s*SELECT\b[\s\S]*?\bFROM\b/gm) ?? [] expect(selectBlocks.length).toBeGreaterThan(0) for (const block of selectBlocks) { expect(block, "a read path selects the TOASTed snapshot column").not.toMatch(/\bsnapshot\b/) } - // …and the RETURNING projections must not drag it back either. for (const block of code.match(/^\s*RETURNING\b[\s\S]*?`/gm) ?? []) { expect(block, "a RETURNING projection selects the TOASTed snapshot column").not.toMatch( /\bsnapshot\b/, diff --git a/services/api/test/integration/signup-seats-pg.test.ts b/services/api/test/integration/signup-seats-pg.test.ts index bf7e6d46..d4a4809c 100644 --- a/services/api/test/integration/signup-seats-pg.test.ts +++ b/services/api/test/integration/signup-seats-pg.test.ts @@ -1,17 +1,6 @@ -/** - * Signup seats against real Postgres (Docker-gated; DECISIONS §44). - * - * The unit suite proves the service/repository contract against fakes. Only a live database can prove - * the three things that are pure SQL: that the free registration's `ON CONFLICT (cleanup_id, user_id) - * WHERE status = 'registered' AND user_id IS NOT NULL DO NOTHING` actually resolves the partial unique - * index (a mis-specified arbiter raises 42P10 at plan time, not at review time), that the seat row - * satisfies every CHECK on `cleanup_registration_seats`, and that the host's roster query and the - * scanner's token lookup then find the seat the sign-up minted. It also pins the two discriminators - * the cancel path rests on: a ticket type created AFTER a plain sign-up must not strand an active - * seat on a departed attendee, and a genuinely ticketed registration must survive a plain leave. - * - * When Docker is unavailable the whole block SKIPS so the local suite stays green; CI runs it for real. - */ +// Only a live database can prove the pure-SQL parts: the free registration's ON CONFLICT arbiter +// resolves the partial unique index (a mis-specified one raises 42P10 at plan time, not review time), the +// seat row satisfies every CHECK on `cleanup_registration_seats`, and the roster and scanner lookups find it. import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -140,7 +129,7 @@ describe.skipIf(!pg)("signup seats (integration)", () => { expect((await host.checkinCounters(cleanupId)).checkedIn).toBe(1) }) - it("a second join is a no-op — the partial unique arbiter is resolved, not a 42P10", async () => { + it("a second join is a no-op: the partial unique arbiter is resolved, not a 42P10", async () => { const organizer = await newUser("Olive Organizer") const volunteer = await newUser("Vic Volunteer") const cleanupId = await newCleanup(organizer) @@ -177,7 +166,7 @@ describe.skipIf(!pg)("signup seats (integration)", () => { expect(await seatRows(cleanupId, volunteer)).toHaveLength(1) }) - it("a legacy cleanups.capacity does not gate the sign-up — the slot is the binding gate", async () => { + it("a legacy cleanups.capacity does not gate the sign-up; the slot is the binding gate", async () => { const organizer = await newUser("Olive Organizer") const first = await newUser("First Volunteer") const second = await newUser("Second Volunteer") @@ -190,7 +179,7 @@ describe.skipIf(!pg)("signup seats (integration)", () => { expect(await seatRows(cleanupId, second)).toHaveLength(1) }) - it("a ticketed event mints nothing — registerIn owns that path", async () => { + it("a ticketed event mints nothing; registerIn owns that path", async () => { const organizer = await newUser("Olive Organizer") const volunteer = await newUser("Vic Volunteer") const cleanupId = await newCleanup(organizer) @@ -299,7 +288,7 @@ describe.skipIf(!pg)("signup seats (integration)", () => { expect((await host.checkinCounters(cleanupId)).registered).toBe(0) }) - it("releasing a slot keeps the seat — releasing keeps membership", async () => { + it("releasing a slot keeps the seat; releasing keeps membership", async () => { const organizer = await newUser("Olive Organizer") const volunteer = await newUser("Vic Volunteer") const cleanupId = await newCleanup(organizer) diff --git a/services/api/test/integration/social-connections-pg.test.ts b/services/api/test/integration/social-connections-pg.test.ts index 428a31ce..76085c4b 100644 --- a/services/api/test/integration/social-connections-pg.test.ts +++ b/services/api/test/integration/social-connections-pg.test.ts @@ -1,17 +1,7 @@ -/** - * F158: the follower / following pages used to sort the whole joined edge set by users.display_name with - * no supporting index — O(edges log edges) per page on a high-follower account, and a name cursor that - * has to carry a text key. The pages now key on the EDGE's created_at (served by - * follows_people_follower_created_idx / the followee_id index, drizzle/0093) and sort by name only - * WITHIN the fetched page. - * - * That makes two things testable against the real schema (Docker-gated), neither of which held before: - * - a page is the next slice of the edge timeline (newest edges first), NOT the next slice of an - * alphabetical list — the fixture below deliberately names the OLDEST edge "Aaron" so a name-ordered - * implementation would put it on page 1; - * - walking the cursor visits every edge exactly once, including across a same-timestamp boundary, - * which is where a keyset that ignores the id tiebreak silently drops or repeats a row. - */ +// Follower/following pages key on the edge's created_at (indexed, drizzle/0093) and sort by name only +// within the fetched page; sorting the whole edge set by display_name was O(edges log edges) per page. +// The oldest edge is deliberately named "Aaron" so a name-ordered implementation would put it on page 1, +// and a same-timestamp boundary catches a keyset that ignores the id tiebreak. import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, seedFollowEdge, testHandle, type PgHarness } from "../helpers/pg.js" diff --git a/services/api/test/integration/spatial.test.ts b/services/api/test/integration/spatial.test.ts index 79a60ff9..d4bb124a 100644 --- a/services/api/test/integration/spatial.test.ts +++ b/services/api/test/integration/spatial.test.ts @@ -1,14 +1,3 @@ -/** - * Spatial integration test: jurisdiction resolution (place -> county -> state precedence). - * - * Requires Docker (a real PostGIS container). When Docker is unavailable the whole describe block is - * SKIPPED (not failed) via describe.skipIf, so the local suite stays green; CI runs it for real. - * - * It exercises the SAME query the jurisdiction-service will use: src/db/sql/jurisdiction.ts. The - * probe points and their expected results come from src/db/seed-fixtures.ts, the single source of - * truth shared with the seed. - */ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { resolveJurisdiction } from "../../src/db/sql/jurisdiction.js" @@ -26,7 +15,7 @@ const pg = await withPg() describe.skipIf(!pg)("spatial: jurisdiction resolution", () => { let h: PgHarness beforeAll(() => { - // Non-null here: skipIf(!pg) guarantees we only run when pg is a live harness. + // skipIf(!pg) guarantees pg is a live harness here. h = pg as PgHarness }) afterAll(async () => { @@ -48,9 +37,7 @@ describe.skipIf(!pg)("spatial: jurisdiction resolution", () => { }) it("resolves each real federal / tribal land to its OWN distinct unit (different parks -> different mappings)", async () => { - // A point in each curated federal/tribal land must resolve to THAT unit and read back as a - // federal/tribal layer - proving Yellowstone, Yosemite, the Angeles National Forest, the Navajo - // Nation, etc. are separate jurisdictions, each routable on its own. + // Each curated federal/tribal land must be its own jurisdiction, routable on its own. for (const probe of FEDERAL_PROBES) { const r = await resolveJurisdiction(h.sql, probe.lng, probe.lat) expect(r, probe.name).not.toBeNull() @@ -60,7 +47,6 @@ describe.skipIf(!pg)("spatial: jurisdiction resolution", () => { }) it("routes a national-forest point to the forest, not the surrounding city (ownership overrides place)", async () => { - // PROBE_ANGELES_OVER_CITY sits inside the Angeles National Forest AND the LA city box; federal wins. const r = await resolveJurisdiction( h.sql, PROBE_ANGELES_OVER_CITY.lng, diff --git a/services/api/test/integration/threads-report-pg.test.ts b/services/api/test/integration/threads-report-pg.test.ts index 69f65ea9..be1e9d22 100644 --- a/services/api/test/integration/threads-report-pg.test.ts +++ b/services/api/test/integration/threads-report-pg.test.ts @@ -1,18 +1,3 @@ -/** - * Task D-E3: report chats in the threads inbox + real per-conversation `muted` (Docker-gated). Boots a - * live PostGIS container (via withPg) and exercises the DB-backed report threads SQL the offline suite - * covers only with a fake source (test/unit/threads-report.test.ts): - * - * - a member's listThreads includes a kind:"report" thread with the right id/refId/title (category - * label + short address) / last / ago / lastFromMe / unread (others' messages after joined_at, - * including sender-less system rows) / members, and muted:false; - * - inserting a conversation_mutes ('report') row flips that thread's muted to true; - * - a NON-member's listThreads excludes the report thread (membership-scoped query); - * - a soft-deleted report (deleted_at set) is excluded. - * - * When Docker is unavailable the whole block SKIPS so the local suite stays green; CI runs it for real. - */ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -36,7 +21,6 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { await h.teardown() }) - /** Insert a user and return its id. */ async function newUser(name: string): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES (${name}) RETURNING id @@ -44,7 +28,6 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { return u!.id } - /** Insert a report (category + addr drive the thread title) and return its id. */ async function newReport( opts: { category?: string @@ -76,7 +59,6 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { return r!.id } - /** Add `userId` to a report chat with an explicit join time. */ async function joinReportChat(reportId: string, userId: string, joinedAt: Date): Promise { await h.sql` INSERT INTO report_chat_members (report_id, user_id, role, joined_at) @@ -84,7 +66,6 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { ` } - /** Insert a report chat message (sender_id null => system message). */ async function addReportMessage( reportId: string, body: string, @@ -97,7 +78,6 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { ` } - /** Build the full inbox service exactly as the route wires it (cleanup repo + report source + mutes). */ function service() { return makeThreadsService({ repo: makeDrizzleThreadsRepository(h.sql), @@ -116,8 +96,7 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { await joinReportChat(reportId, me, new Date("2026-06-01T09:00:00.000Z")) await joinReportChat(reportId, other, new Date("2026-06-01T09:00:00.000Z")) - // A message from OTHER after I joined -> unread 1; and a later SYSTEM message (sender_id null) that - // must ALSO count as "from others" (IS DISTINCT FROM) -> unread 2, and is the last message. + // A system message (sender_id null) must also count as "from others", hence IS DISTINCT FROM. await addReportMessage(reportId, "please look", other, new Date("2026-06-01T11:00:00.000Z")) await addReportMessage( reportId, @@ -131,7 +110,6 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { expect(t, "member should see the report thread").toBeDefined() expect(t!.kind).toBe("report") expect(t!.refId).toBe(reportId) - // Title = category label + first comma-segment of addr. expect(t!.title).toBe("Trash - 123 Main St") expect(t!.last).toBe("Report was acknowledged.") expect(t!.ago).toBe("30m") diff --git a/services/api/test/integration/void-report-hours-pg.test.ts b/services/api/test/integration/void-report-hours-pg.test.ts index 565116bf..8cd041c3 100644 --- a/services/api/test/integration/void-report-hours-pg.test.ts +++ b/services/api/test/integration/void-report-hours-pg.test.ts @@ -1,17 +1,7 @@ -/** - * drizzle/0065_void_report_volunteer_hours.sql, against a real database. - * - * Filing a report is not volunteer service, so the report auto-award was removed AND every credit it ever - * wrote is voided. Every other test in the suite gets its database from the template, where 0065 was - * applied to EMPTY tables — so the branch that matters in production (a live ledger full of report rows - * and a rollup inflated by them) had no coverage at all. This file covers the void, the rollup RECOMPUTE - * (which is authoritative: it overwrites total_hours wholesale), what it must NOT touch, and the re-apply - * guarantee the migration's banner makes. - * - * The SQL is READ OUT OF THE MIGRATION FILE rather than retyped, so this cannot pass against a copy that - * has drifted from what the production runner applies. It runs against a template-cloned database where - * 0065 has already been applied once, which is exactly the "safe to re-apply" case. - */ +// drizzle/0065_void_report_volunteer_hours.sql against populated tables: the template applies 0065 to +// EMPTY tables, so the production case (a live ledger full of report rows) had no coverage otherwise. +// The SQL is read out of the migration file so it cannot drift from what the runner applies, and the +// template clone has already applied 0065 once, which is exactly the re-apply case. import { readFile } from "node:fs/promises" import { fileURLToPath } from "node:url" @@ -30,10 +20,7 @@ const MIGRATION = fileURLToPath( new URL("../../drizzle/0065_void_report_volunteer_hours.sql", import.meta.url), ) -/** - * Statement 0, matched at the START of a line so the banner paragraphs that TALK about the lock cannot - * satisfy (or be stripped by) this. `m` + `[^;]*;` spans the one statement only. - */ +// Anchored to line start so banner comments that mention the lock cannot match. const LOCK_STATEMENT = /^LOCK TABLE[^;]*;/m describe.skipIf(!pg)("0065 report-hours void (integration)", () => { @@ -53,7 +40,7 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { await h.teardown() }) - /** Apply the migration exactly as the runner does: the whole file, one `unsafe` call, no parameters. */ + // Exactly as the runner does: the whole file, one `unsafe` call, no parameters. async function applyMigration(): Promise { await h.sql.unsafe(migration) } @@ -65,7 +52,6 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { return u!.id } - /** A done cleanup, so an event credit has a real row to hang off. */ async function newCleanup(organizerId: string): Promise { return await seedCleanup(h.sql, { organizerUserId: organizerId, @@ -78,7 +64,6 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { }) } - /** A published report row, so a report-source credit satisfies its FK. */ async function newReport(reporterId: string): Promise { const [r] = await h.sql<{ id: string }[]>` INSERT INTO reports ( @@ -96,7 +81,6 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { return r!.id } - /** The pre-0065 report auto-award: a ledger row plus its contribution to the public rollup. */ async function seedReportCredit(userId: string, note: string | null = null): Promise { const reportId = await newReport(userId) const [row] = await h.sql<{ id: string }[]>` @@ -145,10 +129,7 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { return row! } - /** - * The invariant the recompute establishes, platform-wide: every rollup total equals the SUM of that - * (user, jurisdiction)'s non-voided ledger rows. Empty is what this migration exists to guarantee. - */ + // Rollup totals that differ from the SUM of their non-voided ledger rows; the migration guarantees none. async function drift(): Promise<{ user_id: string }[]> { return await h.sql<{ user_id: string }[]>` SELECT ujh.user_id @@ -164,14 +145,11 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { it("voids every report credit, leaves event credits alone, and recomputes the rollup", async () => { const host = await newUser("Void Host") - // Mixed: an event credit that must survive, plus a report credit that must not. const alice = await newUser("Void Alice") const aliceEvent = await seedEventCredit(alice, 3, host) const aliceReport = await seedReportCredit(alice) - // Report-only, i.e. most of the platform: their public total goes to 0. const bob = await newUser("Void Bob") const bobReport = await seedReportCredit(bob) - // Event-only control: nothing about this user may move. const carol = await newUser("Void Carol") await seedEventCredit(carol, 2, host) @@ -180,7 +158,6 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { await applyMigration() - // The report rows are void, and carry the explanatory note. for (const id of [aliceReport, bobReport]) { const row = await ledgerRow(id) expect(row.voided_at).not.toBeNull() @@ -189,12 +166,10 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { expect(row.hours).toBeCloseTo(0.1, 5) } - // The event credit is untouched — this is the hours a host logged for time actually served. const kept = await ledgerRow(aliceEvent) expect(kept.voided_at).toBeNull() expect(kept.note).toBeNull() - // The rollup is rebuilt from the surviving ledger, NOT decremented. expect(await rollupFor(alice)).toBe(3) expect(await rollupFor(bob)).toBe(0) expect(await rollupFor(carol)).toBe(2) @@ -213,7 +188,6 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { const host = await newUser("Rerun Host") const dave = await newUser("Rerun Dave") await seedEventCredit(dave, 1.5, host) - // A row an operator had already annotated: COALESCE must leave their note alone. const annotated = await seedReportCredit(dave, "operator: spam filing") await applyMigration() @@ -232,23 +206,13 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { expect(await drift()).toEqual([]) }) - /** - * STATEMENT 0'S LOCK IS LOAD-BEARING, and this proves it is actually taken. - * - * The runner's transaction is READ COMMITTED (migrate.ts sets no isolation level) and the API may still - * be serving. Under READ COMMITTED the recompute's correlated SUM over `volunteer_hours` keeps its - * ORIGINAL statement snapshot for that table, so a ledger row a live `logEventHours` commits mid-flight - * is invisible to it — and the rollup it then writes silently discards that credit, permanently (the - * rollup is maintained by DELTA afterwards, every read trusts it, and `_civfix_migrations` guarantees - * 0065 never re-runs). - * - * The fixture is a user with NO ledger and NO rollup row, deliberately: statement 1 row-locks only - * report rows and statement 2 row-locks only rollup rows that already exist, so WITHOUT statement 0 - * nothing in this transaction conflicts with crediting this user — which is exactly what the second - * half asserts. The only thing that can make the credit queue is the table lock. - */ + // Statement 0's lock is load-bearing. The runner's transaction is READ COMMITTED and the API may still + // be serving, so the recompute's correlated SUM keeps its original snapshot of `volunteer_hours`: a + // credit committed mid-flight would be silently and permanently discarded (the rollup is delta-maintained + // afterwards and 0065 never re-runs). The fixture user has no ledger and no rollup row on purpose: + // statements 1 and 2 only row-lock existing rows, so without statement 0 nothing conflicts with the + // credit, which is what the second half asserts. describe("statement 0 (LOCK TABLE)", () => { - /** How many backends in this database are parked waiting on a lock. */ async function waitingOnLock(): Promise { const [r] = await h.sql<{ n: number }[]>` SELECT count(*)::int AS n @@ -269,7 +233,7 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { return false } - /** A live host crediting an attendee, through the REAL repository — the writer the lock exists for. */ + // Through the real repository: the writer the lock exists for. async function creditThroughRepository( host: string, userId: string, @@ -313,7 +277,6 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { reserved.release() } - // The credit landed AFTER the recompute rather than being erased by it. expect(await rollupFor(erin)).toBe(4) expect(await drift()).toEqual([]) }) @@ -331,11 +294,10 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { await reserved.unsafe("begin") await reserved.unsafe(unlocked) // No table lock and nothing of Frank's is row-locked, so this commits WHILE the migration's - // transaction is still open — the window in which the recompute's snapshot can miss it. + // transaction is still open: the window in which the recompute's snapshot can miss it. await creditThroughRepository(host, frank, cleanupId, 4) expect(await rollupFor(frank)).toBe(4) } finally { - // Roll the stripped run back: it is a demonstration, not a state change the file should keep. await reserved.unsafe("rollback").catch(() => {}) reserved.release() } @@ -344,12 +306,8 @@ describe.skipIf(!pg)("0065 report-hours void (integration)", () => { }) }) - /** - * The recompute is AUTHORITATIVE: a rollup value with no backing non-voided ledger row is erased. That - * is only safe because logEventHours is the sole remaining writer of either table (there is no - * source='manual' writer anywhere, and no script or worker touches them). Pinned here so the day - * somebody adds a second writer, this fails and they have to think about it. - */ + // The recompute is authoritative, which is only safe while logEventHours is the sole writer of either + // table. Pinned so adding a second writer fails here and forces that decision. it("erases a rollup value that has no backing ledger row", async () => { const orphan = await newUser("Rollup Orphan") await addRollup(orphan, 12) diff --git a/services/api/test/unit/abuse.test.ts b/services/api/test/unit/abuse.test.ts index 1c900c49..5986824b 100644 --- a/services/api/test/unit/abuse.test.ts +++ b/services/api/test/unit/abuse.test.ts @@ -28,18 +28,8 @@ import { ANON_TOKEN_REPORT_CAP, } from "../../src/abuse/anon-token.js" -/** - * Local, Docker-free unit tests for the API-layer abuse stack. Each control is exercised against an - * in-memory CounterStore + FakeAbuseChecks + an in-memory token store, with an injectable clock where a - * window/expiry must be advanced. These are the meaningful tests that prove the abuse logic. - */ - const KEY = "test-anon-signing-key" -// --------------------------------------------------------------------------- -// honeypot (pure) -// --------------------------------------------------------------------------- - describe("honeypotTripped", () => { it("trips on real content, not on empty/whitespace/absent", () => { expect(honeypotTripped("i-am-a-bot")).toBe(true) @@ -51,10 +41,6 @@ describe("honeypotTripped", () => { }) }) -// --------------------------------------------------------------------------- -// IP rate limit -// --------------------------------------------------------------------------- - describe("normalizeIp", () => { it("keeps the full IPv4 address as the bucket", () => { expect(normalizeIp("203.0.113.7")).toBe("203.0.113.7") @@ -65,7 +51,6 @@ describe("normalizeIp", () => { }) it("reduces an IPv6 address to its /64 prefix", () => { - // Two addresses sharing a /64 normalize to the SAME bucket; a different /64 does not. const a = normalizeIp("2001:db8:abcd:1234:1111:2222:3333:4444") const b = normalizeIp("2001:db8:abcd:1234:9999:8888:7777:6666") const c = normalizeIp("2001:db8:abcd:5678:1111:2222:3333:4444") @@ -75,7 +60,6 @@ describe("normalizeIp", () => { }) it("expands a ':: ' compressed IPv6 before taking the /64", () => { - // 2001:db8::1 -> first four hextets are 2001:db8:0:0 expect(normalizeIp("2001:db8::1")).toBe("2001:db8:0:0::/64") }) @@ -90,14 +74,12 @@ describe("enforceIpRateLimit", () => { it("allows up to the hard cap, then rejects the (cap+1)-th with RATE_LIMITED", async () => { const counters = new InMemoryCounterStore(() => 0) const ip = "198.51.100.5" - // The first IP_HARD_LIMIT_PER_HOUR submissions pass. for (let i = 0; i < IP_HARD_LIMIT_PER_HOUR; i++) { await expect(enforceIpRateLimit(ip, { counters })).resolves.toMatchObject({ normalizedIp: ip, count: i + 1, }) } - // The next one is rejected. await expect(enforceIpRateLimit(ip, { counters })).rejects.toMatchObject({ code: "RATE_LIMITED", }) @@ -105,12 +87,11 @@ describe("enforceIpRateLimit", () => { it("counts the IPv6 /64, so rotating the low 64 bits does not evade the cap", async () => { const counters = new InMemoryCounterStore(() => 0) - const limitFor = (): number => 2 // tighten for a terse test + const limitFor = (): number => 2 const a = "2001:db8:1:2:aaaa::1" const b = "2001:db8:1:2:bbbb::2" // same /64 await enforceIpRateLimit(a, { counters, limitFor }) await enforceIpRateLimit(b, { counters, limitFor }) - // Third hit on the same /64 (via yet another low-bits value) trips the cap. await expect( enforceIpRateLimit("2001:db8:1:2:cccc::3", { counters, limitFor }), ).rejects.toMatchObject({ code: "RATE_LIMITED" }) @@ -125,7 +106,6 @@ describe("enforceIpRateLimit", () => { await expect(enforceIpRateLimit(ip, { counters, limitFor })).rejects.toMatchObject({ code: "RATE_LIMITED", }) - // Advance past the 1h window: the counter key has expired, so a fresh window starts. nowMs += 60 * 60 * 1000 + 1 await expect(enforceIpRateLimit(ip, { counters, limitFor })).resolves.toMatchObject({ count: 1, @@ -138,10 +118,6 @@ describe("enforceIpRateLimit", () => { }) }) -// --------------------------------------------------------------------------- -// H3 per-cell cap (anon-only) -// --------------------------------------------------------------------------- - describe("abuseH3Cell", () => { it("computes a stable cell at the configured resolution", () => { const cell = abuseH3Cell(34.1, -118.35) @@ -177,11 +153,9 @@ describe("enforceH3CellCap", () => { const counters = new InMemoryCounterStore(() => 0) const limit = 1 await enforceH3CellCap(34.1, -118.35, { counters, limit }) // LA cell - // A point ~thousands of km away lands in a different cell -> its own fresh budget. await expect(enforceH3CellCap(40.71, -74.0, { counters, limit })).resolves.toMatchObject({ count: 1, }) - // But a second LA submission trips the LA cell's cap. await expect(enforceH3CellCap(34.1, -118.35, { counters, limit })).rejects.toMatchObject({ code: "RATE_LIMITED", }) @@ -194,10 +168,6 @@ describe("enforceH3CellCap", () => { }) }) -// --------------------------------------------------------------------------- -// GPS sanity -// --------------------------------------------------------------------------- - describe("parseCfGeo", () => { it("parses CF lat/lng headers into a point", () => { expect(parseCfGeo({ "cf-iplatitude": "34.05", "cf-iplongitude": "-118.25" })).toEqual({ @@ -220,13 +190,12 @@ describe("parseCfGeo", () => { describe("cfGeoFromTrustedEdge (P1-2)", () => { it("is TRUE only when Fastify trusted a forwarding hop (request.ips length > 1)", () => { - // A trusted proxy forwarded a real client: [proxyPeer, client]. + // [proxyPeer, client] expect(cfGeoFromTrustedEdge({ ips: ["127.0.0.1", "203.0.113.7"] })).toBe(true) expect(cfGeoFromTrustedEdge({ ips: ["10.0.0.5", "8.8.8.8", "1.2.3.4"] })).toBe(true) }) it("is FALSE for an untrusted/direct client (single entry) or no ips", () => { - // An untrusted public peer's spoofed XFF is not trusted, so ips collapses to the peer alone. expect(cfGeoFromTrustedEdge({ ips: ["8.8.8.8"] })).toBe(false) expect(cfGeoFromTrustedEdge({ ips: [] })).toBe(false) expect(cfGeoFromTrustedEdge({})).toBe(false) @@ -248,7 +217,7 @@ describe("gpsSanityCheck", () => { }) it("passes a point JUST INSIDE the ~50km threshold of the IP geo", async () => { - // ~0.4 deg of latitude is ~44.5 km (1 deg lat ~ 111.2 km), comfortably inside 50 km. + // ~0.4 deg of latitude is ~44.5 km, inside 50 km. const point = { lat: 34.5, lng: -118.35 } const ipGeo = { lat: 34.1, lng: -118.35 } const res = await gpsSanityCheck({ point, ipGeo }, { abuseChecks }) @@ -256,7 +225,6 @@ describe("gpsSanityCheck", () => { }) it("fails a point JUST OUTSIDE the ~50km threshold of the IP geo", async () => { - // ~0.6 deg of latitude is ~66.7 km, outside 50 km. const point = { lat: 34.7, lng: -118.35 } const ipGeo = { lat: 34.1, lng: -118.35 } const res = await gpsSanityCheck({ point, ipGeo }, { abuseChecks }) @@ -264,11 +232,6 @@ describe("gpsSanityCheck", () => { }) }) -// --------------------------------------------------------------------------- -// anon token -// --------------------------------------------------------------------------- - -/** A tiny in-memory AnonTokenStore. */ class MemTokenStore implements AnonTokenStore { readonly rows = new Map() insert(row: AnonTokenRecord): Promise { @@ -311,7 +274,6 @@ describe("issueAnonToken / resolveAnonToken", () => { expect(issued.record.reportCount).toBe(0) expect(issued.record.expiresAt.toISOString()).toBe("2026-01-02T00:00:00.000Z") - // Resolve at a time within the token's lifetime (the same injected clock origin). const resolved = await resolveAnonToken(issued.token, { store, signingKey: KEY, @@ -328,7 +290,6 @@ describe("issueAnonToken / resolveAnonToken", () => { newId: () => "tok-exp", now: () => new Date("2026-01-01T00:00:00Z"), }) - // 24h + 1ms later -> expired. const resolved = await resolveAnonToken(issued.token, { store, signingKey: KEY, @@ -356,9 +317,7 @@ describe("per-token report cap", () => { flagged: false, claimCode: null, } - // One under the cap is fine (returns remaining 1). expect(assertUnderReportCap(base)).toEqual({ remaining: 1 }) - // At the cap -> throws. expect(() => assertUnderReportCap({ ...base, reportCount: ANON_TOKEN_REPORT_CAP })).toThrow() }) @@ -367,20 +326,17 @@ describe("per-token report cap", () => { let n = 0 const deps = { store, signingKey: KEY, newId: () => `tok-${++n}` } - // No token presented -> a fresh one is issued (and handed back). const first = await resolveOrIssueAnonToken(undefined, deps) expect(first.issuedToken).toBeTruthy() expect(first.record.id).toBe("tok-1") - // Simulate the create tx bumping report_count up to the cap, reusing the SAME token each time. const token = signAnonToken("tok-1", KEY) for (let i = 1; i <= ANON_TOKEN_REPORT_CAP; i++) { store.rows.get("tok-1")!.reportCount = i - 1 const r = await resolveOrIssueAnonToken(token, deps) expect(r.record.id).toBe("tok-1") - expect(r.issuedToken).toBeUndefined() // existing token reused -> not re-issued + expect(r.issuedToken).toBeUndefined() } - // Now at the cap: the next reuse is rejected. store.rows.get("tok-1")!.reportCount = ANON_TOKEN_REPORT_CAP await expect(resolveOrIssueAnonToken(token, deps)).rejects.toMatchObject({ code: "RATE_LIMITED", diff --git a/services/api/test/unit/account-status-guard.test.ts b/services/api/test/unit/account-status-guard.test.ts index d1eb21ce..3186a655 100644 --- a/services/api/test/unit/account-status-guard.test.ts +++ b/services/api/test/unit/account-status-guard.test.ts @@ -71,7 +71,7 @@ describe("H4: suspended accounts are read-only", () => { expect(write.statusCode).toBe(200) }) - it("REVIEW is a flag only — it restricts nothing", async () => { + it("REVIEW is a flag only; it restricts nothing", async () => { harness = await makeAuthHarness() const { token, userId } = await harness.signIn("under.review@example.com") harness.stores.users.setAccountStatus(userId, "review") diff --git a/services/api/test/unit/adapters-i18n-messages.test.ts b/services/api/test/unit/adapters-i18n-messages.test.ts index 8dd494b8..dc4a306d 100644 --- a/services/api/test/unit/adapters-i18n-messages.test.ts +++ b/services/api/test/unit/adapters-i18n-messages.test.ts @@ -1,21 +1,6 @@ -/** - * Tests for the server message renderer (src/i18n) — previously untested, though every push body, bell - * title and OTP email subject goes through it. - * - * What is pinned here: - * - resolution order: the target locale's catalog -> the English source -> the literal key (never empty, - * never a throw), - * - {{var}} interpolation, including an UNMATCHED placeholder being left intact rather than printing - * "undefined" to a user, - * - BCP-47 clamping (es-419 -> es, en_US -> en, junk -> en), - * - CATALOG INTEGRITY: every translated key exists in the English source (a typo'd key is silently dead - * copy, since the catalogs are Partial), and every translated string carries the SAME {{vars}} as its - * English source (a dropped placeholder means a name/status never appears in the notification). - * - * The naming here is deliberate: `Partial>` makes a MISSING key legal, so only - * a test can tell an intentional gap from an accident. The English keys that no catalog translates are - * listed explicitly below. - */ +// The catalogs are `Partial>`, so a missing or typo'd key is legal and silently +// dead, and a dropped {{var}} means a name or status never appears in the notification. Only a test can +// tell an intentional gap from an accident, so the untranslated keys are listed explicitly below. import { describe, it, expect } from "vitest" import { renderMessage } from "../../src/i18n/renderMessage.js" @@ -27,7 +12,6 @@ import { ko } from "../../src/i18n/messages/ko.js" const CATALOGS: Record>> = { es, de, ko } -/** Keys intentionally left English in every catalog (see en.ts SCOPE note). */ const INTENTIONALLY_UNTRANSLATED: readonly MessageKey[] = [] function placeholders(template: string): string[] { @@ -81,7 +65,7 @@ describe("renderMessage", () => { }) it("falls back to English for a key the catalog does not translate", () => { - // Pick a key present in en and absent from es; if es ever translates it, this asserts nothing false. + // If es ever translates every key, this asserts nothing false. const missing = (Object.keys(en) as MessageKey[]).find((k) => es[k] === undefined) if (missing !== undefined) { expect(renderMessage("es", missing)).toBe(en[missing]) @@ -108,7 +92,7 @@ describe("renderMessage", () => { expect(renderMessage("en", "notification.follower.body", { name: "@ada" })).toBe( "@ada started following you.", ) - // No vars at all: the template is returned untouched (never "undefined started following you"). + // Never "undefined started following you". expect(renderMessage("en", "notification.follower.body")).toContain("{{name}}") expect(renderMessage("en", "notification.follower.body", { other: "x" })).toContain("{{name}}") }) @@ -147,12 +131,9 @@ describe("catalog integrity", () => { } }) - /** - * The service-hours transcript is a PDF handed to a school or a court, and every chrome string on it - * comes from these catalogs. A missing overlay key falls back to English SILENTLY, so a ko transcript - * would ship half in English and look forged. The generic gap check below would catch it too, but this - * block names the feature so the failure message says which document broke. - */ + // The service-hours transcript is a PDF handed to a school or a court; a missing key falls back to + // English silently and a half-English transcript looks forged. The generic gap check would catch it + // too, but this names the feature so the failure says which document broke. it("every certificate.* key has a real es/de/ko translation (the PDF must not ship half-English)", () => { const certificateKeys = (Object.keys(en) as MessageKey[]).filter((k) => k.startsWith("certificate."), diff --git a/services/api/test/unit/adapters-mailer-oci.test.ts b/services/api/test/unit/adapters-mailer-oci.test.ts index 9b1bf453..2f5e8cc9 100644 --- a/services/api/test/unit/adapters-mailer-oci.test.ts +++ b/services/api/test/unit/adapters-mailer-oci.test.ts @@ -126,7 +126,7 @@ describe("OciMailer error classification", () => { await mailer.sendOutbound(outbound()).catch((err: AppError) => { expect(err.message).toBe( "Email not sent: the sending address is not an approved sender. In OCI Email Delivery, " + - "add an Approved Sender for the whole domain (@civfix.org) once DKIM is active — this covers " + + "add an Approved Sender for the whole domain (@civfix.org) once DKIM is active. That covers " + "every per-thread reply address. (550 5.7.1 relay access denied)", ) }) diff --git a/services/api/test/unit/adapters-push-classification.test.ts b/services/api/test/unit/adapters-push-classification.test.ts index 8d31be46..01e26761 100644 --- a/services/api/test/unit/adapters-push-classification.test.ts +++ b/services/api/test/unit/adapters-push-classification.test.ts @@ -1,11 +1,5 @@ -/** - * The prune-vs-warn matrix of every push dispatcher, tested as pure functions. - * - * Getting this wrong is expensive in both directions: classifying a TRANSIENT failure as "prune" revokes a - * live device's token (the user silently stops receiving push), while failing to classify a DEAD token - * leaves it in push_tokens forever and every future send re-attempts it. Only the Expo dispatcher had - * coverage; APNs/FCM/WebPush had none because the classification was buried inside SDK-calling factories. - */ +// Prune-vs-warn is expensive to get wrong both ways: pruning on a transient failure silently stops push +// to a live device, and missing a dead token keeps it in push_tokens with every send re-attempting it. import { describe, it, expect, vi } from "vitest" import { isApnsPruneFailure } from "../../src/adapters/push-apns.js" @@ -96,7 +90,6 @@ describe("collectExpoInvalidTokens", () => { logger, ) expect(invalid).toEqual(["dead"]) - // The non-prune ticket error is still surfaced. expect(warns).toHaveLength(1) }) diff --git a/services/api/test/unit/adapters-push-webpush-agent-pool.test.ts b/services/api/test/unit/adapters-push-webpush-agent-pool.test.ts index b5c0cf68..4e5d4160 100644 --- a/services/api/test/unit/adapters-push-webpush-agent-pool.test.ts +++ b/services/api/test/unit/adapters-push-webpush-agent-pool.test.ts @@ -1,34 +1,20 @@ -/** - * Tests for the web-push pinned-agent pool (src/adapters/push-webpush.ts). - * - * The pool exists for SSRF pinning (the connection may only go to the address resolveSafePushTarget - * already validated), but its eviction policy is what these tests pin, because the first version of it - * had a real availability bug: on reaching the ceiling it called `destroy()` on EVERY cached agent, and - * node's Agent.destroy() tears down sockets that are mid-request — so one eviction during a fan-out - * aborted the other in-flight pushes, which this dispatcher warns about and never retries. - * - * Pinned here: - * - one agent per (family, address), reused across calls, and keepAlive so the reuse is real, - * - eviction is LRU and ONE agent at a time (a cache hit refreshes recency), - * - an evicted IDLE agent is destroyed at once, - * - an evicted BUSY agent is parked, never destroyed under its own sockets, and reaped once drained, - * - destroyAll (the container-close path) takes everything, busy included. - */ +// The pool exists for SSRF pinning, but its eviction policy is what these tests pin: the first version +// destroyed EVERY cached agent at the ceiling, and node's Agent.destroy() tears down mid-request sockets, +// so one eviction during a fan-out aborted the other in-flight pushes (which are never retried). import { describe, it, expect, vi } from "vitest" import { makePinnedAgentPool } from "../../src/adapters/push-webpush.js" import type { Agent } from "node:https" -/** Make an agent look like it is serving one request, the way node's Agent tracks it. The stub carries - * a destroy() because Agent.destroy() calls it on every socket — which is the whole bug: an eviction - * that destroys the agent destroys the socket under the request that is using it. */ +// The stub socket carries a destroy() because Agent.destroy() calls it on every socket, which is the +// whole bug: destroying the agent destroys the socket under the request using it. function markInFlight(agent: Agent): void { ;(agent as unknown as { sockets: Record }).sockets = { "push.example:443:": [{ destroy: () => {} }], } } -/** ...and like it has finished (node deletes the bucket when the last socket goes). */ +// Node deletes the bucket when the last socket goes. function markDrained(agent: Agent): void { ;(agent as unknown as { sockets: Record }).sockets = {} } @@ -46,11 +32,10 @@ describe("makePinnedAgentPool", () => { expect(other).not.toBe(a) expect(sameAddressV6).not.toBe(a) expect(pool.stats()).toEqual({ cached: 3, retiring: 0 }) - // Without keepAlive the cache buys nothing: every send would open a fresh TCP+TLS connection. - // (`keepAlive` is a runtime property of node's Agent that @types/node does not surface.) + // Without keepAlive the cache buys nothing. @types/node does not surface `keepAlive`. const opts = a as unknown as { keepAlive?: boolean; options: { timeout?: number } } expect(opts.keepAlive).toBe(true) - // ...and an idle-socket ceiling, so a quiet pinned address does not hold a socket forever. + // An idle-socket ceiling, so a quiet pinned address does not hold a socket forever. expect(opts.options.timeout).toBeGreaterThan(0) } finally { pool.destroyAll() @@ -72,7 +57,6 @@ describe("makePinnedAgentPool", () => { expect(secondDestroy).toHaveBeenCalledTimes(1) expect(firstDestroy).not.toHaveBeenCalled() expect(pool.stats()).toEqual({ cached: 2, retiring: 0 }) - // The survivor is still the cached instance, not a rebuilt one. expect(pool.get("198.51.100.1", 4)).toBe(first) } finally { pool.destroyAll() @@ -88,11 +72,9 @@ describe("makePinnedAgentPool", () => { pool.get("203.0.113.11", 4) - // THE regression guard: the in-flight push must survive the eviction. expect(busyDestroy).not.toHaveBeenCalled() expect(pool.stats()).toEqual({ cached: 1, retiring: 1 }) - // A sweep while it is still busy is a no-op, not a teardown. pool.sweep() expect(busyDestroy).not.toHaveBeenCalled() expect(pool.stats().retiring).toBe(1) diff --git a/services/api/test/unit/adapters-ref-counted-subscriptions.test.ts b/services/api/test/unit/adapters-ref-counted-subscriptions.test.ts index daccd16e..d9e2bf41 100644 --- a/services/api/test/unit/adapters-ref-counted-subscriptions.test.ts +++ b/services/api/test/unit/adapters-ref-counted-subscriptions.test.ts @@ -1,15 +1,7 @@ -/** - * Tests for the shared ref-counted subscription registry (src/adapters/ref-counted-subscriptions.ts) and - * the RedisChatPubSub bug it exists to prevent. - * - * THE BUG: subscribe() inserted an EMPTY handler set into the channel map and THEN awaited the Redis - * SUBSCRIBE. When that rejected (a Redis blip; maxRetriesPerRequest is 2), the empty set stayed behind, so - * every later join saw a live-looking entry, SKIPPED the SUBSCRIBE, and attached handlers to a channel - * Redis never delivers — permanent, silent message loss for that room on that worker, surviving every retry. - * - * The registry now (a) shares ONE in-flight subscribe between concurrent first-adders so neither leaks a - * teardown handle, and (b) deletes the entry when that subscribe rejects so the next add really re-opens. - */ +// subscribe() once inserted an empty handler set before awaiting the Redis SUBSCRIBE; when that rejected, +// every later join skipped the SUBSCRIBE and attached handlers to a channel Redis never delivers: +// permanent, silent message loss for that room on that worker. The registry now shares one in-flight +// subscribe between concurrent first-adders and deletes the entry when it rejects. import { describe, it, expect, vi } from "vitest" import { EventEmitter } from "node:events" @@ -67,7 +59,6 @@ describe("RefCountedSubscriptions", () => { expect(subs.size("room")).toBe(2) await releaseA() await releaseB() - // Exactly ONE teardown existed to call. expect(closed).toEqual(["room"]) }) @@ -84,7 +75,6 @@ describe("RefCountedSubscriptions", () => { }) await expect(subs.add("room", "a")).rejects.toThrow("upstream blip") - // No phantom entry: the failed add left NOTHING behind. expect(subs.size("room")).toBe(0) expect(subs.keyCount).toBe(0) expect(subs.membersOf("room")).toBeUndefined() @@ -186,7 +176,6 @@ describe("RefCountedSubscriptions", () => { }) }) -/** Minimal ioredis stand-in: only what RedisChatPubSub touches, with a scriptable SUBSCRIBE failure. */ class FakeRedis extends EventEmitter { subscribed = new Set() subscribeCalls: string[] = [] @@ -216,7 +205,6 @@ class FakeRedis extends EventEmitter { } publish(channel: string, payload: string): Promise { - // Deliver only to the duplicated subscriber connections that actually SUBSCRIBEd. let n = 0 for (const dup of this.duplicated) { if (dup.subscribed.has(channel)) { @@ -243,7 +231,7 @@ describe("RedisChatPubSub subscribe failure (the stranded-channel bug)", () => { const handler = vi.fn() await expect(pubsub.subscribe(channel, handler)).rejects.toThrow("Stream isn't writeable") - // The retry must issue a REAL Redis SUBSCRIBE (this is what the stale empty handler set used to skip). + // The stale empty handler set used to skip this SUBSCRIBE. const unsubscribe = await pubsub.subscribe(channel, handler) expect(sub.subscribeCalls).toEqual([channel, channel]) expect(sub.subscribed.has(channel)).toBe(true) @@ -267,7 +255,6 @@ describe("RedisChatPubSub subscribe failure (the stranded-channel bug)", () => { await pubsub.close() expect(sub.disconnected).toBe(true) - // A frame arriving after close reaches nobody: the local handler registry was cleared. await pubsub.publish(chatChannel("a"), "x") expect(handler).not.toHaveBeenCalled() }) diff --git a/services/api/test/unit/adapters-reverse-geocode-chain-budget.test.ts b/services/api/test/unit/adapters-reverse-geocode-chain-budget.test.ts index 4f96fb82..729ad1d4 100644 --- a/services/api/test/unit/adapters-reverse-geocode-chain-budget.test.ts +++ b/services/api/test/unit/adapters-reverse-geocode-chain-budget.test.ts @@ -1,25 +1,13 @@ -/** - * Budget-sharing tests for chainReverse (src/adapters/reverse-geocode.chain.ts). - * - * reverse-geocode-chain.test.ts covers the ordering contract; this file covers the DEADLINE, which is the - * half that regressed: a single 5s chain ceiling handed whole to the first provider left the fallback - * ~1s of a 4s-timeout provider, so the fallback the chain exists for came back empty on a Mapbox stall. - * The budget is now split `remaining / providers-left`, so: - * - a hung first provider is abandoned at its share and the fallback still gets a real one, - * - a fast first provider passes its UNUSED share on (the common case is not slower than before), - * - the TOTAL is still bounded by the chain budget, however many providers are chained. - * - * Everything runs on fake timers (vitest fakes Date.now too, which is what the chain measures with), so - * these assert seconds of behavior in milliseconds of test. - */ +// The deadline half of chainReverse regressed: one 5s ceiling handed whole to the first provider left +// the fallback ~1s after a 4s-timeout stall, so the fallback came back empty. The budget is now split +// `remaining / providers-left`. vitest fakes Date.now too, which is what the chain measures with. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { chainReverse, type PointResolver } from "../../src/adapters/reverse-geocode.chain.js" -/** The budget is scheduling, not shape: a string answer keeps these assertions about the clock. */ +// A string answer keeps these assertions about the clock, not the result shape. type StringResolver = PointResolver -/** A provider that never answers — the stalled-vendor case. Records when it was called. */ function hangs(calls: number[]): StringResolver { return () => { calls.push(Date.now()) @@ -27,7 +15,6 @@ function hangs(calls: number[]): StringResolver { } } -/** A provider that answers `value` after `afterMs`. Records when it was called. */ function answersAfter(afterMs: number, value: string | null, calls: number[]): StringResolver { return () => { calls.push(Date.now()) @@ -50,11 +37,10 @@ describe("chainReverse budget", () => { const start = Date.now() const promise = chainReverse(hangs(calls), answersAfter(500, "fallback", calls))(1, 2) - // Nothing but the stalled provider has been reached yet. await vi.advanceTimersByTimeAsync(2_400) expect(calls.map((t) => t - start)).toEqual([0]) - // 2500 = 5000 / 2 providers: the stall is cut off here, not at 4000 (its own timeout). + // 2500 = 5000 / 2 providers: the stall is cut off here, not at its own 4000 timeout. await vi.advanceTimersByTimeAsync(200) expect(calls.map((t) => t - start)).toEqual([0, 2_500]) @@ -79,7 +65,7 @@ describe("chainReverse budget", () => { const start = Date.now() const promise = chainReverse(hangs(calls), hangs(calls), hangs(calls))(1, 2) - // 5000/3, then remaining/2, then the rest — three attempts, one total deadline. + // 5000/3, then remaining/2, then the rest: three attempts, one total deadline. await vi.advanceTimersByTimeAsync(5_000) await expect(promise).resolves.toBeNull() expect(calls.length).toBe(3) @@ -108,7 +94,6 @@ describe("chainReverse budget", () => { await vi.advanceTimersByTimeAsync(5_000) await expect(promise).resolves.toBeNull() await vi.advanceTimersByTimeAsync(5_000) - // Let any queued rejection callback run. await Promise.resolve() expect(unhandled).toEqual([]) } finally { diff --git a/services/api/test/unit/adapters-version-gate-encoded-path.test.ts b/services/api/test/unit/adapters-version-gate-encoded-path.test.ts index f36cd620..0f80b0ed 100644 --- a/services/api/test/unit/adapters-version-gate-encoded-path.test.ts +++ b/services/api/test/unit/adapters-version-gate-encoded-path.test.ts @@ -1,17 +1,7 @@ -/** - * PERCENT-ENCODED VERSION SEGMENT: the version gate must see the same version Fastify's ROUTER sees. - * - * The gate reads `request.url`, which is RAW, while find-my-way percent-DECODES before matching: `GET - * /%761/ping` is served by the static route `/v1/ping`. So the gate saw the segment "%761", decided it was - * not a `/vN` at all, and skipped enforcement entirely — sunset (410) and unsupported (400) rejection plus - * the Deprecation/Sunset headers were all bypassable with a single percent-escape. Latent while v1 is the - * only version, and a complete defeat of the policy the moment a v2 exists. - * - * Asserting a 400 (not a 404) is what proves the GATE rejected the request: `/v2/*` has no registered - * route, so a gate that skipped enforcement would fall through to the not-found handler. - * - * (File named for this wave's adapters/errors pass; it exercises src/versioning/version-gate.ts.) - */ +// The version gate must see the version Fastify's router sees. `request.url` is raw while find-my-way +// percent-decodes before matching (`/%761/ping` serves `/v1/ping`), so a single escape once bypassed +// sunset and unsupported-version enforcement. A 400 (not a 404) proves the gate rejected the request: +// `/v2/*` has no route, so a skipped gate falls through to not-found. import { describe, it, expect, beforeAll, afterAll } from "vitest" import Fastify, { type FastifyInstance } from "fastify" @@ -67,9 +57,8 @@ describe("version gate with a percent-encoded first segment", () => { }) it("does not turn a malformed escape into a 500", async () => { - // Fastify itself rejects an undecodable URI before onRequest hooks run, so the gate never sees "%zz"; - // the guarded decode exists so that a future hook order (or a decodable-but-odd segment) still cannot - // throw out of the gate. Either way the outcome must be a client error, never a 500. + // Fastify rejects an undecodable URI before onRequest hooks run; the guarded decode exists so a future + // hook order still cannot throw out of the gate. Either way it must be a client error, never a 500. const res = await app.inject({ method: "GET", url: "/%zz/x" }) expect([400, 404]).toContain(res.statusCode) }) diff --git a/services/api/test/unit/address-resolver.test.ts b/services/api/test/unit/address-resolver.test.ts index a4e1866b..52c3edf4 100644 --- a/services/api/test/unit/address-resolver.test.ts +++ b/services/api/test/unit/address-resolver.test.ts @@ -1,20 +1,6 @@ -/** - * The address ladder (src/services/address-resolver.ts) and its cache (src/services/geocode-cache.ts). - * - * This is the one place a coordinate becomes an address, so what is pinned here is the ORDER OF CLAIMS - * and the failure behavior, not any vendor's wire format (the adapters' own tests cover that): - * - * - the provider chain's rung wins when it has one, verbatim, including a RAW landmark line; - * - a chain miss degrades to the TIGER label at `locality`, which callers read as NOT LOCATED; - * - `cityStateLabel` comes back populated on every path, because the contract promises it; - * - a throwing provider, a throwing geocoder and an unreachable cache each cost one rung and never - * an error - this sits on the report-create path, where a geocoder outage must not cost a filing; - * - a cached point makes NO provider call, and ONLY a chain answer is cached: a chain miss leaves a - * short-TTL null row, so the locality rung is recomputed per request and an outage cannot lock a - * point at city grade for half a year. - * - * All offline: fake providers, a fake geocoder and an in-memory cache. - */ +// Pins the order of claims and the failure behavior, not any vendor's wire format. The ladder sits on the +// report-create path, so a failing provider, geocoder or cache costs one rung and never an error, and only +// a chain answer is cached so an outage cannot lock a point at city grade for half a year. import { describe, it, expect } from "vitest" import { FakeGeocoder } from "@civfix/shared/fakes" @@ -38,7 +24,6 @@ import type { Sql } from "../../src/db/client.js" const LA = { lat: 34.05223, lng: -118.24368 } -/** A chain stand-in that records every call, so "did this cost a provider call" is assertable. */ function chain(result: ReverseResult | null): ReverseGeocode & { calls: number } { const fn = Object.assign( async (): Promise => { @@ -64,7 +49,6 @@ function memoryCache(): GeocodeCache & { rows: Map; w return cache } -/** The same store, but applying the REAL freshness rule against a clock the test moves. */ function expiringCache(clock: { now: Date }): GeocodeCache & { rows: Map } { @@ -229,7 +213,7 @@ describe("makeAddressResolver caching", () => { }) await resolve(LA.lat, LA.lng) - // Well inside the 5th decimal (~1.1 m), which is exactly what a dragged pin produces. + // Well inside the 5th decimal (~1.1 m), as a dragged pin produces. await resolve(LA.lat + 0.000001, LA.lng - 0.000002) expect(provider.calls).toBe(1) @@ -308,13 +292,12 @@ describe("makeAddressResolver caching", () => { cache, }) - // The outage: the chain has nothing, so every caller still gets the live locality rung... await expect(resolve(LA.lat, LA.lng)).resolves.toEqual({ address: "Los Angeles, CA", precision: "locality", cityStateLabel: "Los Angeles, CA", }) - // ...off a SHORT-TTL null row, which is what makes the re-run possible at all. + // The short-TTL null row is what makes the re-run possible at all. expect(cache.rows.get(geocodePointKey(LA))?.address).toBeNull() down = false @@ -377,11 +360,8 @@ describe("makeAddressResolver caching", () => { }) }) -/** - * The cache adapter itself. `makeGeocodeCache` is the layer that must swallow a DB that is missing, - * locked or absent entirely (the fully-faked offline server has no connection at all), so the resolver - * above can treat it as infallible. - */ +// `makeGeocodeCache` must swallow a DB that is missing, locked or absent entirely (the fully-faked offline +// server has no connection at all), so the resolver can treat it as infallible. function fakeSql(rows: unknown[], calls: string[] = []): Sql { const fn = (strings: TemplateStringsArray, ..._values: unknown[]): Promise => { calls.push(strings.join("")) diff --git a/services/api/test/unit/admin-audit.test.ts b/services/api/test/unit/admin-audit.test.ts index c60eafb9..bd4eeba8 100644 --- a/services/api/test/unit/admin-audit.test.ts +++ b/services/api/test/unit/admin-audit.test.ts @@ -6,13 +6,6 @@ import { type AuditService, } from "../../src/services/admin/audit-service.js" -/** - * Offline unit tests for the admin audit-view service over the in-memory AuditRepository (no DB, no - * Docker). Cover the row -> DTO projection (null target/meta fall back to ""/{}), the actor / action / - * target filters, newest-first ordering, and the keyset pagination. The real SQL read (LEFT JOIN users) - * is Docker-gated (test/integration/admin-audit.test.ts). - */ - const NOW = new Date("2026-06-15T12:00:00.000Z") function harness(): { repo: InMemoryAuditRepository; svc: AuditService } { @@ -95,7 +88,6 @@ describe("audit list", () => { repo.seedRow({ id: "b", actorId: "op-2", actorName: "Bob Operator", action: "y" }) expect((await svc.list({ actor: "alice" })).items.map((i) => i.action)).toEqual(["x"]) - // Exact id match. expect((await svc.list({ actor: "op-2" })).items.map((i) => i.action)).toEqual(["y"]) }) diff --git a/services/api/test/unit/admin-auth-guard.test.ts b/services/api/test/unit/admin-auth-guard.test.ts index 5dd3a7e3..f84a00a9 100644 --- a/services/api/test/unit/admin-auth-guard.test.ts +++ b/services/api/test/unit/admin-auth-guard.test.ts @@ -11,32 +11,19 @@ import type { WriteAuditInput } from "../../src/services/admin/audit.js" import type { AccessIdentity, VerifyAccessJwt } from "../../src/auth/cf-access.js" import type { Role } from "@civfix/shared" -/** - * H3: behavioral authz + admin Cloudflare Access exchange HTTP tests against the REAL Fastify stack - * (app.inject), wired with in-memory auth (stores + cache + FakeMailer) + an ADMIN_EMAILS allowlist, no - * DB / no Docker. These close the gap route-coverage leaves (it only asserts routes are REGISTERED, never - * that the guard returns 401/403 or that the Access exchange enforces the allowlist). We assert: - * - every /admin/* DATA route is 401 unauthenticated and 403 for a non-operator session, 200/handler - * for an operator; - * - POST /admin/auth/access/exchange with a valid Access JWT for an allowlisted email mints an operator - * session + writes operator.login (meta.via = "cf-access"); - * - it is 403 for a verified-but-non-allowlisted email (no session, no user row), 401 for a - * missing/invalid JWT, and 503 when Cloudflare Access is not configured. - * The real Cloudflare Access verifier (cf-access.ts) is unit-tested separately with a local keypair; here - * it is substituted via the adminAuthOverrides.verifyAccessJwt test seam so the route runs offline. - */ +// Route-coverage only proves admin routes are registered; these drive the real Fastify stack to prove the +// guard actually returns 401/403 and the Access exchange enforces the allowlist. The real Access +// verifier (cf-access.ts) has its own keypair tests; here it is swapped via adminAuthOverrides. const ALLOWED = "ops@civfix.org" const ALLOWED_RESERVED_LOCAL = "civfix@civfix.org" const NOT_ALLOWED = "stranger@example.com" -/** A stub Access verifier: returns the given identity, or throws (simulating an invalid/forged token). */ function fakeVerifier(result: AccessIdentity | "throw"): VerifyAccessJwt { return () => result === "throw" ? Promise.reject(new Error("invalid token")) : Promise.resolve(result) } -/** A verified Access identity for an interactive (human) login with the given email. */ function identityFor(email: string | null): AccessIdentity { return { email, commonName: null, sub: "cf-access-sub-1", raw: {} } } @@ -46,7 +33,6 @@ interface Harness { mailer: FakeMailer services: AuthServices stores: ReturnType - /** Captured operator.login (and any other auth) audit rows (the injected sink). */ audits: WriteAuditInput[] } @@ -68,8 +54,7 @@ async function makeHarness(opts: { verifyAccessJwt?: VerifyAccessJwt } = {}): Pr }) const app = await buildServer({ env, authServices: services }) const audits: WriteAuditInput[] = [] - // Capture the operator.login audit (the offline harness has no DB to write through), and optionally - // substitute the Cloudflare Access verifier so the exchange route runs without a real CF JWKS. + // The offline harness has no DB to write audits through, and no real CF JWKS for the exchange. app.adminAuthOverrides = { auditSink: (input) => { audits.push(input) @@ -80,13 +65,8 @@ async function makeHarness(opts: { verifyAccessJwt?: VerifyAccessJwt } = {}): Pr return { app, mailer, services, stores, audits } } -/** - * Seed a user with a role and mint a bearer session token for it (presented as Authorization: Bearer). - * - * H2: an operator seeded here uses an ADMIN_EMAILS-allowlisted address by default, because the guard now - * re-checks the allowlist on EVERY admin request — a `users.role = 'operator'` row alone is no longer - * sufficient. Pass `email` explicitly to seed the off-boarded / never-allowlisted case. - */ +// Operators default to an allowlisted address because the guard re-checks ADMIN_EMAILS on every admin +// request; a `users.role = 'operator'` row alone is not sufficient. Pass `email` for the off-boarded case. async function sessionFor(h: Harness, role: Role, email?: string): Promise { const addr = email ?? (role === "operator" ? ALLOWED : `${role}.${Date.now()}@example.com`) const user = await h.stores.users.create(addr, { @@ -105,7 +85,6 @@ afterEach(async () => { } }) -// A representative spread of admin DATA routes across domains (GET, no body needed) + one mutation. const DATA_ROUTES: ReadonlyArray<{ method: "GET" | "POST"; url: string }> = [ { method: "GET", url: "/v1/admin/home/summary" }, { method: "GET", url: "/v1/admin/discovery" }, @@ -158,8 +137,7 @@ describe("H3: admin data routes are operator-gated", () => { url: r.url, headers: { authorization: `Bearer ${token}` }, }) - // The guard let it through: the status is NOT an auth rejection. (The handler may still 500 for a - // read that needs Postgres in this offline harness; what matters here is the guard, not the data.) + // The handler may still 500 for a read that needs Postgres in this offline harness. expect([401, 403], `${r.method} ${r.url} operator should pass guard`).not.toContain( res.statusCode, ) @@ -168,20 +146,12 @@ describe("H3: admin data routes are operator-gated", () => { }) describe("H2: operator authority is re-checked against ADMIN_EMAILS on EVERY admin request", () => { - /** - * The vulnerability this closes: `users.role = 'operator'` used to be sufficient forever. Any auth path - * minted a session carrying roles:["operator"] — including the PUBLIC citizen Email-OTP / Google / Apple - * login — and removing the address from ADMIN_EMAILS never demoted the row. So an off-boarded operator - * signed in through the consumer app and kept full console access (and with a bearer token, no CSRF). - * - * These sessions are minted DIRECTLY via SessionService.createSession, i.e. exactly what the citizen - * login flow does: no Cloudflare Access exchange anywhere in sight. - */ + // `users.role = 'operator'` was once sufficient forever: any auth path, including the public citizen + // login, minted an operator session, and removing the address from ADMIN_EMAILS never demoted the row. + // These sessions are minted via SessionService.createSession, exactly as the citizen login does. it("REJECTS an operator-role session whose email is NOT in ADMIN_EMAILS (the off-boarded operator)", async () => { harness = await makeHarness() - // A real users.role='operator' row + a real live session, but the address was never allowlisted (or - // was removed from the allowlist after off-boarding). const token = await sessionFor(harness, "operator", NOT_ALLOWED) for (const r of DATA_ROUTES) { const res = await harness.app.inject({ @@ -217,14 +187,9 @@ describe("H2: operator authority is re-checked against ADMIN_EMAILS on EVERY adm expect([401, 403]).not.toContain(res.statusCode) }) - /** - * F096: GET /admin/auth/session is the console's "am I signed in?" probe, and it lived OUTSIDE the - * DATA_ROUTES guard above — it answered from the session claim + users.role alone. So an off-boarded - * operator (or anyone who signed in through the PUBLIC citizen login while their row still said - * operator) was told authenticated:true, handed their identity back and given a live session-bound CSRF - * token, while every data route 403'd. The allowlist is the single source of operator truth on this - * route too: no operator payload, and no CSRF cookie handed to a caller who cannot use it. - */ + // GET /admin/auth/session sits outside the data-route guard and once answered from users.role alone, + // handing an off-boarded operator authenticated:true and a live CSRF token. The allowlist is the single + // source of operator truth here too. it("F096: reports an off-boarded operator's session as UNAUTHENTICATED and sets no CSRF cookie", async () => { harness = await makeHarness() const token = await sessionFor(harness, "operator", NOT_ALLOWED) @@ -288,8 +253,7 @@ describe("M5: malformed :id path params are rejected as 400/422, not 500", () => it("a non-uuid :id on an admin detail route is a typed validation error (not a 500)", async () => { harness = await makeHarness() const token = await sessionFor(harness, "operator") - // These reach the handler (operator passed the guard); a non-uuid id must be caught by idParam - // (AppError.validation -> 400) BEFORE the SQL layer, where a non-uuid would otherwise 500. + // A non-uuid id must be rejected by idParam before the SQL layer, where it would otherwise 500. for (const url of [ "/v1/admin/reports/not-a-uuid", "/v1/admin/moderation/not-a-uuid", @@ -320,20 +284,16 @@ describe("doc 16: admin Cloudflare Access exchange (POST /admin/auth/access/exch headers: HEADER, }) expect(res.statusCode).toBe(200) - // A web session cookie was set (the dashboard is a browser SPA). const setCookie = res.headers["set-cookie"] expect(setCookie).toBeDefined() expect(String(setCookie)).toContain("civfix_session=") - // The returned operator + a CSRF token. const body = res.json() expect(body.user.role).toBe("operator") expect(typeof body.csrfToken).toBe("string") - // The user was granted operator in the store (idempotent grant on find-or-create). const stored = await harness.stores.users.findByEmail(ALLOWED) expect(stored?.role).toBe("operator") - // operator.login was audited, tagged with the Access path. expect(harness.audits).toHaveLength(1) expect(harness.audits[0]).toMatchObject({ action: "operator.login", @@ -341,7 +301,6 @@ describe("doc 16: admin Cloudflare Access exchange (POST /admin/auth/access/exch meta: { via: "cf-access" }, }) - // After exchange, the minted session passes the operator guard on a data route (not 401/403). const sessionCookie = (String(setCookie).split(";")[0] ?? "").trim() const data = await harness.app.inject({ method: "GET", @@ -374,9 +333,9 @@ describe("doc 16: admin Cloudflare Access exchange (POST /admin/auth/access/exch headers: HEADER, }) expect(res.statusCode).toBe(403) - expect(res.headers["set-cookie"]).toBeUndefined() // no session issued - expect(harness.audits).toHaveLength(0) // no operator.login audit - // The allowlist check runs BEFORE find-or-create, so no bare user row is provisioned. + expect(res.headers["set-cookie"]).toBeUndefined() + expect(harness.audits).toHaveLength(0) + // The allowlist check runs before find-or-create, so no bare user row is provisioned. expect(await harness.stores.users.findByEmail(NOT_ALLOWED)).toBeNull() }) @@ -401,7 +360,7 @@ describe("doc 16: admin Cloudflare Access exchange (POST /admin/auth/access/exch }) it("is 503 when Cloudflare Access is not configured (no CF_ACCESS_* and no verifier)", async () => { - harness = await makeHarness() // no verifyAccessJwt override; test env has no CF_ACCESS_* + harness = await makeHarness() // test env has no CF_ACCESS_* const res = await harness.app.inject({ method: "POST", url: "/v1/admin/auth/access/exchange", @@ -411,17 +370,12 @@ describe("doc 16: admin Cloudflare Access exchange (POST /admin/auth/access/exch }) }) -/** - * POST /admin/auth/logout had NO coverage at all. It is now deliberately IDEMPOTENT: it used to run behind - * requireAuth, so a stale tab whose session had already expired or been revoked got a 401 and the dead - * session + CSRF cookies were never cleared — the one thing logout exists to do. csrfProtect is still - * applied (it self-skips when no session cookie is presented), so this cannot be used as a cross-site - * forced logout. - */ +// Logout is deliberately idempotent: behind requireAuth, a stale tab with an expired or revoked session got +// a 401 and its dead cookies were never cleared. csrfProtect still applies (it self-skips with no session +// cookie), so this cannot be used as a cross-site forced logout. describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement", () => { const HEADER = { "cf-access-jwt-assertion": "stub.jwt.token" } - /** Sign in through the Access exchange and return the web-transport credentials it issued. */ async function login(h: Harness): Promise<{ cookie: string; csrfToken: string }> { const res = await h.app.inject({ method: "POST", @@ -439,7 +393,6 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement } } - /** Every cleared cookie name in a reply's Set-Cookie headers (Max-Age=0 / Expires in the past). */ function clearedCookies(res: { headers: Record }): string[] { const raw = res.headers["set-cookie"] const list = raw === undefined ? [] : Array.isArray(raw) ? raw.map(String) : [String(raw)] @@ -460,15 +413,13 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ ok: true }) - // Both the legacy and the __Host- names are cleared, for the session AND the CSRF cookie, so a logout - // can never leave a stale legacy cookie that outlives the session. + // Both the legacy and the __Host- names are cleared so no stale legacy cookie outlives the session. const cleared = clearedCookies(res) expect(cleared).toContain("civfix_session") expect(cleared).toContain("__Host-civfix_session") expect(cleared).toContain("civfix_csrf") expect(cleared).toContain("__Host-civfix_csrf") - // operator.logout was written, attributed to the operator, tagged with whether a live session died. const stored = await harness.stores.users.findByEmail(ALLOWED) expect(harness.audits.map((a) => a.action)).toEqual(["operator.login", "operator.logout"]) expect(harness.audits[1]).toMatchObject({ @@ -478,7 +429,6 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement meta: { sessionRevoked: true }, }) - // The session really is dead: the same cookie no longer authenticates an admin data route. const after = await harness.app.inject({ method: "GET", url: "/v1/admin/home/summary", @@ -497,8 +447,7 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement }) expect(first.statusCode).toBe(200) - // The stale tab retries with the SAME (now dead) session cookie: previously a 401 that left the dead - // cookies in the browser. + // A stale tab retrying with the dead cookie once got a 401 that left the cookies in the browser. const second = await harness.app.inject({ method: "POST", url: "/v1/admin/auth/logout", @@ -515,9 +464,8 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement it("401s a caller presenting NO credential at all (contract: adminLogout.auth === required)", async () => { harness = await makeHarness({ verifyAccessJwt: fakeVerifier(identityFor(ALLOWED)) }) const res = await harness.app.inject({ method: "POST", url: "/v1/admin/auth/logout" }) - // Idempotency covers a PRESENTED-but-dead session (the test above). A request with NO credential has - // nothing to revoke and no cookie of its own to clear, so it stays 401 — the same rule the citizen - // POST /v1/auth/logout applies, and what the shared contract's auth:"required" pins. + // Idempotency covers a presented-but-dead session. A request with no credential has nothing to revoke + // or clear, so it stays 401, the same rule as the citizen logout and the contract's auth:"required". expect(res.statusCode).toBe(401) expect(clearedCookies(res)).toHaveLength(0) expect(harness.audits).toHaveLength(0) @@ -536,7 +484,6 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement expect(clearedCookies(bad)).toHaveLength(0) expect(harness.audits.map((a) => a.action)).toEqual(["operator.login"]) - // Rejected, not merely unrecorded: the session is still live. const still = await harness.app.inject({ method: "GET", url: "/v1/admin/home/summary", @@ -560,7 +507,7 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement it("a CSRF token derived from a DIFFERENT session does not authorize this one (403)", async () => { harness = await makeHarness({ verifyAccessJwt: fakeVerifier(identityFor(ALLOWED)) }) const a = await login(harness) - const b = await login(harness) // a second session for the same operator + const b = await login(harness) expect(b.csrfToken).not.toBe(a.csrfToken) const res = await harness.app.inject({ @@ -602,9 +549,8 @@ describe("POST /admin/auth/logout: idempotent cookie clearing + CSRF enforcement url: "/v1/admin/auth/logout", headers: { authorization: `Bearer ${token}` }, }) - // Public + idempotent: a citizen calling it still gets its cookies cleared… expect(res.statusCode).toBe(200) - // …but operator.logout is reserved for operators, so nothing is recorded. + // operator.logout is reserved for operators. expect(harness.audits).toHaveLength(0) }) }) diff --git a/services/api/test/unit/admin-discovery.test.ts b/services/api/test/unit/admin-discovery.test.ts index 09957431..bc5e24e7 100644 --- a/services/api/test/unit/admin-discovery.test.ts +++ b/services/api/test/unit/admin-discovery.test.ts @@ -12,13 +12,6 @@ import { type DiscoveryService, } from "../../src/services/admin/discovery-service.js" -/** - * Offline unit tests for the admin discovery service over the in-memory DiscoveryRepository (no DB, no - * Docker). They cover the queue list (filter all|attention|clear + sort pop|reports + search), the - * detail projection (per-category counts + contacts + sample pins), and the note / flag / draft - * mutations, plus the pure helpers (dominant category, contact state, SLA, priority band). - */ - const NOW = new Date("2026-06-06T00:00:00.000Z") function harness(): { repo: InMemoryDiscoveryRepository; svc: DiscoveryService } { @@ -29,7 +22,6 @@ function harness(): { repo: InMemoryDiscoveryRepository; svc: DiscoveryService } return { repo, svc } } -/** A timestamp `hours` before NOW. */ function hoursAgo(hours: number): Date { return new Date(NOW.getTime() - hours * 60 * 60 * 1000) } @@ -133,7 +125,6 @@ describe("discovery list", () => { expect(row.overSla).toBe(true) expect(row.priority).toBe("high") expect(row.lastReport).toBe("1h") - // No contacts on file + waiting reports -> both categories missing. expect(row.contactState.missing).toEqual(expect.arrayContaining(["trash", "hazard"])) }) @@ -163,9 +154,7 @@ describe("discovery list", () => { it("filters attention (missing contact) vs clear (fully routed)", async () => { const { repo, svc } = harness() - // Needs attention: waiting trash, no contact. repo.seedTask({ id: "ATTN", geoid: "1", place: "Needs", perCategory: { trash: 3 } }) - // Clear: waiting trash but a default contact covers it. repo.seedTask({ id: "CLEAR", geoid: "2", @@ -184,11 +173,8 @@ describe("discovery list", () => { expect(all.items).toHaveLength(2) }) - /** - * The SQL predicate is `ilikeAnyOf(sql, [j.name, t.geoid], q)` — the jurisdiction NAME or the GEOID. - * The task id is not a search column, so a q matching one must find nothing (the fake used to search it, - * which is how a search test passed against behavior production lacks). - */ + // The SQL searches only the jurisdiction name and geoid. The fake once also searched the task id, which + // let a test pass against behavior production lacks. it("search matches place or geoid (case-insensitive) and NEVER the task id", async () => { const { repo, svc } = harness() repo.seedTask({ @@ -224,7 +210,6 @@ describe("discovery list", () => { expect(first.nextCursor).not.toBeNull() const second = await svc.list({ limit: 2, cursor: first.nextCursor ?? undefined }) expect(second.items).toHaveLength(2) - // No overlap between pages. const firstIds = new Set(first.items.map((i) => i.id)) expect(second.items.every((i) => !firstIds.has(i.id))).toBe(true) }) @@ -289,7 +274,6 @@ describe("discovery detail", () => { it("interleaves operator notes and citizen suggestions oldest-first", async () => { const { repo, svc } = harness() repo.seedTask({ id: "JUR-1", geoid: "4805000", place: "Austin", perCategory: { trash: 1 } }) - // Suggestion lands first (earlier clock tick), then an operator note. repo.seedSuggestion("4805000", { formUrl: "https://austin.gov/report" }) await svc.addNote("JUR-1", { text: "Verified the form", actorId: "op", who: "jane" }) @@ -351,9 +335,7 @@ describe("discovery mutations", () => { const detail = await svc.getTask("JUR-1") expect(detail.contacts).toContainEqual({ category: "trash", email: "trash@city.gov" }) - // Draft does NOT resolve the task. expect(repo.tasks.get("JUR-1")?.task.status).toBe("open") - // trash now routed; hazard still missing. expect(detail.contactState.routed).toContain("trash") expect(detail.contactState.missing).toContain("hazard") }) diff --git a/services/api/test/unit/admin-events.test.ts b/services/api/test/unit/admin-events.test.ts index 439b9893..e590daba 100644 --- a/services/api/test/unit/admin-events.test.ts +++ b/services/api/test/unit/admin-events.test.ts @@ -9,14 +9,6 @@ import { type AdminEventService, } from "../../src/services/admin/admin-event-service.js" -/** - * Offline unit tests for the admin events (cleanups) service over the in-memory AdminEventRepository (no - * DB, no Docker). They cover the list (status + flagged facet, search, pagination), the detail (timeline - * + messages + turnout), status changes (cleanup_timeline + audit), the flag toggle - * (cleanup_timeline-tracked + audit), cancel (-> cancelled + audit), and post-message (chat row + - * notify each member + audit), plus the pure helpers. - */ - const NOW = new Date("2026-06-06T00:00:00.000Z") function harness(): { repo: InMemoryAdminEventRepository; svc: AdminEventService } { @@ -26,7 +18,6 @@ function harness(): { repo: InMemoryAdminEventRepository; svc: AdminEventService return { repo, svc } } -/** A timestamp `hours` before NOW. */ function hoursAgo(hours: number): Date { return new Date(NOW.getTime() - hours * 60 * 60 * 1000) } @@ -50,7 +41,6 @@ describe("admin events pure helpers", () => { expect(flaggedFromTimeline(["flag"])).toBe(true) expect(flaggedFromTimeline(["flag", "unflag"])).toBe(false) expect(flaggedFromTimeline(["flag", "unflag", "flag"])).toBe(true) - // Non-flag rows do not change the state. expect(flaggedFromTimeline(["flag", "status", "message"])).toBe(true) }) @@ -78,7 +68,7 @@ describe("admin events linking", () => { repo.seedReport({ id: HELD, status: "held" }) const result = await svc.linkReports(ev.record.id, [R1, R2, HELD], "op-1") - // The held report is silently skipped (visibility gate), only the visible ones link. + // The visibility gate skips the held report silently. expect(result.linked.sort()).toEqual([R1, R2].sort()) expect(repo.audits.some((a) => a.action === "event.reports_linked")).toBe(true) @@ -178,26 +168,22 @@ describe("admin events list", () => { status: "upcoming", timeline: [{ kind: "flag", note: null, who: "op", createdAt: NOW }], }) - // counts span ALL events (not the active facet) so the chips stay accurate. + // Counts span all events, not the active facet, so the chips stay accurate. const { counts } = await svc.list({ filter: "completed" }) expect(counts).toEqual({ all: 5, upcoming: 2, in_progress: 1, completed: 1, flagged: 1 }) }) - // H1: a Phase-1 row stored as 'active'/'done' must surface as the Phase-2 EventStatus AND be caught by - // the matching facet (the bug: filter=completed missed stored 'done', and 'active'/'done' leaked as an - // invalid EventStatus). Seed RAW stored values to mimic a legacy row. + // Legacy rows stored as 'active'/'done' must map to a valid EventStatus and be caught by the matching + // facet; filter=completed once missed stored 'done'. it("H1: legacy stored active/done surface as in_progress/completed and the facet matches them", async () => { const { repo, svc } = harness() repo.seedEvent({ id: "legacy-active", storedStatus: "active" }) repo.seedEvent({ id: "legacy-done", storedStatus: "done" }) repo.seedEvent({ id: "p2-progress", status: "in_progress" }) - // Read maps the stored Phase-1 value to the Phase-2 EventStatus DTO. expect((await svc.get("legacy-active")).status).toBe("in_progress") expect((await svc.get("legacy-done")).status).toBe("completed") - // The completed facet catches the legacy 'done' row; the in_progress facet catches both 'active' and - // a Phase-2 'in_progress' row. expect((await svc.list({ filter: "completed" })).items.map((i) => i.id)).toEqual([ "legacy-done", ]) @@ -226,16 +212,13 @@ describe("admin events list", () => { expect((await svc.list({ q: "beach" })).items.map((i) => i.id)).toEqual(["evt-1"]) expect((await svc.list({ q: "denver" })).items.map((i) => i.id)).toEqual(["evt-2"]) expect((await svc.list({ q: "pat" })).items.map((i) => i.id)).toEqual(["evt-2"]) - // u.handle::text is one of the four ILIKE columns; the fake used not to search it at all. + // u.handle::text is one of the four ILIKE columns; the fake once did not search it. expect((await svc.list({ q: "TRAILboss" })).items.map((i) => i.id)).toEqual(["evt-2"]) }) - /** - * searchEventsFragment's id branch is `OR c.id = $q::uuid`, gated on isUuid(q): an EXACT uuid equality. - * The fake used to match any id SUBSTRING, so an id-prefix search "worked" offline and returned nothing - * against Postgres. - */ - it("matches an id ONLY on a full uuid — never a substring, never a non-uuid needle", async () => { + // The SQL id branch is exact uuid equality; a fake that matched id substrings passed offline while the + // same search returned nothing against Postgres. + it("matches an id ONLY on a full uuid, never a substring, never a non-uuid needle", async () => { const { repo, svc } = harness() const id = "7a1b2c33-4455-4666-8777-99aa00bb44dd" repo.seedEvent({ id, title: "Creek cleanup", place: "Boise", organizer: null }) @@ -284,7 +267,7 @@ describe("admin events detail", () => { }) describe("admin events mutations", () => { - it("setStatus 409s every status but cancelled — the rest is a clock reading", async () => { + it("setStatus 409s every status but cancelled; the rest is a clock reading", async () => { const { repo, svc } = harness() repo.seedEvent({ id: "evt-1", status: "upcoming" }) for (const status of ["upcoming", "in_progress", "completed"] as const) { @@ -377,13 +360,11 @@ describe("admin events mutations", () => { actorId: "op-1", }) expect(notified).toBe(3) - // A chat message was appended. expect(repo.messages.get("evt-1")?.at(-1)).toMatchObject({ who: "CivFix", text: "Rescheduled to Saturday 9am", }) expect(repo.timeline.get("evt-1")?.at(-1)).toMatchObject({ kind: "message", who: "operator" }) - // One notification per member, all carrying the body. expect(repo.notifications).toHaveLength(3) expect(repo.notifications.map((n) => n.userId).sort()).toEqual(["m-1", "m-2", "m-3"]) expect(repo.notifications.every((n) => n.body === "Rescheduled to Saturday 9am")).toBe(true) diff --git a/services/api/test/unit/admin-host-platform-routes.test.ts b/services/api/test/unit/admin-host-platform-routes.test.ts index 0dac5b50..5792a126 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -201,7 +201,6 @@ async function createOrg( return res.json() as { id: string; slug: string } } -/** Seed an org owned by HOST with one open (pending) verification application. */ async function seedPendingOrg(orgs: InMemoryOrganizationRepository): Promise { orgs.seedUser({ id: HOST, displayName: "Ada", handle: "ada", email: "ada@example.org" }) const org = await orgs.createOrganizationTx({ @@ -325,10 +324,8 @@ describe("admin legal versions", () => { }) describe("admin org verification decision", () => { - // The decision's operator audit is written INSIDE the repository transaction (decideVerificationTx -> - // writeHostAudit), not by the route like the sibling admin mutations. These tests pin that the row exists - // with the operator as actor, the org as target and the decision detail as meta, so the route's lack of a - // second writeAudit call is a deliberate no-double-write, not a gap. + // The operator audit is written inside the repository transaction, not by the route like sibling admin + // mutations, so the route's missing writeAudit call is a deliberate no-double-write, not a gap. it("approves, audits the operator + decision, and notifies the owner", async () => { const h = await harness() const id = await seedPendingOrg(h.orgs) diff --git a/services/api/test/unit/admin-inbox.test.ts b/services/api/test/unit/admin-inbox.test.ts index 0a5537d1..ff74997a 100644 --- a/services/api/test/unit/admin-inbox.test.ts +++ b/services/api/test/unit/admin-inbox.test.ts @@ -11,24 +11,13 @@ import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" import { MEDIA_GET_URL_TTL_SEC } from "../../src/services/media-intake-service.js" -/** - * HTTP tests for the admin INBOX router (`src/routes/admin/inbox.routes.ts`), which had none: every other - * admin router has a unit HTTP test, while the inbox had only a repository integration test. So the whole - * route layer was unexercised — the list filters, the detail 404, the attachment presigning + its - * MAX_INBOX_ATTACHMENTS cap, and the L6 operator threading on the status mutation. - * - * These run against the REAL Fastify stack (app.inject) with in-memory auth (stores + cache + an - * ADMIN_EMAILS allowlist) and `app.adminInboxOverrides` supplying an in-memory InboundRepository plus a - * recording Storage — no DB, no R2, no Docker. The operator authenticates with a BEARER token, which is - * CSRF-exempt by transport (see auth/csrf.ts), so the mutation is reachable without a cookie dance; the - * cookie/CSRF half of the admin surface is covered in admin-auth-guard.test.ts. - */ +// The operator uses a bearer token, which is CSRF-exempt by transport, so the mutation is reachable +// without a cookie dance; the cookie/CSRF half is covered in admin-auth-guard.test.ts. const OPERATOR = "ops@civfix.org" -/** Mirrors MAX_INBOX_ATTACHMENTS in inbox.routes.ts (module-private there, by design). */ +// Mirrors MAX_INBOX_ATTACHMENTS in inbox.routes.ts, which is module-private by design. const ATTACHMENT_CAP = 50 -/** A Storage that records what was presigned (and with which TTL) so the cap is observable. */ class RecordingStorage extends FakeStorage { readonly signed: { key: string; ttlSec: number }[] = [] override presignGet(key: string, ttlSec: number): Promise { @@ -43,7 +32,6 @@ interface Harness { storage: RecordingStorage services: AuthServices stores: ReturnType - /** Bearer token for an allowlisted operator. */ token: string operatorId: string } @@ -81,7 +69,6 @@ afterEach(async () => { } }) -/** Insert one inbound email; `minute` orders rows deterministically (received_at DESC on the wire). */ async function seed( h: Harness, opts: { @@ -110,7 +97,6 @@ async function seed( return id } -/** GET as the operator. */ function get(h: Harness, url: string): Promise<{ statusCode: number; json: () => unknown }> { return h.app.inject({ method: "GET", url, headers: { authorization: `Bearer ${h.token}` } }) } @@ -156,7 +142,6 @@ describe("GET /admin/inbox (list)", () => { expect(top.status).toBe("unread") expect(top.unread).toBe(true) expect(top.hasAttachments).toBe(true) - // The list projection never ships bodies or attachment keys. expect(top).not.toHaveProperty("bodyHtml") expect(top).not.toHaveProperty("attachments") }) @@ -259,8 +244,7 @@ describe("GET /admin/inbox/:id (detail)", () => { expect(dto.bodyText).toBe("See attached.") expect(dto.bodyHtml).toBe("

See attached.

") expect(dto.messageId).toBe("") - // The RAW R2 key never reaches the wire; it is swapped for a time-limited GET URL, and the filename + - // size ride along unchanged so the console can render the link. + // The raw R2 key never reaches the wire; it is swapped for a time-limited GET URL. expect(dto.attachments).toEqual([ { key: "memory://inbound-emails/d/permit.pdf", filename: "permit.pdf", size: 2048 }, { key: "memory://inbound-emails/d/map.png", filename: "map.png", size: 512 }, @@ -285,7 +269,6 @@ describe("GET /admin/inbox/:id (detail)", () => { const dto = res.json() as { attachments: MailAttachment[]; hasAttachments: boolean } expect(dto.hasAttachments).toBe(true) expect(dto.attachments).toHaveLength(ATTACHMENT_CAP) - // The FIRST 50 in order, each presigned; the tail is neither signed nor shipped. expect(dto.attachments[0]?.key).toBe("memory://inbound-emails/cap/part-0.bin") expect(dto.attachments[ATTACHMENT_CAP - 1]?.key).toBe( `memory://inbound-emails/cap/part-${ATTACHMENT_CAP - 1}.bin`, @@ -411,7 +394,6 @@ describe("inbox routes are operator-gated like every other admin router", () => }) expect(asCitizen.statusCode, `${r.method} ${r.url} citizen`).toBe(403) } - // Nothing leaked and nothing changed. expect(harness.storage.signed).toHaveLength(0) expect(harness.repo.audits).toHaveLength(0) expect(harness.repo.rows[0]?.status).toBe("unread") diff --git a/services/api/test/unit/admin-jurisdiction-contacts.test.ts b/services/api/test/unit/admin-jurisdiction-contacts.test.ts index 3c9d1855..ee989f58 100644 --- a/services/api/test/unit/admin-jurisdiction-contacts.test.ts +++ b/services/api/test/unit/admin-jurisdiction-contacts.test.ts @@ -17,14 +17,6 @@ import { type JurisdictionDirectoryRecord, } from "../../src/services/admin/jurisdiction-contacts-service.js" -/** - * Offline unit tests for the admin jurisdiction-contacts service over the in-memory repo + FakeJobs (no - * DB, no Docker). They prove the "Save & route" core action (persist per-category contacts, set - * contact_updated_at, resolve the discovery task, leave every report alone, enqueue throttled outreach), - * the patch path (no routing), the directory projection (coverage / method / status), and the pure - * helpers. - */ - const NOW = new Date("2026-06-06T00:00:00.000Z") const THROTTLE_DAYS = 7 @@ -46,7 +38,6 @@ function harness(): { return { repo, jobs, svc } } -/** Build a directory record with overridable fields (for the pure-helper tests). */ function record(over: Partial = {}): JurisdictionDirectoryRecord { return { geoid: "1", @@ -117,7 +108,6 @@ describe("saveAndRoute", () => { const { repo, jobs, svc } = harness() repo.seedJurisdiction({ geoid: "0644000", name: "Los Angeles" }) repo.seedTask({ id: "JUR-1", geoid: "0644000", status: "open" }) - // Two waiting reports + one already-resolved; none of them may change. repo.seedReport({ id: "r1", geoid: "0644000", category: "trash", status: "submitted" }) repo.seedReport({ id: "r2", geoid: "0644000", category: "hazard", status: "published" }) repo.seedReport({ id: "r3", geoid: "0644000", category: "trash", status: "resolved" }) @@ -134,28 +124,24 @@ describe("saveAndRoute", () => { "op-1", ) - // (a) per-category contact persisted. const j = repo.jurisdictions.get("0644000")! expect(j.categoryContacts.get("trash")).toBe("trash@lacity.gov") expect(j.defaultEmails).toEqual(["311@lacity.gov"]) expect(j.forwardSubjectTemplate).toBe("Report {title}") expect(j.forwardBodyTemplate).toBe("Please review {description}") - // (b) contact_updated_at set. expect(j.contactUpdatedAt).toEqual(NOW) - // (c) discovery task resolved. expect(repo.tasks.find((t) => t.id === "JUR-1")?.status).toBe("done") expect(result.taskResolved).toBe(true) - // (d) NO report is touched: saving a contact mails nobody, so nothing is marked routed. + // Saving a contact mails nobody, so no report may be marked routed. expect(repo.reports.find((r) => r.id === "r1")?.status).toBe("submitted") expect(repo.reports.find((r) => r.id === "r2")?.status).toBe("published") expect(repo.reports.find((r) => r.id === "r3")?.status).toBe("resolved") - // (e) outreach enqueued (singletonKey=geoid). const enqueued = jobs.jobsFor(OUTREACH_DIGEST_JOB) expect(enqueued).toHaveLength(1) expect(enqueued[0]?.data).toMatchObject({ geoid: "0644000" }) expect(enqueued[0]?.opts?.singletonKey).toBe("0644000") expect(result.outreachEnqueued).toBe(true) - // (f) H4: the save was audited IN the repo (atomic with the routing), recorded on the audit sink. + // Audited inside the repo, atomic with the routing. expect(repo.audits.at(-1)).toMatchObject({ actorId: "op-1", action: "discovery.contacts_saved", @@ -212,7 +198,6 @@ describe("saveAndRoute", () => { it("does NOT enqueue outreach inside the throttle window", async () => { const { repo, jobs, svc } = harness() repo.seedJurisdiction({ geoid: "1", name: "City" }) - // Last outreach 2 days ago, throttle is 7 days -> suppressed. repo.seedOutreach("1", { lastOutreachAt: new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000) }) const result = await svc.saveAndRoute( @@ -252,11 +237,8 @@ describe("saveAndRoute", () => { expect(jobs.jobsFor(OUTREACH_DIGEST_JOB)).toHaveLength(1) }) - // C1 regression: save-and-route on a freshly-onboarded jurisdiction (no prior outreach state) MUST - // enqueue the immediate digest. Before the fix the Drizzle saveAndRoute pre-stamped - // outreach_state.last_outreach_at = now(), so maybeEnqueueOutreach (and the worker) read a just-set - // timestamp and ALWAYS throttled -> the first digest could never go out on save. The in-memory repo hid - // this by omitting the stamp (C2). This asserts the enqueue fires on a never-contacted geoid. + // saveAndRoute once pre-stamped outreach_state.last_outreach_at = now(), so the enqueue throttle (and the + // worker) always read a just-set timestamp and the first digest could never go out on save. it("C1: save-and-route on a never-contacted jurisdiction ENQUEUES the immediate digest (not throttled-by-construction)", async () => { const { repo, jobs, svc } = harness() repo.seedJurisdiction({ geoid: "0644000", name: "Los Angeles" }) @@ -272,24 +254,18 @@ describe("saveAndRoute", () => { "op-1", ) - // The save did NOT stamp the send window, so the enqueue fires. expect(result.outreachEnqueued).toBe(true) expect(jobs.jobsFor(OUTREACH_DIGEST_JOB)).toHaveLength(1) - // And no outreach_state row was written by the save itself (only a SEND stamps it). + // Only a send stamps outreach_state. expect(repo.outreach.get("0644000")).toBeUndefined() }) }) -/** - * C1 + C2 end-to-end: the full discovery outreach loop over a SINGLE shared outreach_state, exactly as - * production wires it (one outreach_state table read by both the save-and-route enqueue throttle and the - * worker's runForGeoid). This is the test the review demands: it FAILS if the immediate-outreach path is - * throttled-by-construction (a save that does not enqueue, or a worker run that does not send), AND it - * proves a same-window SECOND save is throttled because the worker's send stamped the window for real. - */ +// One outreach_state shared by the save-and-route throttle and the worker, as production wires it: fails if +// the immediate path is throttled by construction, and proves a same-window second save is throttled +// because the worker's send stamped the window for real. describe("C1/C2: save -> enqueue -> worker send -> stamp -> second save throttled (shared outreach_state)", () => { it("first save sends a digest and stamps the window; the same-window second save is throttled", async () => { - // ONE outreach_state store shared by the contacts repo (enqueue throttle) + the mail repo (send stamp). const sharedOutreach = new Map() const contactsRepo = new InMemoryJurisdictionContactsRepository(sharedOutreach) contactsRepo.now = NOW @@ -302,8 +278,6 @@ describe("C1/C2: save -> enqueue -> worker send -> stamp -> second save throttle now: () => NOW, }) - // The worker side: the outreach read repo + the mail repo (sharing the SAME outreach store) + a - // FakeMailer-backed OutboundMailService, assembled into the OutreachService the worker drains. const outreachRepo = new InMemoryOutreachRepository() const mailRepo = new InMemoryMailRepository(sharedOutreach) const mailer = new FakeMailer() @@ -320,8 +294,6 @@ describe("C1/C2: save -> enqueue -> worker send -> stamp -> second save throttle now: () => NOW, }) - // Arrange the same jurisdiction + waiting report on BOTH read seams (contacts repo for save-and-route, - // outreach repo for the digest aggregation), with the routing contact the digest will resolve. contactsRepo.seedJurisdiction({ geoid: "0644000", name: "Los Angeles" }) contactsRepo.seedReport({ id: "r1", geoid: "0644000", category: "trash", status: "submitted" }) outreachRepo.seedJurisdiction({ @@ -331,7 +303,6 @@ describe("C1/C2: save -> enqueue -> worker send -> stamp -> second save throttle }) outreachRepo.seedReport({ geoid: "0644000", category: "trash" }) - // 1) Save & route: persists the contact + enqueues the immediate (throttled) outreach. const save1 = await contactsSvc.saveAndRoute( "0644000", { @@ -344,19 +315,16 @@ describe("C1/C2: save -> enqueue -> worker send -> stamp -> second save throttle expect(save1.outreachEnqueued).toBe(true) expect(jobs.jobsFor(OUTREACH_DIGEST_JOB)).toHaveLength(1) - // 2) The worker drains that enqueue (runForGeoid is exactly what the job dispatches to for {geoid}). + // runForGeoid is exactly what the job dispatches to. const run = await outreachSvc.runForGeoid("0644000") - expect(run.sent).toBe(true) // <- fails if the path were throttled-by-construction + expect(run.sent).toBe(true) expect(run.reportCount).toBe(1) expect(mailer.sent).toHaveLength(1) expect(mailer.sent[0]?.to).toBe("311@lacity.gov") - // The SEND stamped the shared window to NOW (this is the only place last_outreach_at is set now). + // The send is the only place last_outreach_at is set. expect(sharedOutreach.get("0644000")?.lastOutreachAt?.getTime()).toBe(NOW.getTime()) - // 3) A same-window SECOND save must NOT enqueue (the send already stamped the window) - and even if it - // somehow did, the worker would re-skip. This proves the throttle is real, not load-bearing on a - // save-time pre-stamp. const save2 = await contactsSvc.saveAndRoute( "0644000", { @@ -367,9 +335,9 @@ describe("C1/C2: save -> enqueue -> worker send -> stamp -> second save throttle "op-1", ) expect(save2.outreachEnqueued).toBe(false) - expect(jobs.jobsFor(OUTREACH_DIGEST_JOB)).toHaveLength(1) // still just the first enqueue + expect(jobs.jobsFor(OUTREACH_DIGEST_JOB)).toHaveLength(1) - // Defense in depth: even a forced worker re-run inside the window is a no-op (throttled, no 2nd mail). + // Defense in depth: a forced worker re-run inside the window sends no second mail. const rerun = await outreachSvc.runForGeoid("0644000") expect(rerun.sent).toBe(false) expect(rerun.skipped).toBe("throttled") @@ -390,11 +358,10 @@ describe("patch", () => { expect(j.categoryContacts.get("trash")).toBe("t@city.gov") expect(j.notes).toBe("Prefers email") expect(j.contactUpdatedAt).toEqual(NOW) - // Patch does not route or resolve. expect(repo.reports.find((r) => r.id === "r1")?.status).toBe("submitted") expect(repo.tasks.find((t) => t.id === "JUR-1")?.status).toBe("open") expect(jobs.jobsFor(OUTREACH_DIGEST_JOB)).toHaveLength(0) - // H4: the patch was audited in the repo (atomic), recorded on the audit sink. + // Audited inside the repo, atomically. expect(repo.audits.at(-1)).toMatchObject({ actorId: "op-1", action: "jurisdiction.patched", @@ -486,10 +453,8 @@ describe("listDirectory", () => { it("surfaces a synthetic 'Unmapped' row for waiting reports whose jurisdiction did not resolve", async () => { const { repo, svc } = harness() repo.seedJurisdiction({ geoid: "1", name: "Mapped City", defaultEmails: ["311@city.gov"] }) - // Reports whose geoid is NOT a seeded jurisdiction (unresolved/orphaned) and still waiting. repo.seedReport({ geoid: "ZZZ-unknown", category: "trash", status: "published" }) repo.seedReport({ geoid: "ZZZ-unknown", category: "hazard", status: "submitted" }) - // A resolved orphan must NOT count (not waiting); a mapped report must NOT leak into the bucket. repo.seedReport({ geoid: "ZZZ-unknown", category: "water", status: "resolved" }) repo.seedReport({ geoid: "1", category: "trash", status: "published" }) @@ -497,12 +462,10 @@ describe("listDirectory", () => { const unmapped = all.items.find((i) => i.geoid === "__unmapped__")! expect(unmapped).toBeDefined() expect(unmapped.org).toBe("Unmapped / Unknown jurisdiction") - expect(unmapped.reportsWaiting).toBe(2) // the published + submitted orphans; resolved excluded + expect(unmapped.reportsWaiting).toBe(2) expect(unmapped.perCategoryCounts).toEqual({ trash: 1, hazard: 1 }) expect(unmapped.method).toBe("none") - // It pins to the top of the first page. expect(all.items[0]!.geoid).toBe("__unmapped__") - // It appears under the "none" facet (no contacts) but never under email/form. expect( (await svc.listDirectory({ filter: "none" })).items.some((i) => i.geoid === "__unmapped__"), ).toBe(true) @@ -522,7 +485,6 @@ describe("listDirectory", () => { it("surfaces the jurisdiction TYPE, population, waiting counts, contacts, and flag state", async () => { const { repo, svc } = harness() - // A FEDERAL-land jurisdiction with an existing trash contact, a flag, and a mix of reports. repo.seedJurisdiction({ geoid: "FED-ANF", name: "Angeles National Forest", @@ -535,13 +497,12 @@ describe("listDirectory", () => { repo.seedReport({ geoid: "FED-ANF", category: "trash", status: "submitted" }) repo.seedReport({ geoid: "FED-ANF", category: "trash", status: "held" }) repo.seedReport({ geoid: "FED-ANF", category: "hazard", status: "published" }) - // An already-routed report must NOT be counted as waiting. repo.seedReport({ geoid: "FED-ANF", category: "trash", status: "acknowledged" }) const row = (await svc.listDirectory({})).items.find((i) => i.geoid === "FED-ANF")! expect(row.layer).toBe("federal") expect(row.population).toBe(12345) - expect(row.reportsWaiting).toBe(3) // 2 trash + 1 hazard; the acknowledged one is excluded + expect(row.reportsWaiting).toBe(3) expect(row.perCategoryCounts).toEqual({ trash: 2, hazard: 1 }) expect(row.contacts).toContainEqual({ category: "trash", email: "info@fs.usda.gov" }) expect(row.flaggedAt).not.toBeNull() @@ -562,7 +523,6 @@ describe("listDirectory", () => { it("filters by jurisdiction TYPE (layer) and scopes total/facets to it", async () => { const { repo, svc } = harness() - // A routed state, an unrouted county, and two cities (one routed, one not). repo.seedJurisdiction({ geoid: "06", name: "California", @@ -578,17 +538,15 @@ describe("listDirectory", () => { }) repo.seedJurisdiction({ geoid: "0666000", name: "San Diego", layer: "place" }) - // Cities only: the two places, and total/facets count within the type (1 routed, 1 unrouted). + // Total and facets count within the type. const cities = await svc.listDirectory({ layer: "place" }) expect(cities.items.map((i) => i.geoid).sort()).toEqual(["0644000", "0666000"]) expect(cities.total).toBe(2) expect(cities.facets).toEqual({ routed: 1, unrouted: 1 }) - // The type filter combines with the routing-posture filter (unrouted cities only -> San Diego). const unroutedCities = await svc.listDirectory({ layer: "place", filter: "none" }) expect(unroutedCities.items.map((i) => i.geoid)).toEqual(["0666000"]) - // Other single-type selections resolve to their one match. expect((await svc.listDirectory({ layer: "state" })).items.map((i) => i.geoid)).toEqual(["06"]) expect((await svc.listDirectory({ layer: "county" })).items.map((i) => i.geoid)).toEqual([ "06037", @@ -612,7 +570,6 @@ describe("listDirectory", () => { it("suppresses the synthetic 'Unmapped' row under a type filter (it has no jurisdiction type)", async () => { const { repo, svc } = harness() repo.seedJurisdiction({ geoid: "0644000", name: "Los Angeles", layer: "place" }) - // An orphaned waiting report would surface the unmapped row under "all", but never under a layer filter. repo.seedReport({ geoid: "ZZZ-unknown", category: "trash", status: "submitted" }) expect((await svc.listDirectory({})).items.some((i) => i.geoid === "__unmapped__")).toBe(true) diff --git a/services/api/test/unit/admin-keyset-precision.test.ts b/services/api/test/unit/admin-keyset-precision.test.ts index 3f5d86ce..1380eb29 100644 --- a/services/api/test/unit/admin-keyset-precision.test.ts +++ b/services/api/test/unit/admin-keyset-precision.test.ts @@ -1,9 +1,6 @@ -/** - * Admin keyset cursors must carry the column's full microsecond instant. postgres-js hands timestamptz back - * as a JS Date (millisecond precision) and serializes a Date param with toISOString(), so a cursor built - * from the Date skips every row in the anchor's millisecond on a DESC list and repeats them on an ASC one. - * Rows written by one transaction share now(), so a burst of audit rows is exactly that case. - */ +// Admin keyset cursors must carry the column's full microsecond instant. postgres-js returns timestamptz +// as a millisecond JS Date, so a cursor built from it skips rows in the anchor's millisecond on a DESC +// list and repeats them on an ASC one; rows from one transaction share now(), so audit bursts hit this. import { describe, it, expect } from "vitest" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" @@ -22,7 +19,6 @@ const ID_A = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e01" const ID_B = "0b8f3a52-7a55-4d6e-9d0c-5a8f1b7c9e02" const CURSOR = `${AT_TEXT}|${ID_A}` -/** Two rows sharing one millisecond, so a limit-1 page has a next cursor anchored on the first. */ function twoRows(extra: Record): Record[] { return [ { id: ID_A, cursor_at: AT_TEXT, ...extra }, @@ -36,7 +32,6 @@ function lastStatement(ctl: FakeSqlControl, match: RegExp): { sql: string; value return hit } -/** The page-2 statement binds the anchor as full-precision text cast to timestamptz, never a Date. */ function expectExactAnchor(ctl: FakeSqlControl, match: RegExp): void { const stmt = lastStatement(ctl, match) expect(stmt.values).toContain(AT_TEXT) diff --git a/services/api/test/unit/admin-message-broadcast.test.ts b/services/api/test/unit/admin-message-broadcast.test.ts index 3e0cfe61..3d27fee1 100644 --- a/services/api/test/unit/admin-message-broadcast.test.ts +++ b/services/api/test/unit/admin-message-broadcast.test.ts @@ -1,8 +1,5 @@ -/** - * Operator removals of chat and DM messages must reach connected clients the way a member's own delete - * does: one message_update frame for the room carrying the tombstone. Before, only the SQL ran, so every - * open web/mobile client kept rendering removed content until it refetched history. - */ +// Operator removals must broadcast the tombstone like a member's own delete, or open clients keep +// rendering removed content until they refetch history. import { describe, it, expect } from "vitest" import type { ChatMessageDTO, RoomKind } from "@civfix/shared" diff --git a/services/api/test/unit/admin-pagination.test.ts b/services/api/test/unit/admin-pagination.test.ts index 007e7352..8fc63d82 100644 --- a/services/api/test/unit/admin-pagination.test.ts +++ b/services/api/test/unit/admin-pagination.test.ts @@ -1,10 +1,5 @@ -/** - * services/admin/pagination.ts — offset-cursor clamping (L22). - * - * The offset cursor is opaque but UNAUTHENTICATED: anyone can mint one carrying any integer, and an - * unbounded OFFSET makes Postgres walk and discard that many rows per request. It was clamped at the - * lower bound only; both ends are now clamped. - */ +// The offset cursor is opaque but unauthenticated: anyone can mint one carrying any integer, and an +// unbounded OFFSET makes Postgres walk and discard that many rows per request. import { describe, it, expect } from "vitest" import { @@ -13,7 +8,7 @@ import { encodeOffsetCursor, } from "../../src/services/admin/pagination.js" -/** Mint a cursor for an arbitrary offset, bypassing encodeOffsetCursor's own lower clamp. */ +// Bypasses encodeOffsetCursor's own lower clamp. function forgeCursor(offset: number): string { return Buffer.from(JSON.stringify({ o: offset }), "utf8").toString("base64url") } diff --git a/services/api/test/unit/admin-read-audit.test.ts b/services/api/test/unit/admin-read-audit.test.ts index 9a5971f4..04377212 100644 --- a/services/api/test/unit/admin-read-audit.test.ts +++ b/services/api/test/unit/admin-read-audit.test.ts @@ -4,18 +4,10 @@ import type { Container } from "../../src/di.js" import { auditRead } from "../../src/routes/admin/_audit-read.js" import { makeFakeSql } from "../helpers/fake-sql.js" -/** - * L4: every admin READ used to be unaudited — including GET /admin/users/:id/messages, which dumps the full - * TEXT of one user's private DMs, group chats and report chats, INCLUDING messages they soft-deleted. That - * disclosure left no trace of who made it. - * - * Four per-SUBJECT reads now write an audit row (user detail, user messages, inbox detail, mail thread). - * Aggregate/list/analytics reads stay unaudited by design (documented in audit.routes.ts). These tests pin - * the helper the four routes share: it writes an audit_log row naming the operator and the subject, and it - * NEVER throws — unlike a write, a read must not 500 because the audit insert failed. - */ +// Per-subject admin reads (e.g. a user's private messages, including soft-deleted ones) must leave a trace +// of who made the disclosure. Unlike a write, a read must not 500 because the audit insert failed. -/** A minimal FastifyRequest stand-in: auditRead only uses `request.log.warn`. */ +// auditRead only uses `request.log.warn`. function fakeRequest(): { request: FastifyRequest; warnings: unknown[] } { const warnings: unknown[] = [] const request = { @@ -42,13 +34,13 @@ describe("L4: auditRead", () => { const insert = fake.statements.find((s) => /INSERT INTO audit_log/i.test(s.sql)) expect(insert).toBeDefined() - // actor_id, action, target, meta — in the writeAudit column order. + // actor_id, action, target, meta: the writeAudit column order. expect(insert?.values.slice(0, 3)).toEqual(["op-1", "user.messages_viewed", "user:u-1"]) expect(insert?.values[3]).toMatchObject({ returned: 20 }) expect(warnings).toHaveLength(0) }) - it("does NOT throw when the audit write fails — a read must not 500 on an audit hiccup", async () => { + it("does NOT throw when the audit write fails; a read must not 500 on an audit hiccup", async () => { const container = { getDb: () => { throw new Error("no DATABASE_URL configured") @@ -62,7 +54,6 @@ describe("L4: auditRead", () => { target: "inbound_email:i-1", }), ).resolves.toBeUndefined() - // The failure is surfaced in the log rather than swallowed silently. expect(warnings).toHaveLength(1) }) }) diff --git a/services/api/test/unit/admin-reports.test.ts b/services/api/test/unit/admin-reports.test.ts index 518dae00..14744ce9 100644 --- a/services/api/test/unit/admin-reports.test.ts +++ b/services/api/test/unit/admin-reports.test.ts @@ -153,7 +153,7 @@ describe("admin reports pure helpers", () => { }) }) - it("statusChangeNote reads as English to a resident — never a raw enum token", () => { + it("statusChangeNote reads as English to a resident, never a raw enum token", () => { expect(statusChangeNote("submitted")).toBe("Status set to Submitted") expect(statusChangeNote("held")).toBe("Status set to Under review") expect(statusChangeNote("published")).toBe("Status set to Published") @@ -425,7 +425,7 @@ describe("admin reports list", () => { expect((await svc.list({ q: "figueroa" })).counts.all).toBe(1) }) - it("matches an id ONLY on a full uuid — never a substring, never a non-uuid needle", async () => { + it("matches an id ONLY on a full uuid, never a substring, never a non-uuid needle", async () => { const { repo, svc } = harness() const id = "3f2b1c44-0a55-4d66-8e77-99aa00bb11cc" repo.seedReport({ id, title: "Broken swing", place: "Austin", reporter: null }) @@ -604,7 +604,7 @@ describe("admin reports mutations", () => { expect(repo.reports.get("rep-1")?.record.status).toBe("held") }) - it("setStatus SURVIVES a notifier that throws — the committed status change is not undone", async () => { + it("setStatus SURVIVES a notifier that throws; the committed status change is not undone", async () => { const { repo, notifier, emitter, svc } = harness() repo.seedReport({ id: "rep-1", @@ -745,7 +745,7 @@ describe("admin reports mutations", () => { const thread = mailRepo.seedThread({ reportId: "rep-1", jurisdictionGeoid: "0644000", - subject: "Hazard report — Los Angeles", + subject: "[civfix: DU-42-000123] Hazard report", status: "sent", }) mailRepo.seedMessage({ threadId: thread.id, direction: "out", toAddr: "311@lacity.gov" }) @@ -775,7 +775,7 @@ describe("admin reports mutations", () => { expect(mailRepo.messagesOf(thread.id).at(-1)).toMatchObject({ direction: "out", toAddr: "311@lacity.gov", - subject: "Re: Hazard report — Los Angeles", + subject: "Re: [civfix: DU-42-000123] Hazard report", }) expect([...mailRepo.threads.values()]).toHaveLength(1) expect(mailer.sent.at(-1)?.to).toBe("311@lacity.gov") diff --git a/services/api/test/unit/admin-url-field.test.ts b/services/api/test/unit/admin-url-field.test.ts index a8adac51..52bce4fd 100644 --- a/services/api/test/unit/admin-url-field.test.ts +++ b/services/api/test/unit/admin-url-field.test.ts @@ -1,16 +1,10 @@ import { describe, it, expect } from "vitest" import { httpUrlField } from "../../src/routes/admin/_route-utils.js" -/** - * L7: `formUrl` on jurisdictions + discovery contacts is persisted and re-served as an href in BOTH the - * admin console and the public jurisdiction directory. The wire schema in @civfix/shared uses Zod's - * `.url()`, which only asserts `new URL()` PARSES the string — and `javascript:`, `data:` and `vbscript:` - * all parse. A stored `javascript:` URI is therefore a stored XSS in two UIs, and in the console that is a - * full authz bypass (the CSRF cookie is JS-readable). - * - * The shared schema cannot be edited from this repo, so the scheme allowlist is enforced at the persist - * boundary. These tests pin that boundary. - */ +// `formUrl` is re-served as an href in the admin console and the public directory. Zod's `.url()` only +// checks that `new URL()` parses, and `javascript:`, `data:` and `vbscript:` all parse: a stored XSS in +// two UIs, and a full authz bypass in the console (the CSRF cookie is JS-readable). The shared schema +// is not edited from this repo, so the scheme allowlist is enforced at the persist boundary. describe("L7: httpUrlField scheme allowlist", () => { it("accepts http and https", () => { expect(httpUrlField("https://lacity.gov/report", "formUrl")).toBe("https://lacity.gov/report") diff --git a/services/api/test/unit/anon-routes.test.ts b/services/api/test/unit/anon-routes.test.ts index 27bd95a6..be775e5f 100644 --- a/services/api/test/unit/anon-routes.test.ts +++ b/services/api/test/unit/anon-routes.test.ts @@ -218,8 +218,7 @@ describe("POST /anon/reports", () => { payload: anonPayload(), }) const firstBody = first.json() - // The replay is the SAME anon session retrying: it carries the token the first submit issued, - // which is what the snapshot is keyed by (F028). + // The snapshot is keyed by the anon token, so the replay carries the token the first submit issued. const anonToken = first.headers["x-anon-token"] as string const second = await app.inject({ method: "POST", @@ -502,14 +501,9 @@ describe("claim flow", () => { }) }) -/** - * F131: every other test in this file injects `anonOverride`, so the PRODUCTION wiring in - * anon.routes.ts `service()` — the branch that supplies `raiseAbuseFlag` and `log` — was never - * executed. Both default to no-ops inside the service, so before the fix a honeypot hit in production - * wrote nothing (anon_tokens/abuse_flags never learned about the bot) and every observability line was - * discarded, while CI proved behavior production did not have. These tests boot the route with NO - * override and a scripted `sql` so the real closure runs. - */ +// Every other test here injects `anonOverride`, so the production wiring that supplies `raiseAbuseFlag` +// and `log` (both no-ops by default) never ran: a honeypot hit once wrote nothing while CI stayed green. +// These boot the route with no override and a scripted `sql` so the real closure runs. describe("F131: the PRODUCTION anon service raises abuse flags and logs", () => { interface ProdHarness { app: FastifyInstance diff --git a/services/api/test/unit/anon-service.test.ts b/services/api/test/unit/anon-service.test.ts index 0cb5a768..6307fefa 100644 --- a/services/api/test/unit/anon-service.test.ts +++ b/services/api/test/unit/anon-service.test.ts @@ -92,8 +92,8 @@ describe("submitAnonReport: held create", () => { const token = store.tokens.get("anontok-1")! expect(token.reportCount).toBe(1) expect(token.claimCode).toBeNull() - // F150: only the DIGEST of the per-report code is persisted; the plaintext lives solely in the - // one-time response above. + // Only the digest of the per-report code is persisted; the plaintext lives solely in the one-time + // response above. expect(store.reports.get("report-1")!.claimCodeHash).toBe(await sha256Hex("claim-1")) const tl = store.timeline.filter((t) => t.reportId === "report-1") @@ -485,7 +485,7 @@ describe("claim codes are persisted as a digest only (F150)", () => { it("resolves a row that carries ONLY a backfilled digest (a pre-0091 plaintext-era report)", async () => { const { store, service } = makeHarness() - // Exactly what 0091's backfill leaves behind: claim_code_hash = sha256(the old plaintext code). + // What 0091's backfill leaves behind: claim_code_hash = sha256(the old plaintext code). store.seedReport({ id: "legacy-1", anonSessionId: "anontok-legacy", @@ -570,11 +570,7 @@ function uuid(n: number): string { return `00000000-0000-4000-8000-${h}` } -/** - * Address provenance on the anonymous path. It shares one helper with the signed-in path by - * construction, so what is worth pinning here is the WIRING: the resolver is consulted only when the - * reporter typed nothing, and the resulting source/precision land on the row alongside the address. - */ +// The anonymous path shares one address helper with the signed-in path, so only the wiring is pinned here. describe("submitAnonReport: address provenance", () => { it("records the reporter's own text as 'user', with no provider precision", async () => { const { store, service } = makeHarness({ diff --git a/services/api/test/unit/auth-crypto.test.ts b/services/api/test/unit/auth-crypto.test.ts index 42aea892..6146615a 100644 --- a/services/api/test/unit/auth-crypto.test.ts +++ b/services/api/test/unit/auth-crypto.test.ts @@ -16,7 +16,6 @@ describe("auth crypto", () => { const node = createHash("sha256").update(input, "utf8").digest("hex") expect(ours).toBe(node) expect(ours).toMatch(/^[0-9a-f]{64}$/) - // Stable across calls. expect(await sha256Hex(input)).toBe(ours) }) @@ -24,7 +23,6 @@ describe("auth crypto", () => { const a = generateToken() const b = generateToken() expect(a).not.toBe(b) - // base64url of 32 bytes (no padding) decodes back to 32 bytes. expect(Buffer.from(a, "base64url").length).toBe(TOKEN_BYTES) expect(a).toMatch(/^[A-Za-z0-9_-]+$/) }) diff --git a/services/api/test/unit/auth-csrf.test.ts b/services/api/test/unit/auth-csrf.test.ts index d30413ba..73ed1811 100644 --- a/services/api/test/unit/auth-csrf.test.ts +++ b/services/api/test/unit/auth-csrf.test.ts @@ -4,7 +4,6 @@ import { AppError, ErrorCode } from "@civfix/shared" import { makeCsrf, CSRF_HEADER } from "../../src/auth/csrf.js" import { CSRF_COOKIE, SESSION_COOKIE, SESSION_COOKIE_HOST } from "../../src/auth/transport.js" -/** Minimal FastifyRequest stub carrying just the cookies + headers + url csrfProtect reads. */ function req(opts: { cookies?: Record headers?: Record @@ -30,8 +29,8 @@ async function expectForbidden(p: Promise): Promise { const SESSION = "session-token-value" -// The injected env IS the seam under test (C1): both halves are taken off ONE instance, exactly as -// di.ts builds one per container. +// The injected env is the seam under test: both halves come off one instance, as di.ts builds one per +// container. const { protect: csrfProtect, tokenForSession: csrfTokenForSession } = makeCsrf({ SESSION_SIGNING_KEY: "unit-test-session-signing-key", }) @@ -93,7 +92,6 @@ describe("csrfProtect (session-bound token, L3)", () => { }) it("is EXEMPT for bearer requests (no ambient cookie)", async () => { - // A bearer request carrying a session cookie too should still be exempt: bearer wins. await expect( csrfProtect( req({ @@ -123,7 +121,6 @@ describe("csrfProtect (session-bound token, L3)", () => { ), ) - // ...and the session-bound token still works there, so the operator console is not locked out. const token = await csrfTokenForSession(SESSION) await expect( csrfProtect( @@ -145,7 +142,6 @@ describe("csrfTokenForSession", () => { expect(a).toBe(b) expect(a).not.toBe(c) expect(a.length).toBeGreaterThan(20) - // The session token itself must not be recoverable from (or embedded in) the CSRF value. expect(a).not.toContain(SESSION) }) diff --git a/services/api/test/unit/auth-jwks.test.ts b/services/api/test/unit/auth-jwks.test.ts index 03a910fb..35851eea 100644 --- a/services/api/test/unit/auth-jwks.test.ts +++ b/services/api/test/unit/auth-jwks.test.ts @@ -7,7 +7,6 @@ const ISS = "https://accounts.google.com" const AUD = "test-client-id" const KID = "test-key-1" -/** Sign a minimal RS256 JWT with the given private key + claims. */ function signJwt(privateKey: KeyObject, claims: Record): string { const header = { alg: "RS256", kid: KID, typ: "JWT" } const enc = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url") @@ -18,7 +17,6 @@ function signJwt(privateKey: KeyObject, claims: Record): string return `${signingInput}.${sig}` } -/** Build a verifier whose JWKS fetch returns the public JWK for our generated key. */ function makeVerifier(publicKey: KeyObject, nowSeconds: number) { const jwk = { ...(publicKey.export({ format: "jwk" }) as object), kid: KID, alg: "RS256" } const fetchImpl: FetchLike = async () => ({ @@ -82,7 +80,6 @@ describe("RemoteJwksVerifier", () => { }) it("accepts a token whose aud matches any configured audience (multi-client)", async () => { - // A token minted for the iOS/Android native client id, verified against the set {web, iOS}. const token = signJwt(privateKey, { iss: ISS, aud: "ios-client", sub: "s", exp: NOW + 60 }) const verifier = makeVerifier(publicKey, NOW) const result = await verifier.verify(token, params(NOW, [AUD, "ios-client"])) @@ -103,7 +100,6 @@ describe("RemoteJwksVerifier", () => { it("rejects a token signed by the wrong key", async () => { const other = generateKeyPairSync("rsa", { modulusLength: 2048 }) const token = signJwt(other.privateKey, { iss: ISS, aud: AUD, sub: "s", exp: NOW + 60 }) - // Verifier only knows our public key, not `other`'s. const verifier = makeVerifier(publicKey, NOW) await expectUnauthorized(verifier.verify(token, params(NOW))) }) @@ -113,7 +109,7 @@ describe("RemoteJwksVerifier", () => { await expectUnauthorized(verifier.verify("not-a-jwt", params(NOW))) }) - // P2-3: nonce binding (closes ID-token replay when the client issued a nonce). + // Closes ID-token replay when the client issued a nonce. describe("nonce binding (P2-3)", () => { it("accepts a token whose nonce claim equals the expected RAW nonce", async () => { const token = signJwt(privateKey, { @@ -168,12 +164,10 @@ describe("RemoteJwksVerifier", () => { ) }) - it("a caller that passes NO expectedNonce gets no nonce checking — which is why sign-in always passes one", async () => { - // The verifier only checks what it is asked to check. That is a footgun, not a feature: it is - // exactly the shape H1 exploited when the sign-in route supplied the attacker's own nonce (or - // none). The route-level guarantee — a nonce is MANDATORY and must be one the server issued and - // has not yet spent — is asserted in auth-routes.test.ts ("server-issued single-use sign-in - // nonce (H1)"). No production call site may omit expectedNonce. + it("a caller that passes NO expectedNonce gets no nonce checking, which is why sign-in always passes one", async () => { + // The verifier only checks what it is asked to check, a footgun once exploited when sign-in + // supplied the attacker's own nonce. The route-level guarantee (mandatory, server-issued, + // single-use) is asserted in auth-routes.test.ts. No production call site may omit expectedNonce. const token = signJwt(privateKey, { iss: ISS, aud: AUD, @@ -182,7 +176,7 @@ describe("RemoteJwksVerifier", () => { nonce: "whatever", }) const verifier = makeVerifier(publicKey, NOW) - const result = await verifier.verify(token, params(NOW)) // no expectedNonce + const result = await verifier.verify(token, params(NOW)) expect(result.sub).toBe("s") }) }) diff --git a/services/api/test/unit/auth-route-mounting.test.ts b/services/api/test/unit/auth-route-mounting.test.ts index a95949a6..17309726 100644 --- a/services/api/test/unit/auth-route-mounting.test.ts +++ b/services/api/test/unit/auth-route-mounting.test.ts @@ -1,22 +1,15 @@ /** - * ENV-GATED AUTH/ADMIN ROUTE MOUNTING — regression guard for the operator-dashboard 404. - * - * The bug: admin.civfix.org returned + * Regression guard for the operator-dashboard 404: admin.civfix.org returned * {"code":"NOT_FOUND","message":"Route GET /admin/auth/session not found"} - * The request DID reach the backend (that JSON is Fastify's not-found handler). Root cause: the API - * container had booted in dev/all-fakes mode (NODE_ENV !== "production", so env.ts did not REQUIRE - * DATABASE_URL/REDIS_URL and they stayed empty). With no infra, buildServer builds no auth bundle, and - * registerRoutes then mounts NEITHER the citizen /auth/* NOR the operator /admin/* routes — so every - * such request falls through to the route-not-found handler. + * because the API booted in all-fakes mode (NODE_ENV !== "production", so DATABASE_URL/REDIS_URL were + * not required and stayed empty). With no infra there is no auth bundle, and registerRoutes mounts + * neither /auth/* nor /admin/*. * - * The deploy fix forces NODE_ENV=production in infra/compose so the env loader requires the infra (the - * API boots correctly or fails loudly). These tests lock the APPLICATION-level invariant that fix relies - * on: the auth + admin routes mount IFF an auth bundle is present, and that presence is driven by the - * DATABASE_URL/REDIS_URL env pair. + * The deploy fix forces NODE_ENV=production; these tests lock the invariant it relies on: the auth and + * admin routes mount IFF an auth bundle is present, and the DATABASE_URL/REDIS_URL pair drives that. * - * Infra-free: the "present" case asserts only that buildServer DECORATED the bundle (synchronous, before - * any ready()/inject()), so the lazily-connecting DB/Redis handles never open a socket. The end-to-end - * "mounted route responds" case uses an in-memory auth bundle. + * Infra-free: the "present" case asserts only that buildServer DECORATED the bundle, before any + * ready()/inject(), so the lazily-connecting DB/Redis handles never open a socket. */ import { describe, it, expect } from "vitest" @@ -35,25 +28,23 @@ describe("auth/admin route mounting is gated on the auth bundle", () => { it("does NOT mount /auth/* or /admin/* with no DATABASE_URL/REDIS_URL (reproduces the reported 404)", async () => { const app = await buildServer({ env: loadEnv({ NODE_ENV: "test" }) }) try { - // No infra => no auth bundle is decorated on the app. expect(app.hasDecorator("authServices")).toBe(false) - // GET /admin/auth/session is the exact failing request — it is route-missing, not a domain 404. + // The exact failing request: route-missing, not a domain 404. const admin = await app.inject({ method: "GET", url: "/v1/admin/auth/session" }) expect(admin.statusCode).toBe(404) const adminBody = admin.json() as { code?: string; message?: string } expect(adminBody.code).toBe("NOT_FOUND") expect(adminBody.message?.startsWith(routeMissingPrefix("GET"))).toBe(true) - // The citizen session route is gated by the SAME bundle, so it is route-missing too — proving the - // failure is systemic (the whole auth surface is gone), not specific to the admin plugin. + // The citizen route is gated by the SAME bundle, so the whole auth surface is gone, not just the + // admin plugin. const citizen = await app.inject({ method: "GET", url: "/v1/auth/session" }) expect(citizen.statusCode).toBe(404) expect( (citizen.json() as { message?: string }).message?.startsWith(routeMissingPrefix("GET")), ).toBe(true) - // Sanity: an always-mounted public route still answers, so the server itself is up and routing. const health = await app.inject({ method: "GET", url: "/healthz" }) expect(health.statusCode).toBe(200) } finally { @@ -62,8 +53,7 @@ describe("auth/admin route mounting is gated on the auth bundle", () => { }) it("builds the auth bundle when DATABASE_URL + REDIS_URL are present (the gate opens)", async () => { - // ioredis is lazyConnect and postgres connects on first query, so building the real bundle from env - // opens no socket. We assert the DECISION (bundle decorated) without ready()/inject(), so the bogus + // ioredis is lazyConnect and postgres connects on first query, so without ready()/inject() the bogus // URLs below are never dialed. const app = await buildServer({ env: loadEnv({ @@ -80,9 +70,8 @@ describe("auth/admin route mounting is gated on the auth bundle", () => { }) it("serves GET /admin/auth/session (200, unauthenticated) when an auth bundle is present", async () => { - // In-memory auth bundle => routes mount AND respond with zero infra. The anonymous (no-cookie) path - // returns {authenticated:false} without any DB/Redis call, which is exactly what the SPA bootstrap - // reads first before falling back to the Access exchange. + // The anonymous path answers without any DB/Redis call; it is what the SPA bootstrap reads before + // falling back to the Access exchange. const authServices = buildAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), diff --git a/services/api/test/unit/auth-session.test.ts b/services/api/test/unit/auth-session.test.ts index 900d22ed..060b0c19 100644 --- a/services/api/test/unit/auth-session.test.ts +++ b/services/api/test/unit/auth-session.test.ts @@ -406,7 +406,7 @@ describe("SessionService revoke robustness", () => { expect(await store.findById(hash)).not.toBeNull() }) - it("H3: revokeAllForUser survives a failed cache eviction — the stranded entry no longer authenticates", async () => { + it("H3: revokeAllForUser survives a failed cache eviction; the stranded entry no longer authenticates", async () => { const { service, store, cache } = makeFlakyService() const token = await service.createSession(USER, ["gov_admin"]) const hash = await sha256Hex(token) @@ -709,7 +709,7 @@ describe("sliding expiry is coarse-grained, so a hot session does not UPDATE on expect(spy).toHaveBeenCalledTimes(2) }) - it("the session still slides — it never expires under continuous use", async () => { + it("the session still slides; it never expires under continuous use", async () => { const { service, store, advance } = slidingService(60_000) const token = await service.createSession(USER, ["citizen"]) const hash = await sha256Hex(token) diff --git a/services/api/test/unit/boundaries-manifest.test.ts b/services/api/test/unit/boundaries-manifest.test.ts index 9fc3ef01..7db0a57f 100644 --- a/services/api/test/unit/boundaries-manifest.test.ts +++ b/services/api/test/unit/boundaries-manifest.test.ts @@ -1,16 +1,13 @@ /** - * Unit tests for the Path-A boundary-prep manifest (src/db/boundaries/manifest.ts). Pure: no DB, no - * network, no file IO — the manifest is deterministic for a given vintage, so we assert its shape. + * The boundary-prep manifest is deterministic for a given vintage, so its shape is asserted directly. + * It is load-bearing in three ways: + * 1. Coverage: exactly the 50 states get a per-state PLACE job (territories are not ingested yet). + * 2. Conversion args: every job reprojects to EPSG:4326; only places filter G4110. + * 3. Single-application prefix: AIANNH/PAD-US carry their prefix as METADATA only; it must NEVER leak + * into the ogr2ogr conversion args, because prefixing happens once, at ingest. * - * The manifest is load-bearing in three ways that these tests pin: - * 1. Coverage: exactly the 50 states get a per-state PLACE job (territories deferred to Design B). - * 2. Correctness of the conversion args: every job reprojects to EPSG:4326; only places filter G4110. - * 3. The single-application prefix rule: AIANNH/PAD-US carry their prefix as METADATA only — it must - * NEVER leak into the ogr2ogr conversion args (prefixing happens once, at ingest). - * - * The layer-enum drift guard ties every job.layer to JURISDICTION_LAYER_VALUES so a future enum change - * can't silently desync the manifest. The federal { expect(placeJobs).toHaveLength(50) expect(placeJobs).toHaveLength(STATE_FIPS.length) - // The baked into each place job's outFile/url must be exactly the STATE_FIPS set. const fipsFromOutFile = new Set( placeJobs.map((j) => j.outFile.replace(/^places_(\d{2})\.geojson$/, "$1")), ) @@ -49,7 +44,7 @@ describe("boundaryManifest", () => { it("points every TIGER job at the census TIGER directory", () => { const re = new RegExp(`^https://www2\\.census\\.gov/geo/tiger/TIGER${DEFAULT_TIGER_VINTAGE}/`) - // Every job EXCEPT the PAD-US federal one (USGS-hosted) is a TIGER download. + // PAD-US (federal) is USGS-hosted, not a TIGER download. const tigerJobs = jobs.filter((j) => j.layer !== "federal") expect(tigerJobs.length).toBeGreaterThan(0) for (const j of tigerJobs) { @@ -64,7 +59,6 @@ describe("boundaryManifest", () => { expect(i).toBeGreaterThanOrEqual(0) expect(j.ogr2ogrArgs[i + 1]).toBe("MTFCC='G4110'") } else { - // state / county / tribal jobs must NOT carry the G4110 MTFCC filter. expect(j.ogr2ogrArgs.join(" ")).not.toContain("MTFCC='G4110'") } } @@ -81,9 +75,8 @@ describe("boundaryManifest", () => { it("scopes the PAD-US federal job to FEDERAL manager (via OGR -sql on the Fee layer)", () => { const federal = jobs.find((j) => j.layer === "federal") expect(federal).toBeDefined() - // Federal reads the version-named Fee feature class from the national GDB via the DEFAULT OGR SQL - // dialect (reliably carries geometry), filters Mang_Type='FED', and aliases the GDB's OBJECTID/Unit_Nm - // -> GEOID/NAME (so they land as GeoJSON properties the ingest CLI reads), promoting to MultiPolygon. + // The DEFAULT OGR SQL dialect is used because it reliably carries geometry; OBJECTID/Unit_Nm are + // aliased to GEOID/NAME because those are the GeoJSON properties the ingest CLI reads. const i = argIndex(federal!.ogr2ogrArgs, "-sql") expect(i).toBeGreaterThanOrEqual(0) const sql = federal!.ogr2ogrArgs[i + 1] ?? "" @@ -92,7 +85,6 @@ describe("boundaryManifest", () => { expect(sql).toContain("AS NAME") expect(sql).toContain("Fee") expect(federal!.ogr2ogrArgs).toContain("PROMOTE_TO_MULTI") - // The federal source is the USGS ScienceBase GDB, and it extracts to a .gdb the runner reads. expect(federal!.sourceUrl).toContain("sciencebase.gov") expect(federal!.sourcePath).toMatch(/\.gdb$/) }) @@ -100,7 +92,6 @@ describe("boundaryManifest", () => { it("declares a concrete sourcePath for every job (no basename heuristic)", () => { for (const j of jobs) { expect(j.sourcePath.length).toBeGreaterThan(0) - // TIGER/AIANNH jobs convert a like-named shapefile; only the federal job reads a geodatabase. if (j.layer === "federal") expect(j.sourcePath).toMatch(/\.gdb$/) else expect(j.sourcePath).toMatch(/\.shp$/) } @@ -122,7 +113,7 @@ describe("boundaryManifest", () => { }) it("never leaks a geoid prefix into the ogr2ogr conversion args", () => { - // The prefix is applied ONCE, at ingest — it must not appear in any conversion command. + // The prefix is applied ONCE, at ingest; a second application here would double it. for (const j of jobs) { const joined = j.ogr2ogrArgs.join(" ") expect(joined).not.toContain("AIANNH-") @@ -136,15 +127,12 @@ describe("boundaryManifest", () => { expect(valid.has(j.layer)).toBe(true) } const used = new Set(jobs.map((j) => j.layer)) - // Design A ingests all five layers; the set is a subset of the enum AND exactly these five. expect(used).toEqual(new Set(["state", "county", "place", "tribal", "federal"])) }) it("documents (no code assertion needed) that federal { - // The manifest only declares WHICH layers to ingest. The resolver precedence - // (federal 0 < tribal 1 < place 2 < county 3 < state 4) lives in JURISDICTION_LAYER_RANK_CASE in - // src/db/sql/jurisdiction.ts and is drift-guarded by jurisdiction-sql.test.ts. Nothing to assert - // here beyond recording the ownership boundary. + // The resolver precedence (federal 0 < tribal 1 < place 2 < county 3 < state 4) lives in + // JURISDICTION_LAYER_RANK_CASE and is drift-guarded by jurisdiction-sql.test.ts. expect(true).toBe(true) }) @@ -156,7 +144,7 @@ describe("boundaryManifest", () => { describe("vintageTag", () => { it("is the canonical 'tiger-padus' identity shared by CI + the on-box cron", () => { expect(vintageTag(2025, "4.1")).toBe("tiger2025-padus4.1") - // Defaults to the manifest's PAD-US version when omitted (the value CI + cron both derive). + // CI and the cron both derive the tag without a version, so the default must be the manifest's. expect(vintageTag(DEFAULT_TIGER_VINTAGE)).toBe( `tiger${DEFAULT_TIGER_VINTAGE}-padus${PADUS_VERSION}`, ) diff --git a/services/api/test/unit/certificate-code.test.ts b/services/api/test/unit/certificate-code.test.ts index 65d08c70..90396299 100644 --- a/services/api/test/unit/certificate-code.test.ts +++ b/services/api/test/unit/certificate-code.test.ts @@ -1,5 +1,5 @@ /** - * The printed capability (DP §5.1). The code is what a registrar types off paper, so the tests care + * The printed capability. The code is what a registrar types off paper, so the tests care * about two things: the shape survives the round trip through the shared normalizer, and the draw is a * real CSPRNG draw over the whole alphabet. */ diff --git a/services/api/test/unit/certificate-fonts.test.ts b/services/api/test/unit/certificate-fonts.test.ts index baffe074..fad050c7 100644 --- a/services/api/test/unit/certificate-fonts.test.ts +++ b/services/api/test/unit/certificate-fonts.test.ts @@ -1,10 +1,10 @@ /** - * The vendored-font guard (DP §8.6): the same promise `scripts/copy-contract-fonts.mjs` gives the web - * app, ported to the backend's `assets/fonts/`. + * The vendored-font guard: the same promise `scripts/copy-contract-fonts.mjs` gives the web app, ported + * to the backend's `assets/fonts/`. * * A font that silently changes changes the printed document, and a font that silently DISAPPEARS takes - * the certificate endpoint down in production only — the resolver probes at render time, not at boot. So - * the manifest is re-hashed here, on every run, with no Docker and no network. + * the certificate endpoint down in production only, because the resolver probes at render time, not at + * boot. So the manifest is re-hashed here on every run. */ import { createHash } from "node:crypto" diff --git a/services/api/test/unit/certificate-layout.test.ts b/services/api/test/unit/certificate-layout.test.ts index 5a5a85d1..c5366a06 100644 --- a/services/api/test/unit/certificate-layout.test.ts +++ b/services/api/test/unit/certificate-layout.test.ts @@ -1,5 +1,5 @@ /** - * Pure pagination (DP §8.2). Measured row heights come in as plain numbers, so every page boundary in + * Pure pagination. Measured row heights come in as plain numbers, so every page boundary in * the transcript is provable without pdfkit, fonts or Docker. */ @@ -13,7 +13,7 @@ import { } from "../../src/services/certificate-layout.js" const ROW = 22 -/** DP §3.4: 17 rows fit below the page-1 furniture, 28 below a continuation header. */ +/** 17 rows fit below the page-1 furniture, 28 below a continuation header. */ const FIRST_PAGE_ROWS = 17 const CONTINUATION_ROWS = 28 @@ -70,10 +70,8 @@ describe("planPages", () => { it("paginates 1000 rows into the expected page count", () => { const pages = planPages(uniform(1000)) - // 17 on page 1 + 28 per continuation. expect(pages).toHaveLength(1 + Math.ceil((1000 - FIRST_PAGE_ROWS) / CONTINUATION_ROWS)) expect(pages.at(-1)?.endIndex).toBe(1000) - // No page may cross the content floor. for (const page of pages) expect(page.endY).toBeLessThanOrEqual(OPTS.contentFloor) }) diff --git a/services/api/test/unit/certificate-model.test.ts b/services/api/test/unit/certificate-model.test.ts index d7ff3e0e..b6022c7e 100644 --- a/services/api/test/unit/certificate-model.test.ts +++ b/services/api/test/unit/certificate-model.test.ts @@ -6,6 +6,7 @@ import { buildTranscriptModel, certificateTranslator, communitiesLabel, + EMPTY_VALUE, ledgerFingerprint, type CertificateTranslator, type TranscriptLedgerRow, @@ -133,7 +134,7 @@ describe("buildTranscriptModel", () => { expect(model.rows[0]?.creditedBy).toBe("certificate.credited_by.automatic") }) - it("prints an em dash rather than inventing a name for a titleless event row", () => { + it("prints the empty placeholder rather than inventing a name for a titleless event row", () => { const model = buildTranscriptModel({ holder: HOLDER, locale: "en", @@ -149,10 +150,10 @@ describe("buildTranscriptModel", () => { ], }) expect(model.rows[0]?.activity).toBe("E-2026-0007") - expect(model.rows[1]?.activity).toBe("—") + expect(model.rows[1]?.activity).toBe(EMPTY_VALUE) }) - it("prints an em dash for a row with no community and lists distinct communities", () => { + it("prints the empty placeholder for a row with no community and lists distinct communities", () => { const model = buildTranscriptModel({ holder: HOLDER, locale: "en", @@ -165,8 +166,8 @@ describe("buildTranscriptModel", () => { row({ id: "e", jurisdictionName: "Los Angeles", occurredAt: "2026-01-05T00:00:00.000Z" }), ], }) - expect(model.rows[0]?.community).toBe("—") - expect(model.rows[1]?.community).toBe("—") + expect(model.rows[0]?.community).toBe(EMPTY_VALUE) + expect(model.rows[1]?.community).toBe(EMPTY_VALUE) expect(model.jurisdictions).toEqual(["Santa Monica", "Los Angeles"]) }) diff --git a/services/api/test/unit/certificate-pdf.test.ts b/services/api/test/unit/certificate-pdf.test.ts index 435525c1..450b7b5a 100644 --- a/services/api/test/unit/certificate-pdf.test.ts +++ b/services/api/test/unit/certificate-pdf.test.ts @@ -1,12 +1,9 @@ /** - * PDF structure (DP §8.3/§8.4). - * * NO BYTE SNAPSHOTS. A snapshot of a PDF churns on every pdfkit patch, every font re-instancing and * every copy edit, and no reviewer can read the diff. The assertions below are structural: the header * and trailer, the page count the pure planner predicts, a byte-size band that catches both "the fonts - * failed to embed" and "subsetting is off", and the Info dictionary — which is the only reliable TEXT - * hook, because body text is written with subset glyph ids and grepping the content streams for a name - * would fail. + * failed to embed" and "subsetting is off", and the Info dictionary, which is the only reliable TEXT + * hook because body text is written with subset glyph ids. */ import { describe, expect, it } from "vitest" @@ -23,11 +20,11 @@ const CODE = "A1B2C3D4E5F6" const DISPLAY_CODE = "CFX-A1B2-C3D4-E5F6" const ISSUED_AT = new Date("2026-07-27T18:22:04.000Z") -/** English-shaped fixture copy, so the assertions do not depend on the catalog landing (WP21). */ +/** English-shaped fixture copy, so the assertions do not depend on the locale catalog. */ const t: CertificateTranslator = (key, vars) => { switch (key) { case "certificate.doc.pdf_title": - return `civfix service hours — ${String(vars?.name)} — ${String(vars?.code)}` + return `civfix service hours: ${String(vars?.name)}, ${String(vars?.code)}` case "certificate.doc.title": return "Record of Volunteer Service" case "certificate.attestation.body": @@ -150,7 +147,7 @@ describe("buildServiceHoursPdf", () => { // /Keywords expect(buf.includes(pdfString(DISPLAY_CODE))).toBe(true) // /Title - expect(buf.includes(pdfString(`civfix service hours — Jane Doe — ${DISPLAY_CODE}`))).toBe(true) + expect(buf.includes(pdfString(`civfix service hours: Jane Doe, ${DISPLAY_CODE}`))).toBe(true) }) it("writes a non-ASCII Title as UTF-16BE behind a BOM", async () => { @@ -166,7 +163,7 @@ describe("buildServiceHoursPdf", () => { }) const bytes = await buildServiceHoursPdf({ model, code: CODE, issuedAt: ISSUED_AT, t }) const buf = Buffer.from(bytes) - expect(buf.includes(pdfString(`civfix service hours — 홍길동 — ${DISPLAY_CODE}`))).toBe(true) + expect(buf.includes(pdfString(`civfix service hours: 홍길동, ${DISPLAY_CODE}`))).toBe(true) }) it("renders a Hangul transcript without throwing (the lazy CJK registration path)", async () => { @@ -203,7 +200,7 @@ describe("buildServiceHoursPdf", () => { }) /** - * DP §8.4. `CreationDate`/`ModDate` are pinned to `issuedAt`, and the renderer takes no clock, so the + * `CreationDate`/`ModDate` are pinned to `issuedAt`, and the renderer takes no clock, so the * same snapshot must produce the same bytes. If this ever fails on a pdfkit upgrade: delete it, and * redefine `document_sha256` as "the hash of the bytes currently stored". Do not fight pdfkit. */ diff --git a/services/api/test/unit/certificate-routes.test.ts b/services/api/test/unit/certificate-routes.test.ts index 98e8ccf3..d9a88f33 100644 --- a/services/api/test/unit/certificate-routes.test.ts +++ b/services/api/test/unit/certificate-routes.test.ts @@ -1,16 +1,14 @@ /** - * Service-hours certificate routes (DP §8.5) — `app.inject` over `certificateOverrides`, no DB. - * * THE HIGHEST-VALUE TEST IN THIS FILE is `presignGet(key, 900, { forceSigned: true })`. `FakeStorage` * declares `presignGet(key, _ttlSec)` and always returns `memory://`, so asserting on the returned - * STRING proves exactly nothing about the third argument — and in production `R2_PUBLIC_BASE` is set, so - * a missing `forceSigned` makes `R2Storage.presignGet` hand back an UNSIGNED, PERMANENT CDN URL. That - * would publish every volunteer's itemised service record forever, with no expiry and no revocation - * (finding H9). A thin recording wrapper is therefore injected and its ARGUMENTS are asserted. + * STRING proves nothing about the third argument, and in production `R2_PUBLIC_BASE` is set, so a + * missing `forceSigned` makes `R2Storage.presignGet` hand back an UNSIGNED, PERMANENT CDN URL. That + * would publish every volunteer's itemised service record forever, with no expiry and no revocation. + * A thin recording wrapper is therefore injected and its ARGUMENTS are asserted. * * The other rules pinned here: an empty ledger is a 409 (no empty official-looking documents), a repeat * tap reuses one document rather than minting a second, an edited ledger row mints a genuinely new one, - * revoking someone else's code is a 404 (never a 403 — no existence oracle), and the PUBLIC verification + * revoking someone else's code is a 404 (never a 403: no existence oracle), and the PUBLIC verification * projection leaks no url / userId / r2Key. */ @@ -216,7 +214,6 @@ describe("POST /me/volunteer-hours/certificates", () => { const res = await issue(h.app, me) expect(res.statusCode).toBe(409) expect(res.json().code).toBe("CONFLICT") - // Nothing was rendered or stored on the refusal path. expect(h.objects.objects.size).toBe(0) }) @@ -246,7 +243,6 @@ describe("POST /me/volunteer-hours/certificates", () => { expect(head?.contentDisposition).toBe( `inline; filename="civfix-service-hours-${formatCertificateCode(body.certificate.code)}.pdf"`, ) - // Really a PDF, not an empty buffer. expect(Buffer.from(h.objects.get(key)!).subarray(0, 5).toString()).toBe("%PDF-") }) @@ -277,7 +273,7 @@ describe("POST /me/volunteer-hours/certificates", () => { expect(second.json().reused).toBe(true) expect(h.objects.objects.size).toBe(1) expect(h.certs.all()).toHaveLength(1) - // The reuse path still mints a fresh URL — that is the whole point of calling again. + // The reuse path still mints a fresh URL; that is the whole point of calling again. expect(second.json().certificate.url).toBeTruthy() expect(h.storage.presigns).toHaveLength(2) }) @@ -291,7 +287,7 @@ describe("POST /me/volunteer-hours/certificates", () => { expect(first.statusCode).toBe(200) // The host corrects the credit. `volunteer_hours.id` survives the upsert, so the row identity is - // stable and only `hours` moves — which is exactly what the fingerprint keys on. + // stable and only `hours` moves, which is exactly what the fingerprint keys on. await h.hours.logEventHours({ actorId: "00000000-0000-4000-8000-0000000000aa", cleanupId, @@ -400,7 +396,7 @@ describe("POST /me/volunteer-hours/certificates/:code/revoke", () => { expect(second.json().reused).toBe(false) }) - it("404s (NOT 403) when the code belongs to somebody else — no existence oracle", async () => { + it("404s (NOT 403) when the code belongs to somebody else: no existence oracle", async () => { const h = await makeHarness() const owner = await h.signIn("owner@example.com", "Owner") const stranger = await h.signIn("stranger@example.com", "Stranger") @@ -415,7 +411,6 @@ describe("POST /me/volunteer-hours/certificates/:code/revoke", () => { payload: {}, }) expect(res.statusCode).toBe(404) - // The owner's document is untouched. expect(h.objects.objects.size).toBe(1) }) }) @@ -443,7 +438,7 @@ describe("certificate service: recovery branches", () => { const second = await issue(h.app, me) expect(second.statusCode).toBe(200) - // SAME document: same code, same key, no second row — a re-render, not a re-issue. + // SAME document: same code, same key, no second row. A re-render, not a re-issue. expect(second.json().certificate.code).toBe(code) expect(second.json().reused).toBe(true) expect([...h.objects.objects.keys()]).toEqual([key]) diff --git a/services/api/test/unit/cf-access.test.ts b/services/api/test/unit/cf-access.test.ts index 9c2c2ec8..cb38b898 100644 --- a/services/api/test/unit/cf-access.test.ts +++ b/services/api/test/unit/cf-access.test.ts @@ -1,11 +1,7 @@ /** - * Unit tests for the Cloudflare Access JWT verifier (doc 16 §6.7). - * - * Fully offline: a local RSA keypair is generated, the public key is served via `createLocalJWKSet` - * (injected into the verifier), and tokens are minted with jose's `SignJWT`. No network / no real CF - * JWKS. We assert the verifier ACCEPTS a well-formed token and REJECTS the spoofing vectors the security - * model depends on: wrong `aud`, wrong `iss`, expired, a signature from an unknown key, and a non-RS256 - * `alg` (alg-confusion). It also surfaces the email vs service-token (`common_name`) shapes. + * Offline: a local RSA keypair's public key is served via `createLocalJWKSet`, so no real CF JWKS is + * fetched. The verifier must REJECT every spoofing vector the security model depends on: wrong `aud`, + * wrong `iss`, expired, a signature from an unknown key, and a non-RS256 `alg` (alg-confusion). */ import { describe, it, expect } from "vitest" diff --git a/services/api/test/unit/chat-attachments-servable.test.ts b/services/api/test/unit/chat-attachments-servable.test.ts index 83d79f34..e54ff8c6 100644 --- a/services/api/test/unit/chat-attachments-servable.test.ts +++ b/services/api/test/unit/chat-attachments-servable.test.ts @@ -5,7 +5,7 @@ * state an asset is ever in at send time: `finalizeMedia` returns `{ status: "validating" }` and hands * the flip to `ready` to the media worker. The READ used to require `status = 'ready' AND served_key IS * NOT NULL`, so the WS ack and every history page came back with `attachments: []` and the client threw - * its optimistic local attachment away on reconcile — a photo-only message rendered as an empty row. + * its optimistic local attachment away on reconcile: a photo-only message rendered as an empty row. * * The reports path solved this long ago with `servedKeyExpr` / `servableMediaFilter` * (report-repository.drizzle.ts). These tests pin that chat now uses the same two fragments. @@ -195,7 +195,7 @@ describe("loadServableAttachmentsFor", () => { }) describe("makeAttachmentRepo().attach", () => { - it("still claims a finalized validating asset — the read now matches what the claim accepts", async () => { + it("still claims a finalized validating asset: the read now matches what the claim accepts", async () => { const fake = makeFakeSql([{ match: /UPDATE media_assets/, rows: [{ upload_id: UPLOAD }] }]) await makeAttachmentRepo("chat_message_id").attach( diff --git a/services/api/test/unit/chat-block-gates.test.ts b/services/api/test/unit/chat-block-gates.test.ts index e326e6af..45f60119 100644 --- a/services/api/test/unit/chat-block-gates.test.ts +++ b/services/api/test/unit/chat-block-gates.test.ts @@ -5,14 +5,13 @@ import { makeGroupChatNotifier } from "../../src/services/group-chat-notifier.js import { makeChatPowersResolver } from "../../src/services/chat-room-roles.js" /** - * Block-bypass regressions from the 2026-07-24 backend review: + * Block-bypass regressions: * - * M11 — the report-room and group-room notification fan-outs had NO block check (unlike the mention - * and reply bells in chat-bells), so a blocked user in a shared public room pushed a - * notification carrying their own name and a text preview to their target, per message. - * L10 — the chat-powers resolver's dm lane granted canPin to any participant without re-checking - * blocks, so a blocked user could pin/unpin in a thread they are cut off from, broadcasting - * each time. + * - the report-room and group-room notification fan-outs had NO block check (unlike the mention and + * reply bells in chat-bells), so a blocked user in a shared public room pushed a notification + * carrying their own name and a text preview to their target, per message; + * - the chat-powers resolver's dm lane granted canPin to any participant without re-checking blocks, + * so a blocked user could pin/unpin in a thread they are cut off from, broadcasting each time. */ const ROOM = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" @@ -87,7 +86,6 @@ describe("M11: the report-room fan-out skips blocked pairs", () => { return Promise.resolve(false) }) await notify(ROOM, message()) - // Both non-sender members are checked, each against the message author. expect(seen).toEqual([ [ACTOR, BLOCKED], [ACTOR, NEUTRAL], @@ -95,9 +93,9 @@ describe("M11: the report-room fan-out skips blocked pairs", () => { }) /** - * The dep is REQUIRED, not optional-with-a-fail-open-default. The first cut of M11 made it optional so - * offline harnesses could omit it, and the poll fan-out (chat-poll-notifier) promptly did exactly that - * — silently reproducing the bypass on the REST poll path with nothing in the type system to catch it. + * The dep is REQUIRED, not optional-with-a-fail-open-default. When it was optional so offline + * harnesses could omit it, the poll fan-out (chat-poll-notifier) did exactly that, silently + * reproducing the bypass on the REST poll path with nothing in the type system to catch it. * This is a COMPILE-time assertion: `tsc --noEmit` covers test/, so if the dep ever goes back to * optional the @ts-expect-error below becomes an unused-suppression error and typecheck fails. */ @@ -110,7 +108,7 @@ describe("M11: the report-room fan-out skips blocked pairs", () => { isMuted: () => Promise.resolve(false), roomKeyFor: (_k: "report", id: string) => `report:${id}`, } - // @ts-expect-error isBlockedEitherWay is required — omitting it must never compile. + // @ts-expect-error isBlockedEitherWay is required: omitting it must never compile. expect(makeReportChatNotifier(withoutBlocks)).toBeTypeOf("function") const withoutBlocksGroup = { @@ -121,12 +119,12 @@ describe("M11: the report-room fan-out skips blocked pairs", () => { isMuted: () => Promise.resolve(false), roomKeyFor: (_k: "group", id: string) => `group:${id}`, } - // @ts-expect-error isBlockedEitherWay is required — omitting it must never compile. + // @ts-expect-error isBlockedEitherWay is required: omitting it must never compile. expect(makeGroupChatNotifier(withoutBlocksGroup)).toBeTypeOf("function") }) it("an EXPLICIT no-blocks seam (offline harness) still bells everyone but the sender", async () => { - // The old fail-open behaviour is still available — it just has to be asked for by name now. + // The old fail-open behaviour is still available; it just has to be asked for by name now. const { notify, spy } = build(() => Promise.resolve(false)) await notify(ROOM, message()) expect(spy.recipients().sort()).toEqual([BLOCKED, NEUTRAL].sort()) @@ -160,7 +158,7 @@ describe("M11 batch seam: blockedIdsFor replaces the per-candidate gate, with th expect(spy.recipients()).toEqual([NEUTRAL]) // One query for both candidates (a 200-member room used to cost 200 round trips here)... expect(seen).toEqual([[ACTOR, [BLOCKED, NEUTRAL]]]) - // ...and the seam is authoritative: the per-candidate gate is not also run. + // The seam is authoritative: the per-candidate gate is not also run. expect(single).not.toHaveBeenCalled() }) diff --git a/services/api/test/unit/chat-edit-service.test.ts b/services/api/test/unit/chat-edit-service.test.ts index bf12757f..b879027f 100644 --- a/services/api/test/unit/chat-edit-service.test.ts +++ b/services/api/test/unit/chat-edit-service.test.ts @@ -4,12 +4,12 @@ * * - the LOST-RACE branch: the meta resolves live but the sender-gated UPDATE matches nothing * (e.g. tombstoned between the gate ladder and the write) -> 409; - * - mention re-resolution REPLACES the recorded set — an edit that drops every @mention records + * - mention re-resolution REPLACES the recorded set: an edit that drops every @mention records * an EMPTY replace (clearing the stale rows), and the resolver sees the parsed handles; * - the DM room-send re-check: a blocked-either-way peer is a plain 403 (before any state gate); * - the slur filter runs on the edited body -> 422. * - * Uses the in-memory repos (the same fakes the dm route tests use) — no DB, no server boot. + * Uses the in-memory repos (the same fakes the dm route tests use); no DB, no server boot. */ import { describe, it, expect } from "vitest" diff --git a/services/api/test/unit/chat-fanout-job.test.ts b/services/api/test/unit/chat-fanout-job.test.ts index 6606ecc0..937270bc 100644 --- a/services/api/test/unit/chat-fanout-job.test.ts +++ b/services/api/test/unit/chat-fanout-job.test.ts @@ -103,7 +103,7 @@ describe("chat.room.fanout job dispatch (H19)", () => { expect(jobs.sent[1]!.singletonKey).not.toBe(jobs.sent[0]!.singletonKey) }) - it("queues IDS ONLY — no sender name, no message body reaches pgboss.job", async () => { + it("queues IDS ONLY: no sender name, no message body reaches pgboss.job", async () => { const jobs = new RecordingJobs() const { deps } = harness() const notify = makeRoomFanoutNotifier(ROOM_FANOUT_SPEC.group, { diff --git a/services/api/test/unit/chat-gateway-fanout-handoff.test.ts b/services/api/test/unit/chat-gateway-fanout-handoff.test.ts index 3465036d..9967f0d1 100644 --- a/services/api/test/unit/chat-gateway-fanout-handoff.test.ts +++ b/services/api/test/unit/chat-gateway-fanout-handoff.test.ts @@ -131,7 +131,7 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in expect(p.notified).toEqual([]) }) - it("the enqueued job carries ids only — no body, no sender name reaches pgboss.job", async () => { + it("the enqueued job carries ids only: no body, no sender name reaches pgboss.job", async () => { const p = probe() const notify = makeGroupChatNotifier({ ...notifierDeps(p), diff --git a/services/api/test/unit/chat-group-service-gates.test.ts b/services/api/test/unit/chat-group-service-gates.test.ts index 72c2ee37..ddbf3a61 100644 --- a/services/api/test/unit/chat-group-service-gates.test.ts +++ b/services/api/test/unit/chat-group-service-gates.test.ts @@ -139,7 +139,7 @@ describe("addMembers reports the ACCEPTED invitees, independently of the members expect(res.added).toEqual([INVITEE_OK]) }) - it("never reports an invitee the block filter dropped (M12) — they must learn nothing", async () => { + it("never reports an invitee the block filter dropped (M12): they must learn nothing", async () => { const repo = fakeRepo({ blocked: [[MEMBER, INVITEE_BLOCKED_BY_MEMBER]] }) const res = await svcOver(repo).addMembers(OWNER, { id: GROUP, @@ -237,7 +237,7 @@ describe("L8: unknown group and no-access answer identically (no existence oracl }) }) -describe("F044 — group bans survive removal", () => { +describe("F044: group bans survive removal", () => { it("a moderation removal (actor !== target) writes a ban row", async () => { const repo = fakeRepo({ visibility: "public" }) await svcOver(repo).removeMember(OWNER, GROUP, MEMBER) @@ -252,7 +252,7 @@ describe("F044 — group bans survive removal", () => { expect(repo.bannedSet.has(MEMBER)).toBe(false) }) - it("a banned user can't re-join a public group — same 403 not_public, no oracle", async () => { + it("a banned user can't re-join a public group: same 403 not_public, no oracle", async () => { const repo = fakeRepo({ visibility: "public", banned: [STRANGER] }) const res = await statusOf(() => svcOver(repo).joinGroup(STRANGER, GROUP)) expect(res).toEqual({ status: 403, code: "not_public" }) @@ -272,10 +272,10 @@ describe("F044 — group bans survive removal", () => { }) /** - * F045: the invite gates used to issue ONE block-scan query per candidate (~100 per addMembers call). - * They are two bulk reads now — the invitee/actor scan and the pairwise scan — whatever the roster size. + * The invite gates used to issue ONE block-scan query per candidate (~100 per addMembers call). They + * are two bulk reads now (the invitee/actor scan and the pairwise scan), whatever the roster size. */ -describe("F045 — invite block scans are bulk, not per-candidate", () => { +describe("F045: invite block scans are bulk, not per-candidate", () => { it("issues a constant number of block queries for a 40-invitee addMembers", async () => { const repo = fakeRepo() const invitable = vi.spyOn(repo, "invitableIdsOf") diff --git a/services/api/test/unit/chat-history-window.test.ts b/services/api/test/unit/chat-history-window.test.ts index e91fe953..13e51681 100644 --- a/services/api/test/unit/chat-history-window.test.ts +++ b/services/api/test/unit/chat-history-window.test.ts @@ -1,6 +1,6 @@ /** - * Pure unit coverage for the around-mode window helpers (P2 2.4) so the split/merge/has-more math is - * proven without Docker. The DB-backed window queries are covered by chat-around-pg.test.ts. + * The around-mode split/merge/has-more math, proven without a database. The DB-backed window queries + * are covered by chat-around-pg.test.ts. */ import { describe, expect, it } from "vitest" diff --git a/services/api/test/unit/chat-mentions-gateway.test.ts b/services/api/test/unit/chat-mentions-gateway.test.ts index 57e6b71b..c4328659 100644 --- a/services/api/test/unit/chat-mentions-gateway.test.ts +++ b/services/api/test/unit/chat-mentions-gateway.test.ts @@ -1,9 +1,6 @@ /** - * Offline gateway test for CHAT @-mentions (B2): a `send` frame whose body @mentions a user (and/or carries - * mentionedUserIds) is resolved via the injected GatewayChatMentions seam, persisted (recordChatMentions), - * PROJECTED onto the broadcast + ack ChatMessageDTO.mentions, and the mentioned user is notified - * (notifyChatMention). Drives the REAL handleClientFrame over the real WsChatService + an in-memory pub/sub, - * mirroring chat-realtime.test.ts. The seam is a spy so the persist/notify wiring is asserted with no DB. + * Chat @-mentions through the REAL handleClientFrame over the real WsChatService and an in-memory + * pub/sub. The GatewayChatMentions seam is a spy, so the persist/notify wiring is asserted with no DB. */ import { describe, it, expect, beforeEach } from "vitest" @@ -112,13 +109,11 @@ describe("chat @-mentions over the gateway send path", () => { JSON.stringify({ type: "send", cleanupId: ROOM, body: "hey @bob look", clientId: "c1" }), ) - // The broadcast {type:"message"} frame B received carries the resolved mention. const msgFrame = bConn.framesOfType("message").at(-1) as { message: ChatMessageDTO } | undefined expect(msgFrame?.message.mentions.map((m) => m.id)).toEqual([BOB]) - // A's ack also carries the projected mention. const ackFrame = aConn.framesOfType("ack").at(-1) as { message: ChatMessageDTO } | undefined expect(ackFrame?.message.mentions.map((m) => m.id)).toEqual([BOB]) - // Persisted + notified exactly once for Bob (the author Alice is excluded). + // The author Alice is excluded. expect(recorded).toHaveLength(1) expect(recorded[0]!.ids).toEqual([BOB]) expect(notified).toEqual([{ mentionedUserId: BOB, actorUserId: ALICE, roomId: ROOM }]) diff --git a/services/api/test/unit/chat-neutralize-viewer-fields.test.ts b/services/api/test/unit/chat-neutralize-viewer-fields.test.ts index ac07e886..885c88ec 100644 --- a/services/api/test/unit/chat-neutralize-viewer-fields.test.ts +++ b/services/api/test/unit/chat-neutralize-viewer-fields.test.ts @@ -27,7 +27,7 @@ const base: ChatMessageDTO = { }, } -describe("F043 neutralizeChatViewerFields — no viewer-scoped state leaks in a room broadcast", () => { +describe("F043 neutralizeChatViewerFields: no viewer-scoped state leaks in a room broadcast", () => { it("blanks message.mine, reaction.mine, poll.myVote and poll option.mine", () => { const out = neutralizeChatViewerFields(base) expect(out.mine).toBe(false) diff --git a/services/api/test/unit/chat-poll-close-membership.test.ts b/services/api/test/unit/chat-poll-close-membership.test.ts index aaca81a3..f1462550 100644 --- a/services/api/test/unit/chat-poll-close-membership.test.ts +++ b/services/api/test/unit/chat-poll-close-membership.test.ts @@ -65,7 +65,7 @@ async function statusOf(run: () => Promise): Promise<{ status: number; } } -describe("F047 — closePoll requires room membership on the author branch", () => { +describe("F047: closePoll requires room membership on the author branch", () => { it("an author who is NOT a member and NOT a moderator is refused (403 poll_close_forbidden)", async () => { const { svc, close } = makeService({ isMember: false, isModerator: false }) const res = await statusOf(() => svc.closePoll({ messageId: POLL_MSG, userId: AUTHOR })) diff --git a/services/api/test/unit/chat-poll-notifier.test.ts b/services/api/test/unit/chat-poll-notifier.test.ts index 70b621a1..b9c8d990 100644 --- a/services/api/test/unit/chat-poll-notifier.test.ts +++ b/services/api/test/unit/chat-poll-notifier.test.ts @@ -1,18 +1,16 @@ /** - * P6 Task 6.3: the POLL-CREATE member fan-out (src/services/chat-poll-notifier.ts). - * - * makeContainerPollNotifier is the REST poll route's only bell path — it assembles the same two lane - * notifiers the WS gateway wiring builds (report + group) out of container primitives. It had zero tests, - * which is exactly how the M11 block gate came to be missing from this path in the first cut (a poll - * create raises a member-wide push carrying the author's name, so the same gate the message fan-out - * applies has to be here too). This file pins the module's whole contract: + * The POLL-CREATE member fan-out. makeContainerPollNotifier is the REST poll route's only bell path: it + * assembles the same two lane notifiers the WS gateway wiring builds (report + group) out of container + * primitives. Untested, it once shipped without the block gate (a poll create raises a member-wide push + * carrying the author's name, so it needs the same gate as the message fan-out). This file pins the + * module's whole contract: * * - DISPATCH: report -> `report_chat` bells (/messages/report/:id), group -> `group_chat` bells * (/messages/group/:id), cleanup -> NO fan-out at all (matching a plain cleanup send, which only * bells @-mentions / reply targets) and no member scan either. * - MUTE suppression, through the BATCH seam the module wires (mutedUserIdsFor), including that the - * lane's roomKind is carried into it — a mute on the group room must not silence the report room. - * - BLOCK suppression (M11), in BOTH directions of the block edge. + * lane's roomKind is carried into it: a mute on the group room must not silence the report room. + * - BLOCK suppression, in BOTH directions of the block edge. * - The sender is never belled; a member's own poll doesn't ring their phone. * - USE_FAKE_CHAT degrades to a no-op WITHOUT touching container.getDb(). * @@ -39,7 +37,7 @@ const stub = vi.hoisted(() => ({ muted: new Set(), /** Whether the stubbed mutes repo implements the OPTIONAL batch member lookup. */ batchMutes: true, - /** Every DB-bound leaf call, in order — lets a test assert a lane was never even scanned. */ + /** Every DB-bound leaf call, in order, so a test can assert a lane was never even scanned. */ calls: [] as string[], })) @@ -183,10 +181,10 @@ function containerFor(useFakeChat = false): Container { } /** - * The M11 gate has two shapes: the required per-candidate `isBlockedEitherWay` and the OPTIONAL batch + * The block gate has two shapes: the required per-candidate `isBlockedEitherWay` and the OPTIONAL batch * `blockedIdsAmong` (one user_blocks query for the whole roster). Production's Drizzle repo implements * both; the in-memory repo implements only the first. `blockGateCalls`, when set, makes the container hand - * out a repo carrying BOTH and records which shape the notifier actually used — the notifier treats a + * out a repo carrying BOTH and records which shape the notifier actually used. The notifier treats a * present batch seam as authoritative, so "which one ran" is the thing worth pinning. */ let blockGateCalls: string[] | undefined @@ -238,7 +236,7 @@ describe("makeContainerPollNotifier: report lane", () => { const bell = bells(A, "report_chat")[0]! expect(bell.title).toBe("Dana") expect(bell.link).toBe(`/messages/report/${ROOM}`) - // A poll is not a text message, so no preview leaks into the push body — the generic copy is used. + // A poll is not a text message, so no preview leaks into the push body; the generic copy is used. expect(bell.body).toBe("Sent you a message") }) @@ -367,11 +365,11 @@ describe("makeContainerPollNotifier: the M11 batch block seam", () => { notify("group", ROOM, pollMessage("group", ACTOR)) await flush() - // Same verdicts as the per-candidate path (both block directions suppressed, everyone else belled)... + // Same verdicts as the per-candidate path (both block directions suppressed, everyone else belled), expect(bells(A, "group_chat")).toHaveLength(1) expect(bells(B, "group_chat")).toHaveLength(0) expect(bells(C, "group_chat")).toHaveLength(0) - // ...at one round trip for the three candidates instead of three, and the per-candidate gate is not + // at one round trip for the three candidates instead of three, and the per-candidate gate is not // ALSO run (the batch seam is authoritative in chat-room-fanout-notifier). expect(calls).toEqual(["batch:3"]) }) diff --git a/services/api/test/unit/chat-presence.test.ts b/services/api/test/unit/chat-presence.test.ts index 7dc4d6eb..654e64a3 100644 --- a/services/api/test/unit/chat-presence.test.ts +++ b/services/api/test/unit/chat-presence.test.ts @@ -126,7 +126,6 @@ describe("gateway presence flow (snapshot to joiner, deltas to others)", () => { await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: ROOM })) await handleClientFrame(a1Session, JSON.stringify({ type: "join", cleanupId: ROOM })) - // B saw Alice come online exactly once. expect(bConn.framesOfType("presence").filter((f) => f.userId === ALICE)).toHaveLength(1) // Alice opens a second device: B must NOT see another "join" for Alice (she was already online). diff --git a/services/api/test/unit/chat-pubsub-redis.test.ts b/services/api/test/unit/chat-pubsub-redis.test.ts index cafd2065..4cd831ac 100644 --- a/services/api/test/unit/chat-pubsub-redis.test.ts +++ b/services/api/test/unit/chat-pubsub-redis.test.ts @@ -52,7 +52,6 @@ describe("RedisChatPubSub over ioredis-mock", () => { expect(received).toHaveLength(1) expect(JSON.parse(received[0]!).message.body).toBe("hi") - // After unsubscribe, a further publish is not delivered. await unsubscribe() await pubsub.publish(chatChannel(ROOM), "ignored") await new Promise((r) => setTimeout(r, 20)) @@ -79,13 +78,11 @@ describe("RedisChatPubSub over ioredis-mock", () => { created.push({ close: () => worker2.close(), redis: redis2 }) created.push({ close: () => Promise.resolve(), redis: root }) - // Bob's socket is on worker 2. const bConn = new MockConnection("B") await worker2.joinRoom(ROOM, bConn, BOB) // Give the subscribe a tick to register on the mock. await new Promise((r) => setTimeout(r, 20)) - // Alice sends from worker 1. const msg = await worker1.persist({ cleanupId: ROOM, userId: ALICE, diff --git a/services/api/test/unit/chat-pubsub.test.ts b/services/api/test/unit/chat-pubsub.test.ts index 5eea958a..55be4af2 100644 --- a/services/api/test/unit/chat-pubsub.test.ts +++ b/services/api/test/unit/chat-pubsub.test.ts @@ -33,10 +33,8 @@ describe("Redis pub/sub fan-out across two workers (in-memory pub/sub)", () => { const repo2 = new InMemoryChatRepository() const worker2 = new WsChatService({ repo: repo2, pubsub }) - // Bob's socket is on worker 2; he joins the room there. const bConn = new MockConnection("B-on-worker2") await worker2.joinRoom(ROOM, bConn, BOB) - // The shared pub/sub now has a subscriber for the room channel. expect(pubsub.channelCount).toBe(1) // Alice sends from worker 1: persist there, then broadcast (PUBLISH) on the shared pub/sub. @@ -48,7 +46,6 @@ describe("Redis pub/sub fan-out across two workers (in-memory pub/sub)", () => { }) await worker1.broadcast(ROOM, msg) - // Bob (worker 2) received the broadcast even though Alice's socket lives on a different worker. const frames = bConn.framesOfType("message") expect(frames).toHaveLength(1) expect((frames[0] as { message: ChatMessageDTO }).message.body).toBe("cross-worker hi") @@ -86,7 +83,6 @@ describe("Redis pub/sub fan-out across two workers (in-memory pub/sub)", () => { const conn = new MockConnection() await worker.joinRoom(ROOM, conn, BOB) - // Broadcast to a different cleanup id; this room's connection must NOT receive it. const other = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" const msg = await worker.persist({ cleanupId: other, diff --git a/services/api/test/unit/chat-reaction-service.test.ts b/services/api/test/unit/chat-reaction-service.test.ts index 9ce5ab56..4d76e5c3 100644 --- a/services/api/test/unit/chat-reaction-service.test.ts +++ b/services/api/test/unit/chat-reaction-service.test.ts @@ -1,14 +1,6 @@ /** - * Offline unit tests for the chat reaction service over the in-memory chat/dm/blocks repos (no DB, no - * Docker). Exercises the ChatReactionService seam for BOTH room kinds: - * - toggleCleanupReaction: member-gated; toggles a reaction on a cleanup-chat message and returns the - * recomputed ChatMessageDTO (count + the viewer's `mine`); a second toggle removes it; a non-member 403s; - * a missing message 404s; an unsupported emoji 422s. - * - toggleDmReaction: participant + not-blocked gated; toggles + returns the recomputed message; a - * non-participant 403s; a blocked-either-way pair 403s; a missing message 404s. - * - * The Drizzle repos + the chat_message_reactions table are covered by the Docker-gated integration suite; - * these fakes exercise the same seam. + * The chat reaction service over the in-memory chat/dm/blocks repos. The Drizzle repos and the + * chat_message_reactions table are covered by the integration suite; these fakes exercise the same seam. */ import { describe, expect, it } from "vitest" @@ -61,7 +53,7 @@ function makeHarness(): Harness { return { chat, dm, blocks, members, service } } -describe("chat reaction service — cleanup group chat", () => { +describe("chat reaction service: cleanup group chat", () => { it("toggles a reaction on, then off, recomputing the count + the viewer's mine", async () => { const h = makeHarness() await h.chat.insertMessage({ cleanupId: CLEANUP, userId: ALICE, body: "hi" }, MSG_ID) @@ -106,7 +98,7 @@ describe("chat reaction service — cleanup group chat", () => { }) }) -describe("chat reaction service — direct messages", () => { +describe("chat reaction service: direct messages", () => { it("toggles a reaction on a dm message and recomputes for the viewer", async () => { const h = makeHarness() const thread = await h.dm.openOrCreateThread(ALICE, BOB) diff --git a/services/api/test/unit/chat-realtime.test.ts b/services/api/test/unit/chat-realtime.test.ts index bd6bf546..b2bd9e76 100644 --- a/services/api/test/unit/chat-realtime.test.ts +++ b/services/api/test/unit/chat-realtime.test.ts @@ -7,18 +7,10 @@ import { InMemoryChatRepository, MockConnection } from "../helpers/chat.js" import { WsClientMessageSchema, WsServerMessageSchema, type ChatMessageDTO } from "@civfix/shared" /** - * THE PHASE-1 DONE-CRITERION, proven locally: two devices chat in real time. - * - * This drives the gateway's frame handler (handleClientFrame) with TWO mock ChatConnections through the - * REAL WsChatService over an in-memory pub/sub (the same fan-out wiring as Redis, minus the network). It - * asserts: - * - A and B (both members) join room R; A sends a message; - * - B's send() receives the broadcast {type:"message"} frame (A -> B flows); - * - A receives an {type:"ack"} with its clientId and the persisted message (optimistic reconcile) and - * EXACTLY ONE total frame for the send (no echoed {type:"message"} - P1-2 exactly-once to sender); - * - the message was persisted (history returns it); - * - a NON-member is rejected on join AND cannot send (membership gate); - * - every inbound/outbound frame validates against the shared WS schemas. + * Two devices chat in real time, proven locally: TWO mock ChatConnections drive handleClientFrame + * through the REAL WsChatService over an in-memory pub/sub (the same fan-out wiring as Redis, minus the + * network). The sender gets EXACTLY ONE frame per send (the ack, never an echoed {type:"message"}), + * because the client reconciles its optimistic copy against that ack. */ const ROOM = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" @@ -37,7 +29,6 @@ let pubsub: InMemoryChatPubSub let repo: InMemoryChatRepository let presence: InMemoryChatPresence -/** Build a fresh gateway session for a user over a mock connection. */ function sessionFor(userId: string, conn: MockConnection): GatewaySession { const deps: GatewayDeps = { chat, isMember: memberOf, presence } return { @@ -49,12 +40,10 @@ function sessionFor(userId: string, conn: MockConnection): GatewaySession { } } -/** Assert a raw frame string parses as a valid client frame (outbound-from-client direction). */ function assertClientFrame(raw: string): void { expect(WsClientMessageSchema.safeParse(JSON.parse(raw)).success).toBe(true) } -/** Assert a raw frame string parses as a valid server frame (outbound-to-client direction). */ function assertServerFrame(raw: string): void { const parsed = WsServerMessageSchema.safeParse(JSON.parse(raw)) expect(parsed.success, `frame failed server schema: ${raw}`).toBe(true) @@ -76,7 +65,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { const aSession = sessionFor(ALICE, aConn) const bSession = sessionFor(BOB, bConn) - // Both join room R (both members). The join frames are valid client frames. const joinFrameA = JSON.stringify({ type: "join", cleanupId: ROOM }) const joinFrameB = JSON.stringify({ type: "join", cleanupId: ROOM }) assertClientFrame(joinFrameA) @@ -95,7 +83,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { const bSnap = bConn.framesOfType("presence_snapshot")[0]! as { userIds: string[] } expect([...bSnap.userIds].sort()).toEqual([ALICE, BOB].sort()) - // A sends a message with a client-generated id. const clientId = "client-temp-1" const sendFrame = JSON.stringify({ type: "send", @@ -106,7 +93,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { assertClientFrame(sendFrame) await handleClientFrame(aSession, sendFrame) - // ---- B received the broadcast {type:"message"} (the A -> B real-time path) ---- const bMessages = bConn.framesOfType("message") expect(bMessages).toHaveLength(1) const broadcast = bMessages[0]! as { type: "message"; message: ChatMessageDTO } @@ -117,7 +103,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { expect(broadcast.message.from?.id).toBe(ALICE) expect(broadcast.message.cleanupId).toBe(ROOM) - // ---- A received an {type:"ack"} carrying its clientId + the persisted message ---- const aAcks = aConn.framesOfType("ack") expect(aAcks).toHaveLength(1) const ack = aAcks[0]! as { type: "ack"; clientId: string; message: ChatMessageDTO } @@ -125,7 +110,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { expect(ack.message.id).toBe(broadcast.message.id) expect(ack.message.body).toBe("hello bob") - // ---- P1-2: the sender receives EXACTLY ONE frame for the send (the ack), NOT the broadcast ---- // A's socket is in the room, but it is excluded from the broadcast fan-out, so it never gets a // {type:"message"} echo of its own message. The ack is its only copy (exactly-once to the sender). expect(aConn.framesOfType("message")).toHaveLength(0) @@ -137,14 +121,12 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { expect(aFramesAfterJoin).toHaveLength(1) expect(aFramesAfterJoin[0]!.type).toBe("ack") - // Validate EVERY outbound frame on both sockets against the server schema (the broadcast frame B got - // must NOT carry the internal excludeConnId hint - it is stripped before the wire). + // The internal excludeConnId hint must be stripped before the wire. for (const raw of [...aConn.sent, ...bConn.sent]) assertServerFrame(raw) expect( JSON.parse(bConn.sent.find((s) => JSON.parse(s).type === "message")!), ).not.toHaveProperty("excludeConnId") - // ---- The message was persisted: history returns it ---- const page = await chat.history(ROOM, undefined, 50) expect(page.items).toHaveLength(1) expect(page.items[0]!.id).toBe(broadcast.message.id) @@ -156,7 +138,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { const mConn = new MockConnection("M") const mSession = sessionFor(MALLORY, mConn) - // Join is rejected with an error frame; the room does not admit Mallory. await handleClientFrame(mSession, JSON.stringify({ type: "join", cleanupId: ROOM })) const joinErrors = mConn.framesOfType("error") expect(joinErrors).toHaveLength(1) @@ -164,7 +145,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { expect(chat.roomSize(ROOM)).toBe(0) expect(mSession.joined.has(ROOM)).toBe(false) - // Send is rejected too; nothing is persisted and no message is broadcast. await handleClientFrame( mSession, JSON.stringify({ type: "send", cleanupId: ROOM, clientId: "x", body: "intrude" }), @@ -175,7 +155,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { expect(mConn.framesOfType("ack")).toHaveLength(0) expect(repo.count(ROOM)).toBe(0) - // Every outbound frame (the two error frames) is a valid server frame. for (const raw of mConn.sent) assertServerFrame(raw) }) @@ -185,19 +164,16 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { const aSession = sessionFor(ALICE, aConn) const bSession = sessionFor(BOB, bConn) - // Only A joins; B is a member but has not opened the room. await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: ROOM })) await handleClientFrame( aSession, JSON.stringify({ type: "send", cleanupId: ROOM, clientId: "c1", body: "anyone?" }), ) - // B received nothing (not in the room), but the message is persisted and A is acked. expect(bConn.framesOfType("message")).toHaveLength(0) expect(aConn.framesOfType("ack")).toHaveLength(1) expect(repo.count(ROOM)).toBe(1) - // Now B joins and a SECOND message reaches B. await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: ROOM })) await handleClientFrame( aSession, @@ -218,11 +194,9 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: ROOM })) expect(chat.roomSize(ROOM)).toBe(2) - // B leaves. await handleClientFrame(bSession, JSON.stringify({ type: "leave", cleanupId: ROOM })) expect(chat.roomSize(ROOM)).toBe(1) - // A sends; B (left) receives nothing further. const beforeB = bConn.framesOfType("message").length await handleClientFrame( aSession, @@ -230,7 +204,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { ) expect(bConn.framesOfType("message").length).toBe(beforeB) - // A leaves -> the room empties and the pub/sub channel is unsubscribed. await handleClientFrame(aSession, JSON.stringify({ type: "leave", cleanupId: ROOM })) expect(chat.roomSize(ROOM)).toBe(0) expect(pubsub.channelCount).toBe(0) @@ -296,7 +269,6 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { pubsubCloseSpy.mock.invocationCallOrder[0]!, ) - // Nothing survives shutdown: no room, no channel, no delivery on either side. expect(chat.roomSize(ROOM)).toBe(0) expect(pubsub.channelCount).toBe(0) const framesBefore = aConn.sent.length @@ -348,20 +320,18 @@ describe("history `before` cursor is room-scoped (P1-5)", () => { const roomB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" repo.registerSender({ id: ALICE, displayName: "Alice" }) - // Room A has 3 messages; room B has 1. const a1 = await repo.insertMessage({ cleanupId: roomA, userId: ALICE, body: "a1" }, "a1-id") const a2 = await repo.insertMessage({ cleanupId: roomA, userId: ALICE, body: "a2" }, "a2-id") const a3 = await repo.insertMessage({ cleanupId: roomA, userId: ALICE, body: "a3" }, "a3-id") const b1 = await repo.insertMessage({ cleanupId: roomB, userId: ALICE, body: "b1" }, "b1-id") - // Paging room A with room B's message id as the `before` cursor: the foreign id is not found in - // room A, so we get room A's newest page (a3, a2) - never b1, never a B-timestamp-carved window. + // A foreign cursor is not found in room A, so the result is room A's newest page, never b1 and never + // a window carved at B's timestamp. const page = await chat.history(roomA, b1.id, 2) expect(page.items.map((m) => m.id)).toEqual([a3.id, a2.id]) expect(page.items.every((m) => m.cleanupId === roomA)).toBe(true) expect(page.items.some((m) => m.id === b1.id)).toBe(false) - // Control: a valid in-room cursor pages correctly. const within = await chat.history(roomA, a3.id, 2) expect(within.items.map((m) => m.id)).toEqual([a2.id, a1.id]) }) diff --git a/services/api/test/unit/chat-reply-gateway.test.ts b/services/api/test/unit/chat-reply-gateway.test.ts index 7012f5d7..8386d4b3 100644 --- a/services/api/test/unit/chat-reply-gateway.test.ts +++ b/services/api/test/unit/chat-reply-gateway.test.ts @@ -1,15 +1,15 @@ /** - * WS-layer reply test (P2 2.5, closing the 2.3 gap): drives the REAL handleClientFrame over the real - * WsChatService + in-memory pub/sub + InMemoryChatRepository (which mirrors the drizzle reply - * validation/hydration), asserting that: + * Replies at the WS layer: drives the REAL handleClientFrame over the real WsChatService + in-memory + * pub/sub + InMemoryChatRepository (which mirrors the drizzle reply validation/hydration), asserting + * that: * * - a `send` frame carrying replyToId persists the reply and BOTH the ack and the broadcast frame * carry replyToId + the hydrated replyTo preview; - * - the P2 2.5 reply bell seam (deps.onChatReply) fires exactly once for the target message's + * - the reply bell seam (deps.onChatReply) fires exactly once for the target message's * sender, and NOT when the author replies to their own message; * - MENTION-vs-REPLY DEDUPE: when the replied-to user is also @-mentioned in the same message, the - * mention bell (notifyChatMention) is suppressed for that user — but the mention is still - * RECORDED and projected — while a different mentioned user still gets their mention bell; + * mention bell (notifyChatMention) is suppressed for that user (the mention is still RECORDED + * and projected), while a different mentioned user still gets their mention bell; * - a replyToId pointing nowhere surfaces the machine subcode as a room-stamped error frame * (reply_wrong_room) and produces no ack. */ @@ -124,7 +124,6 @@ describe("replies over the gateway send path (P2 2.5)", () => { await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: ROOM })) await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: ROOM })) - // Alice posts the original. await handleClientFrame( aSession, JSON.stringify({ type: "send", cleanupId: ROOM, body: "original text", clientId: "c1" }), @@ -132,7 +131,6 @@ describe("replies over the gateway send path (P2 2.5)", () => { const originalAck = aConn.framesOfType("ack").at(-1) as { message: ChatMessageDTO } const originalId = originalAck.message.id - // Bob replies to it. await handleClientFrame( bSession, JSON.stringify({ @@ -162,7 +160,6 @@ describe("replies over the gateway send path (P2 2.5)", () => { from: { id: ALICE, displayName: "Alice" }, }) - // The reply bell fired exactly once, targeting Alice (the replied-to sender). expect(seams.replyBells).toEqual([ { kind: "cleanup", roomId: ROOM, actorUserId: BOB, targetUserId: ALICE }, ]) @@ -222,7 +219,7 @@ describe("replies over the gateway send path (P2 2.5)", () => { ) await flush() - // Both mentions recorded + projected (the row/broadcast are NOT deduped — only the bell is). + // The row and broadcast are NOT deduped; only the bell is. expect(seams.recorded).toHaveLength(1) expect(seams.recorded[0]!.ids.sort()).toEqual([BOB, CARA].sort()) const ack = aConn.framesOfType("ack").at(-1) as { message: ChatMessageDTO } diff --git a/services/api/test/unit/chat-room-roles.test.ts b/services/api/test/unit/chat-room-roles.test.ts index e79eacc3..c6702cd5 100644 --- a/services/api/test/unit/chat-room-roles.test.ts +++ b/services/api/test/unit/chat-room-roles.test.ts @@ -1,7 +1,6 @@ /** - * Offline unit tests for the P3 chat-room powers resolver (src/services/chat-room-roles.ts) — - * the SINGLE source of truth for who may pin / delete-others in a chat room. Full §3.4 matrix, - * stubbed deps, no DB: + * The chat-room powers resolver is the SINGLE source of truth for who may pin / delete-others in a + * chat room. The full matrix: * * dm participant -> pin YES, delete-others NO (always), not a moderator * dm non-participant -> nothing @@ -14,7 +13,7 @@ * unknown / non-member -> nothing * * Lane isolation is asserted with throwing stubs: each room kind may only consult its own - * lookup(s) (dm -> participant; cleanup -> cleanup role ONLY — operator status is deliberately + * lookup(s) (dm -> participant; cleanup -> cleanup role ONLY, operator status is deliberately * never consulted there; report -> report role + global role). */ @@ -30,7 +29,7 @@ const ROOM = "cccccccc-cccc-cccc-cccc-cccccccccccc" const NONE: ChatPowers = { canPin: false, canDeleteOthers: false, isModerator: false } -/** Deps where every lookup throws unless overridden — proves which lanes are consulted. */ +/** Deps where every lookup throws unless overridden, which proves which lanes are consulted. */ function deps(overrides: Partial): ChatRoomRoleDeps { const never = (name: string) => async () => { throw new Error(`unexpected dep call: ${name}`) @@ -45,7 +44,7 @@ function deps(overrides: Partial): ChatRoomRoleDeps { } } -describe("resolveChatPowers — dm rooms", () => { +describe("resolveChatPowers: dm rooms", () => { it("participant: canPin, NEVER canDeleteOthers, not a moderator (peer power, not moderation)", async () => { const resolve = makeChatPowersResolver(deps({ isDmParticipant: async () => true })) await expect(resolve({ roomKind: "dm", roomId: ROOM, userId: USER })).resolves.toEqual({ @@ -61,14 +60,14 @@ describe("resolveChatPowers — dm rooms", () => { }) it("consults ONLY the participant lookup (an operator gets nothing extra in a dm)", async () => { - // cleanupRoleOf / reportChatRoleOf / globalRoleOf all throw — resolving must not touch them. + // cleanupRoleOf / reportChatRoleOf / globalRoleOf all throw: resolving must not touch them. const resolve = makeChatPowersResolver(deps({ isDmParticipant: async () => true })) const powers = await resolve({ roomKind: "dm", roomId: ROOM, userId: USER }) expect(powers.canDeleteOthers).toBe(false) }) }) -describe("resolveChatPowers — cleanup rooms", () => { +describe("resolveChatPowers: cleanup rooms", () => { it("organizer: canPin AND canDeleteOthers (room moderator)", async () => { const resolve = makeChatPowersResolver(deps({ cleanupRoleOf: async () => "organizer" })) await expect(resolve({ roomKind: "cleanup", roomId: ROOM, userId: USER })).resolves.toEqual({ @@ -101,7 +100,7 @@ describe("resolveChatPowers — cleanup rooms", () => { ) }) - it("operator gets NOTHING beyond their cleanup role — globalRoleOf is never even consulted", async () => { + it("operator gets NOTHING beyond their cleanup role: globalRoleOf is never even consulted", async () => { // globalRoleOf throws; a plain-member operator must resolve to nothing without touching it. const resolve = makeChatPowersResolver(deps({ cleanupRoleOf: async () => "member" })) await expect(resolve({ roomKind: "cleanup", roomId: ROOM, userId: USER })).resolves.toEqual( @@ -110,7 +109,7 @@ describe("resolveChatPowers — cleanup rooms", () => { }) }) -describe("resolveChatPowers — report rooms", () => { +describe("resolveChatPowers: report rooms", () => { it("owner: canPin but NOT canDeleteOthers (public civic space); still a moderator", async () => { const resolve = makeChatPowersResolver( deps({ reportChatRoleOf: async () => "owner", globalRoleOf: async () => "citizen" }), @@ -177,8 +176,8 @@ describe("resolveChatPowers — report rooms", () => { }) }) -describe("resolveChatPowers — group rooms (P4)", () => { - it("owner: both powers + moderator (only groupRoleOf consulted — throwing stubs prove isolation)", async () => { +describe("resolveChatPowers: group rooms (P4)", () => { + it("owner: both powers + moderator (only groupRoleOf consulted; throwing stubs prove isolation)", async () => { const resolve = makeChatPowersResolver(deps({ groupRoleOf: async () => "owner" })) await expect(resolve({ roomKind: "group", roomId: ROOM, userId: USER })).resolves.toEqual({ canPin: true, @@ -208,7 +207,7 @@ describe("resolveChatPowers — group rooms (P4)", () => { }) }) -describe("resolveChatPowers — unknown room kind", () => { +describe("resolveChatPowers: unknown room kind", () => { it("resolves to nothing without consulting any lookup", async () => { const resolve = makeChatPowersResolver(deps({})) await expect( diff --git a/services/api/test/unit/chat-system-columns.test.ts b/services/api/test/unit/chat-system-columns.test.ts index 0f06f240..0af86076 100644 --- a/services/api/test/unit/chat-system-columns.test.ts +++ b/services/api/test/unit/chat-system-columns.test.ts @@ -1,10 +1,7 @@ /** - * Task D-B1: widen the Drizzle mirror of chat_messages for sender-less SYSTEM messages (report - * status/timeline events posted into report chat) and sync the related enum tuples. No database - * needed: this only inspects the Drizzle table config + the const tuples in schema/types.ts. - * - * Nothing CONSUMES system_* yet (producers land in D-C1, triggers in D-D1, renderers later); this - * test only asserts the storage + enum widening this task is responsible for. + * The Drizzle mirror of chat_messages carries the columns for sender-less SYSTEM messages (report + * status/timeline events posted into report chat), and the related enum tuples stay in sync. Only the + * Drizzle table config and the const tuples in schema/types.ts are inspected, so no database is needed. */ import { describe, expect, it } from "vitest" diff --git a/services/api/test/unit/chat-typing.test.ts b/services/api/test/unit/chat-typing.test.ts index 3f68c22a..fd6d794c 100644 --- a/services/api/test/unit/chat-typing.test.ts +++ b/services/api/test/unit/chat-typing.test.ts @@ -57,7 +57,6 @@ describe("typing fan-out", () => { expect(bTyping[0]).toMatchObject({ type: "typing", cleanupId: ROOM, userId: ALICE }) expect(aConn.framesOfType("typing")).toHaveLength(0) - // The emitted frame is a valid server frame. expect(WsServerMessageSchema.safeParse(bTyping[0]).success).toBe(true) }) @@ -72,7 +71,6 @@ describe("typing fan-out", () => { expect(mConn.framesOfType("error")).toHaveLength(1) expect((mConn.framesOfType("error")[0] as { code: string }).code).toBe("FORBIDDEN") - // The member in the room saw no typing from the non-member. expect(aConn.framesOfType("typing")).toHaveLength(0) }) diff --git a/services/api/test/unit/city-forward-wiring.test.ts b/services/api/test/unit/city-forward-wiring.test.ts index 7ccf8fe3..d36b11df 100644 --- a/services/api/test/unit/city-forward-wiring.test.ts +++ b/services/api/test/unit/city-forward-wiring.test.ts @@ -183,7 +183,7 @@ describe("chat-gateway-wiring: the LIVE @city forward gate is the durable thrott expect(sent[0]!.input.toAddr).toBe("fix@sf.gov") }) - it("caps one sender across DISTINCT reports — rotating report ids no longer buys a fresh email", async () => { + it("caps one sender across DISTINCT reports: rotating report ids no longer buys a fresh email", async () => { const onReportMessage = wire(new InMemoryCounterStore()) for (let i = 0; i < CITY_FORWARD_PER_SENDER_PER_HOUR + 5; i++) { @@ -218,7 +218,7 @@ describe("chat-gateway-wiring: the LIVE @city forward gate is the durable thrott expect(keys).toContain("citfwd:geoid:0600001") }) - it("FAILS CLOSED — no government email leaves when the shared counter store is down", async () => { + it("FAILS CLOSED: no government email leaves when the shared counter store is down", async () => { const broken = new InMemoryCounterStore() vi.spyOn(broken, "incr").mockRejectedValue(new Error("redis down")) const onReportMessage = wire(broken) diff --git a/services/api/test/unit/city-reply-quote-stripping.test.ts b/services/api/test/unit/city-reply-quote-stripping.test.ts index af220314..b7492058 100644 --- a/services/api/test/unit/city-reply-quote-stripping.test.ts +++ b/services/api/test/unit/city-reply-quote-stripping.test.ts @@ -121,7 +121,7 @@ describe("cityReplyChatBody: HTML-only replies go through htmlToText, not mailpa const out = await publishedFromHtml( `
Pickup scheduled—Tuesday.

From: civfix Reports <${ADDR}>
Sent: Tuesday
A resident reported a Trash issue.
`, ) - expect(out).toBe("Pickup scheduled—Tuesday.") + expect(out).toBe("Pickup scheduled\u2014Tuesday.") }) it("Apple Mail: keeps inline links and drops an unattributed trailing blockquote", async () => { diff --git a/services/api/test/unit/claim-service.test.ts b/services/api/test/unit/claim-service.test.ts index 285cb351..405d76c3 100644 --- a/services/api/test/unit/claim-service.test.ts +++ b/services/api/test/unit/claim-service.test.ts @@ -7,13 +7,11 @@ import type { ReportOwner } from "../../src/services/report-service.js" import { InMemoryAnonStore } from "../helpers/anon.js" /** - * Offline unit tests for the account-claim service: the post-submit nudge and the single-use claim that - * links a held anon report to a signed-in account. Run against the in-memory anon store; the ReportDTO - * projection is faked (a tiny stub that echoes the now-owner). + * The account-claim service: the post-submit nudge and the single-use claim that links a held anon + * report to a signed-in account. * - * F150 shapes both paths: only sha256(code) is ever stored, so the service hashes a presented code - * before matching, and the nudge - which cannot read a code back - MINTS a fresh one and stamps its - * digest onto the pending report. + * Only sha256(code) is ever stored, so the service hashes a presented code before matching, and the + * nudge, which cannot read a code back, MINTS a fresh one and stamps its digest onto the pending report. */ const SIGNING_KEY = "test-anon-signing-key" @@ -124,7 +122,6 @@ describe("claimReport", () => { const res = await service.claimReport("claim-xyz", "user-1") expect(res.report.id).toBe(reportId) expect(res.report.mine).toBe(true) - // The report is now owned by the user. expect(store.reports.get(reportId)!.reporterUserId).toBe("user-1") }) @@ -154,9 +151,8 @@ describe("claimReport", () => { it("P2-5: two reports under ONE token are each independently claimable by their own code", async () => { const { store, service } = harness() - // One token, two held reports, each with its OWN per-report claim code (0005), stored as digests - // (0091). Before the fix the single anon_tokens.claim_code column held only the LATEST code, so the - // first report was unclaimable. + // When the single anon_tokens.claim_code column held only the LATEST code, the first report under + // a token was unclaimable. const token = store.seedToken({ id: "tok-multi" }) const r1 = store.seedReport({ id: "rep-a", @@ -175,18 +171,15 @@ describe("claimReport", () => { createdAt: new Date(Date.UTC(2026, 0, 1, 0, 1, 0)), }) - // The OLDER report (rep-a) is claimable by its own code - the whole point of the fix. const claimedA = await service.claimReport("code-a", "user-1") expect(claimedA.report.id).toBe(r1.id) expect(store.reports.get(r1.id)!.reporterUserId).toBe("user-1") - // rep-b is untouched and still claimable independently by ITS code. expect(store.reports.get(r2.id)!.reporterUserId).toBeNull() const claimedB = await service.claimReport("code-b", "user-2") expect(claimedB.report.id).toBe(r2.id) expect(store.reports.get(r2.id)!.reporterUserId).toBe("user-2") - // Each code is single-use afterward. await expect(service.claimReport("code-a", "user-3")).rejects.toMatchObject({ code: "NOT_FOUND", }) diff --git a/services/api/test/unit/cleanup-complete.test.ts b/services/api/test/unit/cleanup-complete.test.ts index 5937c43f..700a9875 100644 --- a/services/api/test/unit/cleanup-complete.test.ts +++ b/services/api/test/unit/cleanup-complete.test.ts @@ -21,7 +21,7 @@ let service: CleanupService /** * DECISIONS §40: `completeCleanup` is DEPRECATED. Status is derived from the clock, so the endpoint - * writes nothing at all — it stays registered only so a mobile binary older than 0.46.0 neither crashes + * writes nothing at all; it stays registered only so a mobile binary older than 0.46.0 neither crashes * nor changes state. These tests pin the no-op contract: auth still applies, the call is idempotent, and * neither the stored status, `completed_at` nor the timeline moves. */ @@ -60,7 +60,7 @@ beforeEach(() => { }) }) -describe("completeCleanup — the deprecated no-op contract", () => { +describe("completeCleanup: the deprecated no-op contract", () => { it("returns the event unchanged: no status write, no completed_at, no timeline row", async () => { const id = seedEvent() @@ -83,7 +83,7 @@ describe("completeCleanup — the deprecated no-op contract", () => { expect(statusRows(id)).toEqual([]) }) - it("no longer refuses an event that has not started — it simply does nothing", async () => { + it("no longer refuses an event that has not started: it simply does nothing", async () => { const id = seedEvent({ scheduledAt: FUTURE, endsAt: new Date(FUTURE.getTime() + 4 * 3_600_000), @@ -126,7 +126,7 @@ describe("completeCleanup — the deprecated no-op contract", () => { }) }) -describe("completeCleanup — authorization still applies", () => { +describe("completeCleanup: authorization still applies", () => { it("the organizer and a cohost may call it", async () => { const id = seedEvent() @@ -163,7 +163,7 @@ describe("completeCleanup — authorization still applies", () => { }) }) -describe("completeCleanup — B19: completion rings nobody", () => { +describe("completeCleanup (B19): completion rings nobody", () => { it("emits NO notification, on a first call or a repeat", async () => { const bells: string[] = [] const svc = makeCleanupService({ diff --git a/services/api/test/unit/cleanup-service.test.ts b/services/api/test/unit/cleanup-service.test.ts index 909c0b4c..801ce442 100644 --- a/services/api/test/unit/cleanup-service.test.ts +++ b/services/api/test/unit/cleanup-service.test.ts @@ -1324,7 +1324,7 @@ describe("M17: attendee removal is enforceable (cleanup_bans)", () => { expect(joined.joined).toBe(true) }) - it("leaving voluntarily does NOT ban you — you can RSVP again", async () => { + it("leaving voluntarily does NOT ban you: you can RSVP again", async () => { const created = await svc.createCleanup(baseInput(), ORG) await svc.joinCleanup(created.id, BOB) await svc.leaveCleanup(created.id, BOB) @@ -1390,7 +1390,7 @@ describe("M18: promote/demote notification bombing", () => { expect(err).toMatchObject({ code: "RATE_LIMITED" }) }) - it("the cap is per TARGET — throttling one attendee does not lock out the roster", async () => { + it("the cap is per TARGET: throttling one attendee does not lock out the roster", async () => { repo.seedUser({ id: ALICE, displayName: "Alice" }) repo.seedUser({ id: BOB, displayName: "Bob" }) const svc = makeCleanupService({ diff --git a/services/api/test/unit/cleanup-slots-claim.test.ts b/services/api/test/unit/cleanup-slots-claim.test.ts index 61c0aa14..5d26b9fb 100644 --- a/services/api/test/unit/cleanup-slots-claim.test.ts +++ b/services/api/test/unit/cleanup-slots-claim.test.ts @@ -1,5 +1,5 @@ /** - * P9 signup slots — the CLAIM half (B27/B28), against the in-memory CleanupRepository. + * Signup slots, the CLAIM half, against the in-memory CleanupRepository. * * `PUT /cleanups/:id/slot` is ONE endpoint for claim, MOVE and release, because the v1 rule is exactly * one slot per person per event, so "my slot on this event" is a singular resource. That shape is what @@ -8,14 +8,14 @@ * * The rules pinned here are the ones a reader would otherwise have to reconstruct from the SQL: * - * - claiming AUTO-RSVPs a non-member (B28b) — picking a shift IS an RSVP — but the BAN probe runs - * first, so a removed attendee cannot re-enter through the slot door (M17's whole point); + * - claiming AUTO-RSVPs a non-member (picking a shift IS an RSVP), but the BAN probe runs first, so + * a removed attendee cannot re-enter through the slot door; * - an idempotent re-claim of a slot you already hold must NOT run the capacity check, or it 409s on * a full slot you are already sitting in; * - releasing does NOT leave the event: asymmetric on purpose; - * - claiming is refused on a done/cancelled event (B28e), because logEventHours requires current + * - claiming is refused on a done/cancelled event, because logEventHours requires current * membership and the auto-RSVP would therefore be a credit-laundering path; - * - leaving and being removed both FREE the seat (B28d) — otherwise a departed attendee occupies a + * - leaving and being removed both FREE the seat; otherwise a departed attendee occupies a * phantom-full slot forever. * * The genuinely concurrent half (N racers on a capacity-1 slot) can only be shown against a real @@ -47,7 +47,7 @@ let repo: InMemoryCleanupRepository let service: CleanupService /** * Per-test counter store. A service constructed WITHOUT one falls back to a module-level singleton - * (cleanup-service.ts `fallbackCounters`), which is process-wide — so without this every test in the + * (cleanup-service.ts `fallbackCounters`), which is process-wide, so without this every test in the * file would spend the same (event, user) flip budget and the later ones would 429 for no reason. */ let counters: InMemoryCounterStore @@ -86,7 +86,7 @@ describe("claim", () => { const dto = await service.claimEventSlot(id, MEMBER, grill.id) - // C5: the response IS GetCleanupResponse — it already carries the refreshed board (with `claimed` + // The response IS GetCleanupResponse: it already carries the refreshed board (with `claimed` // and `mine`), `joined` and `going`, which is why the contract has no bespoke claim response. expect(dto.slots.map((s) => [s.title, s.claimed, s.mine ?? false])).toEqual([ ["Grill", 1, true], @@ -121,7 +121,7 @@ describe("claim", () => { const slot = repo.seedSlot({ cleanupId: id, title: "Grill", capacity: 1 }) await service.claimEventSlot(id, MEMBER, slot.id) - // The capacity check must be skipped when the current claim IS the target — otherwise a retrying + // The capacity check must be skipped when the current claim IS the target; otherwise a retrying // client (or a double tap) gets "That slot is already full" about the slot it is sitting in. const again = await service.claimEventSlot(id, MEMBER, slot.id) expect(again.slots[0]?.claimed).toBe(1) @@ -155,7 +155,7 @@ describe("claim", () => { }) describe("move (A -> B)", () => { - it("frees A's seat in the same call — never two rows for one person", async () => { + it("frees A's seat in the same call: never two rows for one person", async () => { const id = seedEvent() const a = repo.seedSlot({ cleanupId: id, title: "Grill", capacity: 1, sortOrder: 0 }) const b = repo.seedSlot({ cleanupId: id, title: "Sign-in", capacity: 1, sortOrder: 1 }) @@ -169,7 +169,7 @@ describe("move (A -> B)", () => { ]) // The (cleanup_id, user_id) PK is the one-slot-per-person rule: a move is an UPDATE of slot_id. expect(repo.slotClaims.filter((c) => c.cleanupId === id && c.userId === MEMBER)).toHaveLength(1) - // ...and the seat it vacated is immediately claimable by someone else. + // The seat it vacated is immediately claimable by someone else. await service.claimEventSlot(id, COHOST, a.id) expect(await repo.slotOf(id, COHOST)).toBe(a.id) }) @@ -297,7 +297,7 @@ describe("auto-RSVP (B28b)", () => { expect(await repo.slotOf(id, OUTSIDER)).toBe(a.id) }) - it("403s a REMOVED (banned) attendee — the ban probe runs BEFORE the auto-RSVP", async () => { + it("403s a REMOVED (banned) attendee: the ban probe runs BEFORE the auto-RSVP", async () => { const id = seedEvent() const slot = repo.seedSlot({ cleanupId: id, title: "Grill" }) await service.removeMember(id, ORG, MEMBER) @@ -305,7 +305,7 @@ describe("auto-RSVP (B28b)", () => { await expect(service.claimEventSlot(id, MEMBER, slot.id)).rejects.toMatchObject({ code: "FORBIDDEN", }) - // The slot door is not a way back into an event a host removed you from (M17). + // The slot door is not a way back into an event a host removed you from. expect(await repo.isMember(id, MEMBER)).toBe(false) expect(repo.slotClaims).toEqual([]) }) @@ -425,7 +425,7 @@ describe("leaving and removal free the seat (B28d)", () => { expect(await repo.slotOf(id, MEMBER)).toBeNull() const board = await repo.listSlots(id, null) expect(board[0]?.claimed).toBe(0) - // ...and the freed seat really is claimable again. + // The freed seat really is claimable again. await service.claimEventSlot(id, COHOST, slot.id) expect(await repo.slotOf(id, COHOST)).toBe(slot.id) }) @@ -485,7 +485,7 @@ describe("the flip budget (B29c)", () => { }) } // The point of the budget is to keep a flapper off a contended row's lock, so a 409 must still - // consume allowance — otherwise polling a full slot is unlimited. + // consume allowance; otherwise polling a full slot is unlimited. await expect(limited.claimEventSlot(id, MEMBER, slot.id)).rejects.toMatchObject({ code: "RATE_LIMITED", }) diff --git a/services/api/test/unit/cleanup-slots-routes.test.ts b/services/api/test/unit/cleanup-slots-routes.test.ts index 105743b5..7259b6fa 100644 --- a/services/api/test/unit/cleanup-slots-routes.test.ts +++ b/services/api/test/unit/cleanup-slots-routes.test.ts @@ -1,19 +1,18 @@ /** - * P9 signup slots — the HTTP surface, through `app.inject` with an injected in-memory repository (no - * database, no Redis). + * Signup slots, the HTTP surface, through `app.inject` with an injected in-memory repository. * * The service suites own the rules; this file owns the things that only exist at the route layer and * that a service test can never catch: * * - `claimEventSlot` is actually REGISTERED at `PUT /v1/cleanups/:id/slot` (the shared registry is * frozen, so the method+path have to match it exactly); - * - the `{ ...body, id }` path-param merge — the typed client extracts `id` into the path, so a route + * - the `{ ...body, id }` path-param merge: the typed client extracts `id` into the path, so a route * that forgot the merge would 422 on every single call; * - the CSRF preHandler is present on the mutation (a cookie-session browser client is the reason it * exists at all); * - the per-IP rate limit is configured at 30/min; * - the response really is a CleanupDTO carrying the REFRESHED `slots`, so the client needs no - * refetch — and `slotCount` (not `slots`) is what the list read carries. + * refetch, and `slotCount` (not `slots`) is what the list read carries. */ import { describe, it, expect, afterEach } from "vitest" @@ -88,7 +87,6 @@ afterEach(async () => { const FUTURE = new Date(Date.now() + 7 * 86_400_000).toISOString() -/** Create an event WITH a slot board and return { id, slots }. */ async function createWithSlots( app: FastifyInstance, token: string, @@ -137,8 +135,8 @@ describe("PUT /cleanups/:id/slot", () => { expect(res.statusCode).toBe(200) const dto = res.json() - // C5 reuses GetCleanupResponseSchema precisely so this is possible: the caller updates its cache - // straight from the mutation response instead of refetching the event. + // The claim reuses GetCleanupResponseSchema precisely so this is possible: the caller updates + // its cache straight from the mutation response instead of refetching the event. expect(dto.id).toBe(id) expect( dto.slots.map((s: { title: string; claimed: number; mine?: boolean }) => [ @@ -271,7 +269,7 @@ describe("route configuration", () => { ) // Whitespace-collapsed so the assertions pin the declarations, not the formatter's line breaks. const src = raw.replace(/\s+/g, " ") - // B29c: the OUTER, per-IP layer. The inner per-(event, user) budget is asserted behaviorally in + // The OUTER, per-IP layer. The inner per-(event, user) budget is asserted behaviorally in // cleanup-slots-claim.test.ts; this one would otherwise be untested until production. expect(src).toContain( 'const CLAIM_SLOT_RATE_LIMIT = { max: 30, timeWindow: "1 minute" } as const', @@ -296,7 +294,7 @@ describe("the other cleanup reads carry the slot fields", () => { const list = await app.inject({ method: "GET", url: "/v1/cleanups", headers: auth(token) }) const card = list.json().items.find((c: { id: string }) => c.id === id) - // A feed card renders no board, so the list read pays for one aggregate instead of a join — and + // A feed card renders no board, so the list read pays for one aggregate instead of a join, and // `slotCount` is what keeps the empty `slots` from being ambiguous. expect(card.slots).toEqual([]) expect(card.slotCount).toBe(2) diff --git a/services/api/test/unit/cleanup-slots-service.test.ts b/services/api/test/unit/cleanup-slots-service.test.ts index ccd836c0..8744052a 100644 --- a/services/api/test/unit/cleanup-slots-service.test.ts +++ b/services/api/test/unit/cleanup-slots-service.test.ts @@ -1,22 +1,22 @@ /** - * P9 signup slots — the SERVICE half (B22–B26), against the in-memory CleanupRepository. + * Signup slots, the SERVICE half, against the in-memory CleanupRepository. * * The claim/release transaction has its own file (cleanup-slots-claim.test.ts); this one pins the * host-authoring side, where every interesting rule is a REFUSAL that has to happen before a row is * touched: * * - the reconcile diff itself (add / update / delete, and that `[]` is REFUSED because every event - * needs at least one slot, while OMITTING the key leaves the board alone — on create, where there + * needs at least one slot, while OMITTING the key leaves the board alone; on create, where there * is no board yet, an omitted key synthesizes the default slot rather than refusing, because the * wire schema keeps it optional and released clients still ship without it); - * - a slot id belonging to ANOTHER event is a hard 422, never a quiet re-parent (B23) — the single + * - a slot id belonging to ANOTHER event is a hard 422, never a quiet re-parent: the single * nastiest failure mode here, because a silent insert would move someone else's roster row; * - case-insensitive duplicate titles 422 DETERMINISTICALLY, before cleanup_slots_cleanup_title_uidx * fires (a raw constraint violation would surface as an unactionable 500); * - the cap and the slur gate, on the same footing as bring/title/reason; - * - slots are refused on a done/cancelled event (B26) — deleting a slot after completion rewrites the - * roster the credited hours were attested against; - * - lowering capacity below the live claim count is ALLOWED and evicts nobody (B25); + * - slots are refused on a done/cancelled event, because deleting a slot after completion rewrites + * the roster the credited hours were attested against; + * - lowering capacity below the live claim count is ALLOWED and evicts nobody; * - slots are legal on BOTH eventKind values, unlike linkedReportIds. */ @@ -88,7 +88,7 @@ beforeEach(() => { service = makeCleanupService({ tickets: TEST_TICKET_SIGNER, repo, counters }) }) -describe("createCleanup — slots ride the create transaction (B22)", () => { +describe("createCleanup: slots ride the create transaction (B22)", () => { it("creates the slots with the event and returns the hydrated board", async () => { const dto = await service.createCleanup( { @@ -147,7 +147,7 @@ describe("createCleanup — slots ride the create transaction (B22)", () => { }, ORG, ) - // A fresh row, NOT the id the client sent — which is what makes a foreign id harmless here. + // A fresh row, NOT the id the client sent, which is what makes a foreign id harmless here. expect(dto.slots).toHaveLength(1) expect(dto.slots[0]?.id).not.toBe("99999999-9999-9999-9999-999999999999") }) @@ -209,7 +209,7 @@ describe("createCleanup — slots ride the create transaction (B22)", () => { }) }) -describe("duplicateCleanup — a legacy slot-less source still yields a valid copy", () => { +describe("duplicateCleanup: a legacy slot-less source still yields a valid copy", () => { it("synthesizes the default slot when the source board is empty", async () => { const source = repo.seedCleanup({ id: CLEANUP_ID, @@ -256,7 +256,7 @@ describe("duplicateCleanup — a legacy slot-less source still yields a valid co }) }) -describe("updateCleanup — the reconcile diff (B23)", () => { +describe("updateCleanup: the reconcile diff (B23)", () => { it("adds, updates and deletes to match the FULL desired set", async () => { const id = seedEvent() const keep = repo.seedSlot({ cleanupId: id, title: "Grill", capacity: 2, sortOrder: 0 }) @@ -273,13 +273,12 @@ describe("updateCleanup — the reconcile diff (B23)", () => { ORG, ) - // The kept row is UPDATED in place — its id survives the rename, which is the whole reason slots + // The kept row is UPDATED in place: its id survives the rename, which is the whole reason slots // carry a surrogate uuid instead of an ordinal key. expect(dto.slots.map((s) => [s.id === keep.id, s.title, s.capacity])).toEqual([ [true, "Grill duty", 4], [false, "Sign-in", undefined], ]) - // "Cleanup crew" is absent from the desired set, so it is gone. expect( repo.slots .filter((s) => s.cleanupId === id) @@ -336,7 +335,7 @@ describe("updateCleanup — the reconcile diff (B23)", () => { ) // (cleanup_id, lower(title)) is an IMMEDIATELY-checked unique index, so the first UPDATE of a naive - // swap writes a title the second row still holds — a raw 23505, i.e. a 500 on an ordinary edit. The + // swap writes a title the second row still holds: a raw 23505, i.e. a 500 on an ordinary edit. The // repo parks renamed rows on sentinel titles first; both slots keep their identity through it. expect(dto.slots.map((s) => [s.id, s.title])).toEqual([ [reg.id, "Grill"], @@ -353,7 +352,7 @@ describe("updateCleanup — the reconcile diff (B23)", () => { const dto = await service.updateCleanup(id, { slots: [{ title: "Grill", capacity: 5 }] }, ORG) expect(dto.slots.map((s) => [s.title, s.capacity])).toEqual([["Grill", 5]]) - // A genuinely NEW row — dropping the id is how a host resets a slot's claimants. + // A genuinely NEW row: dropping the id is how a host resets a slot's claimants. expect(dto.slots[0]?.id).not.toBe(old.id) expect(repo.slots.filter((s) => s.cleanupId === id)).toHaveLength(1) }) @@ -414,7 +413,7 @@ describe("updateCleanup — the reconcile diff (B23)", () => { ).rejects.toSatisfy((err: unknown) => fieldsOf(err).slots === `unknown slot: ${unknown}`) }) - it("a foreign slot id 422s the WHOLE patch — the scalar fields are not written either", async () => { + it("a foreign slot id 422s the WHOLE patch: the scalar fields are not written either", async () => { const id = seedEvent() seedEvent(OTHER_CLEANUP_ID) const foreign = repo.seedSlot({ cleanupId: OTHER_CLEANUP_ID, title: "Someone else's grill" }) @@ -438,7 +437,7 @@ describe("updateCleanup — the reconcile diff (B23)", () => { // The reconcile's own refusal fires inside a transaction opened AFTER the scalar UPDATE commits, so // without the pre-check the host got a 422 for a save whose title/description/scheduledAt had - // ALREADY been applied — a half-written PATCH behind an error response. `slots` is validated + // ALREADY been applied: a half-written PATCH behind an error response. `slots` is validated // against the CURRENT board before anything is written. const after = repo.cleanups.get(id)! expect(after.title).toBe(originalTitle) @@ -670,10 +669,8 @@ describe("the cleanup_slot bell (B34/B35)", () => { }) const dto = await notified.updateCleanup(id, { slots: [{ title: "Sign-in" }] }, ORG) - // The edit itself committed... expect(dto.slots.map((s) => s.title)).toEqual(["Sign-in"]) - // ...and the healthy recipient still got their bell (best-effort PER RECIPIENT, not one try/catch - // around the whole loop). + // Best-effort PER RECIPIENT, not one try/catch around the whole loop. expect(reached.sort()).toEqual([COHOST, MEMBER].sort()) }) }) @@ -715,7 +712,7 @@ describe("read shapes (B29a)", () => { expect(page.items.find((c) => c.id === id)?.slotCount).toBe(0) }) - it("the attendee roster carries each person's slot (no second endpoint — B29b)", async () => { + it("the attendee roster carries each person's slot (no second endpoint, B29b)", async () => { const id = seedEvent() const grill = repo.seedSlot({ cleanupId: id, title: "Grill" }) await service.claimEventSlot(id, MEMBER, grill.id) @@ -951,7 +948,7 @@ describe("slot windows (0167)", () => { expect(repo.cleanups.get(id)?.endsAt).toEqual(EVENT_END) }) - it("422s clearing the event's end entirely — every event must have one", async () => { + it("422s clearing the event's end entirely: every event must have one", async () => { const id = seedTimedEvent() repo.seedSlot({ cleanupId: id, diff --git a/services/api/test/unit/cleanup-status-derivation.test.ts b/services/api/test/unit/cleanup-status-derivation.test.ts index 4ab76f79..0d12dd73 100644 --- a/services/api/test/unit/cleanup-status-derivation.test.ts +++ b/services/api/test/unit/cleanup-status-derivation.test.ts @@ -1,5 +1,5 @@ /** - * The event status derivation, pinned on BOTH sides of the seam (0.46.0, DECISIONS §40). + * The event status derivation, pinned on BOTH sides of the seam (DECISIONS §40). * * `cleanupStatusExpr` (SQL, projected into every select that feeds a DTO's `status`) and * `deriveCleanupStatus` (TS, from @civfix/shared/host, used by every in-memory repository twin) must diff --git a/services/api/test/unit/cleanups-routes.test.ts b/services/api/test/unit/cleanups-routes.test.ts index 449cc09d..deff0d9b 100644 --- a/services/api/test/unit/cleanups-routes.test.ts +++ b/services/api/test/unit/cleanups-routes.test.ts @@ -1344,7 +1344,7 @@ describe("GET /cleanups/:id/ics", () => { const ics = (res.json() as { ics: string }).ics expect(ics).toContain(`DTSTART:${stamp(slotStart)}`) expect(ics).toContain(`DTEND:${stamp(slotEnd)}`) - expect(ics).toContain("SUMMARY:Sweep — Morning sweep") + expect(ics).toContain("SUMMARY:Sweep (Morning sweep)") }) it("gives anyone WITHOUT that claim the event's own window and plain title", async () => { @@ -1418,7 +1418,7 @@ describe("GET /cleanups/:id/ics", () => { }) }) -describe("PATCH /cleanups/:id/members/:userId — the assignable roles", () => { +describe("PATCH /cleanups/:id/members/:userId: the assignable roles", () => { async function seededEvent(): Promise<{ h: Harness; id: string; memberId: string }> { const h = await makeHarness() const id = await createCleanup(h.app, h.token) @@ -1428,7 +1428,7 @@ describe("PATCH /cleanups/:id/members/:userId — the assignable roles", () => { return { h, id, memberId } } - it("accepts staff at the boundary — the contract enum carries it", async () => { + it("accepts staff at the boundary: the contract enum carries it", async () => { const { h, id, memberId } = await seededEvent() const res = await h.app.inject({ method: "PATCH", @@ -1440,7 +1440,7 @@ describe("PATCH /cleanups/:id/members/:userId — the assignable roles", () => { expect(await h.repo.roleOf(id, memberId)).toBe("staff") }) - it("still refuses organizer — ownership moves by transfer, never by a role cell", async () => { + it("still refuses organizer: ownership moves by transfer, never by a role cell", async () => { const { h, id, memberId } = await seededEvent() const res = await h.app.inject({ method: "PATCH", diff --git a/services/api/test/unit/conversation-mutes-routes.test.ts b/services/api/test/unit/conversation-mutes-routes.test.ts index 8de80bfc..1aa9f448 100644 --- a/services/api/test/unit/conversation-mutes-routes.test.ts +++ b/services/api/test/unit/conversation-mutes-routes.test.ts @@ -37,7 +37,7 @@ interface Harness { repo: ReturnType } -/** L9 participation gate seam (see ConversationRoutesOverrides.participates). */ +/** Participation gate seam (see ConversationRoutesOverrides.participates). */ type Participates = NonNullable let current: Harness | undefined @@ -170,10 +170,10 @@ describe("PUT /conversations/mute", () => { }) /** - * L9 (2026-07-24 review): the route wrote an arbitrary roomId with NO existence or membership check, so + * The route used to write an arbitrary roomId with NO existence or membership check, so * an authenticated client could insert unbounded junk rows into conversation_mutes. */ -describe("PUT /conversations/mute — participation gate (L9)", () => { +describe("PUT /conversations/mute: participation gate (L9)", () => { it("403s a room the caller does not participate in, and writes NOTHING", async () => { const { app, token, repo } = await makeHarness(() => Promise.resolve(false)) const res = await app.inject({ diff --git a/services/api/test/unit/cursor-helpers.test.ts b/services/api/test/unit/cursor-helpers.test.ts index 10a28a8e..b8a30a9d 100644 --- a/services/api/test/unit/cursor-helpers.test.ts +++ b/services/api/test/unit/cursor-helpers.test.ts @@ -159,7 +159,7 @@ describe("parseNearCursor / encodeNearCursor", () => { expect(parseNearCursor(encoded)).toEqual({ dist: 1234.5, id: UUID }) }) - it("parses a zero distance and exponent notation (a 0 anchor is legitimate — you are AT the point)", () => { + it("parses a zero distance and exponent notation (a 0 anchor is legitimate: you are AT the point)", () => { expect(parseNearCursor(`0|${UUID}`)).toEqual({ dist: 0, id: UUID }) expect(parseNearCursor(`0.0|${UUID}`)).toEqual({ dist: 0, id: UUID }) expect(parseNearCursor(`1e3|${UUID}`)).toEqual({ dist: 1000, id: UUID }) diff --git a/services/api/test/unit/db-client.test.ts b/services/api/test/unit/db-client.test.ts index e6a7e110..d0839264 100644 --- a/services/api/test/unit/db-client.test.ts +++ b/services/api/test/unit/db-client.test.ts @@ -5,14 +5,12 @@ * makeDb() therefore hands drizzle its OWN client and keeps a SEPARATE raw `sql` client (full default * serialization) for the hand-written PostGIS/transactional repositories. If that split ever regresses * (one shared client passed to drizzle AND reused for raw `sql`), binding a JS Date into a timestamp - * column throws `TypeError [ERR_INVALID_ARG_TYPE]: ... Received an instance of Date` at runtime — the + * column throws `TypeError [ERR_INVALID_ARG_TYPE]: ... Received an instance of Date` at runtime: the * exact production crash this guards against (hosting a cleanup binds scheduled_at as a Date through the * raw tag in cleanup-repository.drizzle.ts). * - * NO DATABASE NEEDED: postgres.js connects lazily, so makeDb() builds both clients (and drizzle runs its - * in-place serializer mutation on its own client) without opening a socket. We only inspect serializer - * config. This is the unit-level guard the Docker-gated integration tests cannot provide locally / in a - * Docker-less CI. + * postgres.js connects lazily, so makeDb() builds both clients (and drizzle runs its in-place serializer + * mutation on its own client) without opening a socket; only serializer config is inspected. */ import { describe, it, expect } from "vitest" @@ -48,7 +46,7 @@ describe("makeDb: the raw sql client keeps postgres.js Date serializers (drizzle }) /** - * M15: postgres.js defaults `ssl` to false, so an unconfigured production link would ship credentials and + * postgres.js defaults `ssl` to false, so an unconfigured production link would ship credentials and * every row in cleartext. makeDb resolves the connection string's sslmode into an EXPLICIT ssl option and * applies it to both clients. loadEnv separately refuses to boot production without a TLS sslmode; this * locks the mapping that turns that promise into an actual TLS handshake. diff --git a/services/api/test/unit/discovery-jobs.test.ts b/services/api/test/unit/discovery-jobs.test.ts index afde7f6b..a377f250 100644 --- a/services/api/test/unit/discovery-jobs.test.ts +++ b/services/api/test/unit/discovery-jobs.test.ts @@ -6,15 +6,11 @@ import type { Container } from "../../src/di.js" import { makeFakeSql, type SqlHandler } from "../helpers/fake-sql.js" /** - * Offline unit tests for the jurisdiction-discovery worker (`registerDiscoveryJobs`). The worker is the - * missing consumer of the `jurisdiction.discovery` job the report-create path already enqueues; it reads - * job.data.{geoid, population}, skips when the geoid is now routable (a race), else materializes ONE open - * discovery task per geoid (idempotent ON CONFLICT). FakeJobs runs a `work()` handler synchronously inside - * `enqueue()`, so enqueuing the job drives the handler; the DB it reaches (the contact-EXISTS probe + the - * discovery INSERT…RETURNING) is the scripted fake `sql` (test/helpers/fake-sql). + * The jurisdiction-discovery worker skips a geoid that became routable meanwhile (a race), else + * materializes ONE open discovery task per geoid (idempotent ON CONFLICT). FakeJobs runs a `work()` + * handler synchronously inside `enqueue()`, so enqueuing drives the handler against the scripted fake `sql`. */ -/** Build a container whose jobs = FakeJobs and getDb().sql = a scripted fake sql. */ function harness(sqlHandlers: SqlHandler[]) { const jobs = new FakeJobs() const db = makeFakeSql(sqlHandlers) @@ -26,22 +22,19 @@ describe("registerDiscoveryJobs", () => { it("materializes a discovery task row when the geoid has no contact", async () => { const geoid = "0644000" const { jobs, db, container } = harness([ - // No routing contact yet -> not a race; proceed to materialize. { match: /SELECT\s+EXISTS[\s\S]*has_contact/i, rows: [{ has_contact: false }] }, - // First materialize: the INSERT…RETURNING yields a new id (a NEW open task was created). { match: /INSERT\s+INTO\s+jurisdiction_discovery_tasks/i, rows: [{ id: "task-1" }] }, ]) await registerDiscoveryJobs(container) await container.jobs.enqueue(JURISDICTION_DISCOVERY_JOB, { geoid, population: 4000000 }) - // The job completed (handler ran without throwing), and the materialize INSERT was issued for the geoid. expect(jobs.jobsFor(JURISDICTION_DISCOVERY_JOB)[0]?.state).toBe("completed") const insert = db.statements.find((s) => /INSERT\s+INTO\s+jurisdiction_discovery_tasks/i.test(s.sql), ) expect(insert).toBeDefined() expect(insert?.values).toContain(geoid) - // The population override from the payload is bound (COALESCE($population, j.population)). + // The payload's population override is bound (COALESCE($population, j.population)). expect(insert?.values).toContain(4000000) }) @@ -61,7 +54,6 @@ describe("registerDiscoveryJobs", () => { await container.jobs.enqueue(JURISDICTION_DISCOVERY_JOB, { geoid }) await container.jobs.enqueue(JURISDICTION_DISCOVERY_JOB, { geoid }) - // Two enqueues both ran the handler (both completed), but the DB's ON CONFLICT deduped to one row. expect(inserts).toBe(2) const insertStmts = db.statements.filter((s) => /INSERT\s+INTO\s+jurisdiction_discovery_tasks/i.test(s.sql), @@ -76,7 +68,6 @@ describe("registerDiscoveryJobs", () => { await registerDiscoveryJobs(container) await container.jobs.enqueue(JURISDICTION_DISCOVERY_JOB, { geoid: "0644000" }) - // The EXISTS probe ran, but the early-return skipped the INSERT entirely. expect(db.statements.some((s) => /SELECT\s+EXISTS[\s\S]*has_contact/i.test(s.sql))).toBe(true) expect( db.statements.some((s) => /INSERT\s+INTO\s+jurisdiction_discovery_tasks/i.test(s.sql)), diff --git a/services/api/test/unit/dm-edit-slur.test.ts b/services/api/test/unit/dm-edit-slur.test.ts index 479a876d..4e827a95 100644 --- a/services/api/test/unit/dm-edit-slur.test.ts +++ b/services/api/test/unit/dm-edit-slur.test.ts @@ -15,9 +15,8 @@ import { InMemoryChatRepository, InMemoryThreadsRepository } from "../helpers/ch import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" /** - * Offline HTTP test for the hate-slur content gate on the DM edit route (App Store 1.2a, Feature F1): - * PATCH /dm/:threadId/messages/:messageId -> assertNoSlur(body.body) -> 422 on a slur, 200 otherwise. - * Mirrors the delete-message-routes harness (in-memory repos via chatOverrides, mobile bearer = CSRF-exempt). + * The hate-slur content gate on the DM edit route (App Store guideline 1.2a): a slur in the edited body + * is a 422. Mobile bearer transport is CSRF-exempt, so no token is needed. */ const PEER = "44444444-4444-4444-4444-444444444444" diff --git a/services/api/test/unit/dm-gateway.test.ts b/services/api/test/unit/dm-gateway.test.ts index e6bce10a..64cafb98 100644 --- a/services/api/test/unit/dm-gateway.test.ts +++ b/services/api/test/unit/dm-gateway.test.ts @@ -174,8 +174,7 @@ describe("DM gateway routing (join/send/ack/block)", () => { expect(msg.body).toBe("hi bob") expect(msg.cleanupId).toBe(THREAD) expect(msg.roomKind).toBe("dm") - // DM messages always have an author (no sender-less SYSTEM messages on the dm path); assert that - // before narrowing so the intent (author == Alice) stays explicit. + // The dm path has no sender-less SYSTEM messages, so `from` is always present here. expect(msg.from).toBeTruthy() expect(msg.from?.id).toBe(ALICE) @@ -225,7 +224,7 @@ describe("DM gateway routing (join/send/ack/block)", () => { for (const raw of [...aConn.sent, ...bConn.sent]) assertServerFrame(raw) }) - it("a slur in a CLEANUP (group) chat send is also BLOCKED — the gate is room-kind-agnostic", async () => { + it("a slur in a CLEANUP (group) chat send is also BLOCKED: the gate is room-kind-agnostic", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) await handleClientFrame( diff --git a/services/api/test/unit/docs-parity.test.ts b/services/api/test/unit/docs-parity.test.ts index 714b924b..97f607a4 100644 --- a/services/api/test/unit/docs-parity.test.ts +++ b/services/api/test/unit/docs-parity.test.ts @@ -1,6 +1,4 @@ /** - * Documentation-vs-tree parity guards. - * * Two doc facts have already drifted from the code once and cost real time: * * 1. The README's reviewer-OTP bypass table listed only two of the three variables the loader @@ -22,11 +20,8 @@ import { describe, expect, it } from "vitest" import { REVIEWER_OTP_CODE_MIN_LENGTH } from "../../src/env.js" const HERE = dirname(fileURLToPath(import.meta.url)) -/** services/api/drizzle */ const DRIZZLE_DIR = join(HERE, "..", "..", "drizzle") -/** services/api/src/env.ts — the source of truth for which variables exist. */ const ENV_TS = join(HERE, "..", "..", "src", "env.ts") -/** Repo root of civfix-backend (services/api/test/unit -> ../../../..). */ const BACKEND_ROOT = join(HERE, "..", "..", "..", "..") const README = join(BACKEND_ROOT, "README.md") const SECURITY_DOCS_DIR = join(BACKEND_ROOT, "docs", "security") diff --git a/services/api/test/unit/drizzle-client-sql-guard.test.ts b/services/api/test/unit/drizzle-client-sql-guard.test.ts index e504bfa9..88b4ca4c 100644 --- a/services/api/test/unit/drizzle-client-sql-guard.test.ts +++ b/services/api/test/unit/drizzle-client-sql-guard.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { findDrizzleClientUses } from "../../../../scripts/check-dynamic-sql.mjs" -describe("check:sql — raw SQL on drizzle's own client", () => { +describe("check:sql: raw SQL on drizzle's own client", () => { it("flags the aliased tag the orphan sweep used to run its Date-bound predicate on", () => { const code = ["const tag = db.$client", "const rows = await tag`SELECT 1`"].join("\n") expect(findDrizzleClientUses(code)).toEqual([".$client"]) diff --git a/services/api/test/unit/env.test.ts b/services/api/test/unit/env.test.ts index 2b3553c1..fbce965f 100644 --- a/services/api/test/unit/env.test.ts +++ b/services/api/test/unit/env.test.ts @@ -57,7 +57,7 @@ describe("loadEnv: outbound SMS", () => { expect(() => loadEnv(source)).not.toThrow() }) - it("H11: refuses USE_FAKE_SMS in production — it swallows guest OTPs while SMS still reads as available", () => { + it("H11: refuses USE_FAKE_SMS in production, since it swallows guest OTPs while SMS still reads as available", () => { const source = validProdEnv() source.SMS_GUEST_ENABLED = "true" delete source.TWILIO_ACCOUNT_SID diff --git a/services/api/test/unit/errors-http-mapper.test.ts b/services/api/test/unit/errors-http-mapper.test.ts index 82bd9f9c..652093f4 100644 --- a/services/api/test/unit/errors-http-mapper.test.ts +++ b/services/api/test/unit/errors-http-mapper.test.ts @@ -1,15 +1,12 @@ /** - * Direct tests for the canonical error handler (src/errors/http-mapper.ts). It was previously covered only - * incidentally, through route tests, so its most consequential behaviors were unpinned: + * The canonical error handler, driven through a real Fastify instance exactly as buildServer wires it: * - * - the STRUCTURAL ZodError branch (name + issues array, NOT instanceof) that keeps a ZodError thrown - * across the dual-zod-realm boundary from rendering as a 500, - * - the STATUS_TO_CODE reverse map the typed client relies on, - * - PROD MESSAGE HIDING on a 500 (a leaked internal message is an information-disclosure regression), - * - captureError forwarding for >=500 only, - * - the not-found handler's prod-stealth vs dev-echo message. - * - * The handler is driven through a real Fastify instance via app.inject, exactly as buildServer wires it. + * - ZodError is matched STRUCTURALLY (name + issues, NOT instanceof), so one thrown across the + * dual-zod-realm boundary does not render as a 500; + * - the STATUS_TO_CODE reverse map the typed client relies on; + * - a 500 hides its message in production (a leaked internal message is an information disclosure); + * - only >=500 is forwarded to captureError; + * - the not-found handler is stealthy in production and echoes in dev. */ import { describe, it, expect, vi, beforeEach } from "vitest" @@ -43,7 +40,6 @@ interface ErrorBody { fields?: Record } -/** A probe server whose routes throw the error under test. */ async function buildProbe(thrown: () => unknown): Promise { const app = Fastify({ logger: false }) app.setErrorHandler(makeErrorHandler()) @@ -82,7 +78,6 @@ describe("makeErrorHandler", () => { expect(body.message).toBe("Bad handle") expect(body.fields).toEqual({ handle: "already taken" }) expect(body.requestId).toBeTruthy() - // A client AppError is NOT forwarded to the error tracker. expect(captured).toHaveLength(0) await app.close() }) @@ -169,7 +164,6 @@ describe("makeErrorHandler", () => { expect(live.statusCode).toBe(500) expect(live.json().message).toBe("Internal error") expect(live.json().message).not.toContain("leaked") - // Both unhandled errors were reported. expect(captured).toHaveLength(2) await app.close() }) diff --git a/services/api/test/unit/event-status.test.ts b/services/api/test/unit/event-status.test.ts index aba47ccd..461a0ca0 100644 --- a/services/api/test/unit/event-status.test.ts +++ b/services/api/test/unit/event-status.test.ts @@ -3,12 +3,9 @@ import { toEventStatus } from "../../src/services/admin/event-status.js" import { EventStatusSchema } from "@civfix/shared" /** - * Unit tests for the one-way event-status bridge (event-status.ts), which maps the DERIVED public - * CleanupStatus (0.46.0, computed from scheduled_at/ends_at) to the admin EventStatus wire enum. These - * prove: - * - every derived CleanupStatus maps to a valid EventStatus, with the two renames; - * - a legacy or leaked Phase-2 value still resolves rather than escaping as an invalid enum; - * - garbage degrades to the safe default. + * The one-way bridge from the DERIVED public CleanupStatus (computed from scheduled_at/ends_at since + * 0.46.0) to the admin EventStatus wire enum. A legacy value already stored in admin vocabulary, or + * garbage, must still resolve to a valid EventStatus rather than escape as an invalid enum. */ describe("event-status mapping", () => { diff --git a/services/api/test/unit/geocoder-tiger.test.ts b/services/api/test/unit/geocoder-tiger.test.ts index c1d26d0f..25dd455c 100644 --- a/services/api/test/unit/geocoder-tiger.test.ts +++ b/services/api/test/unit/geocoder-tiger.test.ts @@ -7,9 +7,8 @@ import { } from "../../src/adapters/geocoder.tiger.js" /** - * Unit tests for the TIGER geocoder's pure, DB-free helpers: the Census-GEOID -> USPS state mapping - * (which underpins the whole "City, ST" derivation) and the label formatter. The DB-backed - * cityStateLabel path is exercised by the Docker-gated integration test. + * The TIGER geocoder's DB-free helpers; the Census-GEOID -> USPS mapping underpins every "City, ST" + * label. The DB-backed cityStateLabel path is covered by the Docker-gated integration test. */ describe("uspsFromGeoid", () => { @@ -22,7 +21,6 @@ describe("uspsFromGeoid", () => { // County 06037 (LA County) and place 0644000 (LA city) both start with 06 -> CA. expect(uspsFromGeoid("06037")).toBe("CA") expect(uspsFromGeoid("0644000")).toBe("CA") - // Texas place. expect(uspsFromGeoid("4805000")).toBe("TX") }) diff --git a/services/api/test/unit/guest-rsvp-service.test.ts b/services/api/test/unit/guest-rsvp-service.test.ts index 241134e8..dc51941c 100644 --- a/services/api/test/unit/guest-rsvp-service.test.ts +++ b/services/api/test/unit/guest-rsvp-service.test.ts @@ -1309,7 +1309,7 @@ describe("guest rsvp: the SMS half of the cancel/update notice", () => { return h } - it("texts an SMS-only guest that the event was cancelled — the broadcast pipeline cannot see them", async () => { + it("texts an SMS-only guest that the event was cancelled, since the broadcast pipeline cannot see them", async () => { const h = await withSmsGuest() const sent = await h.service.notifyGuestsBySms(EVENT_ID, "cancelled") expect(sent).toBe(1) diff --git a/services/api/test/unit/handle-policy.test.ts b/services/api/test/unit/handle-policy.test.ts index d6240a6b..680c150b 100644 --- a/services/api/test/unit/handle-policy.test.ts +++ b/services/api/test/unit/handle-policy.test.ts @@ -1,10 +1,7 @@ /** - * src/auth/handle-policy.ts — the @handle write policy (auth finding #4 / D33). - * - * The module exists ONLY to stop the in-memory and Postgres UserStore.updateProfile impls from carrying - * near-verbatim copies of the same rules and silently diverging — yet nothing exercised the rules it - * centralizes: the rolling-30-day rename cooldown, the exact-30-days boundary, the - * initial-set-leaves-the-clock-null rule, or the unchanged-handle no-op. + * The @handle write policy exists so the in-memory and Postgres UserStore.updateProfile impls share one + * copy of the rules instead of silently diverging: the rolling 30-day rename cooldown, the exact-30-days + * boundary, the initial set leaving the clock null, and the unchanged-handle no-op. * * Two layers are asserted: * 1. `handleChanged` + `decideHandleWrite` directly, with an injected clock (the policy itself); @@ -86,7 +83,7 @@ describe("decideHandleWrite: the rolling 30-day rename cooldown", () => { expect(thrown).toMatchObject({ code: "RATE_LIMITED", httpStatus: 429 }) expect((thrown as AppError).message).toBe(`You can change your username again on ${unlockAt}.`) // The date the user is told is the real unlock instant: 30 days after the last rename - // (2026-06-25T12:00Z), i.e. one day out from "now" — not 30 days out from now. + // (2026-06-25T12:00Z), i.e. one day out from "now", not 30 days out from now. expect(unlockAt).toBe("2026-07-25T12:00:00.000Z") }) @@ -97,7 +94,6 @@ describe("decideHandleWrite: the rolling 30-day rename cooldown", () => { expect(() => decideHandleWrite(input({ handleChangedAt: oneMsShort }))).toThrowError( expect.objectContaining({ code: "RATE_LIMITED" }), ) - // Exactly 30 days later the rename is allowed and re-stamps the clock to now. expect(decideHandleWrite(input({ handleChangedAt: atBoundary }))).toEqual({ handle: "newname", handleChangedAt: NOW, @@ -163,9 +159,8 @@ describe("decideHandleWrite: format + uniqueness gates run BEFORE the cooldown", describe("decideHandleWrite: trim normalization (LOW/readability finding)", () => { it("PERSISTS the trimmed handle, not the submitted one", () => { - // The old code validated `submitted.trim()` but returned `submitted` verbatim, so " bob " passed the - // format check and stored the spaces. It only happened to be safe because the shared HandleSchema - // (.trim()) sanitizes upstream — a store-level source of truth must not depend on route-level zod. + // The shared HandleSchema (.trim()) sanitizes upstream, but a store-level source of truth must not + // depend on route-level zod: validating the trimmed value while storing the raw one keeps the spaces. expect(decideHandleWrite(input({ submitted: " bob " })).handle).toBe("bob") expect(decideHandleWrite(input({ submitted: "\tjane_doe\n" })).handle).toBe("jane_doe") }) @@ -218,7 +213,6 @@ describe("InMemoryUserStore.updateProfile DELEGATES to the policy (no divergent await expect( store.updateProfile(user.id, { handle: "coach_a", displayName: "Coach" }), ).rejects.toMatchObject({ code: "RATE_LIMITED", httpStatus: 429 }) - // The rejected write changed nothing. expect((await store.findById(user.id))!.handle).toBe("coach_ax") clock.value = new Date(renamed.handleChangedAt!.getTime() + HANDLE_RENAME_COOLDOWN_MS) diff --git a/services/api/test/unit/health.test.ts b/services/api/test/unit/health.test.ts index 913fca82..d0408929 100644 --- a/services/api/test/unit/health.test.ts +++ b/services/api/test/unit/health.test.ts @@ -71,7 +71,7 @@ describe("health routes", () => { expect(body.checks.redis).toBe("skipped") }) - it("H11: readiness ignores the USE_FAKE_* flags — a configured backend is always probed", async () => { + it("H11: readiness ignores the USE_FAKE_* flags: a configured backend is always probed", async () => { const env = loadEnv({ NODE_ENV: "development", DATABASE_URL: "postgres://u:p@localhost:5432/civfix", diff --git a/services/api/test/unit/host-broadcast-caps.test.ts b/services/api/test/unit/host-broadcast-caps.test.ts index 6192aba2..36ae7c96 100644 --- a/services/api/test/unit/host-broadcast-caps.test.ts +++ b/services/api/test/unit/host-broadcast-caps.test.ts @@ -235,7 +235,6 @@ describe("org suspension gate (DECISIONS §32)", () => { ).rejects.toMatchObject({ code: "FORBIDDEN" }) // Nothing moved: the draft is still a draft and no send slot was consumed. expect((await repo.findById(draft.id))?.status).toBe("draft") - // Lifting the flag restores the lever. repo.setEventOrganizationSuspended(EVENT, false) const sent = await service.send(EVENT, HOST, draft.id) expect(sent.status).toBe("sending") diff --git a/services/api/test/unit/host-insights.test.ts b/services/api/test/unit/host-insights.test.ts index 571d6127..3ec2f29b 100644 --- a/services/api/test/unit/host-insights.test.ts +++ b/services/api/test/unit/host-insights.test.ts @@ -176,7 +176,7 @@ describe("event insights", () => { expect((await cancelled.service.insights(EVENT, VIEWER)).phase).toBe("cancelled") // DECISIONS §40: the phase is a clock reading. A row a pre-0.46.0 host marked complete, whose window - // is still ahead, is UPCOMING again — the old `status === "done"` short-circuit is gone. + // is still ahead, is UPCOMING again, whatever the stored column says. const legacyDone = build( { eventClock: () => diff --git a/services/api/test/unit/host/host-standing-sql-null-guard.test.ts b/services/api/test/unit/host/host-standing-sql-null-guard.test.ts index 5509b9b7..df911a23 100644 --- a/services/api/test/unit/host/host-standing-sql-null-guard.test.ts +++ b/services/api/test/unit/host/host-standing-sql-null-guard.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { findParamNullTests } from "../../../../../scripts/check-dynamic-sql.mjs" -describe("check:sql — uncast parameter in a NULL test", () => { +describe("check:sql: uncast parameter in a NULL test", () => { it("flags a bare interpolated parameter used as a NULL test", () => { const found = findParamNullTests( "sql`LEFT JOIN cleanup_members m ON m.cleanup_id = c.id AND ${userId} IS NOT NULL`", diff --git a/services/api/test/unit/host/registration-state.test.ts b/services/api/test/unit/host/registration-state.test.ts index e25a609b..c6604b22 100644 --- a/services/api/test/unit/host/registration-state.test.ts +++ b/services/api/test/unit/host/registration-state.test.ts @@ -3,7 +3,7 @@ * * Registration used to close on the stored `done` a host set by hand. Nothing writes that value any * more, so the gate is the clock: `cancelled` (the one stored decision) OR the event's end having - * passed. An UNDERWAY event stays open — walk-ups are the whole point of the check-in desk. + * passed. An UNDERWAY event stays open: walk-ups are the whole point of the check-in desk. */ import { describe, expect, it } from "vitest" diff --git a/services/api/test/unit/host/signup-seats.test.ts b/services/api/test/unit/host/signup-seats.test.ts index 0bb3f697..9867f726 100644 --- a/services/api/test/unit/host/signup-seats.test.ts +++ b/services/api/test/unit/host/signup-seats.test.ts @@ -1,5 +1,5 @@ /** - * DECISIONS §44 — a sign-up on a NON-ticketed event is a free registration. + * DECISIONS §44: a sign-up on a NON-ticketed event is a free registration. * * Before this, a slot-based event's host saw an empty "Attendees" list and zero check-in counters: * joining wrote `cleanup_members` (+ a slot claim) and nothing on the registration side, while the @@ -183,7 +183,7 @@ describe("signing up for a non-ticketed event", () => { }) describe("a ticketed event", () => { - it("gets no auto-registration — registerIn owns that path", async () => { + it("gets no auto-registration; registerIn owns that path", async () => { const id = seedEvent() seedTicketType(id) const slot = repo.seedSlot({ cleanupId: id, title: "Grill" }) diff --git a/services/api/test/unit/inbound-mail-authentication.test.ts b/services/api/test/unit/inbound-mail-authentication.test.ts index 5ff09d86..9af5b089 100644 --- a/services/api/test/unit/inbound-mail-authentication.test.ts +++ b/services/api/test/unit/inbound-mail-authentication.test.ts @@ -11,9 +11,9 @@ import { isJurisdictionSender } from "../../src/services/admin/inbound-thread-co import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" /** - * M7 — forged inbound email could impersonate a jurisdiction: there was NO message authentication at - * all, and a spoofable `X-Thread-Token` HEADER selected the thread. M6 — untrusted `bodyHtml` was - * stored and served verbatim to the admin console. + * Forged inbound email could impersonate a jurisdiction: there was NO message authentication at all, + * and a spoofable `X-Thread-Token` HEADER selected the thread. Untrusted `bodyHtml` was also stored and + * served verbatim to the admin console. */ function mail(over: Partial = {}): ParsedMail { diff --git a/services/api/test/unit/inbound-mail-cf-parse.test.ts b/services/api/test/unit/inbound-mail-cf-parse.test.ts index ef1065d3..330bd6c7 100644 --- a/services/api/test/unit/inbound-mail-cf-parse.test.ts +++ b/services/api/test/unit/inbound-mail-cf-parse.test.ts @@ -68,7 +68,7 @@ function manyPartMessage(parts: number): Uint8Array { return bytes(lines.join("\r\n")) } -describe("CfInboundMail.parse — structured content-type rendering (F099)", () => { +describe("CfInboundMail.parse: structured content-type rendering (F099)", () => { it("renders a structured multipart/report content-type back to header-string form, not a JSON blob", async () => { const parsed = await new CfInboundMail().parse(rfc3464Bounce()) const contentType = parsed.headers["content-type"] @@ -86,7 +86,7 @@ describe("CfInboundMail.parse — structured content-type rendering (F099)", () }) }) -describe("CfInboundMail.parse — MIME part-count pre-scan (F097)", () => { +describe("CfInboundMail.parse: MIME part-count pre-scan (F097)", () => { it("rejects a message with too many MIME parts BEFORE mailparser can stall, and does so quickly", async () => { const started = Date.now() await expect(new CfInboundMail().parse(manyPartMessage(300))).rejects.toThrow( diff --git a/services/api/test/unit/inbound-mail-webhook.test.ts b/services/api/test/unit/inbound-mail-webhook.test.ts index 2fb5f8a3..ab46208d 100644 --- a/services/api/test/unit/inbound-mail-webhook.test.ts +++ b/services/api/test/unit/inbound-mail-webhook.test.ts @@ -44,7 +44,7 @@ function sign(body: string, secret: string): string { /** * Build a minimal RFC822 message the FakeInboundMail subset parses (headers, blank line, body). * - * M7: a DMARC-pass Authentication-Results header is stamped by default. The processor only lets + * A DMARC-pass Authentication-Results header is stamped by default. The processor only lets * DMARC-aligned mail reach the threaded path, so a fixture without one is Inbox-only by design. */ function rfc822(opts: { @@ -198,7 +198,7 @@ describe("inbound-mail webhook: reply threading (token present)", () => { await h.app.close() }) - it("an UNKNOWN reply token mints no thread — it lands in inbound_emails (finding #37)", async () => { + it("an UNKNOWN reply token mints no thread; it lands in inbound_emails (finding #37)", async () => { const h = await harness() const { res } = await ingest(h, { eml: rfc822({ @@ -358,7 +358,7 @@ describe("inbound-mail webhook: malformed / safe handling", () => { }) /** - * L17 — the HMAC covered the body ALONE, with no timestamp and no nonce, so a captured + * The HMAC used to cover the body ALONE, with no timestamp and no nonce, so a captured * (body, signature) pair stayed valid forever. The signed payload is now `.` and the * timestamp must be fresh. */ @@ -415,7 +415,7 @@ describe("inbound-mail webhook: signature replay window (L17)", () => { await h.app.close() }) - it("rejects a fresh timestamp paired with a body-only (legacy) signature — the ts is INSIDE the MAC", async () => { + it("rejects a fresh timestamp paired with a body-only (legacy) signature: the ts is INSIDE the MAC", async () => { const h = await harness() const key = `${INBOUND_PENDING_PREFIX}ts-mixed.eml` await h.storage.put(key, rfc822({ from: "a@b.gov", to: "support@civfix.org", body: "x" })) diff --git a/services/api/test/unit/inbound-sweep.test.ts b/services/api/test/unit/inbound-sweep.test.ts index 9c573efe..8f5e3779 100644 --- a/services/api/test/unit/inbound-sweep.test.ts +++ b/services/api/test/unit/inbound-sweep.test.ts @@ -326,7 +326,7 @@ describe("runInboundSweep: crash-safe lease + idempotent re-drive (B4)", () => { return runInboundSweep(h.container, { deps: h.deps, effectsMinAgeMs: 0, now: () => at }) } - it("a runner that DIES after claiming is re-driven once the lease expires — exactly once", async () => { + it("a runner that DIES after claiming is re-driven once the lease expires, exactly once", async () => { const h = harness() const reportId = "report-crash" const { inbound } = seed(h, reportId) diff --git a/services/api/test/unit/ingest-jurisdictions.test.ts b/services/api/test/unit/ingest-jurisdictions.test.ts index 84aae4ba..bae565a5 100644 --- a/services/api/test/unit/ingest-jurisdictions.test.ts +++ b/services/api/test/unit/ingest-jurisdictions.test.ts @@ -2,20 +2,20 @@ import { describe, expect, it } from "vitest" import { normalizeFeatures } from "../../src/db/ingest-jurisdictions.js" /** - * Pure, DB-free unit tests for the ingest CLI's optional load-time geoid prefix (Design A). The prefix - * namespaces the non-FIPS layers — AIANNH/tribal ("AIANNH-") and PAD-US/federal ("PADUS-") — so their + * Pure, DB-free unit tests for the ingest CLI's optional load-time geoid prefix. The prefix + * namespaces the non-FIPS layers, AIANNH/tribal ("AIANNH-") and PAD-US/federal ("PADUS-"), so their * geoids stay globally unique (no 5-char AIANNH-vs-county PK collision) AND so uspsFromGeoid() returns * null for them, which makes the TIGER geocoder fall back to the authoritative spatial state query - * instead of trusting a bogus FIPS prefix (geocoder.tiger.ts:152-155). TIGER place/county/state ingest + * instead of trusting a bogus FIPS prefix (geocoder.tiger.ts). TIGER place/county/state ingest * with NO prefix and keep their raw Census GEOID so the geocoder's FIPS shortcut stays valid. * * These tests pin: prefix applied to the computed geoid, empty/absent prefix as a no-op (backward * compatibility), single application even across duplicate raw geoids (no double-prefix), prefix * orthogonality to skip-counting and layer fallback, and prefixing of a geoid read from an alternate - * property key. No DB, no fixture files — inline GeoJSON literals. + * property key. No DB, no fixture files: inline GeoJSON literals. */ -/** A minimal valid Polygon ring (closed, CCW-ish) — enough to pass the isPolygon check. */ +/** A minimal valid Polygon ring (closed, CCW-ish), enough to pass the isPolygon check. */ const validPolygon = { type: "Polygon", coordinates: [ @@ -29,7 +29,7 @@ const validPolygon = { ], } -/** Wrap features in a FeatureCollection. Cast through `unknown` to satisfy the loose GeoJSON types. */ +/** Cast through `unknown` to satisfy the loose GeoJSON types. */ function fc(features: unknown[]): Parameters[0] { return { type: "FeatureCollection", features } as Parameters[0] } diff --git a/services/api/test/unit/jobs-pgboss.test.ts b/services/api/test/unit/jobs-pgboss.test.ts index eafffbe5..86e43a79 100644 --- a/services/api/test/unit/jobs-pgboss.test.ts +++ b/services/api/test/unit/jobs-pgboss.test.ts @@ -62,7 +62,6 @@ describe("PgBossJobs (API enqueue adapter)", () => { const jobs = new PgBossJobs({ connectionString: "postgres://u:p@localhost:5432/civfix" }) await jobs.start() - // Constructed with the connection string. expect(ctor).toHaveBeenCalledTimes(1) expect(ctor.mock.calls[0]![0]).toMatchObject({ connectionString: "postgres://u:p@localhost:5432/civfix", @@ -78,7 +77,7 @@ describe("PgBossJobs (API enqueue adapter)", () => { // Each queue is created AND updated with the "short" policy so a singletonKey actually dedupes // pending jobs in pg-boss v10 (the default "standard" policy does not, and createQueue is a no-op on - // an already-existing queue — so updateQueue is what fixes a pre-existing default-policy queue). + // an already-existing queue, so updateQueue is what fixes a pre-existing default-policy queue). for (const name of API_QUEUE_NAMES) { expect(lastBoss.createQueue).toHaveBeenCalledWith( name, diff --git a/services/api/test/unit/jurisdiction-backfill.test.ts b/services/api/test/unit/jurisdiction-backfill.test.ts index b4c6c6a3..757be244 100644 --- a/services/api/test/unit/jurisdiction-backfill.test.ts +++ b/services/api/test/unit/jurisdiction-backfill.test.ts @@ -3,9 +3,9 @@ * (JURISDICTION_LAYER_RANK_CASE in src/db/sql/jurisdiction.ts). * * This constant is THE single ranking definition shared by the write-time resolver - * (JURISDICTION_RESOLVE_SQL) and the Phase-5 backfill CLI (src/db/backfill-jurisdictions.ts, which + * (JURISDICTION_RESOLVE_SQL) and the backfill CLI (src/db/backfill-jurisdictions.ts, which * embeds it verbatim via `sql.unsafe`). Because both paths interpolate the exact same string, a - * backfilled report resolves identically to a fresh insert — there is no second, drifting copy of the + * backfilled report resolves identically to a fresh insert; there is no second, drifting copy of the * CASE expression to keep in sync. Here we pin the literal value and the ordering it encodes. * * NOTE: the byte-for-byte tie between JURISDICTION_RESOLVE_SQL and this constant is asserted in diff --git a/services/api/test/unit/jurisdiction-sql.test.ts b/services/api/test/unit/jurisdiction-sql.test.ts index c2e64700..9797066e 100644 --- a/services/api/test/unit/jurisdiction-sql.test.ts +++ b/services/api/test/unit/jurisdiction-sql.test.ts @@ -38,17 +38,16 @@ describe("JURISDICTION_RESOLVE_SQL", () => { }) it("embeds the shared JURISDICTION_LAYER_RANK_CASE constant so the resolver and backfill can never drift", () => { - // The resolver and the Phase-5 backfill CLI both interpolate this one constant; asserting the + // The resolver and the backfill CLI both interpolate this one constant; asserting the // RESOLVE_SQL contains it byte-for-byte proves the refactor preserved the SQL AND that there is a // single ranking source of truth. expect(JURISDICTION_RESOLVE_SQL).toContain(JURISDICTION_LAYER_RANK_CASE) }) - // --- the same-layer tie-break (audit 2026-07-24, db MEDIUM) -------------------------------------- // The rank CASE alone left SAME-LAYER overlaps (PAD-US federal Fee parcels, a dev-seeded fixture // polygon over the real boundary it duplicates) undecided, so Postgres was free to return either row: - // the write-time resolver and the keyset backfill could stamp DIFFERENT geoids — hence different - // routing — for one point. `priority, geoid` makes the choice a total order. These assertions pin the + // the write-time resolver and the keyset backfill could stamp DIFFERENT geoids, hence different + // routing, for one point. `priority, geoid` makes the choice a total order. These assertions pin the // whole ORDER BY list, because "the rank case is present" (above) stayed true while the tie-breaks // were missing, which is exactly how the bug survived. @@ -77,7 +76,7 @@ describe("JURISDICTION_RESOLVE_ORDER_BY", () => { ) }) - it("ends with `END, priority, geoid` — rank first, then curated priority, then the PK", () => { + it("ends with `END, priority, geoid`: rank first, then curated priority, then the PK", () => { expect(JURISDICTION_RESOLVE_ORDER_BY.endsWith("END, priority, geoid")).toBe(true) // Order among the tie-breaks matters: `priority` (curated per-row ordering within a layer) must be // consulted BEFORE the arbitrary-but-total `geoid`. diff --git a/services/api/test/unit/map-routes.test.ts b/services/api/test/unit/map-routes.test.ts index 8d275884..e46a91ae 100644 --- a/services/api/test/unit/map-routes.test.ts +++ b/services/api/test/unit/map-routes.test.ts @@ -23,8 +23,8 @@ afterEach(async () => { }) describe("GET /map/tileinfo", () => { - // Plan override: the map uses the OpenStreetMap (CARTO Voyager) RASTER basemap directly in the - // clients; the platform serves no pmtiles. tileinfo advertises that same raster basemap. pmtilesUrl is + // The clients draw the OpenStreetMap (CARTO Voyager) RASTER basemap directly and the platform serves + // no pmtiles, so tileinfo advertises that same raster basemap. pmtilesUrl is // always "" (no vector basemap); rasterUrl defaults to the CARTO Voyager template, overridable via the // optional TILES_RASTER_URL. minZoom/maxZoom/bounds remain env-tunable. it("advertises the default OpenStreetMap/CARTO Voyager raster basemap when unconfigured", async () => { @@ -33,10 +33,8 @@ describe("GET /map/tileinfo", () => { const res = await app.inject({ method: "GET", url: "/v1/map/tileinfo" }) expect(res.statusCode).toBe(200) const body = res.json() - // No self-hosted vector basemap. expect(body.pmtilesUrl).toBe("") expect(body.styleUrl).toBeUndefined() - // The OpenStreetMap-derived CARTO Voyager raster default. expect(body.rasterUrl).toBe( "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", ) @@ -250,7 +248,7 @@ describe("POST /map/suggest", () => { } /** - * F162: the contract's `language` field (the caller's app locale) has to REACH the provider. The route + * The contract's `language` field (the caller's app locale) has to REACH the provider. The route * used to destructure only q/proximity/proximityZoom/limit, so every request was silently English and * the drop was invisible (200 + plausible results). "de" is one of Photon's supported languages, so a * forwarded value survives `photonLang` verbatim while a dropped one folds to the "en" default. diff --git a/services/api/test/unit/media-authorization.test.ts b/services/api/test/unit/media-authorization.test.ts index cdf0582a..81609157 100644 --- a/services/api/test/unit/media-authorization.test.ts +++ b/services/api/test/unit/media-authorization.test.ts @@ -145,7 +145,7 @@ describe("makeUnboundOnlyMediaViewAuthorizer (the fail-closed default)", () => { createdAt: new Date(), } - it("DENIES every bound asset — a wiring that forgets the real authorizer serves nothing", async () => { + it("DENIES every bound asset: a wiring that forgets the real authorizer serves nothing", async () => { const auth = makeUnboundOnlyMediaViewAuthorizer() for (const binding of [ { reportId: "r1" }, diff --git a/services/api/test/unit/media-binding-drop-guard.test.ts b/services/api/test/unit/media-binding-drop-guard.test.ts index ae62a7e9..c005f143 100644 --- a/services/api/test/unit/media-binding-drop-guard.test.ts +++ b/services/api/test/unit/media-binding-drop-guard.test.ts @@ -56,7 +56,7 @@ describe("F147: dropping a media binding column must re-point its rows first", ( ) expect( repoint.test(before), - `${drop.file} drops media_assets.${drop.column} without re-pointing the bound rows first — ` + + `${drop.file} drops media_assets.${drop.column} without re-pointing the bound rows first: ` + "every row bound only by it becomes orphan-sweep bait (row DELETEd, R2 object deleted)", ).toBe(true) } diff --git a/services/api/test/unit/media-orphan-bindings.test.ts b/services/api/test/unit/media-orphan-bindings.test.ts index a8971baa..0848689d 100644 --- a/services/api/test/unit/media-orphan-bindings.test.ts +++ b/services/api/test/unit/media-orphan-bindings.test.ts @@ -48,7 +48,7 @@ describe("media binding predicate", () => { const [table, column] = relation.split(".") expect( text, - `${relation} is not exempted — a bound asset older than the TTL would be reaped`, + `${relation} is not exempted; a bound asset older than the TTL would be reaped`, ).toContain(table as string) expect(text).toContain(column as string) } diff --git a/services/api/test/unit/migrate-files.test.ts b/services/api/test/unit/migrate-files.test.ts index 31d66212..a417dbc4 100644 --- a/services/api/test/unit/migrate-files.test.ts +++ b/services/api/test/unit/migrate-files.test.ts @@ -46,8 +46,8 @@ describe("orderMigrationFiles", () => { const entries = ["0001_core.sql", "0000_extensions.sql"] const copy = [...entries] const out = orderMigrationFiles(entries) - expect(entries).toEqual(copy) // input untouched - expect(out).not.toBe(entries) // new array + expect(entries).toEqual(copy) + expect(out).not.toBe(entries) }) it("handles an empty listing", () => { @@ -69,9 +69,6 @@ describe("orderMigrationFiles", () => { // the Docker-gated schema bookkeeping assertion) is updated to match. The same list the runner reads. it("the REAL services/api/drizzle directory holds exactly 0000..0009 (DM + privacy) in order", () => { const ordered = orderMigrationFiles(readdirSync(DRIZZLE_DIR)) - // 0009 adds the direct-messages + blocking schema (dm_threads / dm_messages / dm_read_state / - // user_blocks + users.allow_direct_messages). The list asserts the deploy contract: the runner applies - // exactly these files, in this order. expect(ordered).toContain("0009_dm_and_privacy.sql") expect(ordered.slice(0, 10)).toEqual([ "0000_extensions.sql", diff --git a/services/api/test/unit/migrations-transaction-control.test.ts b/services/api/test/unit/migrations-transaction-control.test.ts index b010d66d..f7d869d4 100644 --- a/services/api/test/unit/migrations-transaction-control.test.ts +++ b/services/api/test/unit/migrations-transaction-control.test.ts @@ -1,9 +1,9 @@ /** * Drift guard: NO migration file may contain a transaction-control statement. * - * src/db/migrate.ts:83-97 owns the transaction. It reserves a connection, drives `begin` explicitly, - * runs the file, INSERTs the _civfix_migrations bookkeeping row and `commit`s — "the DDL and its - * bookkeeping row commit together or not at all" — with a `rollback` in the catch branch. + * src/db/migrate.ts owns the transaction. It reserves a connection, drives `begin` explicitly, + * runs the file, INSERTs the _civfix_migrations bookkeeping row and `commit`s ("the DDL and its + * bookkeeping row commit together or not at all"), with a `rollback` in the catch branch. * * A file that opens its OWN transaction breaks that invariant SILENTLY, with no error anywhere: * - the file's `COMMIT;` ends the runner's transaction mid-flight; @@ -11,7 +11,7 @@ * - the runner's trailing `commit` only warns "there is no transaction in progress"; * - and the catch branch's `rollback` becomes a no-op, so a half-applied file is recorded as applied. * - * Zero of the migrations on disk contain one today. This test is what keeps that true — copying a DDL + * Zero of the migrations on disk contain one today. This test is what keeps that true: copying a DDL * snippet out of a design doc or a Stack Overflow answer is exactly how the first one would arrive. * * No database needed: this reads the real drizzle/ directory as text. @@ -63,7 +63,7 @@ describe("migrations contain no transaction-control statements", () => { const re = new RegExp(String.raw`(^|;)\s*${keyword}\b`) expect( re.test(body), - `${name} contains a "${keyword}" statement — src/db/migrate.ts owns the transaction (see this file's header)`, + `${name} contains a "${keyword}" statement; src/db/migrate.ts owns the transaction (see this file's header)`, ).toBe(false) } }) diff --git a/services/api/test/unit/outbound-mail-service.test.ts b/services/api/test/unit/outbound-mail-service.test.ts index e5ba9367..f62ac6c7 100644 --- a/services/api/test/unit/outbound-mail-service.test.ts +++ b/services/api/test/unit/outbound-mail-service.test.ts @@ -374,7 +374,7 @@ describe("OutboundMailService.compose / appendOutbound", () => { }) }) -describe("OutboundMailService — F110: References chain is trimmed (root + last 9)", () => { +describe("OutboundMailService (F110): References chain is trimmed (root + last 9)", () => { it("emits root + the last 9 prior ids (never the unbounded middle); In-Reply-To stays the true latest", async () => { const { repo, mailer, svc } = harness() const t = repo.seedThread({ subject: "Digest" }) @@ -404,7 +404,7 @@ describe("OutboundMailService — F110: References chain is trimmed (root + last }) }) -describe("OutboundMailService — F109: a delivered send never throws post-delivery", () => { +describe("OutboundMailService (F109): a delivered send never throws post-delivery", () => { it("swallows a thread re-read failure after delivery so the throttle window is not reopened", async () => { const repo = new InMemoryMailRepository() const mailer = new FakeMailer() diff --git a/services/api/test/unit/parse-acs.test.ts b/services/api/test/unit/parse-acs.test.ts index 131397cd..865808a3 100644 --- a/services/api/test/unit/parse-acs.test.ts +++ b/services/api/test/unit/parse-acs.test.ts @@ -1,18 +1,18 @@ /** - * Unit tests for `parseAcs` (src/db/backfill-population-core.ts) — the pure Census ACS5 response parser + * Unit tests for `parseAcs` (src/db/backfill-population-core.ts), the pure Census ACS5 response parser * that turns the API's header+rows matrix into { geoid, population } pairs for the population backfill. * - * Why this is worth pinning (audit 2026-07-24, db LOW test-gap): the geoid is ASSEMBLED by concatenating + * Why this is worth pinning: the geoid is ASSEMBLED by concatenating * geography columns, and it used to take "every column that isn't the population variable, in HEADER * order". That is right for the three shapes we actually request today and silently wrong for anything - * else — a caller adding `NAME` to the `get=` list would have concatenated "Los Angeles city, California" + * else: a caller adding `NAME` to the `get=` list would have concatenated "Los Angeles city, California" * into the geoid, and a Census column reshuffle ("county" emitted before "state") would have produced * "03706". Neither throws: a bad geoid just makes the UPDATE match zero jurisdiction rows, so the backfill * reports "fetched N, updated 0" and the operator has no idea why. * * The hardening is a FIXED whitelist + order (ACS_GEO_COLUMNS = state, county, place), so the tests below - * assert the two properties that whitelist buys — an unlisted column is IGNORED and the geoid follows the - * whitelist order, not header order — alongside the row-level filters. + * assert the two properties that whitelist buys (an unlisted column is IGNORED and the geoid follows the + * whitelist order, not header order) alongside the row-level filters. * * Pure functions: no DB, no network, no fakes. */ diff --git a/services/api/test/unit/posts-routes.test.ts b/services/api/test/unit/posts-routes.test.ts index 404fd86a..5c0c2332 100644 --- a/services/api/test/unit/posts-routes.test.ts +++ b/services/api/test/unit/posts-routes.test.ts @@ -1,14 +1,13 @@ /** - * HTTP-layer tests for the 13 social-feed post routes (routes-core test gap: posts.routes.ts had NO - * route-level coverage — route-coverage.test.ts only asserts the endpoints are REGISTERED, and - * post-service.test.ts calls the service directly, so nothing exercised auth, CSRF, `:id`/query - * validation, the status codes or the serialized DTO). + * HTTP-layer tests for the 13 social-feed post routes. route-coverage.test.ts only asserts the endpoints + * are REGISTERED and post-service.test.ts calls the service directly, so nothing else exercises auth, + * CSRF, `:id`/query validation, the status codes or the serialized DTO. * * Everything runs through the real Fastify stack (`app.inject`) with a real `makePostService` over an * in-memory PostRepository, so the route -> service -> repo path is genuine; only the SQL is replaced. * * SEAM: posts.routes.ts reads `container.getPostService()` and has no `app.*Overrides` hook, so the - * container getter IS the injection point — the harness swaps that one method on the container it passes + * container getter IS the injection point: the harness swaps that one method on the container it passes * to buildServer. `sql` is a throwing stub: `resolveMentionTargets` short-circuits on an empty * `mentionedUserIds`, so every path here provably needs no database. */ @@ -45,10 +44,8 @@ const throwingSql = (() => { throw new Error("posts-routes.test: no SQL may be issued on these paths") }) as unknown as Sql -// --------------------------------------------------------------------------- -// In-memory PostRepository (a behavioral fake, not a stub: counts and viewer -// flags are derived from stored state so the serialized DTO is meaningful) -// --------------------------------------------------------------------------- +// The in-memory PostRepository below is a behavioral fake, not a stub: counts and viewer flags are +// derived from stored state so the serialized DTO is meaningful. interface StoredPost { id: string @@ -287,7 +284,7 @@ class InMemoryPostRepository implements PostRepository { /** * The shared source for BOTH timeline feeds, so the reply exclusion below lands on each of them at - * once — exactly as `AND p.reply_to_id IS NULL` does in the two SQL queries. + * once, exactly as `AND p.reply_to_id IS NULL` does in the two SQL queries. */ private byFilter(filter: "all" | "events" | "fixes"): StoredPost[] { return [...this.posts.values()].filter((p) => { @@ -295,7 +292,7 @@ class InMemoryPostRepository implements PostRepository { // Both byFilter feeds are TOP-LEVEL only, matching `reply_to_id IS NULL` in homeFeed + publicFeed. // A reply is thread content and belongs to listReplies, not the timeline. Note this fake had drifted // BOTH ways: publicFeed's SQL always carried the predicate and the fake never did, while homeFeed's - // SQL was missing it entirely (the bug) — so the route suite was certifying a reply dump. + // SQL was missing it entirely (the bug), so the route suite was certifying a reply dump. if (p.replyToId !== null) return false if (filter === "events") return p.eventId !== null if (filter === "fixes") return p.reportId !== null @@ -396,7 +393,7 @@ class InMemoryPostRepository implements PostRepository { } listUserPosts(authorId: string, args: PostListArgs): Promise { - // `kind !== "reply"`, matching the SQL's `AND p.kind <> 'reply'` — the profile "Posts" tab is the + // `kind !== "reply"`, matching the SQL's `AND p.kind <> 'reply'`: the profile "Posts" tab is the // Twitter Posts-vs-Replies split. The fake had omitted this, so the profile-tab test below could have // certified a reply the real query rejects. const rows = [...this.posts.values()].filter( @@ -414,10 +411,6 @@ class InMemoryPostRepository implements PostRepository { } } -// --------------------------------------------------------------------------- -// Harness -// --------------------------------------------------------------------------- - /** A mobile (bearer) session: CSRF-exempt, used for everything except the CSRF suite. */ interface Session { userId: string @@ -549,8 +542,8 @@ const MUTATIONS = ( /** * The auth-REQUIRED reads only. `homeFeed`, `listUserPosts` and `getPost` are `auth: "optional"` in the - * shared registry — a public profile has to be readable signed-out (P10), so its "Posts" tab does too, and - * a shared post link must preview signed-out (#136) — and each has its own signed-out test. + * shared registry: a public profile has to be readable signed-out, so its "Posts" tab does too, and a + * shared post link must preview signed-out (#136). Each has its own signed-out test. */ const READS = (id: string, _authorId: string): ReadonlyArray<{ method: "GET"; url: string }> => [ { method: "GET", url: `/v1/posts/${id}/replies` }, @@ -573,9 +566,9 @@ describe("posts routes: auth", () => { }) /** - * P10: a public profile is readable signed-out, so its "Posts" tab must be too. The signed-out reader + * A public profile is readable signed-out, so its "Posts" tab must be too. The signed-out reader * is hydrated against the NIL viewer, so every VIEWER flag comes back false while the public counts - * stay real — the same substitution the public feed makes. A 401 here would have made the whole + * stay real, the same substitution the public feed makes. A 401 here would have made the whole * signed-out profile page a dead end. */ it("serves GET /people/:id/posts to a SIGNED-OUT reader (auth: optional)", async () => { @@ -751,7 +744,7 @@ describe("POST /posts (createPost)", () => { expect(h.repo.posts.size).toBe(0) }) - it("422s kind:'repost' — the toggle has its own route", async () => { + it("422s kind:'repost'; the toggle has its own route", async () => { const h = await makeHarness() const me = await h.signIn("rp@example.com", "Rp") const target = h.repo.seed({ authorId: me.userId }) @@ -812,8 +805,6 @@ describe("POST /posts (createPost)", () => { expect(ok.statusCode).toBe(201) }) - // --- content filter + rate limit (the "post to feed" hardening) --------------------------------- - it("422s a body containing a slur, on every post kind that carries one", async () => { const h = await makeHarness() const me = await h.signIn("slur@example.com", "Slur") @@ -839,7 +830,7 @@ describe("POST /posts (createPost)", () => { // The seeded parent is the only row: nothing slurred was written. expect(h.repo.posts.size).toBe(1) - // General profanity is explicitly OUT of scope for the filter — this must still post. + // General profanity is explicitly OUT of scope for the filter, so this must still post. const ok = await h.app.inject({ method: "POST", url: "/v1/posts", @@ -1499,7 +1490,7 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { replyToId: parent, body: "count me in", }) - // A repost of the reply: its OWN row is top-level (repost never sets replyToId), so it MUST survive — + // A repost of the reply: its OWN row is top-level (repost never sets replyToId), so it MUST survive: // amplifying is a deliberate act, and this is the documented carve-out, not an oversight. const repostOfReply = h.repo.seed({ authorId: me.userId, @@ -1515,7 +1506,7 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { expect(homeIds).not.toContain(reply) expect(homeIds).toContain(repostOfReply) - // Signed-out reads the SAME shape — the whole point of using publicFeed's exact predicate. + // Signed-out reads the SAME shape: the whole point of using publicFeed's exact predicate. const publicIds = (await h.app.inject({ method: "GET", url: "/v1/feed/home" })) .json() .items.map((p: PostDTO) => p.id) diff --git a/services/api/test/unit/push-token-registration.test.ts b/services/api/test/unit/push-token-registration.test.ts index e10664e7..536d8f3d 100644 --- a/services/api/test/unit/push-token-registration.test.ts +++ b/services/api/test/unit/push-token-registration.test.ts @@ -108,7 +108,7 @@ describe("registerPushToken validation (H15)", () => { expect(repo.pushTokens).toHaveLength(1) }) - it("REFUSES an endpoint the sender would refuse (internal host) — never stored, never dialed", async () => { + it("REFUSES an endpoint the sender would refuse (internal host): never stored, never dialed", async () => { const { repo, service } = harness((endpoint) => !endpoint.includes("internal")) await expect( service.registerPushToken(U, { diff --git a/services/api/test/unit/refresh-boundaries-script.test.ts b/services/api/test/unit/refresh-boundaries-script.test.ts index 83cd9eb3..7f5873cf 100644 --- a/services/api/test/unit/refresh-boundaries-script.test.ts +++ b/services/api/test/unit/refresh-boundaries-script.test.ts @@ -1,18 +1,18 @@ /** - * scripts/refresh-boundaries.ts — the nationwide boundary refresh (TIGER + PAD-US). The script runs + * scripts/refresh-boundaries.ts, the nationwide boundary refresh (TIGER + PAD-US). The script runs * `main()` on import, so it cannot be exercised in-process; these are structural assertions over its * SOURCE, which is how the two failure modes below present themselves (both are ordering/configuration mistakes * that no runtime assertion in this repo can reach without a live Census download). * - * F141: it is the only DB entry point that used the REQUEST-path timeouts. makeDb defaults to + * The script was the only DB entry point that used the REQUEST-path timeouts. makeDb defaults to * statementTimeoutMs 15s / idleInTxTimeoutMs 30s and pushes them as startup parameters; every other CLI * (src/db/cli.ts runDbCli, scripts/revoke-certificate.ts) deliberately disables both because these are - * exactly the statements those timeouts exist to kill — a 1000-row correlated ST_Contains UPDATE, a + * exactly the statements those timeouts exist to kill: a 1000-row correlated ST_Contains UPDATE, a * full-table UPDATE over reports, and per-feature inserts of continent-scale multipolygons. * - * F142: pruneNonAuthoritative COMMITS destructive deletes (jurisdictions + every FK pointer into them). - * Running it before the fallible download/convert/ingest means any failure after it — a Census layer - * that 404s, a missing ogr2ogr source, a statement timeout — strictly REDUCES coverage with no re-seed + * pruneNonAuthoritative COMMITS destructive deletes (jurisdictions + every FK pointer into them). + * Running it before the fallible download/convert/ingest means any failure after it (a Census layer + * that 404s, a missing ogr2ogr source, a statement timeout) strictly REDUCES coverage with no re-seed * path. In full-refresh mode the prune must therefore happen only AFTER the ingest loop succeeded; * --backfill-only keeps it up front, where nothing fallible precedes it. */ diff --git a/services/api/test/unit/report-chat-emitter-seams.test.ts b/services/api/test/unit/report-chat-emitter-seams.test.ts index 03427ae1..15245a2b 100644 --- a/services/api/test/unit/report-chat-emitter-seams.test.ts +++ b/services/api/test/unit/report-chat-emitter-seams.test.ts @@ -4,7 +4,7 @@ * The emitter is assembled from container primitives (report-chat repo + notification service + mutes repo * + blocks repo) and fans a per-member bell for every timeline system message. Both batch seams it wires * are OPTIONAL on their repositories, and chat-room-fanout-notifier treats a PRESENT batch seam as - * AUTHORITATIVE — it then never runs the per-candidate gate. So binding an absent method through a + * AUTHORITATIVE: it then never runs the per-candidate gate. So binding an absent method through a * `?? Promise.resolve(new Set())` default (which is what this file used to do for mutes) silently turns the * gate OFF for the whole room: every muted member gets belled. * @@ -13,7 +13,7 @@ * - mutes store WITHOUT it -> per-user isMuted runs, muted member STILL suppressed; * - the blocks repo is read LAZILY -> the factory must not touch it (this emitter is built per event * from minimal containers, and a factory throw escapes emit()'s own try/catch and takes the CALLER - * down with it — that is why the batch block seam is not probed here). + * down with it; that is why the batch block seam is not probed here). * * Only the DB-bound factory leaves are mocked; the fan-out pipeline, the lane binder and the real * notification service all run, so the gates under test are the production ones. @@ -180,14 +180,14 @@ describe("makeContainerReportChatEmitter: the mute seam is probed, never default }) describe("makeContainerReportChatEmitter: the block seam stays LAZY", () => { - it("never touches the blocks repo — not at construction, not per event (sender-less system messages)", async () => { + it("never touches the blocks repo: not at construction, not per event (sender-less system messages)", async () => { const blockCalls: string[] = [] stub.members = [A, B] // A container with NO getBlocksRepo at all: constructing the emitter must not reach for it. Callers of // this factory (inbound mail correlation, autoforward/outreach jobs, the report + moderation routers) // build it PER EVENT off containers that are minimal in tests, and a throw in the factory escapes the - // emit() try/catch — it aborted the caller's remaining work (e.g. flipping a mail thread to 'replied'). + // emit() try/catch; it aborted the caller's remaining work (e.g. flipping a mail thread to 'replied'). const container = { env: { USE_FAKE_CHAT: false }, getDb: () => ({ sql: {} }), diff --git a/services/api/test/unit/report-chat-membership.test.ts b/services/api/test/unit/report-chat-membership.test.ts index 56f19248..3b7bc5ec 100644 --- a/services/api/test/unit/report-chat-membership.test.ts +++ b/services/api/test/unit/report-chat-membership.test.ts @@ -198,7 +198,7 @@ describe("POST /reports/:id/chat/leave", () => { }) }) -describe("DELETE /reports/:id/messages/:messageId — membership gate", () => { +describe("DELETE /reports/:id/messages/:messageId (membership gate)", () => { it("403s a NON-member (isMember -> false), never touching softDeleteReport", async () => { const { app, token, chatRepo } = await makeHarness({ isMember: false }) const messageId = await seedReportMessage(chatRepo, "someone-else") @@ -232,7 +232,7 @@ describe("DELETE /reports/:id/messages/:messageId — membership gate", () => { }) }) -describe("POST /reports/:id/messages/:messageId/reactions — membership gate", () => { +describe("POST /reports/:id/messages/:messageId/reactions (membership gate)", () => { it("403s a NON-member (isMember -> false), keeping the room's Join copy", async () => { const { app, token, chatRepo, reportChat } = await makeHarness({ isMember: false }) const messageId = await seedReportMessage(chatRepo, "someone-else") @@ -247,7 +247,7 @@ describe("POST /reports/:id/messages/:messageId/reactions — membership gate", expect(reportChat.isMember).toHaveBeenCalledTimes(1) }) - it("404s (never 403) when the report is no longer visible — visibility gates before membership", async () => { + it("404s (never 403) when the report is no longer visible; visibility gates before membership", async () => { const { app, token, chatRepo, discussionRepo, reportChat } = await makeHarness({ isMember: true, }) diff --git a/services/api/test/unit/report-chat-participants.test.ts b/services/api/test/unit/report-chat-participants.test.ts index 219d38a7..a142667c 100644 --- a/services/api/test/unit/report-chat-participants.test.ts +++ b/services/api/test/unit/report-chat-participants.test.ts @@ -72,7 +72,6 @@ describe("toReportParticipantDTO", () => { it("hides a blocked pair behind the shared hidden identity", () => { const dto = toReportParticipantDTO(row({ blocked_pair: true })) - // Not the real display name, and none of the identifying fields survive. expect(dto.user.name).not.toBe("Ada Lovelace") expect(dto.user.handle).toBeNull() expect(dto.user.bio).toBeNull() diff --git a/services/api/test/unit/report-chat-tables.test.ts b/services/api/test/unit/report-chat-tables.test.ts index e7f1d855..6bdd28c5 100644 --- a/services/api/test/unit/report-chat-tables.test.ts +++ b/services/api/test/unit/report-chat-tables.test.ts @@ -1,8 +1,6 @@ /** - * Task D-B2: three new tables backing report-chat membership (Join), per-conversation mute, and - * the @city forward audit. This task ONLY defines the Drizzle mirrors + migrations — nothing - * reads/writes them yet (consumers land in D-C1 membership repo, D-E1 mute repo, D-C4 forward - * audit). No database needed: this only inspects the Drizzle table configs. + * The Drizzle mirrors of the report-chat membership, per-conversation mute and @city forward audit + * tables. No database needed: this only inspects the Drizzle table configs. */ import { describe, expect, it } from "vitest" @@ -12,7 +10,6 @@ import { reportChatMembers } from "../../src/db/schema/report_chat_members.js" import { conversationMutes } from "../../src/db/schema/conversation_mutes.js" import { reportMessageForwards } from "../../src/db/schema/report_message_forwards.js" -/** Extracts the composite primary-key column names (in declaration order) from a pgTable's config. */ function primaryKeyColumnNames(table: Parameters[0]): string[] { const config = getTableConfig(table) const pk = config.primaryKeys[0] diff --git a/services/api/test/unit/report-chat.test.ts b/services/api/test/unit/report-chat.test.ts index 369e91f6..8f8ebee3 100644 --- a/services/api/test/unit/report-chat.test.ts +++ b/services/api/test/unit/report-chat.test.ts @@ -410,7 +410,7 @@ describe("report chat gateway", () => { }) }) -describe("report chat gateway — member-only send/typing + read watermark (D-C3)", () => { +describe("report chat gateway: member-only send/typing + read watermark (D-C3)", () => { it("still authorizes a report JOIN by a NON-member socket (public join preserved)", async () => { reportChat = makeReportChat(false) const conn = new MockConnection("A") diff --git a/services/api/test/unit/report-content-routes.test.ts b/services/api/test/unit/report-content-routes.test.ts index c7f5bf1e..68610034 100644 --- a/services/api/test/unit/report-content-routes.test.ts +++ b/services/api/test/unit/report-content-routes.test.ts @@ -12,8 +12,7 @@ import type { ContentSubjectGate } from "../../src/services/content-report-subje import { AppError } from "@civfix/shared" /** - * Offline HTTP test for the PUBLIC content-report route (POST /content-reports). The route builds the - * moderation service from the injected in-memory ModerationRepository (the SAME app.moderationOverrides + * The PUBLIC content-report route (POST /content-reports) builds the moderation service from the injected in-memory ModerationRepository (the SAME app.moderationOverrides * seam the admin queue uses), so the whole flow runs with no DB: a signed-in user files a report and an * OPEN `user_report` moderation item is enqueued (deduped by subject). */ diff --git a/services/api/test/unit/report-message-city-forward.test.ts b/services/api/test/unit/report-message-city-forward.test.ts index dfefd90a..d81026f6 100644 --- a/services/api/test/unit/report-message-city-forward.test.ts +++ b/services/api/test/unit/report-message-city-forward.test.ts @@ -1,10 +1,7 @@ /** - * Task D-C4 (follow-up): surfacing forwardedToCity + cityMention on a REPORT chat message DTO. - * * cityForwardFields is the PURE, DB-free helper the report message mapper (chat-repository.drizzle.ts - * toMessageDTO) uses to derive the "@city" surfacing from (a) the per-row forwarded_to_city EXISTS column - * and (b) the report's resolved jurisdiction. It is the genuinely new bit, so it is unit-tested here with - * no database. The SQL join (report_message_forwards -> forwarded_to_city) + the jurisdiction lookup are + * toMessageDTO) uses to derive the "@city" surfacing on a REPORT chat message DTO from (a) the per-row + * forwarded_to_city EXISTS column and (b) the report's resolved jurisdiction. The SQL join (report_message_forwards -> forwarded_to_city) + the jurisdiction lookup are * exercised by the Docker-gated pg suite. * * - forwardedToCity mirrors the forwarded_to_city column (the "Forwarded to city" pill); diff --git a/services/api/test/unit/report-routes.test.ts b/services/api/test/unit/report-routes.test.ts index f8beff58..9bd6d649 100644 --- a/services/api/test/unit/report-routes.test.ts +++ b/services/api/test/unit/report-routes.test.ts @@ -23,7 +23,7 @@ interface Harness { app: FastifyInstance repo: InMemoryReportRepository mailer: FakeMailer - /** A signed-in user's bearer token + id (minted through the real OTP flow). */ + /** Minted through the real OTP flow. */ token: string userId: string } @@ -92,7 +92,6 @@ async function makeHarness( return h } -/** Authorization header for the signed-in user. */ function auth(token: string): Record { return { authorization: `Bearer ${token}` } } @@ -362,7 +361,7 @@ describe("POST /reports", () => { expect(repo.reports.size).toBe(0) }) - // D-C2: the report's creator is auto-joined as an OWNER of its chat, once the report row is committed. + // The report's creator is auto-joined as an OWNER of its chat, once the report row is committed. it("auto-joins the creator as an 'owner' of the report chat with the new report id", async () => { const joinReportChatAsOwner = vi.fn(() => Promise.resolve()) const { app, token, userId } = await makeHarness({ joinReportChatAsOwner }) @@ -406,7 +405,6 @@ describe("POST /reports", () => { }) expect(res.statusCode).toBe(201) expect(joinReportChatAsOwner).toHaveBeenCalledTimes(1) - // The report was still persisted despite the auto-join failure. expect(repo.reports.size).toBe(1) }) }) @@ -452,8 +450,8 @@ describe("GET /reports/:id", () => { }) it("treats a non-UUID id as a reference code (resolve-either): unknown code -> 404", async () => { - // Issue #56 resolve-either: GET /reports/:id accepts a UUID OR a reference_code. A non-UUID id is no - // longer a 422 — it is looked up by reference_code, and an unknown one is NOT_FOUND. + // GET /reports/:id accepts a UUID OR a reference_code: a non-UUID id is looked up by reference_code, + // and an unknown one is NOT_FOUND (not a 422). const { app } = await makeHarness() const res = await app.inject({ method: "GET", url: "/v1/reports/DU-42-999999" }) expect(res.statusCode).toBe(404) @@ -543,7 +541,6 @@ describe("GET /reports/:id", () => { describe("GET /reports (my reports)", () => { it("lists the caller's own reports, newest first", async () => { const { app, token, userId } = await makeHarness() - // Create two reports as the signed-in user. for (const key of [ "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222", @@ -569,7 +566,6 @@ describe("GET /reports (my reports)", () => { expect(body.items).toHaveLength(2) expect(body.items.every((r: { mine: boolean }) => r.mine === true)).toBe(true) expect(body.nextCursor).toBeNull() - // Sanity: they are this user's. void userId }) @@ -609,14 +605,13 @@ describe("GET /reports (my reports)", () => { describe("GET /map/reports", () => { // The shared client serializes bbox as a single JSON param and categories as repeated params. We build // the query with clientQuery() (a byte-for-byte replica of the client's buildQuery) so these tests - // prove the previously-422 client calls now parse + succeed. - // M14: `zoom` is no longer independent of `bbox` — the service clamps it to what the requested extent - // can actually imply (services/report-clustering.ts effectiveMapZoom), so a per-pin (zoom >= 13) case - // needs a bbox that a real client could plausibly be displaying at that zoom. This ~5 km viewport - // implies zoom 14, which clears the cluster threshold; the old 33x22 km box did not and is now - // (correctly) forced to cluster no matter what zoom is claimed. + // prove the client's real calls parse + succeed. + // The service clamps `zoom` to what the requested extent can actually imply + // (services/report-clustering.ts effectiveMapZoom), so a per-pin (zoom >= 13) case needs a bbox a real + // client could plausibly be displaying at that zoom. This ~5 km viewport implies zoom 14, which clears + // the cluster threshold. const BBOX = { west: -118.36, south: 34.09, east: -118.31, north: 34.14 } - // A world-spanning bbox: the M14 attack shape (`zoom=22` over the whole planet). + // A world-spanning bbox: the attack shape (`zoom=22` over the whole planet). const WORLD_BBOX = { west: -180, south: -85, east: 180, north: 85 } it("returns clusters at low zoom and pins at high zoom for points in the bbox (client-encoded bbox)", async () => { @@ -799,7 +794,6 @@ describe("GET /map/reports", () => { expect(res.json().pins).toHaveLength(2) }) - // --- M14: bbox/zoom decoupling ------------------------------------------------------------------- // The attack was `bbox=&zoom=22`: clustering was skipped at zoom >= 13 and `zoom` was a // free client parameter never correlated with the extent, so one anonymous request pulled up to // MAP_REPORTS_CANDIDATE_CAP full report rows AND that many media presign round-trips, with the 60s diff --git a/services/api/test/unit/report-service.test.ts b/services/api/test/unit/report-service.test.ts index 83472c99..c3be8de8 100644 --- a/services/api/test/unit/report-service.test.ts +++ b/services/api/test/unit/report-service.test.ts @@ -671,11 +671,11 @@ describe("getReport: visibility / held hiding", () => { createdAt: new Date(Date.now() - 1000), }) const fullBody = - "We've scheduled a crew and will follow up after the visit — thanks for the report." + "We've scheduled a crew and will follow up after the visit \u2014 thanks for the report." repo.timeline.push({ reportId: r.id, status: "published", - note: "Jurisdiction replied — We've scheduled a crew…", + note: "Jurisdiction replied: We've scheduled a crew…", kind: "reply", body: fullBody, createdAt: new Date(), @@ -782,7 +782,7 @@ describe("resolveReport (owner status toggle)", () => { expect(repo.reports.get(r.id)!.visibility).toBe("hidden") }) - it("L12: the owner is unaffected — they still mutate their own held/unlisted report", async () => { + it("L12: the owner is unaffected; they still mutate their own held/unlisted report", async () => { const { repo, service } = makeHarness() const held = repo.seedReport({ reporterUserId: "owner", status: "held", visibility: "public" }) await service.unlistReport("owner", held.id, true) @@ -1484,9 +1484,9 @@ describe("searchReports", () => { }) /** - * F058: owner-only report media (held / unlisted, and any still-`validating` asset) used to be signed with - * the PUBLIC presigner, which returns a permanent unsigned CDN URL whenever R2_PUBLIC_BASE is configured. - * The presigner is chosen per report + per asset now; the public map surfaces keep the public one. + * Owner-only report media (held / unlisted, and any still-`validating` asset) must never go through the + * PUBLIC presigner, which returns a permanent unsigned CDN URL whenever R2_PUBLIC_BASE is configured. The + * presigner is chosen per report + per asset; the public map surfaces keep the public one. */ describe("report media presigner selection (F058)", () => { function presignHarness() { diff --git a/services/api/test/unit/report-system-message.test.ts b/services/api/test/unit/report-system-message.test.ts index 51d9949e..dbf71e41 100644 --- a/services/api/test/unit/report-system-message.test.ts +++ b/services/api/test/unit/report-system-message.test.ts @@ -1,12 +1,8 @@ /** - * Task D-C1: report-chat SYSTEM messages. A system message is a sender-less report status/timeline - * event stored as a first-class chat_messages row (kind:"system", sender_id NULL, structured - * system_* payload). `mapSystemRow` is the PURE, DB-free mapper from such a row to a ChatMessageDTO; - * it is the genuinely new bit this task owns, so it is unit-tested here without any database. - * - * The membership SQL (join/leave/isMember/advanceReadWatermark/listMemberIds/countMembers) is exercised - * by the Docker-gated pg integration suite (test/integration/report-chat-members-pg.test.ts), which - * SKIPS when Docker is unavailable. This file covers only the pure mapping. + * A report-chat SYSTEM message is a sender-less report status/timeline event stored as a first-class + * chat_messages row (kind:"system", sender_id NULL, structured system_* payload). `mapSystemRow` is the + * PURE, DB-free mapper from such a row to a ChatMessageDTO. The membership SQL is exercised by the + * Docker-gated pg suite (test/integration/report-chat-members-pg.test.ts). */ import { describe, expect, it } from "vitest" @@ -83,9 +79,9 @@ describe("mapSystemRow", () => { /** * insertSystemMessage validates input.status against ReportStatusSchema BEFORE writing any row, so a - * bogus status can never be persisted (making mapSystemRow's status cast sound for the D-D1 timeline + * bogus status can never be persisted (making mapSystemRow's status cast sound for the timeline * writers). The full insert path needs a DB (Docker-gated integration test); this asserts the pure - * validation contract the guard relies on — DB-free. + * validation contract the guard relies on. */ describe("insertSystemMessage status validation (ReportStatusSchema guard)", () => { it("accepts every report-status the system payload can carry", () => { diff --git a/services/api/test/unit/report-timeline-event.test.ts b/services/api/test/unit/report-timeline-event.test.ts index 40e11e97..1cc63d95 100644 --- a/services/api/test/unit/report-timeline-event.test.ts +++ b/services/api/test/unit/report-timeline-event.test.ts @@ -6,10 +6,9 @@ import { } from "../../src/services/report-timeline-event.js" /** - * Offline unit tests for the report-chat SYSTEM-message CHOKE POINT (Task D-D1). The emitter mirrors a - * report timeline event into the report chat: insertSystemMessage -> broadcast -> notify, in that order. - * It is FULLY best-effort: a throw at ANY step is swallowed so a failed system message never fails (nor - * rolls back) the underlying status change. + * The report-chat SYSTEM-message choke point mirrors a report timeline event into the report chat: + * insertSystemMessage -> broadcast -> notify, in that order. It is FULLY best-effort: a throw at ANY step + * is swallowed so a failed system message never fails (nor rolls back) the underlying status change. */ const REPORT = "11111111-1111-1111-1111-111111111111" @@ -58,7 +57,7 @@ function recorder(over?: Partial): Recorder { } describe("report-chat system-message emitter (D-D1 choke point)", () => { - it("emit persists, then broadcasts to the room, then notifies members — in order", async () => { + it("emit persists, then broadcasts to the room, then notifies members, in order", async () => { const { calls, deps } = recorder() const emitter = makeReportChatSystemEmitter(deps) diff --git a/services/api/test/unit/report-verification.test.ts b/services/api/test/unit/report-verification.test.ts index c534be19..ed6e697c 100644 --- a/services/api/test/unit/report-verification.test.ts +++ b/services/api/test/unit/report-verification.test.ts @@ -55,7 +55,7 @@ function verdictHarness(): VerdictHarness { return { repo, svc } } -describe("setVerdict (D7) — verdict write + count + flip", () => { +describe("setVerdict (D7): verdict write + count + flip", () => { it("approve-once: records the verdict but leaves report_verified false (count=1 < threshold)", async () => { const { repo, svc } = verdictHarness() const uid = "user-1" @@ -166,7 +166,7 @@ describe("setVerdict (D7) — verdict write + count + flip", () => { }) }) -describe("setUserReportVerified (D18) — manual override/revoke", () => { +describe("setUserReportVerified (D18): manual override/revoke", () => { function userHarness(): { repo: InMemoryAdminUserRepository; svc: AdminUserService } { const repo = new InMemoryAdminUserRepository() const svc = makeAdminUserService({ @@ -293,7 +293,7 @@ describe("createReport auto-forward enqueue gate (D9)", () => { }) }) -describe("report.autoforward handler (D9) — runAutoForwardWith", () => { +describe("report.autoforward handler (D9): runAutoForwardWith", () => { function handlerHarness(opts: { sendError?: unknown } = {}) { const repo = new InMemoryAdminReportRepository() repo.now = NOW @@ -387,7 +387,7 @@ describe("report.autoforward handler (D9) — runAutoForwardWith", () => { expect(mailer.sent).toHaveLength(0) }) - it("(c) a TERMINAL send failure (409 sender-not-approved) is swallowed — handler completes, never throws", async () => { + it("(c) a TERMINAL send failure (409 sender-not-approved) is swallowed; handler completes, never throws", async () => { const { repo, svc } = handlerHarness({ sendError: AppError.conflict("sender not approved") }) repo.seedReport({ id: "rep-1", @@ -429,7 +429,7 @@ describe("report.autoforward handler (D9) — runAutoForwardWith", () => { expect(warnings).toHaveLength(1) }) - it("(c'') a DEADLINE expiry is NOT retried — the handler completes and leaves it to the operator", async () => { + it("(c'') a DEADLINE expiry is NOT retried; the handler completes and leaves it to the operator", async () => { const { repo, svc } = handlerHarness({ sendError: new OutboundSendDeadlineError(30_000) }) repo.seedReport({ id: "rep-1", diff --git a/services/api/test/unit/reverse-geocode-photon.test.ts b/services/api/test/unit/reverse-geocode-photon.test.ts index 01051557..df1ce860 100644 --- a/services/api/test/unit/reverse-geocode-photon.test.ts +++ b/services/api/test/unit/reverse-geocode-photon.test.ts @@ -8,10 +8,9 @@ import { } from "../../src/adapters/reverse-geocode.photon.js" /** - * Unit tests for the best-effort Photon reverse geocoder. The factory takes an injected `fetchImpl`, so - * the whole thing runs offline against canned responses. The contract under test: a usable result is - * formatted into a one-line address; EVERYTHING else (bad coords, HTTP error, network throw, empty - * result) resolves to null so report creation is never blocked. + * The Photon reverse geocoder is best-effort: a usable result is formatted into a one-line address; + * EVERYTHING else (bad coords, HTTP error, network throw, empty result) resolves to null so report + * creation is never blocked. */ describe("formatPhotonReverse", () => { @@ -43,7 +42,6 @@ describe("formatPhotonReverse", () => { }) }) -/** A fake fetch returning a single Photon feature with the given properties. */ function okFetch(props: unknown): typeof fetch { return (async () => ({ diff --git a/services/api/test/unit/route-geo-helpers.test.ts b/services/api/test/unit/route-geo-helpers.test.ts index a73a8d03..956b9cc1 100644 --- a/services/api/test/unit/route-geo-helpers.test.ts +++ b/services/api/test/unit/route-geo-helpers.test.ts @@ -12,11 +12,9 @@ import { } from "../../src/services/route-geo-helpers.js" /** - * Route-level jurisdiction wiring (services/route-geo-helpers.ts). - * - * The one behavior worth pinning offline is WHICH surfaces memoize the external Census lookup: the anon-ok - * map resolve opts in (`cacheLookup: true`), the submit-path resolver does NOT, and the memo is held per - * CONTAINER — the service is rebuilt on every request, so a per-service cache would never see a hit, and a + * The one behavior of services/route-geo-helpers.ts worth pinning offline is WHICH surfaces memoize the + * external Census lookup: the anon-ok map resolve opts in (`cacheLookup: true`), the submit-path resolver + * does NOT, and the memo is held per CONTAINER: the service is rebuilt on every request, so a per-service cache would never see a hit, and a * bare module singleton would leak one container's coverage answers into another's tests. */ diff --git a/services/api/test/unit/shutdown-drain.test.ts b/services/api/test/unit/shutdown-drain.test.ts index 4e91f67a..74b407fb 100644 --- a/services/api/test/unit/shutdown-drain.test.ts +++ b/services/api/test/unit/shutdown-drain.test.ts @@ -1,14 +1,12 @@ /** - * SIGTERM DRAIN BEHAVIOUR (blue/green deploys). - * - * `makeShutdown` (src/lifecycle.ts) is the whole app-side contract with the blue/green edge: + * `makeShutdown` (src/lifecycle.ts) is the whole app-side SIGTERM contract with the blue/green edge: * * 1. the instant the signal lands, GET /healthz answers 503 + x-civfix-draining so Caddy's active * health check pulls this api color out of the upstream pool on its next probe; * 2. the process keeps serving normally for SHUTDOWN_DRAIN_MS; * 3. then it closes IDLE keep-alive sockets on a short sweep while awaiting app.close(), so parked * connections cannot stretch the close while ACTIVE requests are allowed to finish (Fastify is - * built with forceCloseConnections:false — its default 'idle' destroys in-flight sockets too); + * built with forceCloseConnections:false, because its default 'idle' destroys in-flight sockets too); * 4. only then the container (pg-boss / redis / db), under its own watchdog. * * The ordering tests fake timers so a multi-second drain costs nothing. The availability tests use a diff --git a/services/api/test/unit/social-service.test.ts b/services/api/test/unit/social-service.test.ts index 66c01d36..c92dbb9a 100644 --- a/services/api/test/unit/social-service.test.ts +++ b/services/api/test/unit/social-service.test.ts @@ -487,7 +487,7 @@ describe("getProfile: volunteer-hours privacy tri-state (C18)", () => { return { repo, service, calls } } - it("NULL (never chosen): hours present, flag ABSENT — byte-identical to the pre-column response", async () => { + it("NULL (never chosen): hours present, flag ABSENT, byte-identical to the pre-column response", async () => { const { repo, service, calls } = makeHoursHarness() repo.seedUser({ id: A, displayName: "Alice" }) repo.seedUser({ id: C, displayName: "Carol" }) @@ -597,7 +597,7 @@ describe("getProfile: volunteer-hours privacy tri-state (C18)", () => { expect(toPersonView(row).showVolunteerHours).toBe(false) }) - it("still passes the real tri-state through verbatim — null must NOT collapse to false", () => { + it("still passes the real tri-state through verbatim; null must NOT collapse to false", () => { const nullArm = { ...rowWithoutTheColumn(), show_volunteer_hours: null } as PersonRowSelect const trueArm = { ...rowWithoutTheColumn(), show_volunteer_hours: true } as PersonRowSelect const falseArm = { ...rowWithoutTheColumn(), show_volunteer_hours: false } as PersonRowSelect @@ -1028,7 +1028,7 @@ describe("followSuggestions", () => { expect((await service.followSuggestions(B, 10)).results.map((r) => r.id)).toEqual([A]) }) - it("R4-B2: a Redis fault during block invalidation is swallowed — the block itself must not fail", async () => { + it("R4-B2: a Redis fault during block invalidation is swallowed; the block itself must not fail", async () => { const throwing = { get: () => Promise.reject(new Error("redis down")), set: () => Promise.reject(new Error("redis down")), @@ -1038,7 +1038,7 @@ describe("followSuggestions", () => { await expect(dropSuggestionsFor(undefined, [A, B])).resolves.toBeUndefined() }) - it("H18: a cache that throws is not an error path — suggestions still compute", async () => { + it("H18: a cache that throws is not an error path; suggestions still compute", async () => { const repo = new InMemorySocialRepository() const service = makeSocialService({ repo, diff --git a/services/api/test/unit/storage-list.test.ts b/services/api/test/unit/storage-list.test.ts index 056f7211..b01c8f5e 100644 --- a/services/api/test/unit/storage-list.test.ts +++ b/services/api/test/unit/storage-list.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest" import { FakeStorage } from "@civfix/shared/fakes" /** - * FakeStorage.list / getObject — the new Storage seam methods the inbound sweep relies on. The fake - * mirrors R2's keyset pagination (sorted keys, last-key-as-cursor) so sweep tests are deterministic. + * FakeStorage.list / getObject back the inbound sweep. The fake mirrors R2's keyset pagination (sorted + * keys, last-key-as-cursor) so sweep tests are deterministic. */ const enc = new TextEncoder() diff --git a/services/api/test/unit/storage-r2.test.ts b/services/api/test/unit/storage-r2.test.ts index 0afebaa6..d280a6c8 100644 --- a/services/api/test/unit/storage-r2.test.ts +++ b/services/api/test/unit/storage-r2.test.ts @@ -2,10 +2,9 @@ import { describe, it, expect } from "vitest" import { R2Storage, R2_PUT_TTL_SEC } from "../../src/adapters/storage.r2.js" /** - * Unit tests for the real R2 adapter that need NO network. The AWS SigV4 presigner computes URLs - * locally (it never calls R2), so presignPut/presignGet can be asserted offline against fake creds. - * head/delete/put DO hit the network and are therefore exercised only against real R2 in deployment, - * not here. The DI test separately asserts R2Storage is selected when USE_FAKE_STORAGE is off. + * The AWS SigV4 presigner computes URLs locally (it never calls R2), so presignPut/presignGet can be + * asserted offline against fake creds. head/delete/put DO hit the network and are exercised only against + * real R2 in deployment. */ const config = { @@ -23,11 +22,9 @@ describe("R2Storage.presignPut", () => { byteSize: 1234, }) - // URL points at the R2 account endpoint, path-style bucket, and the key. expect(res.url).toContain( "https://acct123.r2.cloudflarestorage.com/civfix-media/uploads/2026/05/abc", ) - // Signed request -> a SigV4 query string is present. expect(res.url).toContain("X-Amz-Signature=") expect(res.url).toContain(`X-Amz-Expires=${R2_PUT_TTL_SEC}`) // content-length is part of the signed headers (the size is pinned). diff --git a/services/api/test/unit/thread-read-routes.test.ts b/services/api/test/unit/thread-read-routes.test.ts index 38e6e1ba..7eab7588 100644 --- a/services/api/test/unit/thread-read-routes.test.ts +++ b/services/api/test/unit/thread-read-routes.test.ts @@ -223,7 +223,7 @@ describe("PUT /threads/read", () => { }) }) -describe("PUT /threads/read — participation gate (L9)", () => { +describe("PUT /threads/read: participation gate (L9)", () => { it("403s a room the caller does not participate in, and writes NOTHING", async () => { const { app, token, dmRepo, reportMarkRead, groupMarkRead } = await makeHarness(false) const spy = vi.spyOn(dmRepo, "markRead") @@ -249,7 +249,7 @@ describe("PUT /threads/read — participation gate (L9)", () => { }) }) -describe("PUT /threads/read — abuse controls", () => { +describe("PUT /threads/read: abuse controls", () => { it("carries a per-identity rate limit and the csrf guard", () => { expect(THREAD_READ_RATE_LIMIT).toMatchObject({ max: 60, timeWindow: "1 minute" }) // Whitespace-collapsed so the assertion pins the declaration, not the formatter's line breaks. diff --git a/services/api/test/unit/thread-token-roundtrip.test.ts b/services/api/test/unit/thread-token-roundtrip.test.ts index 98a9ec25..f84eab1f 100644 --- a/services/api/test/unit/thread-token-roundtrip.test.ts +++ b/services/api/test/unit/thread-token-roundtrip.test.ts @@ -9,7 +9,7 @@ import { CfInboundMail } from "../../src/adapters/inbound-mail.cf.js" * - the outbound side mints a token via `mintThreadToken()` and sends FROM `{kind}-{token}@`, and * - BOTH the real CfInboundMail adapter AND the FakeInboundMail recover that exact token from the * reply address, so a jurisdiction's reply threads back onto the report/event. - * The two adapters MUST agree on accept/reject — the original bug was the real adapter rejecting a token + * The two adapters MUST agree on accept/reject: the original bug was the real adapter rejecting a token * shape the fake accepted, so replies silently never threaded in prod while every fake-based test passed. * This locks: the current 12-char base32 token round-trips through both, the legacy 24-hex token and the * legacy `+` separator still parse (in-flight replies), and obvious junk is rejected by both. @@ -63,7 +63,7 @@ describe("thread token round-trip (mint -> {kind}-{token}@ -> extract)", () => { // status changes and post an "official city reply" into the public report chat. expect(real.extractThreadToken(mail)).toBeNull() // The shared FakeInboundMail (an external @civfix/shared package) still honors the header. That is - // dev/test-only — the real adapter is what production uses — and the processor's DMARC gate now + // dev/test-only (production uses the real adapter), and the processor's DMARC gate // stands in front of the threaded path regardless. Tracked for the next @civfix/shared release. expect(fake.extractThreadToken(mail)).toBe(token) }) @@ -103,7 +103,7 @@ describe("thread token round-trip (mint -> {kind}-{token}@ -> extract)", () => { it("ignores a typed prefix on a FOREIGN domain (no thread hijack), both adapters", () => { // A city's own alias or a mailing-list decoy whose local-part happens to start report-/event-/reply- - // must NOT be read as a thread token — the token only ever lives on our reply domain. + // must NOT be read as a thread token: the token only ever lives on our reply domain. for (const addr of [ "report-publicworks@city.gov", "event-registration@constantcontact.com", diff --git a/services/api/test/unit/threads-report.test.ts b/services/api/test/unit/threads-report.test.ts index 544f5e16..560d515e 100644 --- a/services/api/test/unit/threads-report.test.ts +++ b/services/api/test/unit/threads-report.test.ts @@ -36,7 +36,7 @@ function mutesSource(muted: Array<{ roomKind: "cleanup" | "dm" | "report"; roomI } } -describe("threads service — report half (DB-free)", () => { +describe("threads service: report half (DB-free)", () => { it("projects a report aggregate into a kind:'report' DTO with muted:false by default", async () => { const report: ReportThreadAggregateView = { reportId: REPORT_A, @@ -155,7 +155,7 @@ describe("threads service — report half (DB-free)", () => { }) }) -describe("threads service — real per-conversation muted on ALL families (DB-free)", () => { +describe("threads service: real per-conversation muted on ALL families (DB-free)", () => { it("stamps muted per-family from the mutes source (cleanup + report muted, others not)", async () => { const repo = new InMemoryThreadsRepository() const c1 = repo.seedCleanup("Muted cleanup", CLEANUP_ID) diff --git a/services/api/test/unit/threads.test.ts b/services/api/test/unit/threads.test.ts index b16b5e93..222650d4 100644 --- a/services/api/test/unit/threads.test.ts +++ b/services/api/test/unit/threads.test.ts @@ -146,7 +146,7 @@ describe("threads service", () => { }) }) -describe("threads cursor paging (F051 — no thread skipped at a same-ms boundary)", () => { +describe("threads cursor paging (F051: no thread skipped at a same-ms boundary)", () => { const ids = [ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", diff --git a/services/api/test/unit/user-channel-gateway.test.ts b/services/api/test/unit/user-channel-gateway.test.ts index fe3eeb94..bdee8843 100644 --- a/services/api/test/unit/user-channel-gateway.test.ts +++ b/services/api/test/unit/user-channel-gateway.test.ts @@ -56,7 +56,6 @@ beforeEach(() => { chat = new WsChatService({ repo, pubsub }) }) -/** Assert a raw frame string parses as a valid server frame. */ function assertServerFrame(raw: string): void { const parsed = WsServerMessageSchema.safeParse(JSON.parse(raw)) expect(parsed.success, `frame failed server schema: ${raw}`).toBe(true) @@ -90,7 +89,6 @@ describe("subscribeUserChannel (per-socket lifecycle)", () => { const aSignals = aConn.framesOfType("signal") expect(aSignals).toHaveLength(1) expect(aSignals[0]).toMatchObject({ type: "signal", topic: "notifications" }) - // Bob (a different user) received nothing. expect(bConn.framesOfType("signal")).toHaveLength(0) for (const raw of aConn.sent) assertServerFrame(raw) }) @@ -115,7 +113,7 @@ describe("subscribeUserChannel (per-socket lifecycle)", () => { await dispose1!() expect(channel.subscriberCount(ALICE)).toBe(1) await channel.publishToUser(ALICE, { topic: "notifications" }) - expect(a1.framesOfType("signal")).toHaveLength(1) // unchanged + expect(a1.framesOfType("signal")).toHaveLength(1) expect(a2.framesOfType("signal")).toHaveLength(2) }) @@ -130,7 +128,6 @@ describe("subscribeUserChannel (per-socket lifecycle)", () => { }) describe("send fires a {topic:'threads'} signal to recipients (sender excluded by the resolver)", () => { - /** Build a gateway session wired with the user channel + a recipients resolver. */ function sessionFor( userId: string, conn: MockConnection, @@ -153,7 +150,7 @@ describe("send fires a {topic:'threads'} signal to recipients (sender excluded b } it("publishes a threads signal to the resolved recipients on a cleanup send", async () => { - // Resolver returns the room's OTHER members (Bob, Carol) — the sender (Alice) is already excluded. + // Resolver returns the room's OTHER members (Bob, Carol); the sender (Alice) is already excluded. const recipients: ThreadRecipientsOf = (_kind, _id, senderId) => Promise.resolve([BOB, CAROL].filter((m) => m !== senderId)) const aConn = new MockConnection("A") @@ -166,13 +163,11 @@ describe("send fires a {topic:'threads'} signal to recipients (sender excluded b // Let the fire-and-forget signal IIFE settle. await flush() - // One threads signal per recipient, NONE to the sender, all carrying the room id. const published = channel.published expect(published).toHaveLength(2) expect(published.map((p) => p.userId).sort()).toEqual([BOB, CAROL].sort()) expect(published.every((p) => p.signal.topic === "threads" && p.signal.id === ROOM)).toBe(true) expect(published.some((p) => p.userId === ALICE)).toBe(false) - // The signal payload is contract-valid. for (const p of published) expect(UserSignalSchema.safeParse(p.signal).success).toBe(true) }) @@ -187,7 +182,6 @@ describe("send fires a {topic:'threads'} signal to recipients (sender excluded b ) await flush() expect(channel.published).toHaveLength(0) - // The send itself still succeeded (Alice was acked). expect(aConn.framesOfType("ack")).toHaveLength(1) }) @@ -201,7 +195,6 @@ describe("send fires a {topic:'threads'} signal to recipients (sender excluded b JSON.stringify({ type: "send", cleanupId: ROOM, clientId: "c1", body: "still works" }), ) await flush() - // No signal published, but the send path is intact: ack delivered + message persisted. expect(channel.published).toHaveLength(0) expect(aConn.framesOfType("ack")).toHaveLength(1) expect(repo.count(ROOM)).toBe(1) @@ -259,12 +252,12 @@ describe("send fires a {topic:'threads'} signal to recipients (sender excluded b /** * The Fastify-adapter wiring in registerChatGateway, exercised through the REAL handler with a mock socket - * (no network / no `ws` client — the handler only needs readyState + send/close/terminate/ping + on()). + * (no network / no `ws` client: the handler only needs readyState + send/close/terminate/ping + on()). * Covers the subscribe-on-handshake / unsubscribe-on-close lifecycle AND the close-during-subscribe race: * - a normal OPEN handshake subscribes the user (subscriberCount === 1); the "close" event disposes it (=== 0); * - if the socket is NON-OPEN while subscribeUser is still pending, the handshake's close-during-subscribe - * guard must dispose the subscription so it never leaks — subscriberCount === 0 after the handshake settles, - * even though the lost "close" event never reached a (not-yet-registered) listener. + * guard must dispose the subscription so it never leaks (subscriberCount === 0 after the handshake settles, + * even though the lost "close" event never reached a (not-yet-registered) listener). */ describe("registerChatGateway (subscribe-on-handshake / unsubscribe-on-close wiring + race)", () => { /** ws readyState constants used by the gateway's OPEN check. */ @@ -285,7 +278,6 @@ describe("registerChatGateway (subscribe-on-handshake / unsubscribe-on-close wir this.listeners.set(event, list) return this } - /** Fire every listener registered for an event (e.g. the gateway's "close" cleanup). */ emit(event: string): void { for (const cb of this.listeners.get(event) ?? []) cb() } @@ -368,7 +360,7 @@ describe("registerChatGateway (subscribe-on-handshake / unsubscribe-on-close wir handler(socket as unknown as WebSocket, authedRequest(ALICE)) await flush() - // The subscribe is still pending — and the socket closes before the handshake installs its "close" + // The subscribe is still pending, and the socket closes before the handshake installs its "close" // listener (the listener never sees this close). Mark the socket CLOSED, then let subscribe complete. socket.readyState = WS_CLOSED releaseSubscribe!() diff --git a/services/api/test/unit/version-gate.test.ts b/services/api/test/unit/version-gate.test.ts index 79ebd94c..6e2b7c18 100644 --- a/services/api/test/unit/version-gate.test.ts +++ b/services/api/test/unit/version-gate.test.ts @@ -1,6 +1,4 @@ /** - * API VERSION-GATE BEHAVIOR TEST. - * * Proves the `onRequest` version gate (`src/versioning/version-gate.ts`) enforces the served-versions * policy (`src/versioning/policy.ts`) correctly under TODAY's dormant policy: only `v1` exists and is * `current`, MIN_SUPPORTED is `v1`, and nothing is deprecated or sunset. Under that policy the gate's @@ -21,7 +19,7 @@ * Unknown (`/v2`, `/v99`) and below-floor (`/v0`) versions have NO registered route on purpose: the gate * runs at `onRequest`, ahead of route matching, so it rejects them with 400 UNSUPPORTED_API_VERSION * before Fastify could ever answer a route-missing 404. Asserting the 400 (not a 404) is exactly what - * proves the gate — not the router — produced the rejection. + * proves the gate, not the router, produced the rejection. */ import { describe, it, expect, beforeAll, afterAll } from "vitest" @@ -72,7 +70,7 @@ afterAll(async () => { await app.close() }) -describe("version gate — dormant policy (v1=current, MIN=v1, nothing deprecated/sunset)", () => { +describe("version gate: dormant policy (v1=current, MIN=v1, nothing deprecated/sunset)", () => { it("lets a SERVED current version through to its handler: GET /v1/ping is not gate-rejected", async () => { const res = await app.inject({ method: "GET", url: "/v1/ping" }) // The gate did not reject: the real handler ran. Specifically it is NOT the gate's 400/410, and the @@ -137,7 +135,7 @@ describe("version gate — dormant policy (v1=current, MIN=v1, nothing deprecate }) }) -describe("version policy — pure helpers (today's policy)", () => { +describe("version policy: pure helpers (today's policy)", () => { it("isVersionSegment: matches /^v\\d+$/ only", () => { expect(isVersionSegment("v1")).toBe(true) expect(isVersionSegment("v0")).toBe(true) diff --git a/services/api/test/unit/volunteer-hours-entries.test.ts b/services/api/test/unit/volunteer-hours-entries.test.ts index ac30eee1..6b91ebe3 100644 --- a/services/api/test/unit/volunteer-hours-entries.test.ts +++ b/services/api/test/unit/volunteer-hours-entries.test.ts @@ -421,7 +421,7 @@ describe("hours ledger: getEventHours scope matrix (C10)", () => { return repo } - it("an acting host gets scope 'all' — every attendee's row, so the log form prefills", async () => { + it("an acting host gets scope 'all': every attendee's row, so the log form prefills", async () => { const repo = await seedEvent() const service = makeService( repo, diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index 35dd8206..e7cdb74d 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -1028,7 +1028,7 @@ describe("volunteer hours routes: leaderboard T1/T4 tripwires", () => { expect(body.entries).toEqual([]) }) - it("T4: anon gets a SHARED cache TTL and authed gets no-store — both carrying Vary", async () => { + it("T4: anon gets a SHARED cache TTL and authed gets no-store; both carrying Vary", async () => { const { app: built, token } = await makeApp() const anon = await built.inject({ diff --git a/services/api/test/unit/ws-socket-lifecycle.test.ts b/services/api/test/unit/ws-socket-lifecycle.test.ts index b108324a..84aaf754 100644 --- a/services/api/test/unit/ws-socket-lifecycle.test.ts +++ b/services/api/test/unit/ws-socket-lifecycle.test.ts @@ -511,7 +511,7 @@ describe("socket close DURING an in-flight join", () => { expect(await presence.online(ROOM)).toEqual([]) }) - it("un-does it with NO presence dep too — the post-joinRoom check is the one that must catch it", async () => { + it("un-does it with NO presence dep too; the post-joinRoom check is the one that must catch it", async () => { await joinThenSettle({ close: true, withPresence: false }) expect(chat.roomSize(ROOM)).toBe(0) diff --git a/services/api/tsup.config.ts b/services/api/tsup.config.ts index 8fa1b24a..e0962c03 100644 --- a/services/api/tsup.config.ts +++ b/services/api/tsup.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ // sequence: it is a manually-invoked one-off (default run is a rehearsal that rolls back), and // it refuses to run twice (purge first). Same operational stance as backfill-reference-codes. "src/db/seed-demo-la.ts", - // db/demo-join-event.ts: companion one-off — RSVPs a subset of the seeded demo users onto one + // db/demo-join-event.ts: companion one-off that RSVPs a subset of the seeded demo users onto one // existing event (by EVENT reference code or uuid), mirroring joinCleanupTx semantics. Same // manual, rehearse-by-default stance as seed-demo-la; never part of the deploy sequence. "src/db/demo-join-event.ts", diff --git a/services/api/vitest.config.ts b/services/api/vitest.config.ts index 5a1e26a6..f2913fb5 100644 --- a/services/api/vitest.config.ts +++ b/services/api/vitest.config.ts @@ -4,9 +4,9 @@ export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"], - // ONE PostGIS container for the whole run (was one per Docker-gated file, ~44 boots). It applies the - // migrations + seed once into a template database and hands the workers a connection URI; withPg() - // then clones that template per test file. Starts nothing when no pg-using file is selected, and + // ONE PostGIS container for the whole run. It applies the migrations + seed once into a template + // database and hands the workers a connection URI; withPg() then clones that template per test + // file. Starts nothing when no pg-using file is selected, and // reports a Docker-absent machine to the workers so describe.skipIf(!pg) still skips instead of // failing. See test/global-setup-pg.ts. globalSetup: ["test/global-setup-pg.ts"], diff --git a/services/media-worker/.env.example b/services/media-worker/.env.example index 094628ab..5516680d 100644 --- a/services/media-worker/.env.example +++ b/services/media-worker/.env.example @@ -4,7 +4,7 @@ # API's HTTP config. Copy to media-worker.env (gitignored) and fill real values for production. # In production the worker REQUIRES DATABASE_URL (it must persist media results); a missing one is fatal. # -# NODE_ENV: `development` here is for LOCAL use only. In a deployment do NOT rely on this line — the +# NODE_ENV: `development` here is for LOCAL use only. In a deployment do NOT rely on this line: the # image (Dockerfile) and the civfix-infra compose force NODE_ENV=production (compose # `environment` overrides this env_file). A deployed worker left at `development` would default every # USE_FAKE_* ON and silently run FAKE NSFW/abuse + dup checks and FAKE storage in production. @@ -16,7 +16,7 @@ DATABASE_URL= # postgres://user:pass@host:5432/civf # for any host that crosses a wire: postgres.js defaults to ssl:false, # so a URL without sslmode ships credentials and rows in cleartext. # EXEMPT (the API's boot check skips these): localhost, 127.x, ::1, or - # a single-label compose alias like `postgres` — packets that never + # a single-label compose alias like `postgres`: packets that never # leave the machine, against an image with no server certificate. # Dotted names and RFC1918 addresses still require TLS. diff --git a/services/media-worker/Dockerfile b/services/media-worker/Dockerfile index 3552b2cb..6310d817 100644 --- a/services/media-worker/Dockerfile +++ b/services/media-worker/Dockerfile @@ -12,30 +12,31 @@ # docker build -f services/media-worker/Dockerfile -t civfix/media-worker:latest . # # FFMPEG / SHARP IN-CONTAINER (the key correctness point): -# * FFMPEG IS PINNED AND CHECKSUMMED HERE, not taken from npm (audit H8). ffprobe-static@3.1.0 ships -# an FFmpeg 4.0.2 binary from 2018 — the tool that must interpret untrusted container bytes — with -# no channel for the demuxer memory-safety fixes released since. The `ffmpeg` stage below downloads -# ONE pinned release per CPU architecture (FFMPEG_URL_/FFMPEG_SHA256_{AMD64,ARM64} ARGs, selected -# by BuildKit's TARGETARCH — the boxes are arm64), verifies its SHA-256 and fails the build on a +# * FFMPEG IS PINNED AND CHECKSUMMED HERE, not taken from npm. ffprobe-static@3.1.0 ships an FFmpeg +# 4.0.2 binary from 2018 (the tool that must interpret untrusted container bytes) with no channel +# for the demuxer memory-safety fixes released since. The `ffmpeg` stage below downloads ONE pinned +# release per CPU architecture (FFMPEG_URL_/FFMPEG_SHA256_{AMD64,ARM64} ARGs, selected +# by BuildKit's TARGETARCH; the boxes are arm64), verifies its SHA-256 and fails the build on a # mismatch. The runtime stage exports FFMPEG_PATH/FFPROBE_PATH, which sandbox/binaries.ts REQUIRES # in production (the npm static packages are devDependencies and are pruned out of this image by # `pnpm --prod deploy`, so there is no silent fallback to the 2018 build). # BUMPING IT: pick the linux64 AND linuxarm64 assets of one newer autobuild, put their URLs in the -# two FFMPEG_URL_* ARGs, and set each FFMPEG_SHA256_* to the `sha256sum` of its download. BtbN prunes old dated autobuild tags, so -# a stale pin eventually 404s the build — that is a loud failure, never a silent downgrade. -# * sharp 0.33.x ships prebuilt linux-x64 binaries (@img/sharp-linux-x64) that run on glibc, which the +# two FFMPEG_URL_* ARGs, and set each FFMPEG_SHA256_* to the `sha256sum` of its download. BtbN +# prunes old dated autobuild tags, so a stale pin eventually 404s the build: a loud failure, never a +# silent downgrade. +# * sharp 0.33.x ships prebuilt linux binaries (@img/sharp-linux-*) that run on glibc, which the # Debian bookworm base provides. No build toolchain or libvips apt package is required. # Both are installed INSIDE this linux image (never copied from the host), so the platform is correct. # -# DECODER PRIVILEGE SEPARATION (audit H7): the runtime stage provisions a SECOND, credential-less user +# DECODER PRIVILEGE SEPARATION: the runtime stage provisions a SECOND, credential-less user # (mediatools, uid/gid 1001) and puts `node` in its group. Node holds DATABASE_URL and the R2 keys; every -# untrusted-byte parser — ffmpeg, ffprobe AND the sharp/libvips image lane (dist/image-lane.js) — runs as +# untrusted-byte parser (ffmpeg, ffprobe AND the sharp/libvips image lane, dist/image-lane.js) runs as # uid 1001 with an empty environment (src/sandbox/exec.ts), so a decoder RCE can read neither the Node # process's /proc//environ nor its descriptors. # # The uid switch is done by /usr/bin/setpriv (util-linux, installed below), NOT by spawn's uid/gid: this # process must hold ambient CAP_SETUID/CAP_SETGID to change a child's uid at all, and ambient caps -# survive execve — a plain uid-1000-to-1001 spawn would leave CAP_SETUID in the decoder, which could then +# survive execve: a plain uid-1000-to-1001 spawn would leave CAP_SETUID in the decoder, which could then # setuid() back to `node`. setpriv switches, then clears the ambient + inheritable sets and sets # no_new_privs before exec'ing, so the decoder has no capabilities at all. The container therefore starts # as root ONLY to run docker-entrypoint.sh, which setpriv's to `node` keeping exactly those two caps @@ -51,7 +52,6 @@ # entrypoint, run the worker. (No port / no HTTP healthcheck: the worker is a queue consumer; # liveness is observed via pg-boss + logs. Docker restarts it on exit.) -# ---------------------------------------------------------------------------------------------------- FROM node:22-bookworm-slim AS base ENV PNPM_HOME="/pnpm" \ PATH="/pnpm:$PATH" \ @@ -59,7 +59,6 @@ ENV PNPM_HOME="/pnpm" \ RUN corepack enable && corepack prepare pnpm@9.12.0 --activate WORKDIR /app -# ---------------------------------------------------------------------------------------------------- FROM base AS ffmpeg ARG TARGETARCH ARG FFMPEG_VERSION=n8.1.2 @@ -86,7 +85,6 @@ RUN case "${TARGETARCH:-$(dpkg --print-architecture)}" in \ && /opt/ffmpeg/bin/ffprobe -version | head -n 1 | grep -q "$FFMPEG_VERSION" \ && /opt/ffmpeg/bin/ffmpeg -version | head -n 1 | grep -q "$FFMPEG_VERSION" -# ---------------------------------------------------------------------------------------------------- FROM base AS build COPY . . RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ @@ -95,13 +93,12 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ # selects the worker AND its workspace dependencies, built in order. RUN pnpm --filter @civfix/media-worker... build # Production-pruned, self-contained deployment of just the worker at /out. The native/external deps -# (sharp, ...) are installed here for linux-x64 and carried into runtime. ffmpeg-static/ffprobe-static -# are devDependencies and are pruned out on purpose (H8): the runtime ffmpeg is the pinned, checksummed -# one from the `ffmpeg` stage. +# (sharp, ...) are installed here for the target platform and carried into runtime. +# ffmpeg-static/ffprobe-static are devDependencies and are pruned out on purpose: the runtime ffmpeg +# is the pinned, checksummed one from the `ffmpeg` stage. RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm --filter @civfix/media-worker --prod deploy /out -# ---------------------------------------------------------------------------------------------------- FROM base AS runtime # Same contract as the api image: the release identity is a property of the promoted artifact, not of the # deploy that placed it. Read by src/seams.ts as the GlitchTip `release`. diff --git a/services/media-worker/src/jobs.ts b/services/media-worker/src/jobs.ts index 8e120209..2db283f2 100644 --- a/services/media-worker/src/jobs.ts +++ b/services/media-worker/src/jobs.ts @@ -1,6 +1,4 @@ /** - * Jobs seam for the media worker (pg-boss-backed, real or fake). - * * The worker is a separate process from the API and does its own minimal seam wiring rather than * importing the API's Fastify DI container. Real-vs-fake is chosen by USE_FAKE_JOBS, mirroring the * API's rule (default ON outside production so the worker boots offline with FakeJobs). @@ -28,25 +26,20 @@ function useFakeJobs(source: NodeJS.ProcessEnv = process.env): boolean { return parseBool(source.USE_FAKE_JOBS, !isProd) } -/** Options accepted when registering a worker handler (concurrency mapping). */ export interface WorkSettings { - /** Max jobs delivered + processed per poll (bounds in-flight concurrency). */ + /** Bounds in-flight concurrency. */ batchSize?: number - /** Poll interval seconds (pg-boss default ~2s). */ pollingIntervalSeconds?: number } /** - * Queue-level retry policy (pg-boss v10 createQueue options). Applied to every job on the queue, so it - * governs how a job whose handler THROWS is retried. This is the knob that makes media.checks' infra - * throws (MediaInfraError) recover with bounded backoff: pg-boss v10's default retryLimit is 2 (retries - * are opt-OUT), but we set it explicitly so the value is intentional, not a default we inherit. Set on - * the WORKER's createQueue (the worker owns the work side); the fake ignores it. + * The queue-level retry policy is what makes media.checks' infra throws (MediaInfraError) recover with + * bounded backoff. pg-boss v10's default retryLimit is 2 (retries are opt-OUT), but callers set it + * explicitly so the value is intentional, not inherited. The worker owns the work side, so it is set on + * the worker's createQueue; the fake ignores it. */ export interface QueueOptions { - /** Max retries before a job is marked failed for good (pg-boss v10 default is 2; retries are opt-OUT). */ retryLimit?: number - /** Exponential backoff between retries (pg-boss spaces them out instead of retrying immediately). */ retryBackoff?: boolean /** * pg-boss queue policy. MUST be set explicitly for any queue the worker shares with another creator or @@ -60,38 +53,30 @@ export interface QueueOptions { policy?: PgBoss.Queue["policy"] } -/** Per-schedule options for a cron (worker extension over Jobs.schedule's data-only signature). */ export interface ScheduleOptions { - /** Expire (and allow re-delivery) a scheduled job after this many seconds; sized above worst-case run. */ + /** Sized above the worst-case run, since an expired job is re-delivered. */ expireInSeconds?: number /** Single-flight key so two fires of the same cron never overlap. */ singletonKey?: string } -/** The worker's Jobs handle: the shared Jobs surface plus worker-only lifecycle + queue helpers. */ export interface WorkerJobs extends Jobs { start(): Promise stop(): Promise - /** - * Ensure a queue exists (idempotent). Required by pg-boss v10 before send/work. Optional retry policy - * is applied to the queue (so a throwing handler retries with bounded backoff rather than failing once). - */ + /** Idempotent; pg-boss v10 requires the queue before send/work. */ createQueue(name: string, options?: QueueOptions): Promise - /** Register a handler with concurrency/poll settings (worker extension over Jobs.work). */ workWithSettings(name: string, handler: JobHandler, settings?: WorkSettings): Promise - /** Schedule a cron, widened over Jobs.schedule with optional expire/singleton options. */ schedule(name: string, cron: string, data?: unknown, options?: ScheduleOptions): Promise } /** - * Shape the failure written to the job's `output` column. pg-boss serializes an Error faithfully - * (serialize-error), so pass it straight through; anything else is wrapped so the reason is never lost. + * pg-boss serializes an Error faithfully (serialize-error) into the job's `output`; anything else is + * wrapped so the reason is never lost. */ function toFailureOutput(err: unknown): object { return err instanceof Error ? err : { message: String(err) } } -/** Map the shared EnqueueOptions onto pg-boss SendOptions. */ function toSendOptions(opts?: EnqueueOptions): PgBoss.SendOptions { const out: PgBoss.SendOptions = {} if (opts?.singletonKey !== undefined) out.singletonKey = opts.singletonKey @@ -100,7 +85,6 @@ function toSendOptions(opts?: EnqueueOptions): PgBoss.SendOptions { return out } -/** Map the worker QueueOptions onto the pg-boss queue policy/retry fields (only set the provided ones). */ function toQueueOptions( opts?: QueueOptions, ): Pick { @@ -118,8 +102,6 @@ function toQueueOptions( const STOP_GRACE_MARGIN_MS = 5_000 /** - * How many times a media.checks handler can pay the per-job budget in one run. - * * MEDIA_JOB_TIMEOUT_MS is applied PER PHASE, not per job: jobs/media-checks.ts wraps the download in one * withJobTimeout and processMedia in a SECOND one, so a job that stalls in both phases runs for ~2x the * budget before it even reaches the persist writes. Sizing the graceful stop at 1x therefore abandoned an @@ -130,18 +112,15 @@ const STOP_GRACE_MARGIN_MS = 5_000 const STOP_GRACE_PHASES = 2 /** - * The graceful-stop timeout for a given per-job budget: every phase's budget plus the persist margin. * Exported because it is the number the CONTAINER's SIGKILL grace must exceed (civfix-infra compose - * `stop_grace_period`) - see stop(). + * `stop_grace_period`); see stop(). */ export function stopGraceMsFor(jobTimeoutMs: number): number { return STOP_GRACE_PHASES * jobTimeoutMs + STOP_GRACE_MARGIN_MS } -/** Real pg-boss-backed implementation of the worker Jobs handle. */ export class PgBossWorkerJobs implements WorkerJobs { private readonly connectionString: string - /** Graceful-stop timeout (ms), DERIVED from the per-job budget rather than a constant. See stop(). */ private readonly stopGraceMs: number private boss: PgBoss | undefined @@ -154,7 +133,7 @@ export class PgBossWorkerJobs implements WorkerJobs { if (this.boss) return const { default: PgBossCtor } = await import("pg-boss") const boss = new PgBossCtor(this.connectionString) - // Surface internal errors loudly instead of crashing the process. + // Without a listener an internal pg-boss error would crash the process. boss.on("error", (err: Error) => console.error("pg-boss error:", err)) await boss.start() this.boss = boss @@ -162,8 +141,7 @@ export class PgBossWorkerJobs implements WorkerJobs { async stop(): Promise { if (!this.boss) return - // Graceful: let in-flight jobs finish, then close. wait:true resolves after fully stopped. The - // explicit timeout must EXCEED the longest per-job budget (media.checks' MEDIA_JOB_TIMEOUT_MS + // The explicit timeout must EXCEED the longest per-job budget (media.checks' MEDIA_JOB_TIMEOUT_MS // wall-clock, charged ONCE PER PHASE - see stopGraceMsFor): pg-boss's 30s graceful default would // abandon an in-flight media job on SIGTERM (the asset stuck `validating` until a sweep reconciles). // That budget is ENV-TUNABLE, so the timeout is derived from it at construction instead of being a @@ -184,9 +162,8 @@ export class PgBossWorkerJobs implements WorkerJobs { async createQueue(name: string, options?: QueueOptions): Promise { const boss = this.requireBoss() - // Build the pg-boss queue policy from the optional retry knobs. createQueue is a no-op if the queue - // already exists, so updateQueue is what actually brings a previously-created (e.g. retryLimit=0) - // queue to the right policy. Both are idempotent (mirrors the API adapter's createQueue/updateQueue). + // createQueue is a no-op if the queue already exists, so updateQueue is what brings a + // previously-created (e.g. retryLimit=0) queue to the right policy. Mirrors the API adapter. const queuePolicy: PgBoss.Queue = { name, ...toQueueOptions(options) } await boss.createQueue(name, queuePolicy) if ( @@ -298,10 +275,6 @@ export interface JobsHandle { stop(): Promise } -/** - * Fake-backed handle: wraps FakeJobs to satisfy the WorkerJobs surface (createQueue/workWithSettings - * are no-ops/aliases) so the same worker wiring runs offline. - */ class FakeWorkerJobs extends FakeJobs implements WorkerJobs { start(): Promise { return Promise.resolve() @@ -317,10 +290,9 @@ class FakeWorkerJobs extends FakeJobs implements WorkerJobs { } } -/** Build the Jobs seam for the worker, selecting fake vs real per USE_FAKE_JOBS. */ export function buildJobs(source: NodeJS.ProcessEnv = process.env): JobsHandle { const fakeJobs = useFakeJobs(source) - // PRODUCTION GUARD (mirrors buildSeams' USE_FAKE_STORAGE guard): FakeJobs consumes NOTHING from + // Mirrors buildSeams' USE_FAKE_STORAGE guard: FakeJobs consumes NOTHING from // pg-boss, so a worker booted with it in production starts cleanly, reports healthy, and every uploaded // media stays `validating` forever with no error anywhere - the silent no-op the storage guard exists // to prevent, in the one seam that makes the whole process pointless. diff --git a/services/media-worker/src/jobs/drain.ts b/services/media-worker/src/jobs/drain.ts index e781fdfe..e04e92f8 100644 --- a/services/media-worker/src/jobs/drain.ts +++ b/services/media-worker/src/jobs/drain.ts @@ -1,27 +1,18 @@ /** - * Bounded page drain for the sweep crons. - * - * M10 lesson: a sweep that reaps ONE bounded batch per run is a nibble, not a sweep. When the daily/hourly - * churn exceeds the batch, the backlog can only ever grow — which is exactly how the orphan sweep let - * never-committed uploads accumulate in R2 forever. The fix is the same everywhere: keep paging while a - * page comes back FULL (there is more work), and stop after `maxPages` so one run cannot monopolize the - * worker or unbound the blast radius of a bad predicate. + * A sweep that reaps ONE bounded batch per run is a nibble, not a sweep: when the daily/hourly churn + * exceeds the batch, the backlog can only grow, which is how the orphan sweep once let never-committed + * uploads accumulate in R2 forever. So keep paging while a page comes back FULL, and stop after + * `maxPages` so one run cannot monopolize the worker or unbound the blast radius of a bad predicate. * * `handlePage` is responsible for its own per-item error isolation; a throw from either callback * propagates so the caller decides whether the whole sweep is degraded. */ export interface DrainOptions { - /** Rows requested per page (the LIMIT handed to fetchPage). */ pageSize: number - /** Hard bound on pages drained in one run. */ maxPages: number } -/** - * Fetch + handle pages until a SHORT page arrives (backlog drained) or `maxPages` is reached. Returns the - * total number of rows seen. - */ export async function drainPages( fetchPage: (limit: number) => Promise, handlePage: (rows: T[]) => Promise | void, @@ -33,7 +24,6 @@ export async function drainPages( if (rows.length === 0) break seen += rows.length await handlePage(rows) - // A short page means there is nothing left for this cutoff; stop rather than re-querying. if (rows.length < opts.pageSize) break } return seen diff --git a/services/media-worker/src/jobs/hold-release-sweep.ts b/services/media-worker/src/jobs/hold-release-sweep.ts index 638f35e6..92874ec8 100644 --- a/services/media-worker/src/jobs/hold-release-sweep.ts +++ b/services/media-worker/src/jobs/hold-release-sweep.ts @@ -1,15 +1,13 @@ /** - * anon.hold.release.sweep cron: self-healing backstop for the hold-then-publish release (P2-8). - * - * WHY THIS EXISTS. The normal release path is the media.checks post-success HOOK: after a media asset - * reaches a terminal status, the worker enqueues anon.hold.release for the media's report, and that job - * re-evaluates + publishes the held anon report once all its media are ready + clean. That inline enqueue - * is best-effort: if SIGTERM lands such that pg-boss begins stopping while a handler sits between + * Self-healing backstop for the anon hold-then-publish release. The normal release path is the + * media.checks post-success HOOK: after a media asset reaches a terminal status, the worker enqueues + * anon.hold.release for the media's report, and that job re-evaluates + publishes the held anon report + * once all its media are ready + clean. That inline enqueue is best-effort: if SIGTERM lands such that pg-boss begins stopping while a handler sits between * "persisted the media result" and "enqueued anon.hold.release", the enqueue can fail (boss stopping) and * is swallowed (the media job still completes). For a single-media anon report there is then no other * media event to ever re-trigger the release, so the report could stay HELD forever. * - * THE GUARANTEE this sweep provides: a held anon report whose media are all ready + clean is ALWAYS + * This sweep guarantees that a held anon report whose media are all ready + clean is ALWAYS * eventually published, independent of whether any single inline enqueue was delivered. Every few minutes * (HOLD_RELEASE_SWEEP_CRON) the sweep lists held anon reports (reporter_user_id IS NULL, status 'held', * not deleted, bounded batch oldest-first) and runs the SAME idempotent release gate @@ -29,7 +27,6 @@ import { resolveJobObs, type JobObsDeps } from "./obs.js" export interface HoldReleaseSweepDeps extends JobObsDeps { repo: AnonHoldReleaseRepo abuseChecks: AbuseChecks - /** Max held anon reports to re-check this run. */ batchSize: number } @@ -39,10 +36,6 @@ export interface HoldReleaseSweepResult { errors: number } -/** - * Run one hold-release sweep. Returns counts. Never throws; per-report failures are counted + logged + - * reported. Idempotent end-to-end (the underlying release gate is a no-op on a no-longer-held report). - */ export async function runHoldReleaseSweep( deps: HoldReleaseSweepDeps, ): Promise { diff --git a/services/media-worker/src/jobs/obs.ts b/services/media-worker/src/jobs/obs.ts index 041f7f65..d805c9e2 100644 --- a/services/media-worker/src/jobs/obs.ts +++ b/services/media-worker/src/jobs/obs.ts @@ -1,6 +1,4 @@ /** - * Shared observability deps for the worker's job runners. - * * Every runner (media.checks + the four crons) takes an injectable clock, logger and error reporter so * tests are deterministic and offline mode stays silent. The fallbacks were hand-rolled at seven call * sites and had already drifted in shape; resolving them in ONE place keeps "what a runner logs/reports @@ -10,9 +8,7 @@ export type JobLogFn = (line: string, extra?: Record) => void export type JobReportFn = (err: unknown, context?: Record) => void -/** The observability slice every job runner's deps interface extends. All optional; see resolveJobObs. */ export interface JobObsDeps { - /** Injectable clock (defaults to a real `new Date()`) for deterministic tests. */ now?: () => Date log?: JobLogFn report?: JobReportFn @@ -29,7 +25,6 @@ const defaultLog: JobLogFn = (line, extra) => console.log(line, extra ?? {}) const noopReport: JobReportFn = () => {} const realClock = (): Date => new Date() -/** Resolve the injectable log/report/clock to their defaults. */ export function resolveJobObs(deps: JobObsDeps): JobObs { return { log: deps.log ?? defaultLog, diff --git a/services/media-worker/src/sandbox/image.ts b/services/media-worker/src/sandbox/image.ts index a1bd1fa4..8bfbd84a 100644 --- a/services/media-worker/src/sandbox/image.ts +++ b/services/media-worker/src/sandbox/image.ts @@ -1,7 +1,5 @@ /** - * Sandboxed image processing (sharp + exifr). - * - * This is the untrusted-image decode boundary. Hardening: + * The untrusted-image decode boundary. Hardening: * - DECODE GUARD: sharp is constructed with a hard `limitInputPixels` so a "pixel bomb" (tiny file * declaring billions of pixels) is rejected at header-parse time, before any large allocation; * `failOn: "warning"` (sharp's documented untrusted-input value - "error" still decodes many @@ -29,11 +27,9 @@ import exifr from "exifr" import type { WorkerLimits } from "../config.js" import { settleWithin } from "../timeout.js" -// Predictable memory under the worker concurrency cap: no internal cache, single libvips thread. sharp.cache(false) sharp.concurrency(1) -/** GPS coordinates read from EXIF, if present and finite. */ export interface ExifGps { lat: number lng: number @@ -42,7 +38,6 @@ export interface ExifGps { export interface ImageMeta { width: number height: number - /** sharp's detected format (e.g. "jpeg", "png", "webp"). */ format: string } @@ -50,16 +45,14 @@ export interface ProcessedImage { meta: ImageMeta /** EXIF-stripped, auto-oriented re-encode of the full image. */ strippedBytes: Buffer - /** Content-type for the stripped image (matches the chosen output encoder). */ strippedContentType: string - /** ~thumbnailMaxEdge JPEG thumbnail (also metadata-free). */ + /** Also metadata-free. */ thumbnailBytes: Buffer thumbnailContentType: string - /** GPS from the ORIGINAL EXIF (for the report GPS cross-check), or null. */ + /** From the ORIGINAL EXIF, for the report GPS cross-check. */ exifGps: ExifGps | null } -/** Error thrown when an image cannot be safely decoded/processed (bomb, garbage, unsupported). */ export class ImageProcessingError extends Error { constructor(message: string, cause?: unknown) { super(message, cause !== undefined ? { cause } : undefined) @@ -68,7 +61,6 @@ export class ImageProcessingError extends Error { } } -/** Reject a promise if it does not settle within `ms`. Used to bound native sharp work. */ function withTimeout(p: Promise, ms: number, label: string): Promise { return settleWithin(p, ms, { timeoutError: () => new ImageProcessingError(`${label} timed out after ${ms}ms`), @@ -79,14 +71,12 @@ function withTimeout(p: Promise, ms: number, label: string): Promise { }) } -/** Formats we will DECODE + re-encode. A spoofed-MIME input that libvips detects as anything else (SVG, - * TIFF, AVIF, GIF, ...) is rejected before any full decode rather than re-encoded via an unintended codec. */ +/** A spoofed-MIME input that libvips detects as anything else (SVG, TIFF, AVIF, GIF, ...) is rejected + * before any full decode rather than re-encoded via an unintended codec. */ const ALLOWED_DECODED_FORMATS: ReadonlySet = new Set(["jpeg", "png", "webp"]) /** - * MAGIC-BYTE CONTAINER SNIFF (security review L15). - * - * The ALLOWED_DECODED_FORMATS check runs on `meta.format`, i.e. AFTER `metadata()` — and `metadata()` + * The ALLOWED_DECODED_FORMATS check runs on `meta.format`, i.e. AFTER `metadata()`, and `metadata()` * is what dispatches untrusted bytes to a libvips loader. libvips picks the loader from the bytes, so an * SVG, PDF or TIFF header reached the corresponding libvips/librsvg/poppler header parser before we ever * got to reject it. The format allowlist was enforced one step too late to keep those codecs off the @@ -94,7 +84,7 @@ const ALLOWED_DECODED_FORMATS: ReadonlySet = new Set(["jpeg", "png", "we * * This sniff runs in pure JS, on the raw bytes, BEFORE any sharp instance is constructed: only JPEG, * PNG and WebP signatures proceed, so libvips is never handed a non-allowlisted container at all. It is - * a container gate, not a validity check — `metadata()` and the existing `meta.format` allowlist still + * a container gate, not a validity check: `metadata()` and the existing `meta.format` allowlist still * run afterwards and remain the authority on what is actually decoded. * * Signatures: @@ -121,14 +111,14 @@ export function sniffAllowedImageContainer(bytes: Uint8Array): "jpeg" | "png" | } if ( bytes.length >= 12 && - bytes[0] === 0x52 && // R - bytes[1] === 0x49 && // I - bytes[2] === 0x46 && // F - bytes[3] === 0x46 && // F - bytes[8] === 0x57 && // W - bytes[9] === 0x45 && // E - bytes[10] === 0x42 && // B - bytes[11] === 0x50 // P + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 ) { return "webp" } @@ -136,38 +126,30 @@ export function sniffAllowedImageContainer(bytes: Uint8Array): "jpeg" | "png" | } /** - * Build a guarded sharp instance over untrusted bytes (no decoding happens until a consumer runs). - * * EVERY untrusted-decode entry point must go through this, not a bare `sharp()`: it is where the container - * sniff, pixel ceiling, failOn, single-page and sequential-read guards live as ONE set. phash.ts had its - * own copy of the options and had already drifted (no container sniff), which is why it now calls this. + * sniff, pixel ceiling, failOn, single-page and sequential-read guards live as ONE set. phash.ts once kept + * its own copy of the options, which drifted (no container sniff). */ export function guardedSharp(bytes: Uint8Array, limits: WorkerLimits): Sharp { - // L15: refuse to even CONSTRUCT the pipeline for a container we would reject anyway, so libvips' + // Refuse to even CONSTRUCT the pipeline for a container we would reject anyway, so libvips' // SVG/PDF/TIFF/GIF header loaders never see untrusted bytes. const container = sniffAllowedImageContainer(bytes) if (container === null) { throw new ImageProcessingError("unsupported image container (magic bytes not JPEG/PNG/WebP)") } return sharp(Buffer.from(bytes), { - // Reject decode bombs at header parse: refuse inputs above this pixel ceiling. limitInputPixels: limits.sharpPixelLimit, - // sharp's documented untrusted-input value: reject a corrupt/partial stream. ("error" still decodes - // many malformed streams; "warning" is the stricter untrusted boundary.) failOn: "warning", - // Decode ONLY the first frame/page of an animated GIF/WebP or multi-page TIFF, so frames×pixels of an - // animation cannot blow past the pixel budget (the post-metadata check multiplies by meta.pages too). pages: 1, animated: false, // Stream large progressive JPEG/TIFF inputs in one forward pass instead of libvips' random-access // read, so we never keep more of the decoded surface resident than necessary (caps peak RSS per // concurrent job under adversarial large-but-legal uploads). sequentialRead: true, - // sharp's own per-pipeline wall clock (seconds), rounded up so a sub-second config still yields >= 1s. + // Seconds, rounded up so a sub-second config still yields >= 1s. }).timeout({ seconds: Math.max(1, Math.ceil(limits.imageTimeoutMs / 1000)) }) } -/** Choose the stripped-image output encoder + content type from the detected (allowlisted) input format. */ function chooseOutput(format: string): { apply: (s: Sharp) => Sharp contentType: string @@ -182,10 +164,7 @@ function chooseOutput(format: string): { } } -/** - * Read GPS from EXIF without decoding pixels. Never throws: a parse failure or missing GPS yields null - * (absence of location is not an error). Returns null unless both lat/lng are finite. - */ +/** Never throws: a parse failure or missing GPS yields null, since absence of location is not an error. */ export async function readExifGps(bytes: Uint8Array): Promise { try { const gps = (await exifr.gps(Buffer.from(bytes))) as @@ -207,9 +186,8 @@ export async function readExifGps(bytes: Uint8Array): Promise { } /** - * Decode-guard + EXIF read/strip + thumbnail. Throws ImageProcessingError on any unsafe/undecodable - * input (the job handler maps that to a rejected asset). On success returns the stripped image, a - * thumbnail, dimensions, and the original EXIF GPS (for the report cross-check). + * Throws ImageProcessingError on any unsafe/undecodable input; the job handler maps that to a rejected + * asset. */ export async function processImage( bytes: Uint8Array, @@ -226,8 +204,6 @@ export async function processImage( if (!meta.format || typeof meta.width !== "number" || typeof meta.height !== "number") { throw new ImageProcessingError("image metadata missing format/dimensions") } - // Format allowlist: reject a spoofed-MIME polyglot (SVG/TIFF/AVIF/GIF/...) before any full decode so an - // unintended libvips codec is never reached. Only jpeg/png/webp are decoded + re-encoded. if (!ALLOWED_DECODED_FORMATS.has(meta.format)) { throw new ImageProcessingError(`unsupported image format: ${meta.format}`) } @@ -241,7 +217,6 @@ export async function processImage( ) } - // GPS from the original EXIF (pre-strip), for the report GPS cross-check note. Never throws. const exifGps = await readExifGps(bytes) // Stripped full image AND thumbnail from a SINGLE decode: a fresh guardedSharp() per output would run @@ -252,9 +227,7 @@ export async function processImage( const out = chooseOutput(meta.format) const base = guardedSharp(bytes, limits).rotate() const [strippedBytes, thumbnailBytes] = await Promise.all([ - // Stripped full image: chosen output encoder, metadata-free. withTimeout(out.apply(base.clone()).toBuffer(), limits.imageTimeoutMs, "strip"), - // Thumbnail: longest edge <= thumbnailMaxEdge, JPEG, metadata-free. withTimeout( base .clone() @@ -281,10 +254,7 @@ export async function processImage( } } -/** - * Assert that a processed image buffer carries NO EXIF GPS. Used by tests (and as a cheap post-strip - * self-check) to prove the strip worked. Returns true when no GPS is present. - */ +/** Proves the strip worked; used by tests and as a cheap post-strip self-check. */ export async function hasNoGps(bytes: Uint8Array): Promise { return (await readExifGps(bytes)) === null } diff --git a/services/media-worker/src/sandbox/phash.ts b/services/media-worker/src/sandbox/phash.ts index 01518561..79720091 100644 --- a/services/media-worker/src/sandbox/phash.ts +++ b/services/media-worker/src/sandbox/phash.ts @@ -1,7 +1,5 @@ /** - * Perceptual hash (dHash) computed from a normalized small grayscale via sharp. - * - * We compute the hash from PIXELS (not raw file bytes) so visually-similar images map to similar + * The hash is computed from PIXELS (not raw file bytes) so visually-similar images map to similar * hashes, which is what near-duplicate detection needs. Algorithm: difference hash (dHash): * 1. downscale to (W+1) x H grayscale (default 9x8 -> 64-bit hash); * 2. for each row, compare each pixel to its right neighbor: bit = left > right; @@ -10,29 +8,26 @@ * * The hash is the worker's own perceptual signature. The job handler separately calls the * AbuseChecks.isNearDuplicate seam (passing this hash) to decide dedupe, so the perceptual signal and - * the dedupe POLICY stay decoupled. sharp is confined to sandbox/ per the seam rule. + * the dedupe POLICY stay decoupled. */ import { guardedSharp, ImageProcessingError } from "./image.js" import type { WorkerLimits } from "../config.js" -/** dHash grid: (HASH_W + 1) columns x HASH_H rows -> HASH_W*HASH_H comparison bits. */ const HASH_W = 8 const HASH_H = 8 /** - * Compute the dHash hex string for `bytes`. The SECOND untrusted-decode boundary (the image path is the - * first), so it uses image.ts's guardedSharp rather than its own sharp options: that is the single place - * the container sniff (L15), pixel ceiling, failOn "warning", single-page and sequential-read guards live. - * It previously duplicated the options and had drifted — no magic-byte sniff — which mattered because - * seams.ts injects this function into RealAbuseChecks.pHash, where a future caller need not have run - * processImage's container check first. Throws ImageProcessingError if the bytes cannot be decoded. + * The SECOND untrusted-decode boundary (the image path is the first), so it uses image.ts's guardedSharp + * rather than its own sharp options: that is the single place the container sniff, pixel ceiling, failOn + * "warning", single-page and sequential-read guards live. seams.ts injects this into + * RealAbuseChecks.pHash, where a caller need not have run processImage's container check first. */ export async function perceptualHash(bytes: Uint8Array, limits: WorkerLimits): Promise { let raw: Buffer try { raw = await guardedSharp(bytes, limits) - // Grayscale, exact small size (ignore aspect so the grid is fixed), raw 1-channel pixels. + // Aspect is ignored so the grid is fixed. .greyscale() .resize(HASH_W + 1, HASH_H, { fit: "fill" }) .raw() @@ -44,7 +39,6 @@ export async function perceptualHash(bytes: Uint8Array, limits: WorkerLimits): P } const cols = HASH_W + 1 - // Build the bitstring row by row: compare each pixel to its right neighbor. const bits: number[] = [] for (let y = 0; y < HASH_H; y++) { for (let x = 0; x < HASH_W; x++) { @@ -54,7 +48,6 @@ export async function perceptualHash(bytes: Uint8Array, limits: WorkerLimits): P } } - // Pack 64 bits into 16 hex chars (4 bits per char). let hex = "" for (let i = 0; i < bits.length; i += 4) { const nibble = diff --git a/services/media-worker/src/timeout.ts b/services/media-worker/src/timeout.ts index 4cccf748..052d1b6b 100644 --- a/services/media-worker/src/timeout.ts +++ b/services/media-worker/src/timeout.ts @@ -1,21 +1,17 @@ /** - * One settle-or-reject-with-a-typed-error primitive. - * * Two boundaries in the worker need a JS-side wall clock: the per-job budget (media.checks) and the sharp * pipeline (sandbox/image.ts, since libvips has no JS-observable timeout). Both had their own copy of the * same race-free shape; the differences that matter are only WHICH error type is thrown and whether an * abort side effect fires, so both are expressed as hooks here. * - * NOTE: rejecting does NOT cancel the wrapped work. The caller must arrange cancellation itself - * (`onElapsed` exists for exactly that - e.g. aborting an in-flight download). + * Rejecting does NOT cancel the wrapped work. The caller must arrange cancellation itself (`onElapsed` + * exists for exactly that, e.g. aborting an in-flight download). */ export interface SettleWithinHooks { - /** Build the rejection used when the budget elapses. */ timeoutError: () => Error - /** Normalize a rejection from the wrapped promise (defaults to a passthrough Error coercion). */ normalizeError?: (err: unknown) => Error - /** Fired the moment the budget elapses, BEFORE the rejection (e.g. abort an in-flight request). */ + /** Fires BEFORE the rejection. */ onElapsed?: () => void /** Unref the timer so a pending budget cannot by itself keep the process alive. */ unref?: boolean @@ -23,7 +19,6 @@ export interface SettleWithinHooks { const coerceError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err))) -/** Reject with `hooks.timeoutError()` if `p` has not settled within `ms`. */ export function settleWithin(p: Promise, ms: number, hooks: SettleWithinHooks): Promise { const normalize = hooks.normalizeError ?? coerceError return new Promise((resolve, reject) => { diff --git a/services/media-worker/test/helpers/pg.ts b/services/media-worker/test/helpers/pg.ts index c0964d45..d9efc87f 100644 --- a/services/media-worker/test/helpers/pg.ts +++ b/services/media-worker/test/helpers/pg.ts @@ -4,15 +4,15 @@ * Mirrors the API's withPg: starts a throwaway postgis container, applies the SAME canonical migrations * via the shared @civfix/api/migrate runner (single source of DDL), and hands back a RAW postgres-js tag * (`sql`, full value serialization) plus a Drizzle client on its OWN separate connection. On machines - * with no Docker (like this dev box) it returns null so the integration suite is skipped, keeping the - * local run green; CI runs it for real — and REFUSES to skip there (see assertSkipAllowed). + * with no Docker it returns null so the integration suite is skipped, keeping the local run green; CI + * runs it for real and REFUSES to skip there (see assertSkipAllowed). * * ONE CONTAINER PER FILE, deliberately, unlike the API side. The API harness boots a single container in * a vitest globalSetup, migrates a TEMPLATE database once and clones it per file, because it has ~44 * Docker-gated files and was paying 44 boots + 44 x 60 migrations. This package has TWO, so the shared * design would save one boot (~3s) at the price of duplicating globalSetup + the template-clone * primitives here: the API's copies live in its private test tree (services/api/test/helpers/pg-container.ts) - * and are NOT reachable from another package — @civfix/api exports src entrypoints only, and exporting test + * and are NOT reachable from another package: @civfix/api exports src entrypoints only, and exporting test * helpers from a service package to share 70 lines of container plumbing is the worse trade. What IS shared * is everything that affects OUTCOMES rather than speed: the same migrate runner, the same image pin, the * same throwaway-database server settings below, and the same CI skip guard. Revisit if this package grows @@ -34,7 +34,7 @@ export const POSTGIS_IMAGE = "postgis/postgis:16-3.4" /** * Server knobs for a THROWAWAY database, matching the API harness's POSTGRES_TUNING: durability is * worthless when the container is destroyed at the end of the run, and every file here pays all ~60 API - * migrations. Adopted for PARITY with the API harness rather than on a measured win — on this dev box + * migrations. Adopted for PARITY with the API harness rather than on a measured win: on a dev box * (colima, ~10s for the two files) tuned and untuned runs were inside run-to-run noise; the fsync cost it * removes is a CI/overlayfs effect. max_connections is left at the default: unlike the API's shared * container, only one file at a time connects here (4 + 2 connections). @@ -59,8 +59,8 @@ function isOn(value: string | undefined): boolean { * Refuse to turn "no Docker" into "no tests" where a skip is not legitimate. * * The skip exists so a developer on a Docker-less machine gets a green unit suite. In CI the same leniency - * would let a broken Docker socket delete this package's ENTIRE integration coverage — the only thing that - * runs the worker's SQL against the canonical schema — while the job still reports success. Same contract + * would let a broken Docker socket delete this package's ENTIRE integration coverage (the only thing that + * runs the worker's SQL against the canonical schema) while the job still reports success. Same contract * and same variable names as the API harness's assertPgSkipAllowed (services/api/test/helpers/pg-container.ts); * duplicated rather than imported because that module is private to the API's test tree. */ diff --git a/services/media-worker/test/integration/media-checks-pg.test.ts b/services/media-worker/test/integration/media-checks-pg.test.ts index 60fb7229..6d7b0fbe 100644 --- a/services/media-worker/test/integration/media-checks-pg.test.ts +++ b/services/media-worker/test/integration/media-checks-pg.test.ts @@ -107,7 +107,6 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { it("orphan sweep deletes a never-attached, aged row", async () => { const r2Key = `uploads/2026/06/${randomUUID()}` const id = randomUUID() - // created_at well in the past, report_id null. await h.sql` INSERT INTO media_assets (id, upload_id, kind, r2_key, status, created_at) VALUES (${id}, ${randomUUID()}, 'image', ${r2Key}, 'validating', now() - interval '2 days') @@ -121,9 +120,9 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { }) /** - * REGRESSION against the REAL SQL (blocker): the predicate was `report_id IS NULL` alone, and every + * REGRESSION against the REAL SQL: the predicate was `report_id IS NULL` alone, and every * non-report lane leaves report_id NULL, so the first sweep after deploy would have deleted every user - * avatar, every social-post photo and every chat/DM attachment older than the TTL — rows AND R2 + * avatar, every social-post photo and every chat/DM attachment older than the TTL: rows AND R2 * objects, with avatar_media_id silently NULLed by its ON DELETE SET NULL FK. * * The unit test covers the same lanes through the in-memory fake; this one is the version that can @@ -194,7 +193,6 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { ` expect(ownerRow!.avatar_media_id).toBe(userAvatar.id) - // ...and the real orphan is gone, row and bytes. const gone = await h.sql<{ id: string }[]>`SELECT id FROM media_assets WHERE id = ${orphan.id}` expect(gone.length).toBe(0) expect(storage.get(orphan.r2Key)).toBeNull() @@ -205,7 +203,7 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { * * It is ONE multi-row `INSERT ... ON CONFLICT (r2_key) DO UPDATE`, and Postgres aborts such a statement * with 21000 ("ON CONFLICT DO UPDATE command cannot affect row a second time") when one of its rows - * repeats the conflict target — which is why the impl collapses `keys` to a Set first. This write is the + * repeats the conflict target, which is why the impl collapses `keys` to a Set first. This write is the * ONLY record of a leak whose media row has already been deleted, so a throw here strands the object in * the bucket permanently. Nothing exercised the dedupe on either side of the seam. */ @@ -226,7 +224,7 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { } // Only rows the API actually FINALIZED are in scope: a row is created 'validating' at PRESIGN time, - // so an unfinalized one is an upload intent whose bytes never arrived — the orphan sweep's job. + // so an unfinalized one is an upload intent whose bytes never arrived (the orphan sweep's job). const neverFinalized = await seedStuck(false, null) const checkedRecently = await seedStuck(true, new Date(Date.now() - 60_000)) const neverChecked = await seedStuck(true, null) @@ -293,7 +291,7 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { expect(won?.status).toBe("ready") // The stuck sweep terminalized this row and deleted its objects; a media.checks job that finished a - // moment later must NOT flip it back to ready — that asset would presign a 404 forever. + // moment later must NOT flip it back to ready: that asset would presign a 404 forever. await h.sql`UPDATE media_assets SET status = 'rejected' WHERE id = ${id}` expect(await repo.applyResult(id, { status: "ready", width: 99 })).toBeNull() @@ -355,7 +353,7 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { ` expect(row).toMatchObject({ attempts: 1, last_error: "storage delete failed" }) - // A SEPARATE call is a genuine retry and must bump — the upsert half of the same statement. + // A SEPARATE call is a genuine retry and must bump: the upsert half of the same statement. await repo.recordLeakedObjects?.({ mediaId, keys: [key], error: "still failing" }) const [bumped] = await h.sql<{ attempts: number; last_error: string | null }[]>` SELECT attempts, last_error FROM media_reap_tombstones WHERE r2_key = ${key} @@ -372,7 +370,6 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { await expect( ensureNextMonthChatPartition(h.sql, new Date("2026-06-15T00:00:00Z")), ).resolves.toBe("chat_messages_2026_07") - // The partition exists in the catalog. const [exists] = await h.sql<{ exists: boolean }[]>` SELECT to_regclass('public.chat_messages_2026_07') IS NOT NULL AS exists ` diff --git a/services/media-worker/test/integration/retention-and-phash-pg.test.ts b/services/media-worker/test/integration/retention-and-phash-pg.test.ts index 2c9cd9ca..72a84eef 100644 --- a/services/media-worker/test/integration/retention-and-phash-pg.test.ts +++ b/services/media-worker/test/integration/retention-and-phash-pg.test.ts @@ -262,7 +262,7 @@ describe.skipIf(!pg)("retention.sweep against the real schema", () => { }) /** - * M10 for retention: the sweep used to delete ONE fixed batch per table per DAY, so any table whose daily + * The sweep used to delete ONE fixed batch per table per DAY, so any table whose daily * expiry churn exceeded the batch grew a backlog forever. Real SQL, real LIMIT: five doomed rows against a * page size of two must all be gone in one run. */ @@ -320,7 +320,7 @@ describe.skipIf(!pg)("retention.sweep against the real schema", () => { * (key, scope, COALESCE(user_or_anon, '')), so ONE key value can legitimately name several live rows. * The lane used to page with `WHERE key IN (SELECT key ... WHERE created_at < cutoff LIMIT n)`, which * re-expands each aged key back over its whole family: a younger row in another scope/owner was - * deleted with it — silently voiding a still-live idempotency guarantee — and a single page could + * deleted with it (silently voiding a still-live idempotency guarantee), and a single page could * delete more rows than the batch limit it was handed. Paging by ctid deletes exactly the rows the age * predicate selected. */ diff --git a/services/media-worker/test/unit/maintenance.test.ts b/services/media-worker/test/unit/maintenance.test.ts index 95a27058..0882b474 100644 --- a/services/media-worker/test/unit/maintenance.test.ts +++ b/services/media-worker/test/unit/maintenance.test.ts @@ -675,7 +675,7 @@ describe("media.stuck.sweep", () => { expect(res).toEqual({ scanned: 1, requeued: 0, terminalized: 0, errors: 1 }) expect(reports.length).toBe(1) }) - it("F087b: NEVER clobbers a terminal status — a row the worker finished mid-sweep is left alone", async () => { + it("F087b: NEVER clobbers a terminal status; a row the worker finished mid-sweep is left alone", async () => { const repo = makeRepo() repo.seed({ id: "raced", diff --git a/services/media-worker/test/unit/media-checks.test.ts b/services/media-worker/test/unit/media-checks.test.ts index 62699e2c..5ea0e7c1 100644 --- a/services/media-worker/test/unit/media-checks.test.ts +++ b/services/media-worker/test/unit/media-checks.test.ts @@ -816,7 +816,7 @@ describe("media.checks with the REAL AbuseChecks (default-flag PUBLISH path)", ( expect(repo.moderationEnqueues).toHaveLength(0) }) - it("M9: MEDIA_UNSCORED_POLICY=hold fails CLOSED — an unscored asset is HELD, not published", async () => { + it("M9: MEDIA_UNSCORED_POLICY=hold fails CLOSED: an unscored asset is HELD, not published", async () => { const { deps, storage, repo } = realDeps() const holdDeps: MediaChecksDeps = { ...deps, diff --git a/services/media-worker/vitest.config.ts b/services/media-worker/vitest.config.ts index bf5a67af..7b638d2a 100644 --- a/services/media-worker/vitest.config.ts +++ b/services/media-worker/vitest.config.ts @@ -1,8 +1,6 @@ import { defineConfig } from "vitest/config" /** - * Worker test config. - * * The unit suite runs REAL sharp + REAL ffmpeg/ffprobe (vendored static binaries) against crafted * fixtures to prove safe-failure, plus the offline fakes (FakeStorage / in-memory repo / FakeAbuse). * Spawning ffmpeg per test makes individual cases slower than a pure unit test, so the timeout is diff --git a/turbo.json b/turbo.json index 260f8aa7..9b8112fe 100644 --- a/turbo.json +++ b/turbo.json @@ -2,9 +2,9 @@ "$schema": "https://turbo.build/schema.json", // Turbo runs tasks with a STRICT env by default, so anything not listed here is stripped before the // task sees it. The integration suites boot Postgres via testcontainers and SKIP THEMSELVES (they do - // not fail) when Docker is unreachable — so without this passthrough a local `pnpm test` silently - // reports a green 2444-test run with all 381 integration tests skipped, which reads exactly like a - // full pass. CI on ubuntu-latest needs none of these (Docker is at the default /var/run/docker.sock); + // not fail) when Docker is unreachable, so without this passthrough a local `pnpm test` silently + // reports a green run with every integration test skipped, which reads exactly like a full pass. + // CI on ubuntu-latest needs none of these (Docker is at the default /var/run/docker.sock); // they exist for colima/Rancher/Podman on a developer machine, where the daemon socket lives // elsewhere and the in-VM path has to be named separately for the reaper's bind mount. "globalPassThroughEnv": [ @@ -16,9 +16,8 @@ "TESTCONTAINERS_RYUK_DISABLED", // The two guards that turn the silent skip above into a LOUD failure (test/helpers/pg.ts). // CIVFIX_REQUIRE_PG=1 makes a Docker-less run fail instead of skipping; CIVFIX_ALLOW_PG_SKIP is the - // deliberate opt-out. Both were being stripped by turbo's strict env, so `CIVFIX_REQUIRE_PG=1 pnpm - // test` at the repo root did nothing at all — the guard against the silent 381-test skip could not - // fire through the only command anybody actually runs. + // deliberate opt-out. Turbo's strict env would strip both, so `CIVFIX_REQUIRE_PG=1 pnpm test` at + // the repo root could not fire the guard. "CIVFIX_REQUIRE_PG", "CIVFIX_ALLOW_PG_SKIP" ],