From d32a6618c091935893673d426f48f187d5590105 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 12:18:24 +0000 Subject: [PATCH 1/7] comments explain why; plan codes, banners and em dashes removed from source --- services/api/scripts/prepare-boundaries.ts | 22 +-- services/api/scripts/refresh-boundaries.ts | 18 +- .../api/scripts/render-sample-certificate.ts | 2 +- services/api/scripts/revoke-certificate.ts | 23 ++- services/api/src/abuse/anon-token.ts | 79 ++------ services/api/src/abuse/h3-cap.ts | 37 +--- services/api/src/abuse/honeypot.ts | 6 +- services/api/src/abuse/ip-rate-limit.ts | 43 +---- services/api/src/adapters/abuse-checks.ts | 4 +- services/api/src/adapters/chat-presence.ts | 105 ++++------- services/api/src/adapters/geocoder.tiger.ts | 70 ++----- services/api/src/adapters/mail-text.ts | 28 +-- services/api/src/adapters/net-ipv6.ts | 21 +-- services/api/src/adapters/push-expo.ts | 8 +- .../src/adapters/ref-counted-subscriptions.ts | 64 ++----- .../api/src/adapters/reverse-geocode.chain.ts | 44 ++--- .../src/adapters/reverse-geocode.mapbox.ts | 28 +-- .../src/adapters/reverse-geocode.photon.ts | 45 +---- services/api/src/adapters/routing-provider.ts | 10 +- services/api/src/adapters/storage.local.ts | 4 +- services/api/src/auth/admin-allowlist.ts | 13 +- services/api/src/auth/admin-guard.ts | 82 +++------ services/api/src/auth/cf-access.ts | 38 ++-- services/api/src/auth/crypto.ts | 44 +---- services/api/src/auth/csrf.ts | 82 +++------ services/api/src/auth/handle-policy.ts | 42 ++--- services/api/src/auth/jwks.ts | 6 +- services/api/src/auth/oauth.ts | 15 +- services/api/src/auth/reserved-handles.ts | 28 +-- services/api/src/auth/single-use-secret.ts | 48 ++--- services/api/src/auth/transport.ts | 81 +++------ .../src/db/backfill-jurisdiction-handles.ts | 69 ++----- .../api/src/db/backfill-jurisdictions-core.ts | 24 +-- services/api/src/db/backfill-jurisdictions.ts | 11 +- services/api/src/db/backfill-keyset.ts | 64 ++----- .../api/src/db/backfill-population-core.ts | 12 +- services/api/src/db/backfill-population.ts | 8 +- services/api/src/db/backfill-post-geom.ts | 2 +- .../src/db/backfill-reference-codes-core.ts | 41 +---- .../api/src/db/backfill-reference-codes.ts | 10 +- services/api/src/db/backfill-signup-seats.ts | 23 +-- services/api/src/db/backfill-user-activity.ts | 2 +- services/api/src/db/boundaries/manifest.ts | 172 +++++------------- services/api/src/db/client.ts | 21 +-- services/api/src/db/demo-join-event.ts | 27 +-- services/api/src/db/demo-signup-seats.ts | 9 +- services/api/src/db/ingest-jurisdictions.ts | 14 +- services/api/src/db/migrate-files.ts | 19 +- services/api/src/db/migrate.ts | 53 ++---- services/api/src/db/reference-code.ts | 68 ++----- services/api/src/db/schema/anon.ts | 12 +- services/api/src/db/schema/audit.ts | 10 +- .../api/src/db/schema/boundary_vintage.ts | 11 +- services/api/src/db/schema/chat-polls.ts | 24 +-- services/api/src/db/schema/chat.ts | 14 +- services/api/src/db/schema/cleanup_bans.ts | 21 +-- services/api/src/db/schema/cleanup_reports.ts | 14 +- services/api/src/db/schema/cleanup_slots.ts | 49 ++--- .../api/src/db/schema/cleanup_timeline.ts | 9 - services/api/src/db/schema/cleanups.ts | 1 - .../api/src/db/schema/conversation_mutes.ts | 25 +-- services/api/src/db/schema/discovery.ts | 12 +- services/api/src/db/schema/dm_messages.ts | 7 +- services/api/src/db/schema/dm_read_state.ts | 8 +- services/api/src/db/schema/dm_threads.ts | 15 +- services/api/src/db/schema/geocode_cache.ts | 8 +- services/api/src/db/schema/gov_claims.ts | 12 +- services/api/src/db/schema/jurisdictions.ts | 50 ++--- .../src/db/schema/media_reap_tombstones.ts | 19 +- .../api/src/db/schema/message_mentions.ts | 25 +-- services/api/src/db/schema/oauth.ts | 15 +- .../api/src/db/schema/organization_invites.ts | 1 - services/api/src/db/schema/organizations.ts | 2 +- services/api/src/db/schema/outreach_state.ts | 9 +- services/api/src/db/schema/post_likes.ts | 8 +- services/api/src/db/schema/post_mentions.ts | 8 +- services/api/src/db/schema/post_saves.ts | 8 +- services/api/src/db/schema/push_tokens.ts | 6 +- .../api/src/db/schema/reference_counters.ts | 16 +- .../api/src/db/schema/report_chat_members.ts | 14 +- .../src/db/schema/report_message_forwards.ts | 22 +-- services/api/src/db/schema/reports.ts | 7 +- services/api/src/db/schema/sessions.ts | 16 +- services/api/src/db/schema/timeline.ts | 14 +- .../api/src/db/schema/user_verification.ts | 22 +-- services/api/src/db/seed-demo-domain.ts | 6 +- services/api/src/db/seed-demo-la.ts | 141 ++++---------- services/api/src/db/seed-fixtures.ts | 58 ++---- services/api/src/db/seed.ts | 4 +- services/api/src/db/sql/jurisdiction.ts | 74 ++------ services/api/src/db/worker-db.ts | 15 +- services/api/src/env.ts | 10 +- services/api/src/env/comms-env.ts | 2 +- services/api/src/env/registration-env.ts | 2 +- services/api/src/i18n/locales.ts | 14 +- services/api/src/i18n/messages/de.ts | 8 +- services/api/src/i18n/messages/en.ts | 105 ++++------- services/api/src/i18n/messages/es.ts | 8 +- services/api/src/i18n/messages/ko.ts | 8 +- services/api/src/i18n/renderMessage.ts | 19 +- services/api/src/plugins/cookie.ts | 9 +- services/api/src/plugins/cors.ts | 23 +-- services/api/src/plugins/helmet.ts | 25 +-- services/api/src/plugins/request-id.ts | 21 +-- services/api/src/plugins/trust-proxy.ts | 17 +- services/api/src/routes/admin/_audit-read.ts | 60 +++--- services/api/src/routes/admin/_route-utils.ts | 63 +++---- .../api/src/routes/admin/activity.routes.ts | 22 +-- services/api/src/routes/admin/audit.routes.ts | 6 +- .../api/src/routes/admin/discovery.routes.ts | 28 +-- .../api/src/routes/admin/events.routes.ts | 16 +- services/api/src/routes/admin/inbox.routes.ts | 41 ++--- .../src/routes/admin/jurisdictions.routes.ts | 20 +- services/api/src/routes/admin/orgs.routes.ts | 15 +- services/api/src/routes/auth.routes.ts | 2 +- services/api/src/routes/chat-groups.routes.ts | 71 ++------ services/api/src/routes/chat-notify-copy.ts | 27 +-- services/api/src/routes/chat-powers-wiring.ts | 45 ++--- services/api/src/routes/forms.routes.ts | 4 +- services/api/src/routes/health.routes.ts | 35 +--- services/api/src/routes/map.routes.ts | 8 +- services/api/src/routes/media.routes.ts | 86 ++++----- services/api/src/routes/query-encoding.ts | 67 ++----- .../service-hours-certificates.routes.ts | 38 ++-- services/api/src/services/address-resolver.ts | 14 +- .../admin/activity-repository.drizzle.ts | 65 +++---- .../admin/activity-repository.memory.ts | 20 +- .../src/services/admin/activity-service.ts | 64 +++---- .../src/services/admin/admin-event-helpers.ts | 15 +- .../admin/admin-event-repository.memory.ts | 73 ++------ .../src/services/admin/admin-event-service.ts | 54 ++---- .../api/src/services/admin/admin-event-sql.ts | 18 +- .../api/src/services/admin/admin-format.ts | 16 +- .../api/src/services/admin/admin-person.ts | 49 ++--- .../admin-report-chat-repository.drizzle.ts | 1 - .../admin/admin-report-chat-service.ts | 5 +- .../admin/admin-report-repository.drizzle.ts | 2 +- .../src/services/admin/admin-report-status.ts | 38 ++-- .../admin/admin-user-repository.memory.ts | 43 +---- .../admin/analytics-repository.memory.ts | 12 +- .../src/services/admin/analytics-service.ts | 10 +- .../src/services/admin/analytics-shaping.ts | 6 +- .../api/src/services/admin/analytics-types.ts | 12 +- .../services/admin/audit-repository.memory.ts | 16 +- .../api/src/services/admin/audit-service.ts | 33 +--- .../api/src/services/admin/category-counts.ts | 34 +--- .../api/src/services/admin/discovery-jobs.ts | 21 +-- .../admin/discovery-repository.memory.ts | 58 ++---- .../src/services/admin/discovery-service.ts | 153 ++++------------ .../api/src/services/admin/event-status.ts | 17 +- .../admin/gov-claims-repository.drizzle.ts | 39 +--- .../admin/gov-claims-repository.memory.ts | 42 +---- .../src/services/admin/gov-claims-service.ts | 139 ++++---------- ...urisdiction-contacts-repository.drizzle.ts | 13 +- .../admin/jurisdiction-contacts-service.ts | 34 +--- .../admin/jurisdiction-contacts-types.ts | 106 ++++------- .../jurisdiction-directory-projection.ts | 29 +-- services/api/src/services/admin/like.ts | 19 +- .../admin/moderation-repository.drizzle.ts | 10 +- services/api/src/services/admin/pagination.ts | 76 +++----- .../services/admin/system-health-probes.ts | 9 +- .../src/services/anon-repository.drizzle.ts | 151 ++++++--------- services/api/src/services/certificate-code.ts | 11 +- .../api/src/services/certificate-fonts.ts | 16 +- .../api/src/services/certificate-layout.ts | 14 +- .../certificate-repository.drizzle.ts | 12 +- .../services/certificate-repository.memory.ts | 5 +- .../api/src/services/certificate-service.ts | 81 ++++----- services/api/src/services/chat-bells.ts | 81 ++------- .../api/src/services/chat-edit-service.ts | 67 ++----- .../api/src/services/chat-history-window.ts | 36 +--- .../api/src/services/chat-mention-resolver.ts | 24 +-- .../api/src/services/chat-mentions.drizzle.ts | 19 +- .../services/chat-poll-repository.drizzle.ts | 62 ++----- .../api/src/services/chat-reaction-service.ts | 56 ++---- .../src/services/chat-reactions.drizzle.ts | 12 +- .../src/services/chat-read-state.drizzle.ts | 36 +--- .../api/src/services/chat-reply-hydration.ts | 58 ++---- .../src/services/chat-room-scope.drizzle.ts | 134 ++++---------- services/api/src/services/cleanup-service.ts | 8 +- .../api/src/services/conversation-bell.ts | 1 - .../conversation-mutes-repository.drizzle.ts | 32 +--- .../services/discussion-repository.drizzle.ts | 9 +- services/api/src/services/discussion-types.ts | 15 +- services/api/src/services/dm-peer.ts | 15 +- services/api/src/services/geocode-cache.ts | 11 +- .../host/organization-repository.types.ts | 6 +- .../src/services/host/organization-service.ts | 4 +- services/api/src/services/media-byte-quota.ts | 13 +- services/api/src/services/media-presign.ts | 16 +- .../src/services/message-mentions.drizzle.ts | 20 +- .../src/services/message-reactions.drizzle.ts | 14 +- services/api/src/services/public-author.ts | 30 +-- .../api/src/services/report-chat-emitter.ts | 62 ++----- .../api/src/services/report-clustering.ts | 36 ++-- .../services/report-forward-audit.drizzle.ts | 23 +-- services/api/src/services/report-service.ts | 1 - .../api/src/services/report-timeline-event.ts | 34 +--- .../api/src/services/report-visibility.ts | 12 +- .../api/src/services/route-geo-helpers.ts | 27 ++- services/api/src/services/route-notifier.ts | 13 +- .../api/src/services/user-search.drizzle.ts | 32 +--- services/api/src/version.ts | 10 +- services/api/src/versioning/policy.ts | 58 +----- services/api/src/versioning/route.ts | 30 +-- services/api/src/ws/gateway.ts | 38 +--- services/media-worker/src/jobs.ts | 58 ++---- services/media-worker/src/jobs/drain.ts | 18 +- .../src/jobs/hold-release-sweep.ts | 16 +- services/media-worker/src/jobs/obs.ts | 5 - services/media-worker/src/sandbox/image.ts | 76 +++----- services/media-worker/src/sandbox/phash.ts | 21 +-- services/media-worker/src/timeout.ts | 11 +- 213 files changed, 1805 insertions(+), 4736 deletions(-) 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